Compiling an entire LLM decode step into ONE persistent megakernel — and what that proves about whether inter-kernel L2 reuse of activations is worth the bytes.
Xinhao Cheng, Zhihao Zhang, Yu Zhou, Mengdi Wu, … Xupeng Miao, Tianqi Chen, Zhihao Jia
Carnegie Mellon University · Mirage Project
In low-batch, latency-sensitive LLM decoding, the model is read once per token and almost nothing is reused across tokens.
MPK (Mirage Persistent Kernel) is a compiler and runtime, from the Mirage group at CMU, that attacks exactly these fixed costs.
Per-kernel launch/dispatch overhead and inter-kernel HBM round-trips dominate the fixed cost of a low-batch decode step.
A compiler + runtime that auto-fuses a decode step's task graph into ONE persistent megakernel.
1.2×–6.7× lower decode latency (single-GPU ~1.0–1.7×), bounded by the WEIGHT roofline.
Our measurement study makes one claim: transient inter-kernel L2 reuse of activations is worth less than 0.65% of step time, and MPK is the perfect adversary for that claim.
A measurement claim is only as strong as the hardest case it survives, and MPK fuses the entire decode step into one kernel.
MPK attributes its gain to launch elimination and intra-kernel pipelining, and bounds latency by the WEIGHT roofline — not by activation reuse.
The weight roofline (the floor MPK cannot beat), illustrated:
$$t_{\text{step}} \;\ge\; t_{\text{weight roofline}} \;=\; \frac{B_{\text{weights}}}{\text{BW}_{\text{HBM}}} \;\approx\; \frac{16\,\text{GB}}{1.6\,\text{TB/s}} \;\approx\; 10\ \text{ms (A100)}$$
A single hidden vector is KB-scale; aggregated over all kernel boundaries, the inter-kernel activation traffic is MB-scale per token — either way multiple orders of magnitude smaller than the ~16 GB weight stream, so even perfect inter-kernel L2 reuse removes a sub-percent slice of the HBM traffic that sets the floor.
MPK splits into a compiler that lowers a decode step into one megakernel's source, and a runtime that launches and drives it.
The baseline issues a long sequence of short kernels; MPK collapses them into one persistent kernel body.
Baseline (one decode step) — many launches, each with an HBM round-trip:
L = CPU-driven kernel launch; each blue block reads activations from HBM and writes them back. Repeated per layer.
MPK (one decode step) — ONE launch, one resident kernel:
A single launch; a dispatch-only scheduler_kernel runs concurrently. No intra-step kernel boundaries.
We work from the mpk branch, cloned into the survey tree. The build pulls three submodules, compiles two Rust crates, runs CMake/nvcc, and cythonizes the bindings.
--use-mirage; prints per-token latency, --save-tokens, --profilingpersistent_kernel.cuh — launch logic, worker/scheduler kernels| File / symbol | Role | Why it matters here |
|---|---|---|
persistent_kernel.cuh:1799–1859 |
The launch fork: split path vs single-kernel path. | Proves the single-GPU mechanism is ONE compute launch. |
persistent_kernel.cuh:1617 |
split_worker_scheduler = true; |
Hardcoded true → single-GPU always uses worker + dispatch scheduler. |
utils.py:33–53 |
get_configurations_from_gpu() |
SM count → workers. A100 (108) → 96; matches README. |
demo/qwen3/demo.py:822 / :862 |
Per-token latency print, baseline / mirage. | Our headline number comes from here. |
analyze.sh |
Repo's ncu wrapper — uses sudo + --set full. | Unusable on PACE (no sudo); we call ncu directly. |
Everything here is a verifiable static fact read directly from the cloned tree.
split_worker_scheduler is hardcoded true FACTIn init_persistent_kernel, the launch-selecting flag is set unconditionally to true at persistent_kernel.cuh:1617:
// include/mirage/persistent_kernel/persistent_kernel.cuh:1617
global_runtime_config.split_worker_scheduler = true;
That flag forks the launch. The else branch is reserved for the NVSHMEM / multi-GPU path; single-GPU always uses the split path:
// persistent_kernel.cuh:1799–1859 (launch fork)
if (global_runtime_config.split_worker_scheduler) { // <-- single-GPU path
// The split kernel does not support NVSHMEM ...
worker_kernel<<<dim3(global_runtime_config.num_workers,1,1),
dim3(WORKER_NUM_THREADS,1,1),
MAX_DYNAMIC_SHARED_MEMORY_SIZE,
global_runtime_config.worker_stream>>>(global_runtime_config);
scheduler_kernel<<<dim3(global_runtime_config.num_local_schedulers,1,1),
dim3(32,1,1), 0,
global_runtime_config.scheduler_stream>>>(global_runtime_config);
// ... worker_kernel = the compute megakernel; scheduler_kernel = dispatch only
} else {
// multi-GPU / NVSHMEM path — NOT used on single GPU
persistent_kernel<<<dim3(num_sms_to_use,1,1), ...>>>(global_runtime_config);
cudaDeviceSynchronize();
}
worker_kernel runs the compute; scheduler_kernel only dispatches. The megakernel of interest is worker_kernel.
// persistent_kernel.cuh:1385–1394
__global__ __launch_bounds__(WORKER_NUM_THREADS,1)
void worker_kernel(RuntimeConfig config) {
worker_checker(config);
execute_worker(config); // <-- all layers' compute, stays resident
}
__global__ void scheduler_kernel(RuntimeConfig config) {
scheduler_checker(config);
execute_scheduler(config, 0); // <-- dispatch only
}
num_workers = SM count, minus a few FACTThe worker count is derived from the SM count: 108 SMs (A100) → 96 workers, matching the README.
# python/mirage/utils.py:33–53
def get_configurations_from_gpu(rank):
props = torch.cuda.get_device_properties(rank)
sm_cnt = props.multi_processor_count
if sm_cnt >= 160: worker = 144
elif sm_cnt >= 132: worker = 128 # H100 = 132 SMs -> 128 workers
elif sm_cnt >= 108: worker = 96 # A100 = 108 SMs -> 96 workers (README config)
elif sm_cnt >= 68: worker = 64
...
return worker, get_scheduler(sm_cnt, worker)
Both modes time with CUDA events. Baseline loops model.forward (many kernels); MPK calls mpk() once. Speedup = baseline_ms / mirage_ms.
# demo/qwen3/demo.py — baseline (:786–824, many kernel launches per step)
for cur_pos in range(prompt_len, decode_limit):
logits = model.forward(input_ids=..., step=step, stream=stream) # N kernels/step
next_token = logits.argmax(dim=-1)
...
run_time = starter.elapsed_time(ender)
per_tok_ms = run_time / max(prompt_len + tokens_generated, 1)
# demo/qwen3/demo.py — MPK (:843–865, ONE megakernel call)
starter.record()
mpk() # <-- the whole decode, one persistent kernel
ender.record(); torch.cuda.synchronize()
run_time = starter.elapsed_time(ender)
per_tok_ms = run_time / max(prompt_lengths[0].item() + tokens_generated, 1)
The table checks the paper's structural claims against the cloned source; the speedup is measured at 2.733× (Section 9).
| Paper claim | Status | Code evidence |
|---|---|---|
| A decode step runs as ONE persistent kernel on a single GPU. | YES | split_worker_scheduler=true (cuh:1617) → one worker_kernel + one dispatch scheduler_kernel. |
| Fusion eliminates per-kernel launch/dispatch overhead. | YES | Baseline loops model.forward (N kernels); MPK calls mpk() once. |
| Single-GPU speedup is ~1.0–1.7×. | 2.733× | Measured on H100 (Qwen3-8B fp16, batch 1): 16.047 → 5.872 ms/tok, above the paper's quoted single-GPU band. The data support the 2.733× value; we do not claim a verified mechanism for why it exceeds the band (model, arch, and config differ from the paper's runs). |
| Latency is bounded by the WEIGHT roofline. | YES | MPK 5.872 ms/tok ≈ 1.21× the H100 weight roofline (≈4.84 ms/tok); baseline ≈ 3.3×. |
| The gain comes from inter-kernel activation / L2 reuse. | NOT CLAIMED | Paper never claims this; this is OUR hypothesis to bound. |
Our a-priori prediction (written before the run) follows directly from the architecture. At batch 1 the decode step streams ~16 GB of weights once, which sets a weight-bandwidth floor neither engine can beat. The baseline pays that floor PLUS the launch/dispatch of N kernels per step PLUS the HBM round-trips of intermediate activations. MPK removes the launches and the activation round-trips and pipelines the resident work, but still pays the weight floor. So the speedup is the ratio of (floor + removable overhead) to (floor + residual). Within the removable part, the launch elimination should dominate; the activation/L2 reuse slice should be a sliver. A single hidden vector passed between two kernels at batch 1 is KB-scale (~hidden_size × 2 bytes ≈ a few KB); aggregated over all kernel boundaries in a step, the inter-kernel activation traffic is only O(MB) per token — either way orders of magnitude below the GB-scale weight stream. The H100 run bore this out: MPK lands at 1.21× the 4.84 ms/token weight roofline while the baseline sits at 3.3× it (Section 9.3).
Illustrative a-priori hypothesis. The red L2 slice is bounded below 0.65% by the Section 9.3 roofline byte-ratio argument — not by ncu counters (not collected; profiler crash). The realized attribution is the Section 9.3 roofline table.
Hardware (as run): one PACE H100 (sm_90, 132 SMs → 128 workers), built with CUTLASS kernels (sm_90a, TMA). Model: Qwen3-8B fp16, batch 1, greedy, max_num_batched_tokens=8. Reported metric is steady-state per-token decode latency.
# cd $REPO/demo/qwen3 (demo imports models.* from cwd)
# BASELINE — native Triton/FlashInfer, many kernel launches per step
# both runs decode to natural EOS (baseline stops at 64 tokens, MPK at 326) —
# do NOT cap with --max-new-tokens, or you cannot reproduce the 326-token MPK run
python demo.py --prompt "<fixed prompt>" \
--save-tokens $RESULT_DIR/outputs/baseline.json
# TREATMENT — single fused megakernel; mpk() does the whole decode
python demo.py --use-mirage --prompt "<fixed prompt>" \
--save-tokens $RESULT_DIR/outputs/mirage.json
# speedup = baseline.latency_ms_per_token / mirage.latency_ms_per_token (measured 2.733x on H100)
# --profiling attempts a tg4perfetto trace + task_graph_0.json + kernel_0.cu
# in our run perfetto export crashed, so no usable trace was collected (attribution is roofline-only)
# (a short --max-new-tokens here is only an illustrative cap for a cheap profiled run,
# NOT the headline runs above, which decode to natural EOS)
python demo.py --use-mirage --profiling --max-new-tokens 4 \
--prompt "<fixed prompt>" --trace-name $RESULT_DIR/mpk_trace
The plan was to split the measured MPK speedup into five buckets and isolate the fifth: (i) kernel-launch/dispatch elimination, (ii) SRAM / shared-memory staging, (iii) prefetch / latency hiding, (iv) reaching the weight-HBM-traffic roofline (weight-roofline-bound — MPK does NOT reduce weight traffic), and (v) inter-kernel L2 ACTIVATION reuse — and to show that (v) is < 0.65% of step time. Because the single-GPU megakernel-of-interest is worker_kernel (Section 5.1), and the repo's analyze.sh sudo-wraps ncu in a way PACE forbids, we would invoke Nsight Compute directly with explicit metrics that replicate its memory sections. (This counter-based pass did not complete; see the banner above.)
| Bucket | Nsight metric(s) | What it isolates |
|---|---|---|
| DRAM (weight) traffic | dram__bytes_read.sum, dram__bytes_write.sum, dram__bytes.sum |
If MPK's DRAM bytes ≈ 16 GB weights, the weight traffic is unchanged — MPK is weight-roofline-bound (reaches the floor without reducing weight traffic), not winning on activation reuse. |
| L2 reuse (the target) | lts__t_sector_hit_rate.pct, lts__t_sectors_lookup_hit.sum / ...lookup_miss.sum |
L2 hit rate on activation footprints — bounds the slice. |
| SRAM / shared | l1tex__t_sector_hit_rate.pct, l1tex__data_bank_conflicts_pipe_lsu_mem_shared_op_ld.sum |
On-chip staging and bank conflicts. |
| Launch / occupancy | launch__grid_size, launch__block_size, launch__waves_per_multiprocessor, sm__warps_active... |
One grid (MPK) vs many grids (baseline). |
| Timing / throughput | gpu__time_duration.sum, sm__throughput..., gpu__compute_memory_throughput... |
Cross-check; CUDA-event latency is authoritative for wall-clock. |
# C1: MPK — profile the single compute megakernel worker_kernel (one launch)
ncu --target-processes all --kernel-name "regex:worker_kernel" --launch-count 1 \
--metrics "$NCU_METRICS" --csv --export $RESULT_DIR/ncu/mpk_worker_kernel.ncu-rep \
python demo.py --use-mirage --max-new-tokens 4 --prompt "<fixed prompt>"
# C2: baseline — capture one steady decode step's full Triton/FlashInfer kernel mix
ncu --target-processes all --launch-count 60 --launch-skip 40 \
--metrics "$NCU_METRICS" --csv --export $RESULT_DIR/ncu/baseline_kernels.ncu-rep \
python demo.py --max-new-tokens 4 --prompt "<fixed prompt>"
$$\text{share}_{\text{L2-act}} \;\le\; \frac{B^{\text{base}}_{\text{act}} - B^{\text{mpk}}_{\text{act}}}{B_{\text{step}}} \;\approx\; \frac{O(\text{MB})}{O(\text{GB})} \;\ll\; 0.65\%$$
| Quantity | Baseline | MPK | Source |
|---|---|---|---|
| Per-token decode latency (ms) | 16.047 FACT | 5.872 FACT | demo.py latency_ms_per_token |
| Speedup (base / MPK) | 2.733× FACT | 16.047 / 5.872 |
|
| Tokens generated to natural EOS | 64 | 326 | differs → per-token latency is the fair metric |
| Kernels launched / decode step | N (many) | 1 (worker_kernel) | FACT cuh:1813 |
| Bucket | Our evidence (H100) | Verdict | Basis |
|---|---|---|---|
| Launch / dispatch elimination | N kernels/step → 1 launch; 16.047 → 5.872 ms/tok. | dominant FACT | latency + cuh:1813 |
| Weight-traffic roofline | 5.872 ms/tok ≈ 1.21× the 4.84 ms/tok weight floor. | near weight roofline FACT | 16.2 GB / 3.35 TB/s |
| KV-cache reads + compute + scheduler | residual ≈ 1.03 ms/tok above the floor. | small residual BOUND | MPK ms/tok − roofline |
| Inter-kernel L2 ACTIVATION reuse | ~16 GB weights vs O(MB) activations/token; saving bounded by act/total. | < 0.65% BOUND | roofline byte ratio (no ncu) |
The 2.733× comes from launch-overhead elimination + intra-kernel pipelining moving the step down to the weight roofline — not from L2 activation reuse.
$$t_{\text{weight roofline}} = \frac{B_{\text{weights}}}{\text{BW}_{\text{HBM}}} = \frac{16.2\,\text{GB}}{3.35\,\text{TB/s}} \approx 4.84\ \text{ms/token (H100)}$$
| Quantity | Value | Interpretation |
|---|---|---|
| Weight roofline / token | 4.84 ms | the floor neither engine can beat |
| MPK measured / token | 5.872 ms | ≈ 1.21× roofline → near the bound |
| Baseline measured / token | 16.047 ms | ≈ 3.3× roofline → launch/dispatch-bound |
| Inter-kernel activations / token | O(MB) | vs ~16 GB weights → << 1% of step bytes |
Baseline (16.047 ms/tok) is ~3.3× roofline → launch/dispatch-bound; MPK (5.872 ms/tok) is ~1.21× the weight roofline. Even 100%-L2-served inter-kernel activations save < act/total ≪ 1% — so the gain is launch elimination + reaching the weight roofline, not L2 reuse, corroborating our <0.65% bound.
# shallow clone leaves deps/ empty — submodule fetch is MANDATORY
cd $REPO # mirage, mpk branch
git submodule update --init --recursive # cutlass, z3, nlohmann/json
module load cuda/12.6.1 # fallback 12.1.1; provides nvcc + ncu
conda create -y -p $ENV python=3.11 && conda activate $ENV
pip install "torch>=2.4" && pip install -r requirements.txt
export CUDACXX=$(which nvcc)
pip install -e . -v # Rust crates + CMake/nvcc + Cython
python -c "import mirage as mi; print('mirage OK')"
cd $REPO/demo/qwen3
# headline runs decode to natural EOS (baseline 64 tokens, MPK 326) — no --max-new-tokens cap
python demo.py --prompt "$P" --save-tokens $OUT/baseline.json
python demo.py --use-mirage --prompt "$P" --save-tokens $OUT/mirage.json
# speedup = baseline.latency_ms_per_token / mirage.latency_ms_per_token
# profiled / ncu runs use a short --max-new-tokens 4 cap (cheap; illustrative, not the headline)
python demo.py --use-mirage --profiling --trace-name $OUT/mpk_trace --max-new-tokens 4 --prompt "$P"
ncu --kernel-name "regex:worker_kernel" --launch-count 1 --metrics "$NCU_METRICS" \
--csv --export $OUT/ncu/mpk_worker_kernel.ncu-rep python demo.py --use-mirage --max-new-tokens 4 --prompt "$P"
| Parameter | Value | Note |
|---|---|---|
--model | Qwen/Qwen3-8B | fp16, ~16.2 GB weights → ~4.84 ms/tok roofline on H100 (~10 ms on A100) |
--use-mirage | on / off | off = baseline; on = megakernel |
--max-new-tokens | EOS / 4 | headline runs decode to natural EOS (uncapped: baseline 64, MPK 326); 4 is a short cap for cheap profiled/ncu runs |
--temperature | 0.0 | greedy → deterministic output |
num_workers | 128 (H100) | auto from 132 SMs (H100, as run); 96 on A100 |