Introduction to Profiling LLM Inference
Large Language Models (LLMs) have transformed how developers build applications, but their inference workloads are notoriously resource-intensive. Whether you are serving a 7B parameter model or a 70B one, understanding where time is spent during inference is critical for delivering responsive user experiences and controlling infrastructure costs. PyTorch Profiler is one of the most powerful tools available for diagnosing these performance issues at a granular level.
This tutorial walks you through what PyTorch Profiler is, why it matters for LLM workloads specifically, how to integrate it into your inference pipeline, and the best practices that will help you extract actionable insights without drowning in noise.
What Is PyTorch Profiler?
PyTorch Profiler is a built-in instrumentation tool that collects performance metrics during model execution. It captures CPU operations, CUDA kernel launches, memory allocations, and synchronization events, then presents them through a rich set of views including trace timelines, operator summaries, and memory profiles. The results can be exported to a Chrome Trace JSON file and visualized in chrome://tracing or in the PyTorch Profiler TensorBoard plugin.
For LLM inference, the profiler is particularly valuable because generation is an iterative process. Each token produced involves a forward pass through the transformer, and small inefficiencies compound across hundreds of decoding steps. A 5 millisecond overhead per token becomes a 5 second delay on a 1000-token response.
Why Profiling LLM Inference Matters
LLM inference has several characteristics that make profiling essential:
- Autoregressive decoding: Each token depends on the previous one, so you cannot parallelize across the sequence. Latency is dominated by per-step overhead, not just raw FLOPs.
- KV cache management: The key-value cache grows with sequence length. Memory bandwidth and allocation patterns can become the dominant bottleneck rather than compute.
- Host-device synchronization: Tokenization, sampling, and detokenization often happen on CPU. Frequent sync points between CPU and GPU can stall the pipeline.
- Batching dynamics: Dynamic batching and continuous batching introduce variability. Profiling helps you understand how batching affects throughput.
- Cost sensitivity: Inference is typically the largest ongoing cost in LLM deployments. Even modest optimizations translate directly to savings.
Without profiling, developers often guess at bottlenecks and waste effort optimizing the wrong layer. The profiler replaces guesswork with evidence.
Setting Up Your Environment
Before diving into profiling, make sure you have the necessary dependencies. You will need PyTorch with CUDA support, the profiler utilities, and optionally TensorBoard for visualization.
pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/cu121
pip install tensorboard torch-tb-profiler transformers accelerate
Verify that CUDA is available and that your GPU is recognized:
import torch
print(f"CUDA available: {torch.cuda.is_available()}")
print(f"Device: {torch.cuda.get_device_name(0)}")
print(f"PyTorch version: {torch.__version__}")
A Baseline LLM Inference Loop
Let us start with a simple inference loop using a Hugging Face model. This will be the code we instrument with the profiler. We use a small model here for demonstration, but the same approach scales to larger models.
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
model_id = "meta-llama/Llama-3.2-1B-Instruct"
tokenizer = AutoTokenizer.from_pretrained(model_id)
model = AutoModelForCausalLM.from_pretrained(
model_id,
torch_dtype=torch.float16,
device_map="cuda",
)
model.eval()
def generate_text(prompt, max_new_tokens=128):
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
with torch.inference_mode():
output_ids = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False,
pad_token_id=tokenizer.eos_token_id,
)
return tokenizer.decode(output_ids[0], skip_special_tokens=True)
# Warm-up run (important before profiling)
_ = generate_text("Hello, how are you?", max_new_tokens=10)
The warm-up run is essential. The first inference call triggers CUDA kernel compilation, memory allocation, and caching that would otherwise pollute your profiling data. Always discard the first run.
Wrapping Inference with PyTorch Profiler
Now we wrap the generation call with the profiler. The key configuration options determine what gets recorded and how much overhead is introduced.
from torch.profiler import profile, ProfilerActivity, schedule
def profiled_generate(prompt, max_new_tokens=128):
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
with profile(
activities=[
ProfilerActivity.CPU,
ProfilerActivity.CUDA,
],
schedule=schedule(
wait=1, # skip first step (warm-up within profile)
warmup=1, # discard this step's data
active=3, # record these steps
repeat=1, # do one cycle
),
on_trace_ready=torch.profiler.tensorboard_trace_handler("./logs/llm_profile"),
record_shapes=True,
profile_memory=True,
with_stack=True,
) as prof:
with torch.inference_mode():
output_ids = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False,
pad_token_id=tokenizer.eos_token_id,
)
return tokenizer.decode(output_ids[0], skip_special_tokens=True)
result = profiled_generate("Explain quantum computing in simple terms.", max_new_tokens=64)
print(result)
Let us break down the configuration:
- ProfilerActivity.CPU and CUDA: Record both CPU-side Python/operator calls and GPU-side kernel executions. You need both to understand host-device interactions.
- schedule: The
wait/warmup/activepattern ensures you skip noisy initialization steps and only record steady-state generation. For LLMs, each decoding step is a separate profiler step. - record_shapes: Captures tensor shapes, which is crucial for understanding how the KV cache grows and where attention computation scales.
- profile_memory: Records allocation and deallocation events. Essential for diagnosing memory pressure during long sequences.
- with_stack: Captures Python stack traces. Adds overhead but makes it much easier to locate the source of expensive operations.
Reading the Profiler Output
After running the profiled generation, you can inspect results programmatically or through TensorBoard. Let us look at the programmatic summaries first.
Operator-Level Summary
print(prof.key_averages().table(
sort_by="cuda_time_total",
row_limit=20,
))
This prints a table of the top 20 operators ranked by total CUDA time. For LLM inference, you will typically see operations like aten::mm, aten::scaled_dot_product_attention, aten::index, and various element-wise kernels. Pay attention to:
- Self CUDA time vs. total time: Self time excludes time spent in child operators. High self time points to the actual expensive kernel.
- Call count: An operator called thousands of times per generation may indicate excessive kernel launch overhead.
- Input shapes: With
record_shapes=True, you can group by shape to see how different sequence lengths affect cost.
Grouping by Shape
print(prof.key_averages(group_by_input_shape=True).table(
sort_by="cuda_time_total",
row_limit=20,
))
This is particularly useful for LLMs because the same operator runs with different shapes during prefill (processing the full prompt at once) versus decoding (processing one token at a time with a growing KV cache).
Memory Profile
print(prof.key_averages().table(
sort_by="self_cuda_memory_usage",
row_limit=15,
))
This reveals which operators allocate the most GPU memory. In LLM inference, attention layers and the KV cache are usually the top consumers. If you see unexpected allocations, it may indicate that your KV cache is not being reused efficiently or that you are recomputing something that could be cached.
Exporting and Visualizing Traces
The TensorBoard trace handler we configured automatically writes trace files to the ./logs/llm_profile directory. To view them, launch TensorBoard:
tensorboard --logdir=./logs/llm_profile --port=6006
Open http://localhost:6006 in your browser and navigate to the PyTorch Profiler plugin. You will see several views:
- Overview: High-level summary including GPU utilization, step time breakdown, and top operators.
- Operator view: Detailed table of every operator with CPU and CUDA time, memory, and call counts.
- Trace view: A timeline showing exactly when each CPU operation was enqueued and when each CUDA kernel executed. This is where you spot gaps between kernel launches and executions.
- Memory view: A timeline of allocations and deallocations, useful for finding memory leaks or fragmentation.
- Module view: Hierarchical breakdown by PyTorch module, letting you see which transformer layers are most expensive.
You can also export a Chrome Trace JSON manually for quick inspection without TensorBoard:
prof.export_chrome_trace("./llm_trace.json")
Then open chrome://tracing in Chrome and load the file. The timeline view lets you zoom into individual decoding steps and see the exact sequence of CPU calls and GPU kernels.
Profiling the Prefill vs. Decode Phases Separately
LLM inference has two distinct phases with very different performance characteristics. The prefill phase processes the entire prompt in parallel and is compute-bound. The decode phase generates one token at a time and is memory-bandwidth-bound. Profiling them together can obscure the real bottleneck.
To profile them separately, you can run the model's forward pass manually instead of using model.generate:
def profile_prefill_and_decode(prompt, max_new_tokens=64):
input_ids = tokenizer(prompt, return_tensors="pt").input_ids.to("cuda")
# --- Profile prefill phase ---
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True,
profile_memory=True,
) as prefill_prof:
with torch.inference_mode():
outputs = model(input_ids)
past_key_values = outputs.past_key_values
next_token = torch.argmax(outputs.logits[:, -1, :], dim=-1, keepdim=True)
print("=== PREFILL PHASE ===")
print(prefill_prof.key_averages().table(sort_by="cuda_time_total", row_limit=15))
# --- Profile decode phase ---
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True,
profile_memory=True,
) as decode_prof:
with torch.inference_mode():
for _ in range(max_new_tokens):
outputs = model(
next_token,
past_key_values=past_key_values,
use_cache=True,
)
past_key_values = outputs.past_key_values
next_token = torch.argmax(outputs.logits[:, -1, :], dim=-1, keepdim=True)
print("=== DECODE PHASE ===")
print(decode_prof.key_averages().table(sort_by="cuda_time_total", row_limit=15))
prefill_prof.export_chrome_trace("./prefill_trace.json")
decode_prof.export_chrome_trace("./decode_trace.json")
profile_prefill_and_decode("Write a short poem about the ocean.", max_new_tokens=64)
This separation makes it immediately clear whether your bottleneck is in the initial prompt processing or in the token-by-token generation loop. In most production deployments, the decode phase dominates total latency, so that is usually where optimization effort should focus.
Identifying Common LLM Inference Bottlenecks
Once you have profiler output in hand, here are the patterns to look for and what they typically indicate:
1. Kernel Launch Overhead
If you see many small CUDA kernels with very short execution times but significant gaps between them on the trace timeline, you are likely CPU-bound on kernel launches. This is common in decode phase where each step involves many small operations. Solutions include using CUDA graphs, fused kernels, or compiled models with torch.compile.
2. Host-Device Synchronization
Look for cudaDeviceSynchronize or cudaStreamSynchronize events in the trace. These indicate the CPU is waiting for the GPU. In LLM inference, this often happens during sampling (when logits are moved to CPU for multinomial sampling) or when logging metrics. Minimize sync points by keeping operations on GPU and batching metric collection.
3. Memory Bandwidth Saturation
During decode, the model weights and KV cache must be loaded from GPU memory for every token. If CUDA time is dominated by memory-bound kernels (like aten::mm with low arithmetic intensity), you are memory-bandwidth-bound. Solutions include quantization (int8 or int4), weight tying, or using models with fewer parameters.
4. Attention Computation Scaling
Use the shape-grouped view to check how attention cost scales with sequence length. If you see quadratic growth, consider switching to FlashAttention or a variant that supports sliding window attention. The profiler will show aten::scaled_dot_product_attention and you can verify whether the FlashAttention kernel is being selected.
5. KV Cache Memory Pressure
In the memory view, watch for steadily growing allocations during decode. If memory usage climbs faster than expected, you may have a KV cache implementation that is reallocating rather than pre-allocating. Pre-allocating the cache with the maximum sequence length avoids fragmentation and allocation overhead.
Using torch.compile with Profiling
torch.compile can dramatically reduce kernel launch overhead and fuse operations. You should profile both before and after applying it to measure the actual improvement.
# Compile the model
compiled_model = torch.compile(model, mode="reduce-overhead")
def profile_compiled_generate(prompt, max_new_tokens=64):
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
# Warm up compiled model (first calls trigger compilation)
for _ in range(3):
with torch.inference_mode():
_ = compiled_model(**inputs)
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True,
) as prof:
with torch.inference_mode():
output_ids = compiled_model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False,
pad_token_id=tokenizer.eos_token_id,
)
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=15))
prof.export_chrome_trace("./compiled_trace.json")
return tokenizer.decode(output_ids[0], skip_special_tokens=True)
result = profile_compiled_generate("List three benefits of exercise.", max_new_tokens=64)
Compare the compiled trace to the eager trace. You should see fewer, larger kernels and reduced gaps between them. The reduce-overhead mode uses CUDA graphs under the hood, which is especially effective for the decode phase where kernel launch overhead dominates.
Best Practices for Profiling LLM Inference
- Always warm up before profiling. Run at least one full generation before starting the profiler. CUDA context initialization, cuDNN algorithm selection, and JIT compilation happen on the first call and will distort your results.
- Profile realistic workloads. Use prompts and token counts that match your production traffic. A 4-token prompt with 10 generated tokens will show a completely different profile than a 2000-token prompt with 500 generated tokens.
- Use the schedule to skip noisy steps. Even with a warm-up, the first few decoding steps inside the profiler can be noisy. Use
waitandwarmupparameters to skip them. - Keep profiling sessions short. Profiling adds overhead, especially with
profile_memory=Trueandwith_stack=True. Profile a small number of steps, not an entire long generation. - Compare apples to apples. When evaluating optimizations, profile the baseline and the optimized version with identical prompts, token counts, and batch sizes. Run each multiple times and check for variance.
- Profile at the batch size you serve. Single-request profiling will not reveal batching inefficiencies. If you use continuous batching, profile with a realistic number of concurrent requests.
- Disable profiling in production. Never ship profiling-enabled code to production. The overhead can be 2-10x depending on configuration. Use it as a development and staging tool only.
- Save traces for comparison. Export traces with descriptive filenames and keep them in version control or a shared location. Being able to compare a trace before and after a change is invaluable.
- Focus on the dominant phase. For most LLM serving scenarios, decode dominates total latency. Spend your optimization budget there first, then revisit prefill if needed.
- Correlate with system metrics. GPU utilization from the profiler should be cross-referenced with
nvidia-smior DCGM metrics. If the profiler shows low GPU utilization butnvidia-smishows high utilization, you may have other processes competing for the GPU.
Putting It All Together: A Reusable Profiling Utility
Here is a reusable utility function that encapsulates the best practices discussed above. You can drop this into your project and call it whenever you need to profile a generation.
import torch
from torch.profiler import profile, ProfilerActivity, schedule
from contextlib import contextmanager
import os
from datetime import datetime
@contextmanager
def llm_profiler(
trace_dir="./profiler_logs",
trace_name=None,
record_shapes=True,
profile_memory=True,
with_stack=False,
wait=1,
warmup=1,
active=5,
):
os.makedirs(trace_dir, exist_ok=True)
if trace_name is None:
trace_name = f"llm_trace_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
trace_path = os.path.join(trace_dir, trace_name)
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=schedule(wait=wait, warmup=warmup, active=active, repeat=1),
on_trace_ready=torch.profiler.tensorboard_trace_handler(trace_path),
record_shapes=record_shapes,
profile_memory=profile_memory,
with_stack=with_stack,
) as prof:
yield prof
# Print summary after the context exits
print("\n=== Top Operators by CUDA Time ===")
print(prof.key_averages().table(sort_by="cuda_time_total", row_limit=20))
if profile_memory:
print("\n=== Top Operators by Memory Usage ===")
print(prof.key_averages().table(sort_by="self_cuda_memory_usage", row_limit=10))
# Also export a Chrome trace
chrome_path = os.path.join(trace_dir, f"{trace_name}.json")
prof.export_chrome_trace(chrome_path)
print(f"\nChrome trace saved to: {chrome_path}")
print(f"TensorBoard logs saved to: {trace_path}")
# Usage example
def benchmark_generation(prompt, max_new_tokens=128):
inputs = tokenizer(prompt, return_tensors="pt").to("cuda")
# Warm up
with torch.inference_mode():
_ = model.generate(**inputs, max_new_tokens=5, pad_token_id=tokenizer.eos_token_id)
# Profile
with llm_profiler(trace_name="baseline_generation"):
with torch.inference_mode():
output_ids = model.generate(
**inputs,
max_new_tokens=max_new_tokens,
do_sample=False,
pad_token_id=tokenizer.eos_token_id,
)
return tokenizer.decode(output_ids[0], skip_special_tokens=True)
result = benchmark_generation("What are the main causes of climate change?", max_new_tokens=100)
print(result)
This utility handles directory creation, naming, warm-up separation, and output formatting. It prints both the time and memory summaries and exports traces for visualization. You can easily toggle with_stack on when you need deeper debugging.
Conclusion
Profiling LLM inference with PyTorch Profiler transforms optimization from guesswork into a data-driven process. By instrumenting your generation loop, separating prefill and decode phases, and learning to read the operator, memory, and trace views, you can pinpoint exactly where latency originates and direct your optimization effort where it matters most. Whether you are evaluating torch.compile, testing a quantization scheme, or tuning your batching strategy, the profiler gives you the before-and-after evidence you need to make confident decisions. Start with a warm-up run, profile a realistic workload, examine the trace timeline for gaps and sync points, and iterate. The bottlenecks you find will rarely be where you expected them, and that is precisely why profiling is indispensable for anyone serious about efficient LLM inference.