Profiling TileLang Programs with torch.profiler
When to reach for this skill
torch.profiler (the Kineto-based one in torch.profiler, not the legacy
torch.autograd.profiler) is the right tool when:
- ncu / nsys aren't installed (cluster without admin, containerized env).
- You want the full forward (or fwd+bwd) view, not a single kernel.
- You need a per-op breakdown that includes PyTorch ops and TileLang custom kernels in the same timeline.
- You're hunting CPU↔GPU overlap, kernel launch overhead, or which step in a model is slowest.
When this skill is not enough:
- Register-spill, bank-conflict, occupancy, warp-stall analysis → use the
profiling-tilelang-programsskill (ncu) instead.torch.profilershows what is slow; ncu shows why. - Single-kernel deep-dive at the SM/DRAM level → ncu.
This skill is the lightweight first pass; escalate to ncu when symptoms point to a hardware-level issue.
Honest scope: what torch.profiler can and cannot tell you
torch.profiler reports time (per op, per kernel, per launch) and memory
allocations. From that alone you can directly identify:
- Memory-bound kernels — achieved GB/s near peak HBM bandwidth.
- Launch-overhead-bound code —
cuLaunchKernelCPU time approaching the kernel's GPU time. - CPU-bound code paths — long CPU intervals with little or no concurrent GPU work.
- Which kernel dominates the timeline.
But for TileLang specifically, the profiler cannot, on its own, distinguish:
- Tensor-core vs CUDA-core use — TileLang names every kernel after its
prim_func, e.g.gemm_kernel. You won't seehmma,wgmma,cutlass_tensoropin the name. Inspect the generated CUDA source instead (kernel.get_kernel_source()). - Register spill, shared-memory bank conflicts, low occupancy — these show up only as "this kernel runs slowly compared to its theoretical peak". You need ncu to confirm the mechanism.
So the workflow is: measure with torch.profiler → roofline-classify → if the kernel underperforms, escalate to source inspection or ncu.
Quick start: profile a single TileLang kernel
A reusable template is bundled at scripts/profile_template.py. Copy it next
to your kernel script and adapt the marked section. The essentials are:
import torch
from torch.profiler import profile, ProfilerActivity, record_function
# Build + warm up the kernel so JIT compile cost doesn't leak into the trace.
kernel = my_tilelang_kernel(...)
for _ in range(10):
kernel(a, b)
torch.cuda.synchronize()
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
record_shapes=True,
) as prof:
for _ in range(20):
with record_function("tilelang_kernel"):
kernel(a, b)
with record_function("torch_ref"):
a @ b
torch.cuda.synchronize()
print(prof.key_averages().table(sort_by="self_cuda_time_total", row_limit=20))
prof.export_chrome_trace("trace.json")
Why each part matters:
warmup loop + cuda.synchronize()— TileLang JIT-compiles on first call (you'll seeTileLang begins to compile kernel ...in the log). Without warmup that compile time pollutes your first iteration. Aim for at least 10 warmup calls for autotuned kernels; the compile log line stops printing once it's done.record_function("...")— names a region so it appears as a single row in the table and as a labeled band in the Chrome trace. Wrap each thing you want to compare. Without it you only see the underlying CUDA kernel name.ProfilerActivity.CUDA— required to capture GPU events. Without it you only get CPU-side launches.record_shapes=True— keeps the input shapes so you can group bykey_averages(group_by_input_shape=True). Costs a little overhead; turn off in production-style benchmarking.- The
synchronize()after the loop ensures the last kernel's events make it into the trace before the context exits.
Scheduled profiling for longer runs
For training loops or multi-step models, replace the simple with profile(...) block with the scheduled form so you sample a stable window rather than including the cold first step:
from torch.profiler import schedule, tensorboard_trace_handler
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
schedule=schedule(skip_first=10, wait=1, warmup=1, active=3, repeat=1),
) as prof:
for step in range(N):
train_step(...)
prof.step()
Cycle: skip 10 → wait 1 → warmup 1 → record 3 → fire on_trace_ready → done. View later with tensorboard --logdir tb_log (requires pip install torch_tb_profiler). The scheduled API is preferred for long-running jobs because it bounds profiler overhead.
Reading the key_averages table
The table has many columns; for TileLang triage the load-bearing ones are:
| Column | What it means |
|---|---|
Self CUDA |
GPU time spent in this op excluding nested kernels — this is the one that ranks "real" cost |
CUDA total |
GPU time including nested calls (use for record_function ranges) |
Self CPU |
CPU time spent inside this op |
# of Calls |
how many times the event fired |
Sort by self_cuda_time_total to find the biggest GPU cost; sort by
self_cpu_time_total to find CPU bottlenecks (often dataloader, host-side
preprocessing, or chatty Python).
A TileLang kernel always shows up twice in the table — once as your
record_function name (the CPU-side range) and once as the underlying CUDA
kernel name (whatever the prim_func is, usually gemm_kernel, add_kernel,
etc.). They report the same Self CUDA value. Use the kernel name when you
need to identify the exact GPU work, and the record_function name when you
want grouping that you control.
cuLaunchKernel / cudaLaunchKernel rows show per-launch CPU dispatch
cost. Their total CPU time vs the total GPU time is the launch-overhead
diagnostic (see below).
Identifying the bottleneck: IO / CUDA core / Tensor core
There is no single metric in torch.profiler that says "tensor-core bound". You
classify by roofline analysis on the numbers you do measure, then confirm
with a glance at the generated CUDA source. See
references/bottleneck-classification.md for the full decision tree and
worked examples; the short version is:
Step 1 — compute achieved throughput
From the per-call GPU time (evt.self_device_time_total / evt.count, in
microseconds), derive:
tflops = (2 * M * N * K) / (us_per_call * 1e-6) / 1e12 # for GEMM-like
gb_per_s = total_bytes / (us_per_call * 1e-6) / 1e9 # for memory-bound
For GEMM-like kernels with non-trivial epilogues, also add the epilogue work
to the numerator (usually negligible). For memory-bound kernels,
total_bytes should count every distinct global-memory byte read or written
exactly once.
Step 2 — compare against hardware peak
Look up the device's peak using torch.cuda.get_device_properties(0) and the
GPU's datasheet. Roughly:
| Class | If achieved is near peak of... | Likely bound |
|---|---|---|
| HBM bandwidth | DRAM peak (e.g., ~1700 GB/s on RTX PRO 6000) | IO-bound (memory) |
| Tensor-core peak (fp16/bf16/fp8) | Tensor TFLOPS peak | Tensor-core bound |
| FP32 FMA peak (CUDA cores) | Scalar TFLOPS peak | CUDA-core bound |
| None of the above | far below every peak | Latency-bound (launch overhead, low occupancy, stalls — escalate to ncu) |
Step 3 — confirm with the generated CUDA source
kernel.get_kernel_source() returns the generated CUDA. Grep for:
Tensor cores in use — TileLang wraps MMA in a template header, so the raw
mma.sync/wgmmatokens often DO NOT appear in the top-level source even when the kernel is tensor-core based. The reliable indicators in TileLang-emitted CUDA are:#include "tl_templates/cuda/instruction/mma.h"(or similartl_templates/.../mma*.h) — TileLang's MMA template header is pulled inCUtensorMapdeclarations — TMA descriptors, the Hopper/Blackwell async tensor-core data pathmbarrier/cp.async.bulk.tensor— the producer/consumer barriers and TMA loads that flank a wgmma pipeline__hmma_*,wmma::mma_sync— older WMMA path (rare in newly generated TileLang)
If the C++ source doesn't show raw MMA tokens, dump the PTX (
cuobjdump --dump-ptx ...) or SASS to confirm at the instruction level. The TileLang cache typically lives under~/.tilelang/cache/— each compiled kernel has its.cu,.ptx, and.fatbinartifacts there.CUDA cores only — no
tl_templates/.../mma.hinclude, noCUtensorMap, just plain scalar+/*/fmaon fp/int. This is whatT.Parallelelementwise math compiles to.Possible register spill — search for
.localreferences in the PTX (cuobjdump --dump-ptx) orSTL/LDLin SASS (cuobjdump --dump-sass). Symptoms in torch.profiler: a kernel that gets nowhere near its theoretical peak despite a generous tile size. The top-level.cusource fromkernel.get_kernel_source()does NOT show this directly — you must drop to PTX or SASS.
Worked example (numbers from the experiment in references/bottleneck-classification.md)
On an RTX PRO 6000 Blackwell, fp16 4096³ GEMM:
| Kernel | us/call | Achieved | Classification |
|---|---|---|---|
| Good GEMM (block 128×128×64, T.gemm) | 376 | 366 TFLOPS | Tensor-core bound (~half of fp16 peak, headroom remains) |
| Spill GEMM (oversized 256×256×32 tile) | 3689 | 37 TFLOPS | Same kernel name, same tile API — but 10× slower; escalate to ncu / source inspection to confirm spill |
FMA-only GEMM (no T.gemm, scalar loops, 1024³) |
73 | 29 TFLOPS | CUDA-core bound — well below tensor-core peak, achieved peak matches scalar FMA |
| Memory-bound add (8192² fp16) | 261 | 1541 GB/s | IO-bound — ~90% of HBM peak |
| Tiny GEMM (128³, 200 launches) | 3.8 | n/a | Launch-overhead-bound — cuLaunchKernel CPU total ≈ kernel CPU total |
The first three all show as gemm_kernel in the trace. You cannot
distinguish them from the kernel name alone — only from achieved TFLOPS
relative to peak plus a peek at the generated source.
Diagnosing launch overhead
If cuLaunchKernel's total CPU time approaches the kernel's total GPU time,
the workload is launch-bound: the CPU side is the bottleneck, not the GPU.
cuLaunchKernel Self CPU: 862us across 200 calls (~4.3us/launch)
gemm_kernel Self CUDA: 760us across 200 calls (~3.8us/kernel)
When per-launch CPU cost rivals per-kernel GPU cost, every option below is worth trying:
- Increase the per-launch work (larger batch / tile / fused kernel) so the GPU side dominates again.
- Use CUDA graphs (or
do_bench(backend="cudagraph")for measurement). The TileLangprofiling-tilelang-programsskill covers the cudagraph backend. - Fuse adjacent kernels (epilogue fusion, kernel fusion across small ops).
Memory profiling
For OOM hunts and allocation-pattern questions, enable profile_memory and
export a timeline. The current path is via the (still working but deprecated)
export_memory_timeline:
with profile(
activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA],
profile_memory=True,
record_shapes=True,
) as prof:
forward_pass()
prof.export_memory_timeline("memory.html", device="cuda:0")
The HTML embeds a stacked-area plot of allocations by category over time.
Use .json.gz for raw data, .raw.json.gz for per-event allocator actions.
The PyTorch docs recommend the newer torch.cuda.memory._record_memory_history
/ _export_memory_snapshot pair for deeper allocator analysis:
torch.cuda.memory._record_memory_history(max_entries=100_000)
forward_pass()
torch.cuda.memory._export_memory_snapshot("snapshot.pickle")
torch.cuda.memory._record_memory_history(enabled=None)
Open snapshot.pickle at https://pytorch.org/memory_viz to see allocations,
frees, and stack traces. This is the right tool for "where did my N GB go".
Comparing TileLang vs a reference in one trace
Put both inside the same profile, each in its own record_function:
with profile(activities=[ProfilerActivity.CPU, ProfilerActivity.CUDA]) as prof:
for _ in range(20):
with record_function("tilelang_matmul"):
kernel(a, b)
with record_function("torch_matmul"):
a @ b
torch.cuda.synchronize()
The Chrome trace then shows both kernel bands side by side, and the
key_averages table reports per-call GPU time for each label, ready for a
TFLOPS-vs-TFLOPS comparison. This is the lightweight equivalent of running
two do_bench calls back-to-back, but with the bonus of seeing exactly which
underlying CUDA kernels each path produces (helpful when you suspect torch is
falling back to a non-tensor-core path).
Visualizing the trace
prof.export_chrome_trace("trace.json") writes a Chrome-format trace.
Two ways to view it:
- chrome://tracing — open Chrome, paste
chrome://tracingin the URL bar, click Load, pick the file. - Perfetto — https://ui.perfetto.dev/ → "Open trace file". Perfetto handles large traces better and has a nicer query UI.
What to look for in the timeline:
- Gaps between kernels with no concurrent CPU work → launch overhead or sync.
- CPU work between kernels but no GPU activity → CPU-bound preprocessing.
- Overlapping kernels on different streams → good multi-stream pipelining.
- A wide gap then a burst → likely a
cudaMallocor first-touch JIT compile slipping past your warmup; widen the warmup loop or freeze allocator behavior withtorch.cuda.memory.set_per_process_memory_fraction.
Common pitfalls
| Pitfall | Symptom | Fix |
|---|---|---|
| No warmup before the profile block | First iteration is 10× slower; JIT log lines inside the trace | Run ≥10 warmup calls + torch.cuda.synchronize() before entering the with profile(...) |
Forgot ProfilerActivity.CUDA |
Self CUDA column is all zeros |
Add ProfilerActivity.CUDA to activities |
Forgot cuda.synchronize() inside the block |
Last few kernels missing from the trace | Call torch.cuda.synchronize() right before exiting with profile(...) |
record_shapes=True left on for benchmarking |
Wall time and table inflated by 10–20% | Turn off when you care about absolute timing |
Reading Self CPU to judge GPU work |
"Why is my kernel only 0us of CPU?" | TileLang kernel = GPU work; look at Self CUDA |
| Using kernel name to infer tensor-core use | "It's called gemm_kernel so it must use tensor cores" | Always confirm with kernel.get_kernel_source() — TileLang names kernels after the prim_func, not after what they lower to |
Comparing tiny kernels with the event backend |
Numbers vary 30% run-to-run | Sort by median over ≥20 reps; for very small kernels use do_bench(backend="cudagraph") |
Profiling a freshly created @tilelang.jit kernel |
First call includes minutes of autotune | Either warm up first or autotune separately and cache before profiling |
Escalation
| Symptom from torch.profiler | Next skill / tool |
|---|---|
| Kernel is slow but achieved TFLOPS far below peak; cause unclear | profiling-tilelang-programs (ncu) — pipe util, warp stalls, register spill |
| Wrong output / NaNs | debugging-tilelang-programs |
| Want to actually fix the slowness (tile sizes, pipeline, fusion) | optimizing-tilelang-programs |
| Need fwd+bwd timeline together | This skill, scheduled mode + tensorboard_trace_handler |
| Allocator fragmentation / OOM | _record_memory_history + https://pytorch.org/memory_viz |
For the detailed roofline-classification decision tree and source-inspection
checklists, read references/bottleneck-classification.md. For deeper
guidance on Chrome trace / Perfetto navigation, read
references/trace-reading-guide.md.