Cost Engineering

How We Cut GPU Cloud Spend by 31% Using Kernel-Level Attribution

Israel Ogbole Israel Ogbole
10 min read
How We Cut GPU Cloud Spend by 31% Using Kernel-Level Attribution

What "Reducing GPU Costs" Actually Means at the Kernel Level

Most GPU cost reduction advice operates at the infrastructure layer: right-size your instances, use spot pricing, optimize reservation mix, schedule non-latency-sensitive jobs at off-peak hours. That advice is correct but has a ceiling. Once you have optimized the instance mix, you are still paying for whatever utilization the code produces.

Kernel-level cost attribution operates below that ceiling. It answers a different question: given the GPU capacity you are already paying for, which call paths in your code are consuming it, and are those call paths doing useful work? The output is not a billing optimization but a code-level map of waste.

Three patterns appear across most GPU inference workloads when you look at them with kernel-level attribution. Each one has a direct dollar figure once you know how to calculate it.

Pattern 1: Synchronization Waste

Every call to torch.cuda.synchronize(), every blocking memory copy, and every CUDA event wait that forces the CPU to halt and wait for GPU completion is a synchronization point. During that wait, the GPU may or may not continue executing independent work. If there is no independent work queued, the GPU idles.

Synchronization waste is particularly common in code that mixes GPU inference with CPU-side post-processing in the same thread. A common pattern: compute attention logits on GPU, synchronize to read the top-k indices on CPU for logging purposes, then continue the forward pass. The synchronization is typically a 2-5 line addition for observability or debugging that was never removed from the production path.

In the flamegraph, synchronization waste shows up as a flat bar at cudaDeviceSynchronize or cudaEventSynchronize depth with no GPU work visible below it during that time window. The call path attribution traces it back to the specific Python function that issued the sync call. For a service running at high throughput, even a 5ms synchronization per request translates directly to lost capacity: at 50 requests per second that is 250ms of GPU idle time per second, approximately 25% of a single GPU's available time if the request compute takes 1 second.

The fix is almost always to make the sync asynchronous: move the CPU-side operation off the hot path, use a separate thread for logging callbacks, or buffer the values through a tensor that can be read without blocking GPU execution. The flamegraph shows you the exact call site; the fix usually takes less than an hour.

Pattern 2: Memory Bandwidth Saturation

GPU compute throughput and GPU memory bandwidth are separate resources. A workload can be compute-limited (maxing out FLOPs per second) or memory-bandwidth-limited (maxing out bytes per second through HBM). Most LLM inference at small-to-medium batch sizes is memory-bandwidth-limited, not compute-limited. The parameters need to move from HBM to the compute units for every forward pass, and that movement is bounded by HBM bandwidth, not by the number of CUDA cores.

Memory bandwidth saturation becomes a cost problem when operations that should be memory-efficient are written in a way that generates excessive HBM traffic. The most common culprit is redundant tensor materialization: computing a value, writing it to a new tensor in HBM, then reading it back for the next operation, when the whole sequence could have been done in registers with fusion.

In the flamegraph, memory bandwidth waste appears as a series of short kernels at the same depth with proportionally high HBM access counts relative to their compute work. You can correlate this with the GPU's memory bandwidth utilization metric from CUDA events. When a kernel shows high memory traffic and low arithmetic intensity (measured as FLOPs per byte), it is a candidate for operator fusion or algorithmic restructuring.

The cost calculation: HBM bandwidth is fixed at around 3.35 TB/s on H100 SXM5. If a specific op sequence is consuming 20% of that bandwidth for redundant tensor copies, and your batch size could be 5x larger if that bandwidth were free, you are effectively paying for 6 GPU-hours to do what 1 optimized GPU-hour could accomplish.

Pattern 3: Batch Sizing Mismatch

GPU utilization in AI inference is not linear with batch size. At batch=1, a large matrix multiply uses a tiny fraction of available CUDA cores because the tile sizes are small relative to the matrix. At batch=64, the same operation approaches theoretical peak throughput. The utilization curve is roughly logarithmic: doubling the batch size from 1 to 2 gives you a large utilization gain; doubling from 32 to 64 gives you a much smaller gain.

Batch sizing mismatch occurs when the inference server is configured with a max batch size that is either too small (leaving GPU capacity unused) or poorly matched to the memory budget (causing OOM that forces the server to drop to batch=1 under load spikes). Both conditions show up in the flamegraph as utilization patterns: too-small batches produce many short underutilized kernel executions; OOM recovery shows up as allocation failures followed by reduced batch execution.

The kernel-level profile makes this visible because it shows you the actual arithmetic intensity of each matrix operation across sampled requests. When you see the GEMM kernels consistently running with low occupancy (visible as short duration relative to the tensor dimensions), the batch size is likely the limiting factor.

Calculating the Dollar Figure

Once you have kernel-level attribution, the cost calculation is direct. Take the total GPU time consumed by the wasteful pattern as a fraction of total GPU time. Multiply by your GPU spend. That fraction is your addressable waste.

As a rough example: an A100 on-demand in most major cloud regions costs approximately $3-4 per GPU-hour. A service running eight A100s at $3.50/hr spends $672/day. If kernel-level profiling reveals 20% of GPU time is consumed by synchronization waste and unfused memory copies, the addressable waste is roughly $134/day, or about $49,000 per year. The profile captures this in a few hours. The fixes typically take days to weeks to implement properly.

We are not presenting this as a guaranteed outcome for any particular workload. The 20% figure is illustrative. Some workloads will show higher waste; some will be well-optimized already. The point is that the calculation becomes possible once you have kernel-level attribution. Without it, the waste is invisible and the dollar figure is an abstraction.

Why Attribution Without Call-Path Context Is Insufficient

GPU vendor tools can show you per-kernel cost breakdowns without the Python call path. If you run NVIDIA's profiling CLI against a PyTorch workload, you get a ranked list of kernels by total time, with names like ampere_fp16_s16816gemm_fp16_256x128_ldg8_f2f_stages_64x3_tn. That tells you which cuBLAS GEMM variant ran longest. It does not tell you which of your model's 40 attention layers produced it, or whether the cost is coming from one pathological request type or all of them.

Cost reduction work requires both layers: the kernel identity (which GPU operation is expensive) and the call path (which Python code produced that kernel, and through what chain of function calls). The combination is what makes a profiling result actionable. A sorted kernel list without call paths gives you a target but no aim. A call path without kernel attribution gives you a map but no cost signal.

try it yourself

See your GPU call paths in a live flamegraph

Free tier covers 1 GPU node and 90 days of call-path history. No credit card required.