LLM + RoboticsModule 09 · Problems 13–15

Profiling, GPU utilisation and writing a fused kernel

A slow model is slow for one of three reasons: the GPU is out of arithmetic, out of memory bandwidth, or sitting idle waiting for work. This page shows how to tell which one with a profiler, how to debug a GPU that looks two-thirds idle, and how to write and fairly benchmark a small fused kernel.

Problems covered13 (Nsight profiling), 14 (30% GPU utilisation), 15 (fused RMSNorm kernel)
Interview levelMid to senior. Problem 15 is a common live-coding task for kernel and performance roles
Key ideasRoofline model, arithmetic intensity, kernel launch overhead, what "GPU utilisation" really measures, kernel fusion, effective bandwidth
Time to prep4–6 h reading and arithmetic, plus 3–5 h on a Colab GPU for the kernel

Source: Vishal Singh's post of 25 inference-engineering problems · saved in sources/09-vishal-inference-25.md

Start here (no maths needed)

When a computer program is slow, guessing why is a waste of time. You measure. Is the worker too slow, are the supplies arriving too slowly, or is the worker standing around waiting to be told what to do next?

Think of it like this

Picture a school canteen kitchen at lunch. The line is slow. Maybe the cook can't chop any faster (that's "compute-bound"). Maybe the cook is fast but keeps walking to the storeroom for one ingredient at a time (that's "memory-bound"). Or maybe the cook is ready, but the manager hands over one order slip at a time and walks off between slips (that's "launch overhead"). Each problem has a different fix: a faster cook, fewer storeroom trips, or a whole stack of slips at once. "Fusing" a kernel is like carrying every ingredient for a dish in one trip, instead of one trip per ingredient.

How it works

  1. A program called a profiler records what the GPU and the main processor did, moment by moment.
  2. You look at the timeline. Gaps where the GPU does nothing mean it's waiting for instructions.
  3. For the busy parts, you check each small GPU program (a "kernel"): was it limited by arithmetic or by fetching data?
  4. You fix the biggest problem first, then measure again.
  5. Sometimes the fix is a new kernel that does several steps in one pass, so data is fetched once instead of many times.

Why robots care

A robot's onboard model has a fixed time to answer, for example before the next camera frame arrives. If the answer is late, the robot acts on old information. Profiling tells you where those milliseconds go, so you can fix the right thing instead of buying a bigger computer that can't fit in the robot.

Try it

Words to know

Kernel
One small program the GPU runs in parallel across many cores, such as "multiply these matrices".
Memory bandwidth
How many bytes per second the GPU can read from its main memory.
Arithmetic intensity
How many calculations a kernel does for each byte it reads or writes.
Profiler
A tool that records where time goes, so you measure instead of guess.
Fusion
Combining several steps into one kernel so data crosses slow memory once.

Going deeper: everything below is the full version, with some maths. On a first read, skim the headings, the interview answers and the "Why it matters for robotics" box, and come back for the rest later.

The numbers this page uses

Every worked answer below uses one GPU, so the arithmetic is comparable. These figures come from NVIDIA's H100 product page, SXM column. The PCIe-class variant listed beside it differs (different memory size and bandwidth), so don't mix the two.

H100 SXM specValueNote
GPU memory80 GBHBM
Memory bandwidth3.35 TB/sThe number that limits decode
BF16 Tensor Core1,979 TFLOPS listedNVIDIA lists this with sparsity. Dense is half: about 989 TFLOPS. Use dense for normal models.
FP8 Tensor Core3,958 TFLOPS listedAlso with sparsity, so about 1,979 dense

Kernel launch costs, CPU step times and request rates below are assumptions chosen to make the arithmetic clear. They are not measurements. Measure your own.

Problem 13 · Profile a slow model with Nsight: compute, memory bandwidth, or launch overhead?

What the interviewer is testing: whether you have a method, not a hunch. Can you pick the right tool, read a timeline, use the roofline model, and name the fix for each kind of bottleneck?

The concepts

Two tools, two zoom levels. Nsight Systems (nsys) records a timeline of the whole program: CPU threads, CUDA API calls, every kernel, memory copies, and communication between GPUs. It answers "where does wall-clock time go, and is the GPU ever idle?" Nsight Compute (ncu) replays individual kernels many times with hardware counters. It answers "why is this kernel slow?" Always start with Systems. Running Compute on everything is slow and drowns you in detail.

# 1. Timeline of a few hundred decode steps
nsys profile -t cuda,nvtx,osrt --cuda-graph-trace=node -o trace python bench.py

# 2. Deep dive on the kernels that dominate the timeline
ncu --set full -k regex:rms -c 20 -o rms_report python bench.py

Wrap the phases of your code in NVTX ranges (torch.cuda.nvtx.range_push("prefill") … range_pop()) so the timeline shows "prefill", "decode step", "sampling" as labelled bars. Without them you are reading thousands of anonymous kernel names.

The roofline model. A kernel does some floating-point operations (FLOPs) and moves some bytes to and from HBM. Its arithmetic intensity is the ratio. Its best possible speed is capped by whichever resource runs out first:

arithmetic intensity  I = FLOPs / bytes moved            (FLOP per byte)
attainable FLOP/s       = min( peak FLOP/s ,  I × bandwidth )
ridge point          I* = peak FLOP/s / bandwidth

H100 SXM, dense BF16:  I* = 989e12 / 3.35e12 ≈ 295 FLOP/byte
I < 295  → memory-bound   (more bandwidth or fewer bytes helps)
I > 295  → compute-bound  (more FLOP/s or fewer FLOPs helps)

Launch overhead is the third case, and the roofline doesn't show it. Each kernel launch costs CPU time in Python, the framework and the driver. If the GPU finishes a kernel before the CPU has queued the next one, the GPU sits idle. Nsight Systems shows this as gaps between kernels on the GPU row, while the CPU row is busy.

A worked answer with real numbers

Take one linear layer of a model with hidden size d = 4,096, weights in BF16 (2 bytes). Feed it N tokens at once: N is the batch size during decode, or the chunk length during prefill.

FLOPs = 2 × N × d²                       (multiply + add per weight per token)
bytes = 2 × (d² + 2·N·d)                 (read weights, read input, write output)
I     = 2·N·d² / (2·(d² + 2·N·d)) = N·d / (d + 2N)

Decode, N = 1:
  I = 4096 / 4098 ≈ 1.0 FLOP/byte          → far below 295, memory-bound
  time ≈ bytes / bandwidth = 33.6 MB / 3.35 TB/s ≈ 10 µs
  attainable = 1.0 × 3.35 TB/s ≈ 3.35 TFLOP/s → about 0.3% of the 989 peak

Prefill chunk, N = 2,048:
  I = 2048 × 4096 / (4096 + 4096) = 1,024 FLOP/byte → compute-bound
  compute time = 2 × 2048 × 4096² / 989e12 ≈ 69 µs
  memory time  = 67 MB / 3.35 TB/s ≈ 20 µs  → compute wins

Crossover: N·d/(d + 2N) = 295  →  N ≈ 345 tokens per step

So for this layer on this GPU, any step with fewer than about 345 tokens is memory-bound. Single-user decode is deep in the memory-bound corner. Attention during decode is worse still, because every sequence reads its own KV cache (see page 02).

The whole-model version gives a hard upper bound on single-stream decode speed:

8B parameters × 2 bytes = 16 GB read per decode step
16 GB / 3.35 TB/s ≈ 4.8 ms per token  →  at most ~209 tokens/s at batch 1
(real numbers are lower: KV-cache reads, imperfect bandwidth use, overheads)

Launch overhead, with assumed numbers. Suppose a decode step launches about 15 kernels per layer across 32 layers: 480 launches. Assume each costs about 5 µs of CPU time in eager PyTorch. That's 2.4 ms of CPU work per step. For the 8B model the GPU needs about 4.8 ms, so the CPU just keeps up. Shrink the model to 1B parameters (2 GB, about 0.6 ms of GPU time per step) and the same 2.4 ms of launches now dominates: the GPU is idle about three-quarters of the time. Small models are where launch overhead bites.

Reading the profile: a decision table

What you seeDiagnosisFixes
Nsight Systems: gaps between short kernels, CPU thread busy in Python or cudaLaunchKernelLaunch or CPU overheadCUDA graphs, torch.compile, kernel fusion, a C++ runtime, overlapping CPU scheduling with GPU work
Nsight Compute: memory throughput near peak, compute throughput low; dot left of the ridge on the roofline chartMemory-bound kernelMove fewer bytes: fuse ops, quantise weights or KV cache, bigger batch to reuse weights
Nsight Compute: tensor pipes busy, memory moderate; dot right of the ridgeCompute-bound kernelLower precision (FP8), a better kernel (FlashAttention), fewer FLOPs
Both throughputs low, GPU busyLatency-bound kernel: poor occupancy, small grid, stallsBigger tiles or grid, check occupancy and warp stall reasons in ncu
Long NCCL kernels or memcpy barsCommunication or host transfersOverlap communication, change parallelism, pin memory, avoid host round-trips

Trade-offs

How to say it in the interview

I start with Nsight Systems on a few hundred steps, with NVTX ranges around prefill, decode and sampling. If the GPU row has gaps while the CPU is busy, it's launch or CPU overhead, and the fix is CUDA graphs, compilation or fusion. If the GPU is busy, I take the top kernels by time into Nsight Compute and place them on the roofline. On an H100 the ridge is about 295 FLOPs per byte in dense BF16, and decode GEMMs sit near 1, so decode is memory-bound and the fixes are fewer bytes and bigger batches. Prefill chunks of thousands of tokens are compute-bound, where FP8 and better kernels help.

Common mistakes

Problem 14 · Your GPU sits at 30% utilisation under load. Debug it

What the interviewer is testing: whether you know what the utilisation number actually measures, and whether you debug from the outside in (load, scheduler, CPU) before blaming kernels.

The concepts

What "GPU utilisation" means. The figure in nvidia-smi is the share of a sample period during which at least one kernel was running. It says nothing about how much of the GPU that kernel used. A tiny kernel on one SM counts as 100% busy. So 30% means the GPU ran nothing for about 70% of the time. That points at starvation, not slow kernels. For how well the busy time was used, DCGM's profiling metrics are better: SM activity, SM occupancy, tensor pipe activity and DRAM activity.

Little's law links load to batch size. The average number of requests in the system equals arrival rate × time each spends there:

in-flight requests = arrival rate × mean request duration
GPU busy fraction  = GPU time per step / (GPU time + non-overlapped CPU time per step)

A worked answer with real numbers

Two common stories, with assumed numbers.

Story A: the load is too light. The engine is configured for up to 64 concurrent sequences. Each request lasts about 10 s (prompt plus a few hundred output tokens). The load tester sends 1 request per second.

in-flight = 1 req/s × 10 s = 10 sequences   (the engine allows 64)
to fill 64 slots: 64 / 10 s = 6.4 req/s needed

The GPU is not broken. The test is too gentle, or a gateway in front is limiting concurrency. Check the engine's running and waiting counts. If nothing is waiting, the GPU isn't the bottleneck.

Story B: the CPU is in the critical path. Each decode step needs 12 ms of GPU work. Between steps, the CPU spends 28 ms on scheduling, sampling, detokenising and streaming, and none of it overlaps with the GPU.

busy fraction = 12 / (12 + 28) = 30%
step time     = 40 ms  →  25 steps/s

If CPU work overlaps the next GPU step:
step time = max(12, 28) = 28 ms → 43% busy, 36 steps/s
If the CPU work is also cut to 8 ms:
step time = max(12, 8) = 12 ms → ~100% busy, 83 steps/s

Overlap alone isn't enough here, because the CPU part is longer than the GPU part. You also have to shrink it: move detokenisation off the main loop, sample on the GPU, batch the streaming writes.

The debugging ladder

  1. Is there enough work? Look at the waiting queue and running batch size. Empty queue plus small batch means a load or routing problem.
  2. Is the scheduler capping the batch? Limits on concurrent sequences or tokens per step, or a KV cache that's full and causing preemptions, keep batches small even when requests are waiting.
  3. Is the CPU in the path? Nsight Systems: GPU gaps lined up with Python, tokenisation, sampling or network work on the CPU threads. Also check the CPU itself is not throttled or shared.
  4. Is it waiting on transfers or other GPUs? Host-to-device copies, synchronising calls such as .item() in the loop, or tensor-parallel all-reduces waiting on a slow peer.
  5. Only then, the kernels. Many small kernels with gaps: launch overhead (Problem 13).

Trade-offs

FixGainsCosts
Raise concurrency limitsBigger batches, higher throughputHigher per-token latency, more KV memory, risk of preemption
Overlap CPU scheduling with GPU stepsHides CPU timeEngine complexity; scheduling decisions use slightly stale state
CUDA graphsRemoves launch gapsFixed shapes, extra memory, longer startup
Fewer, larger replicasMore load per GPULess redundancy, larger blast radius on failure

How to say it in the interview

First I'd check what the 30% means: nvidia-smi counts time when any kernel is running, so 30% says the GPU is idle 70% of the time, which is starvation, not slow kernels. I work from the outside in. Is there a waiting queue? If not, the load or the gateway is the problem, and Little's law tells me how much traffic fills the batch. If requests are waiting but batches are small, I look at scheduler limits and KV-cache preemption. If batches are full, I take an Nsight Systems trace and look for CPU work between steps, such as sampling, detokenising or synchronising calls, and overlap or remove it. I'd confirm with DCGM metrics like SM activity rather than nvidia-smi alone.

Common mistakes

Problem 15 · Write a fused RMSNorm kernel and benchmark it against PyTorch

What the interviewer is testing: can you write a correct GPU kernel (indexing, masking, reductions, precision), reason about why fusion helps, and benchmark honestly?

The concepts

RMSNorm (Zhang and Sennrich, 2019) is LayerNorm without the mean subtraction and bias. Llama-style models apply it twice per layer. For a row x of length d:

rms(x) = sqrt( (1/d) × Σ xᵢ²  + ε )
yᵢ     = xᵢ / rms(x) × wᵢ

It does about 4 FLOPs per element (square, add, two multiplies). In BF16 a fused version reads 2 bytes and writes 2 bytes per element, so its intensity is about 1 FLOP per byte. That's far below the H100 ridge of about 295. RMSNorm is memory-bound, so its speed is set by bytes moved. That's why fusion helps.

Written naively in eager PyTorch, each operation is its own kernel and writes its result back to HBM:

x.pow(2)        read x, write tmp1          4 bytes/elem in BF16
.mean(-1)       read tmp1                   2
x * rstd        read x, write tmp2          4
tmp2 * w        read tmp2, write y          4
                                            ── ~14 bytes/elem
fused kernel    read x once, write y once   4 bytes/elem

That simple count says unfused moves about 3.5× more bytes. Real eager code often upcasts to FP32 for the sum, which adds more traffic; PyTorch's built-in torch.nn.functional.rms_norm and torch.compile may already fuse it. So the fair baselines are eager, built-in and compiled, not eager alone.

A Triton kernel sketch

One program instance handles one row. The whole row fits in one block for typical hidden sizes (up to a few tens of thousands), so the reduction is a single tl.sum. Accumulate in FP32 even when inputs are BF16.

import torch, triton, triton.language as tl

@triton.jit
def rmsnorm_kernel(X, W, Y, stride, N, eps, BLOCK: tl.constexpr):
    row = tl.program_id(0)
    cols = tl.arange(0, BLOCK)
    mask = cols < N
    x = tl.load(X + row * stride + cols, mask=mask, other=0.0).to(tl.float32)
    w = tl.load(W + cols, mask=mask, other=0.0).to(tl.float32)
    rstd = tl.rsqrt(tl.sum(x * x, axis=0) / N + eps)   # masked lanes add 0
    y = x * rstd * w
    tl.store(Y + row * stride + cols, y.to(Y.dtype.element_ty), mask=mask)

def rmsnorm(x, w, eps=1e-6):
    N = x.shape[-1]
    x2 = x.reshape(-1, N).contiguous()
    y = torch.empty_like(x2)
    BLOCK = triton.next_power_of_2(N)
    warps = 8 if BLOCK >= 4096 else 4
    rmsnorm_kernel[(x2.shape[0],)](x2, w, y, x2.stride(0), N, eps,
                                   BLOCK=BLOCK, num_warps=warps)
    return y.view_as(x)

Points worth saying aloud: the mask handles hidden sizes that aren't a power of two; other=0.0 keeps masked lanes out of the sum; dividing by N (not BLOCK) gives the true mean; the FP32 accumulator avoids BF16 rounding in the sum. For very wide rows you would loop over chunks of the row, as the Triton layer-norm tutorial does. In a real model, the bigger win is fusing the residual add into the same kernel (read x and residual, write the sum and the normalised output), which serving engines ship as a standard op.

The benchmarking method

from triton.testing import do_bench
import torch.nn.functional as F

def eager(x, w, eps=1e-6):
    xf = x.float()
    return (xf * torch.rsqrt(xf.pow(2).mean(-1, keepdim=True) + eps) * w.float()).to(x.dtype)

builtin  = lambda x, w: F.rms_norm(x, (x.shape[-1],), w, 1e-6)
compiled = torch.compile(eager)

d = 4096
w = torch.randn(d, device="cuda", dtype=torch.bfloat16)
for M in [1, 16, 256, 4096, 32768]:                    # decode-like → prefill-like
    x = torch.randn(M, d, device="cuda", dtype=torch.bfloat16)
    ref = eager(x, w)
    for name, f in [("eager", eager), ("builtin", builtin),
                    ("compiled", compiled), ("triton", rmsnorm)]:
        torch.testing.assert_close(f(x, w), ref, atol=2e-2, rtol=2e-2)
        ms = do_bench(lambda: f(x, w))                 # warm-up + repeats, median
        gbs = 2 * x.numel() * x.element_size() / ms / 1e6   # read x + write y
        print(f"{M:6d} {name:9s} {ms*1e3:8.1f} us  {gbs:7.0f} GB/s")

The rules that make this fair:

What to expect, as a roofline estimate rather than a measurement: at M = 4,096 and d = 4,096 in BF16, a perfect fused kernel moves 67 MB, about 20 µs at 3.35 TB/s. The 14-bytes-per-element eager version would need about 70 µs at full bandwidth, plus four launches. Whether your kernel beats the built-in or compiled version is an empirical question. Run it.

Trade-offs

OptionForAgainst
Eager PyTorchSimplest, easy to debugSeveral kernels, several HBM round-trips
torch.compileOften fuses automatically, no kernel codeCompile time, recompiles on new shapes, less control
Triton kernelPython-level, portable across NVIDIA and AMD, quick to writeYou own correctness and tuning
CUDA C++ kernelFull control (vector loads, warp shuffles)Much more code, build system, less portable

How to say it in the interview

RMSNorm does about one FLOP per byte, so it's memory-bound, and the goal is to read the input once and write the output once. My kernel gives each row to one program, loads it with a mask, sums squares in FP32, multiplies by the reciprocal root and the weight, and stores in the input dtype. I benchmark against eager, the built-in, and torch.compile, check correctness first, and report effective bandwidth as a fraction of the GPU's peak across small and large batch sizes. At small sizes launch overhead dominates, so I'd say so instead of claiming a speedup there. The real production win is fusing the residual add in as well.

Common mistakes

Interactive: roofline for decode and prefill

Attainable TFLOP/s (log scale) against arithmetic intensity (FLOP per byte, log scale). The sloped part is the memory limit, the flat part the compute limit. The filled dot is one linear layer (hidden size d) processing the chosen tokens per step. The hollow dot is a fused RMSNorm (about 4 FLOPs per element, read and write once).

Intensity
–
Ridge point
–
Bound by
–
Share of peak
–
Layer time
–
Memory-bound below
–

Try this: the defaults are an H100 SXM (989 dense BF16 TFLOP/s, 3.35 TB/s), d = 4,096, one token per step: intensity about 1, ridge about 295, memory-bound at 0.3% of peak, about 10 µs. Set tokens to 2,048: intensity 1,024, compute-bound, about 69 µs. Switch bytes to 1 (FP8): intensity doubles and the crossover falls from 345 to about 159 tokens. Set bandwidth to 0.2 TB/s, near an embedded robot module's memory, and see the ridge move far right. Peak compute for FP8 is higher than for BF16 on real GPUs; raise it yourself.

Practise it

On a free Colab GPU (a T4 or better), in an afternoon:

  1. Write the kernel. Paste the Triton sketch above, run the benchmark loop, and record time and effective GB/s per shape for all four versions. Look up your GPU's memory bandwidth on NVIDIA's product page and compute the fraction of peak.
  2. Add the residual. Extend the kernel to take a residual tensor, write x + residual back, and normalise the sum. Count bytes before and after, and check the measured change against the count.
  3. Profile. Run nsys profile on a loop of 100 calls to each version. Count kernels per call and look at the gaps at M = 1. Then run ncu on the Triton kernel and read the memory throughput figure.
  4. Create the 30% problem on purpose. Serve a small model (about 0.5–1B parameters) with vLLM, drive it with a load tool at concurrency 1, 8 and 64, and log the engine's running-request count, nvidia-smi utilisation and tokens per second. Then add a deliberate .item() in a custom sampling hook (or simulate CPU work) and see utilisation fall.

What to measure: effective bandwidth vs peak, kernels per call, GPU idle gaps in microseconds, and how throughput and utilisation change with concurrency.

In this course

Why it matters for robotics

Self-check

What is the ridge point of an H100 SXM in dense BF16, and what does it mean?

About 989 TFLOP/s ÷ 3.35 TB/s ≈ 295 FLOP per byte. Kernels with lower arithmetic intensity are memory-bound; higher ones are compute-bound.

Why is a decode-time linear layer memory-bound at batch 1?

Its intensity is N·d/(d+2N) ≈ 1 FLOP per byte at N = 1: each weight is read once and used for one multiply-add. That's about 300 times below the ridge.

nvidia-smi says 100% utilisation. Is the GPU fully used?

Not necessarily. It means a kernel was running for the whole sample period. That kernel could be using a small part of the GPU. Check SM activity, occupancy and tensor-pipe metrics in DCGM or Nsight Compute.

Which Nsight tool do you open first, and why?

Nsight Systems. It shows the whole timeline with low overhead, including idle gaps and CPU work. Then send the few kernels that dominate time to Nsight Compute.

In your RMSNorm kernel, why use other=0.0 and divide by N rather than BLOCK?

Masked lanes beyond the row length load as zero, so they add nothing to the sum of squares. Dividing by the true length N gives the correct mean even though the block is padded to a power of two.

Your fused kernel shows 4 TB/s on an H100 SXM. What happened?

That's above the 3.35 TB/s HBM bandwidth, so the data was probably served from L2 cache between repeats, or the byte count is wrong. Use larger inputs or flush the cache.

Resources