A modern NVIDIA GPU is a throughput-optimized machine, not a latency-optimized one. The key abstraction is the Streaming Multiprocessor (SM) — the unit that executes groups of threads. An H100 has 132 SMs, an A100 has 108 SMs, and a B200 (Blackwell) has roughly 160 SMs across two reticle-limited dies connected by a high-speed NVLink bridge.
Threads execute in groups of 32 called warps. All 32 threads in a warp run the same instruction on different data (SIMT). Divergent branches within a warp serialize both paths.
Tensor Cores are the secret sauce. An H100 tensor core can do one 16×16×16 matrix multiply per clock per sub-core.
| Metric | A100 SXM | H100 SXM | B200 SXM |
|---|---|---|---|
| FP16 Tensor TFLOPS | 312 | 989 | 1,125 (est.) |
| FP8 Tensor TFLOPS | — | 1,979 | 4,500 (FP4: 9,000) |
| HBM Bandwidth | 2.0 TB/s (HBM2e) | 3.35 TB/s (HBM3) | 8.0 TB/s (HBM3e) |
| L2 Cache | 40 MB | 50 MB | 60 MB |
| SMs | 108 | 132 | ~160 |
| TDP | 400 W | 700 W | 700 W (dual-die) |
| Transistors | 54 B | 80 B | 208 B |
| NVLink | 600 GB/s | 900 GB/s | 1,800 GB/s |
The roofline bottleneck for all three: dense matrix ops are compute-bound on tensor cores; memory-bound ops (element-wise, softmax) are HBM-limited. Arithmetic intensity (FLOP/byte) must increase to keep tensor cores fed.
Register File ~64K registers/SM ~0.5 cycles
↓
Shared Memory (SMEM) 48-228 KB/SM ~20 cycles
↓
L2 Cache 40-60 MB/chip ~200 cycles
↓
Global Memory (HBM) 40-80 GB/chip ~600-800 cycles
Coalescing rule: 32 threads in a warp accessing contiguous, aligned bytes → one 128-byte transaction. Strided or random → many transactions → bandwidth collapse.
Bank conflicts: SMEM is 32 banks (4-byte wide on H100). Multiple threads → same bank → serialized. Stride-2 causes 2-way conflicts; power-of-two strides are dangerous.
__shfl_down_sync.BM×BN tiles in SMEM. Tile 128×128 with BK=64 → 64 KB SMEM. Coalesced 128-byte global loads per warp.Attention math (head, seq N, dim d):
At N=4096, d=128: QK^T intensity = 1024 FLOP/byte. H100 balance point (989 TFLOPS / 3.35 TB/s) = 295 FLOP/byte. Compute-bound. At N=512: 128 FLOP/byte → memory-bound. This is why FlashAttention matters most at medium-long sequences.
long_scoreboard — waiting on global load (memory-bound).short_scoreboard — waiting on SMEM/register.not_selected — other warps ready; low occupancy + this = need more warps.throttle — dependency on previous instruction (tight reduction).barrier — __syncthreads() overuse.math_pipe — compute-bound.Flow: ncu --set full -o profile python train.py → Speed-of-Light tab gives single-page diagnosis.
// Tiled GEMM using shared memory (from PMPP)
__global__ void tiled_gemm(float *A, float *B, float *C,
int M, int N, int K) {
__shared__ float As[128][16];
__shared__ float Bs[16][16];
int row = blockIdx.y * 128 + threadIdx.y;
int col = blockIdx.x * 16 + threadIdx.x;
float sum = 0.0f;
for (int t = 0; t < K / 16; ++t) {
As[threadIdx.y][threadIdx.x] = A[row * K + t * 16 + threadIdx.x];
Bs[threadIdx.y][threadIdx.x] = B[(t * 16 + threadIdx.y) * N + col];
__syncthreads();
for (int k = 0; k < 16; ++k)
sum += As[threadIdx.y][k] * Bs[k][threadIdx.x];
__syncthreads();
}
C[row * N + col] = sum;
}
Standard attention materializes N×N to HBM — O(N²) memory. FlashAttention eliminates it via tiling + online softmax.
v1 (Dao 2022): Split Q, K, V into SMEM tiles. Per Q tile, load K/V tiles sequentially. Maintain running m_i (max) and ℓ_i (sum):
- m_new = max(m_old, rowmax(S_ij))
- ℓ_new = e^(m_old - m_new) × ℓ_old + sum(e^(S_ij - m_new))
- Rescale output continuously. No O(N²) HBM writes.
v2 (Dao 2023): Parallelize across seq dim, not just heads. Block-level softmax removes __shfl_sync overhead. ~2× faster than v1.
v3 (Shah 2024, Hopper): Uses WGMMA async warp-group MMA + cp.async shared memory copies:
async_load(K_tile_0, SMEM)
async_load(V_tile_0, SMEM)
sync_wait(read_all)
WGMMA(Q_0, K_0 → S_0) // async
wgmma_fence()
async_load(K_tile_1, SMEM) // overlap load with compute
FP8 halves memory traffic; TE handles casting.
| Feature | FA v1 | FA v2 | FA v3 |
|---|---|---|---|
| Arch | Ampere | Ampere+ | Hopper |
| Softmax level | warp | block | WGMMA-pipelined |
| HBM reads/head | 2× | ~1× | ~0.5× |
| Non-matmul overhead | High | Medium | Low |
| Seq parallelism | head only | head+segment | head+segment+warp-group |
| FP8 | No | Partial | Native |
| Speed (N=8192, d=128) | 1.0× | 1.7× | 2.5-3× |
def flash_attention_v1(Q, K, V, Bc=128, Br=64):
N, d = Q.shape
O = np.zeros_like(Q)
for i in range(0, N, Br):
Qi = Q[i:i+Br]
m_i, ell_i, Oi = -inf*ones(Br), zeros(Br), zeros((Br, d))
for j in range(0, N, Bc):
Kj, Vj = K[j:j+Bc], V[j:j+Bc]
Sij = Qi @ Kj.T
m_new = max(m_i, rowmax(Sij))
Pij = exp(Sij - m_new[:, None])
alpha = exp(m_i - m_new)
ell_i = alpha * ell_i + rowsum(Pij)
Oi = alpha * Oi + Pij @ Vj
m_i = m_new
O[i:i+Br] = Oi / ell_i[:, None]
return O
Problem: 70B GQA (8 KV heads), FP16, seq 4096 → ~16 MB per request. 100 requests × 2048 tok → 6.4 GB. Contiguous tensors fragment as requests finish at different times.
vLLM's solution: OS-style page table. Cache stored in fixed blocks (16 tokens). A block table maps logical request → physical blocks:
Logical: [tok0..tok15] [tok16..tok31]
↓ ↓
Blocks: phys_7 phys_42
Internal fragmentation ≤ 1 block per request (16 tokens = ~1%). Beats 30-50% waste of contiguous allocation.
Prefill: All KV cache for prompt in one shot. Big GEMM, M = batch × prompt_len. Compute-bound.
Decode: One token per request per step. Loads full KV cache, one vector @ weight matrix. Intensity ≈ 1 FLOP/byte. Memory-bound, GPU utilization <5%.
Never mix prefill+decode in one iteration — prefill's big tiles starve decode's quick ops. Split them across GPUs or fractions of the batch.
Speculative (Leviathan/Chen 2023): Cheap draft model generates γ tokens; target verifies in ONE forward pass. Accept token if target prob ≥ draft prob (or via speculative sampling ratio). Expected tokens/pass = (1 − α^(γ+1))/(1 − α). At α=0.8, γ=4: 3.0 tokens/pass = 3× wall-clock.
MTP (Gloeckle 2024): Train target with γ output heads, no external draft. Each head predicts one future position. 2-3× speedup on code, no draft overhead.
Real: γ=4 + 1/10 draft on 70B target → 2.5× throughput on H100. MTP on 7B (γ=3) → 2× with zero draft overhead.
SGLang's serving process is a three-tier pipeline connected by ZMQ IPC:
PUSH/PULL with backpressure. Zero-copy via send_multipart on the token buffer.The Python-native scheduler is the tradeoff: easier to hack (RadixAttention landed in weeks) but higher per-request overhead than vLLM's C++ scheduler. At low QPS this doesn't matter; at 10K+ QPS the Python GIL becomes the bottleneck.
Prefixes in production LLMs repeat: system prompts, few-shot examples, chat history. RadixAttention stores KV cache in a radix tree keyed by token IDs. Each edge = a run of tokens; each node = the KV blocks for that prefix.
Match: On new request, walk the tree from root, matching tokens edge-by-edge until divergence. All matched blocks are cache hits — skip prefill for them.
Insert: After forward pass, extend the matched leaf with the new tokens' KV blocks.
Evict: LRU on leaf nodes only (internal nodes have children still in use). Eviction returns blocks to the free pool.
Vs vLLM paged blocks: vLLM allocates blocks per-request with a hash lookup for shared prefixes. RadixAttention uses the tree structure itself as the index — cheaper matching, natural hierarchical eviction. RadixAttention wins on chat/agent workloads (long shared prefixes); vLLM's simpler block allocator is more predictable under uniform random workloads.
Constrained decoding masks logits to only valid tokens per an FSM.
-inf elsewhere.SGLang wires xgrammar directly into its sampler. The grammar object is pre-compiled at request time; the FSM state advances alongside the accepted token.
| Dimension | SGLang | vLLM |
|---|---|---|
| Prefix cache | RadixAttention (tree) | Paged blocks + hash |
| Scheduler | Python event loop | C++ core |
| Structured output | xgrammar GPU-parallel | outlines / xgrammar CPU |
| Best at | Long shared prefixes, agents | Uniform workloads, throughput |
| Extensibility | High (Python) | Medium (C++ boundary) |
Rule of thumb: agents, chat, RAG → SGLang; batch inference, uniform prompts → vLLM. Both wrap FlashInfer for the actual attention kernels.
FlashInfer separates plan (metadata setup) from forward (kernel launch). Two main wrappers:
from flashinfer import BatchPrefillWithPagedKVCacheWrapper, BatchDecodeWithPagedKVCacheWrapper
# Prefill
prefill = BatchPrefillWithPagedKVCacheWrapper(workspace_buffer, kv_layout="NHD")
prefill.plan(
qo_indptr, paged_kv_indptr, paged_kv_indices, paged_kv_last_page_len,
num_qo_heads, num_kv_heads, head_dim, page_size,
causal=True, sm_scale=1/math.sqrt(head_dim), q_data_type=torch.float16
)
out = prefill.run(q, paged_kv_cache)
# Decode
decode = BatchDecodeWithPagedKVCacheWrapper(workspace_buffer, kv_layout="NHD")
decode.plan(paged_kv_indptr, paged_kv_indices, paged_kv_last_page_len,
num_qo_heads, num_kv_heads, head_dim, page_size, q_data_type=torch.float16)
out = decode.run(q, paged_kv_cache)
The plan step is called once per batch shape; run is called per forward pass. This amortizes CUDA graph capture and heuristic dispatch.
Two-level attention when many requests share a prefix:
flashinfer.merge_state — log-sum-exp combine of the two partial outputs.Math: if S1 has (o1, lse1) and S2 has (o2, lse2), then merged output = o1 × sigmoid(lse1 - lse2) + o2 × sigmoid(lse2 - lse1) and merged lse = logaddexp(lse1, lse2). Correct because softmax is associative under log-sum-exp merge.
Win: 100 requests sharing a 4K-token system prompt → shared prefix compute is done once, not 100×. Effective throughput scales linearly with batch on the suffix, near-constant on the prefix.
Skeleton of the decode kernel (one head per program):
@triton.jit
def decode_kernel(Q, K_cache, V_cache, block_table, seq_lens, O,
BLOCK_SIZE: tl.constexpr, HEAD_DIM: tl.constexpr):
batch_idx = tl.program_id(0)
head_idx = tl.program_id(1)
# 1. Load Q for this batch/head
q = tl.load(Q + batch_idx * num_heads * HEAD_DIM + head_idx * HEAD_DIM
+ tl.arange(0, HEAD_DIM))
seq_len = tl.load(seq_lens + batch_idx)
m = -float('inf')
l = 0.0
o = tl.zeros([HEAD_DIM], dtype=tl.float32)
# 2. Iterate blocks via block_table indirection
for blk_idx in range(0, seq_len, BLOCK_SIZE):
phys_blk = tl.load(block_table + batch_idx * max_blocks + blk_idx // BLOCK_SIZE)
k_ptr = K_cache + phys_blk * BLOCK_SIZE * HEAD_DIM
v_ptr = V_cache + phys_blk * BLOCK_SIZE * HEAD_DIM
# Load K, V tile
k = tl.load(k_ptr + tl.arange(0, BLOCK_SIZE)[:, None] * HEAD_DIM
+ tl.arange(0, HEAD_DIM)[None, :])
v = tl.load(v_ptr + tl.arange(0, BLOCK_SIZE)[:, None] * HEAD_DIM
+ tl.arange(0, HEAD_DIM)[None, :])
# 3. Scores + online softmax
s = tl.sum(q[None, :] * k, axis=1) * sm_scale
m_new = tl.maximum(m, tl.max(s))
p = tl.exp(s - m_new)
alpha = tl.exp(m - m_new)
l = alpha * l + tl.sum(p)
o = alpha * o + tl.sum(p[:, None] * v, axis=0)
m = m_new
# 4. Final normalize + write out
o = o / l
tl.store(O + batch_idx * num_heads * HEAD_DIM + head_idx * HEAD_DIM
+ tl.arange(0, HEAD_DIM), o)
Reading tip: the four zones are (1) Q load, (2) K/V iteration via page table indirection, (3) online softmax + accumulate, (4) normalize + write.
| Format | Sign | Exp | Mantissa | Range | Precision | Use |
|---|---|---|---|---|---|---|
| E4M3 | 1 | 4 | 3 | ±448 | ~1e-3 | Weights, activations |
| E5M2 | 1 | 5 | 2 | ±57344 | ~5e-3 | Gradients (need wide range) |
| BF16 | 1 | 8 | 7 | ±3.4e38 | ~1e-2 | Fallback, master weights |
| FP32 | 1 | 8 | 23 | ±3.4e38 | ~1e-7 | Reference, optimizer state |
E4M3 has tighter range but more mantissa bits → higher precision within range. E5M2 matches BF16's range but with only 2 mantissa bits — accepts wider dynamic range for gradients where large magnitudes matter more than precision.
FP8 needs per-tensor scale factors to fit values into the narrow range. Two strategies:
Delayed scaling is the default in TE for training. Current scaling used for inference where distributions are stable.
| Kernel | Fuses |
|---|---|
Linear |
matmul + bias + optional GeLU + FP8 cast |
LayerNormLinear |
LayerNorm + matmul + bias (saves one HBM roundtrip) |
LayerNormMLP |
LN + up-proj + activation + down-proj (one giant fused op) |
The wins come from eliminating intermediate HBM writes. A standard MLP: LN write → up-proj read/write → GeLU read/write → down-proj read/write. Fused: one read, one write. On memory-bound decode, this is 2-3× throughput.
FP4 has only 4 bits total — insufficient dynamic range for a whole tensor. Block scaling groups elements and gives each block its own scale:
Blackwell's second-gen Transformer Engine hardware-accelerates block scaling; no software overhead.
| Aspect | Training | Inference |
|---|---|---|
| Scaling | Delayed + amax history | Static per-tensor |
| Gradient FP8 | Yes (E5M2) | N/A |
| Master weights | FP32 | FP8/FP16 |
| Amax collection | Every step | Calibration only |
import transformer_engine.pytorch as te
from transformer_engine.common.recipe import Format, DelayedScaling
# Delayed scaling recipe (training)
fp8_recipe = DelayedScaling(
margin=0,
fp8_format=Format.HYBRID, # E4M3 fwd, E5M2 bwd
amax_history_len=1024,
amax_compute_algo="max",
)
model = te.LayerNormMLP(hidden_size=4096, ffn_hidden_size=16384)
with te.fp8_autocast(enabled=True, fp8_recipe=fp8_recipe):
output = model(input_tensor)
loss = criterion(output, target)
loss.backward() # gradients auto-cast to E5M2
Legacy cuDNN was op-based: one call per operation, opaque descriptors. The graph API (v9+) is declarative:
Same graph can be replayed on new tensors without rebuild. Enables cuDNN's kernel fusion engine to see the whole pattern (matmul + bias + softmax + matmul) and emit one fused kernel.
cuDNN wins over FlashInfer when you need arbitrary attention masks (causal + sliding window, block-diagonal, custom bias):
#include <cudnn_frontend.h>
namespace fe = cudnn_frontend;
auto graph = std::make_shared<fe::graph::Graph>();
graph->set_io_data_type(fe::DataType_t::HALF)
.set_compute_data_type(fe::DataType_t::FLOAT);
auto Q = graph->tensor(fe::graph::Tensor_attributes().set_name("Q")
.set_dim({B, H, S, D}).set_stride({H*S*D, S*D, D, 1}));
auto K = graph->tensor(fe::graph::Tensor_attributes().set_name("K")
.set_dim({B, H, S, D}).set_stride({H*S*D, S*D, D, 1}));
auto V = graph->tensor(fe::graph::Tensor_attributes().set_name("V")
.set_dim({B, H, S, D}).set_stride({H*S*D, S*D, D, 1}));
auto sdpa_attrs = fe::graph::SDPA_attributes()
.set_is_inference(false)
.set_causal_mask(true)
.set_sliding_window_length(4096) // combine causal + sliding
.set_attn_scale(1.0f / std::sqrt(D));
auto [O, stats] = graph->sdpa(Q, K, V, sdpa_attrs);
O->set_output(true).set_dim({B, H, S, D});
graph->validate();
graph->build_operation_graph(handle);
graph->create_execution_plans({fe::HeurMode_t::A});
graph->build_plans(handle);
FlashInfer wins on straightforward causal attention with paged KV. cuDNN wins when you need mask combinations FlashInfer doesn't ship, or when you want kernel fusion across attention + LayerNorm boundaries.
PPO (Schulman 2017) — the workhorse. Clipped surrogate + value + entropy:
L_PPO = E[ min(r_t(θ) A_t, clip(r_t(θ), 1-ε, 1+ε) A_t) ]
- c1 · E[(V_θ(s_t) - V_target)²]
+ c2 · E[H(π_θ(·|s_t))]
- β · KL(π_θ || π_ref)
where r_t(θ) = π_θ(a_t|s_t) / π_old(a_t|s_t)
Needs a critic network (value model) — usually a copy of the LM with a scalar head. Doubles training memory.
GRPO (DeepSeek-Math 2024) — group-relative, no value model:
A_i = (r_i - mean(r_1..r_G)) / std(r_1..r_G)
L_GRPO = E[ min(π_θ/π_old · A_i, clip(...) · A_i) ] - β · KL(π_θ || π_ref)
For each prompt, sample G rollouts (typically 8-16). Advantage = z-score of the group's rewards. No critic. Cuts training memory ~40% and removes a whole training loop.
DPO (Rafailov 2023) — offline, no rollouts, no reward model:
L_DPO = -E[(x,yw,yl)~D] log σ( β · log(π_θ(yw|x)/π_ref(yw|x))
- β · log(π_θ(yl|x)/π_ref(yl|x)) )
Uses paired (winner, loser) preference data directly. Implicit reward = log-ratio. Cheap but ceiling is lower than online RL.
RLOO (Ahmadian 2024) — leave-one-out baseline:
A_i = r_i - (1/(G-1)) · sum(r_j for j != i)
Like GRPO but subtracts the mean of the other rollouts. Unbiased. Simpler than GRPO (no std normalization), competitive on many tasks.
Why GRPO won for reasoning: No critic to train/pretrain/tune. Group mean as baseline is high-variance-reducing when rollouts are diverse. Cheaper wall-clock than PPO. DeepSeek-R1 proved it scales to frontier reasoning.
slime = SGLang rollout engine + Megatron trainer, disaggregated.
Why disaggregation matters: - Rollout = autoregressive decode = memory-bound. Wants many small GPUs or high-bandwidth chips. - Training = big matmul on gradients = compute-bound. Wants fewest, fattest GPUs. - Co-locating wastes both. Disaggregating lets each side scale independently.
Placement: e.g. 32 GPUs for rollout (SGLang serving), 64 GPUs for training (Megatron TP+PP). They talk over NCCL.
Weight sync: After a training step, the new weights are broadcast rollout-side via NCCL broadcast. slime uses tensor-parallel-aware sharding: each rollout worker gets its own shard directly, no gather-then-scatter. Sync cost ~5-15s per iteration for a 7B model on 100G Ethernet.
Rollout loop: 1. Sampler on train side picks prompts. 2. Send to SGLang rollout cluster. 3. SGLang generates G rollouts per prompt (with RadixAttention for shared prefix if any). 4. Rollouts + logprobs return to trainer. 5. Reward model scores; GRPO/PPO loss computed. 6. Megatron backward + optimizer step. 7. Broadcast new weights to rollout.
Miles is a slime fork with enterprise deltas:
torchrun; Miles adds resource requests, node selectors, gang scheduling.Miles ≠ new algorithm; it's slime made production-safe.
| Framework | Trainer | Rollout | Parallelism | Wins at |
|---|---|---|---|---|
| TRL | HF Accelerate | HF .generate() |
DDP, FSDP-1 | Research, ≤7B, single-node |
| OpenRLHF | DeepSpeed + Ray | vLLM | ZeRO-3, Ray placement | First to scale 70B+ |
| verl | FSDP or Megatron | vLLM or SGLang | HybridFlow (flexible) | Most flexible placement |
| slime/Miles | Megatron | SGLang | TP+PP+DP+CP | Prod perf, disaggregated |
All four eventually call SGLang or vLLM for rollout. The real differentiator is the trainer stack + orchestration layer.
HybridFlow (verl): Single controller programs multi-controller execution. You write rollout(), reward(), train() as normal Python functions; verl schedules them across arbitrary GPU placements (co-located, disaggregated, mixed). Most flexible but hardest to debug.
Fine-tune Qwen2.5-7B on NuminaMath-CoT with Miles + GRPO on 1× H100, serve with SGLang + FlashInfer + FP8 TE, profile with Nsight Compute. Budget: ~$11.
# Via RunPod CLI (see runpod skill)
runpodctl create pod \
--name miles-grpo \
--gpuType "NVIDIA H100 80GB HBM3" \
--imageName nvcr.io/nvidia/pytorch:24.10-py3 \
--containerDiskInGb 100 \
--volumeInGb 200 \
--ports "22/tcp,8000/http"
# ssh in
Spot pricing ~$2.50-3/hr. Expect ~3 hr training = ~$9.
git clone https://github.com/radixark/miles && cd miles
pip install -e ".[all]"
pip install "sglang[all]>=0.4" flashinfer-python
pip install transformer-engine[pytorch]
Verify:
python -c "import sglang, flashinfer, transformer_engine.pytorch as te; print('ok')"
python -c "
from datasets import load_dataset
ds = load_dataset('AI-MO/NuminaMath-CoT', split='train').select(range(20000))
ds.to_parquet('numina_20k.parquet')
"
20K prompts is enough for GRPO convergence on math in 3 hours.
torchrun --nproc_per_node=1 miles/train_grpo.py \
--model Qwen/Qwen2.5-7B \
--dataset numina_20k.parquet \
--rollout_engine sglang \
--rollouts_per_prompt 8 \
--learning_rate 1e-6 \
--kl_coef 0.001 \
--max_new_tokens 1024 \
--batch_size 4 \
--gradient_accumulation 8 \
--max_steps 500 \
--save_dir ./ckpt/qwen-grpo-math \
--reward_fn math_verify \
--log_prometheus_port 9090
Expected timeline (1× H100): - Rollout: ~40s/step (8 rollouts × 4 prompts × 1024 tok) - Train: ~4s/step - ~45s/step × 240 steps ≈ 3 hr
Watch reward mean climb from ~0.15 → ~0.55 on math_verify.
# TE FP8 calibration
python miles/quantize_fp8.py \
--model ./ckpt/qwen-grpo-math \
--calib_dataset numina_20k.parquet \
--calib_samples 512 \
--out ./ckpt/qwen-grpo-math-fp8
# Launch SGLang w/ FlashInfer + FP8
python -m sglang.launch_server \
--model-path ./ckpt/qwen-grpo-math-fp8 \
--quantization fp8 \
--attention-backend flashinfer \
--port 8000 \
--enable-radix-cache
python -m sglang.bench_serving \
--backend sglang \
--host 127.0.0.1 --port 8000 \
--num-prompts 500 --request-rate 8 \
--dataset-name random --random-input 512 --random-output 512
Expected on H100: - batch=1 decode: ~140 tok/s - batch=16 decode: ~1200 tok/s - Prefill (512 tok): ~15 ms - E2E (in=512, out=512) @ batch=16: ~450 ms/req
# Single-request profile
ncu --set full --target-processes all \
-o qwen_decode.ncu-rep \
python -c "
import sglang
llm = sglang.Runtime(model_path='./ckpt/qwen-grpo-math-fp8',
quantization='fp8', attention_backend='flashinfer')
print(llm.generate('Solve: 2x+3=7', {'max_new_tokens': 64}))
"
# Open in Nsight Compute GUI
Check:
- Attention kernel: Achieved Occupancy > 40%, Mem Busy > 85% (decode is memory-bound → this is expected).
- MLP FP8 kernel: Tensor Core Utilization > 70%.
- Warp stalls dominated by long_scoreboard on decode (waiting on KV load from HBM) — normal.
If tensor cores underutilized on prefill, check FP8 recipe applied. If memory bandwidth < 70% on decode, KV cache layout is wrong (want contiguous heads).
./ckpt/qwen-grpo-math-fp8http://<pod-ip>:9090/metrics scraped to Grafanabench.jsonqwen_decode.ncu-rep