Production Systems

Profile with Nsight Systems

Nsight Systems shows you the timeline of every CUDA kernel, memory transfer, and CPU call in your inference pipeline. I used it to find the gaps between kernels where the GPU sits idle.

Yesterday I used Locust to find the saturation point of an inference endpoint. That tells you when performance degrades, but not why. To understand why, you need to look inside the GPU execution timeline. NVIDIA Nsight Systems is the tool for this.

Nsight Systems is a system-wide profiler that captures a timeline of CPU threads, CUDA API calls, GPU kernel executions, memory copies, and NCCL communication. Unlike torch.profiler (which instruments PyTorch operations), Nsight Systems works at the CUDA driver level, so it captures everything, including activity from libraries like cuBLAS, FlashAttention, and NCCL that PyTorch's profiler may aggregate or miss.

Capturing a profile

The simplest way to profile an inference server is to use nsys profile to wrap the server process and trigger a few requests while it runs:

# Profile the vLLM server process
nsys profile \
  --trace=cuda,nvtx,osrt,cudnn,cublas \
  --output=vllm_profile \
  --duration=30 \
  --delay=10 \
  python -m vllm.entrypoints.openai.api_server \
    --model meta-llama/Llama-2-13b-hf \
    --dtype float16

The flags:

While the profile runs, I sent a batch of requests from another terminal:

# Send requests during the profiling window
for i in $(seq 1 20); do
  curl -s http://localhost:8000/v1/completions \
    -H "Content-Type: application/json" \
    -d '{"model":"meta-llama/Llama-2-13b-hf","prompt":"Explain transformers","max_tokens":64}' &
done
wait

This produces a .nsys-rep file that you open in the Nsight Systems GUI or analyze with nsys stats.

Reading the timeline

The timeline view shows horizontal lanes for each CPU thread and CUDA stream. The key things to look for:

What I found

Profiling Llama 2 13B on vLLM revealed a clear pattern in the decode phase. Each decode step consists of:

  1. A burst of GEMM kernels (the linear layers in each transformer block): about 60% of wall time.
  2. FlashAttention kernels (one per layer): about 15% of wall time.
  3. Small element-wise kernels (LayerNorm, SiLU, RoPE): about 5%.
  4. Gaps between kernel launches: about 15% of wall time.
  5. NCCL all-reduce (if TP > 1): about 5% for TP=2.

That 15% in launch gaps is the interesting finding. The CPU scheduler in vLLM needs to decide which requests to batch, allocate KV cache blocks, and prepare the metadata for each step. This scheduling work happens on the CPU between GPU kernel launches, and the GPU sits idle during it.

CUDA graphs eliminate launch gaps

vLLM supports CUDA graph capture for decode steps. Once captured, the entire sequence of kernels for a decode step is replayed as a single graph launch, eliminating the CPU overhead between individual kernel launches. In my profile, enabling CUDA graphs reduced the gap time from 15% to under 3%, which translated to a 12% improvement in decode throughput.

Using nsys stats for quick analysis

If you do not have the GUI available (for example, profiling on a remote server), nsys stats gives you a command-line summary:

# Summary of CUDA kernels by time
nsys stats --report cuda_gpu_kern_sum vllm_profile.nsys-rep

# Top 10 kernels by total time
nsys stats --report cuda_gpu_kern_sum \
  --format csv vllm_profile.nsys-rep | \
  sort -t, -k2 -rn | head -10

A typical output shows the cuBLAS GEMM kernels at the top, followed by FlashAttention:

# Kernel Name                              Total Time (ms)  Count
# ampere_fp16_s16816gemm_fp16_...          142.3            960
# void flash_fwd_kernel<...>                38.7            480
# void layernorm_kernel<...>                 8.2            960
# void silu_and_mul_kernel<...>              4.1            480

The counts make sense: a 13B model has 40 transformer layers, and a decode step of 12 tokens in a batch executes each layer once. The GEMM count (960) is 40 layers times 3 linear projections (Q, K, V) per attention block times ~8 from the MLP layers, and the attention kernel count (480) is 40 layers times 12 batch entries.

Nsight Systems vs. torch.profiler

Both tools have their place:

I typically start with torch.profiler to identify which operations are slow, then switch to Nsight Systems when I need to understand why a particular operation is slow (is the kernel itself slow, or is the GPU idle between kernel launches?).

Practical tips

The profile never lies. When you are guessing about performance, you are wrong about half the time. When you are profiling, you are learning every time.

Next: building an SSE streaming client to consume tokens as they are generated, rather than waiting for the full response.