Skip to content

[EXL3] dispatch prefill-shaped GEMMs to reconstruct+hgemm (+113 % prefill, decode unchanged) - #316

Open
malaiwah wants to merge 3 commits into
local-inference-lab:codex/gg-exl3-r7-k345-20260810from
malaiwah:feat/exl3-prefill-reconstruct-dispatch
Open

[EXL3] dispatch prefill-shaped GEMMs to reconstruct+hgemm (+113 % prefill, decode unchanged)#316
malaiwah wants to merge 3 commits into
local-inference-lab:codex/gg-exl3-r7-k345-20260810from
malaiwah:feat/exl3-prefill-reconstruct-dispatch

Conversation

@malaiwah

Copy link
Copy Markdown

Stacked on #314 (same branch base). Addresses dense EXL3 prefill throughput.

Problem

exl3_gemm is decode-shaped: it decodes the trellis once per output tile, so cost grows with M. Exl3LinearMethod._apply_one routes every non-K6 shard through it at every row count. exllamav3's own LinearEXL3.reconstruct_hgemm switches to reconstruct + hgemm above 1024 rows precisely for this reason.

Effect on a real dense checkpoint (Qwen3.8-27B, 193 EXL3 matrices): prefill 2,369 tok/s, against 10,667 for the same model in official FP8 and 14,528 in NVFP4 on the same GPU.

Kernel measurements

Speedup of reconstruct+hgemm over exl3_gemm on the checkpoint's three real geometries, using the extension VLLM_EXL3_EXT_PATH loads (which does not export the fused reconstruct_had_slice, so the unfused had_r_128reconstruct[_slice]hgemmhad_r_128 sequence is timed, reconstruct cost included):

geometry m=1 m=32 m=64 m=128 m=512 m=2048
5120×17408 K5 0.16x 0.30x 0.55x 1.06x 2.61x 4.10x
17408×5120 K6 0.19x 0.35x 0.63x 1.28x 3.10x 4.35x
5120×248320 K6 (lm_head) 0.16x 0.32x 0.62x 1.19x 3.23x 5.21x

Crossover is m=128 for all three, hence the default threshold.

Change

Inside the existing vllm::exl3_gemm custom op: if x.shape[0] >= VLLM_EXL3_PREFILL_RECONSTRUCT_M (default 128, 0 disables), materialise the weight and use hgemm; otherwise call exl3_gemm exactly as today. Scratch is one fp16 buffer per (device, K, N-chunk) reused across all layers of the same geometry, bounded at 336 MB by 32768-column slicing, so peak memory does not scale with layer count.

The dispatch must be inside the op. A Python-level if rows >= threshold around the two calls is resolved once at trace time — with a single compiled shape range the profile run bakes in the prefill branch, the decode CUDA graphs then capture reconstruct+hgemm, and decode throughput collapses. Measured while developing this: 56.5 → 22.6 tok/s at C1 with prefill improving, i.e. a silent regression that looks like a win if only prefill is benchmarked. Two related traps: torch.cuda.is_current_stream_capturing() is not Dynamo-traceable (torch.* op returned non-Tensor), and raw pybind extension calls are not either (Attempted to call function marked as skipped: …had_r_128).

End-to-end results

Same box, --max-num-seqs 8, greedy, ignore_eos, 256 output tokens, median of 3 runs (dispersion <1 %), prefill measured with exact token-count prompts:

configuration TG C1 TG C4 TG C8 PP 2k PP 6k
before 56.5 199.6 402.7 2,369 2,362
after 56.6 199.6 404.6 5,050 5,146

+113 % / +118 % prefill, decode unchanged.

Numerical impact, disclosed

This changes fp16 summation order, so unlike #314 it is not bit-exact. Measured with a full-vocabulary teacher-forced comparison over 32 contexts × 2047 positions:

comparison mean KLD top-1
patched vs unpatched capture, same checkpoint 9.17e-04 98.81 %
vs BF16 reference, patch active 0.008032 96.5987 %
vs BF16 reference, patch inactive 0.007998 96.5972 %

+0.43 % measured divergence, top-1 unchanged to four decimals. For scale, the same harness measures a 61 % gap between this checkpoint and official FP8, and the patched-vs-unpatched difference is the same order as the difference between vLLM's own logit path and an offline replay of it. VLLM_EXL3_PREFILL_RECONSTRUCT_M=0 restores the previous path exactly, so operators who need bit-identical output to today can opt out.

Follow-up, not in this PR

The MLP GEMMs are no longer prefill-dominant, so the remaining gap to NVFP4 is elsewhere. The prime suspect is the online-K6 overlay: 208 attention projections go through _b12x_trellis_linear, whose dense K6 kernel is documented as a small-M decode kernel, at prefill row counts. The same row-count dispatch likely applies there.

…gemm

exl3_gemm autotunes with timing launches, so _require_enforce_eager refuses
non-eager execution for every dense tensor_storage checkpoint. Decode capture
sizes are a known finite set, so prime every serialized shard over them during
process_weights_after_loading -- the same pattern _warm_decode_shapes already
uses for the online-K6 path -- and permit decode-only capture behind
VLLM_EXL3_GRAPH_DECODE.

Measured on Qwen3.8-27B EXL3 K4 (RTX PRO 6000, SM120, TP1): decode throughput
+92%/+84%/+98% at C1/C4/C8, distribution parity vs eager exactly 0.000000 KLD
over 32 contexts.
exl3_gemm decodes the trellis once per output tile, so its cost grows with M.
exllamav3's own LinearEXL3.reconstruct_hgemm switches to reconstruct+hgemm above
1024 rows for that reason. Measured crossover with the extension vLLM loads is
m=128, and the win at m=2048 is 4.1-5.2x per geometry.

Dispatch happens inside the existing custom op, at runtime: a Python-level branch
around the call is resolved at trace time, which makes the decode CUDA graphs
capture the prefill path and collapses decode throughput.

Measured on Qwen3.8-27B EXL3 (RTX PRO 6000, SM120, TP1): prefill 2,369 -> 5,050
tok/s at 2k and 2,362 -> 5,146 at 6k, decode unchanged, +0.43% divergence.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

🗂️ Base branches to auto review (1)
  • dev/*

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 3128df69-6eba-430d-a48e-d8edb9441f4e

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Same change as on the vllm-project#314 branch, kept in sync so the stacked prefill
dispatch PR is testable on its own.
@malaiwah

Copy link
Copy Markdown
Author

Follow-up measurements on this PR, including a negative result and the ceiling arithmetic that says where to stop.

1. Attribution: the MLP kernel was the whole story, the attention overlay is not

Four configurations on 1x RTX PRO 6000 Blackwell (SM120), dense Qwen3.8-27B EXL3 K5/K6, 2,048-token prefill, median of 3, --kv-cache-dtype fp8. "reconstruct" is this PR (VLLM_EXL3_PREFILL_RECONSTRUCT_M=128); "trellis" is the decode-shaped exl3_gemm path used for every row count before it.

# attention MLP kernel PP 2k tok/s PP 6k tok/s weights
A online K6 trellis 2,369 2,362 21.8 GB
B online K6 reconstruct 5,050 5,146 21.8 GB
C BF16 (overlay off) trellis 2,485 2,473 28.5 GB
D BF16 (overlay off) reconstruct 5,618 5,728 28.5 GB
  • MLP kernel: 2.13x (A->B), 2.26x (C->D).
  • Attention representation: 1.05x (A->C), 1.11x (B->D). I had predicted the 208 attention projections going through a small-M decode kernel were the remaining bottleneck. Refuted - the overlay costs about a tenth of prefill and saves 6.7 GB.

2. ext.hgemm is already at cuBLAS parity, so there is no free win left in the GEMM

fp16, same device, torch.mm as the cuBLAS reference:

K N m ext.hgemm torch.mm ratio
5120 17408 256 0.154 ms 0.158 ms 0.98x
5120 17408 2048 0.903 ms 0.911 ms 0.99x
5120 17408 4096 1.760 ms 1.702 ms 1.03x
17408 5120 1024 0.465 ms 0.506 ms 0.92x
17408 5120 2048 0.968 ms 0.919 ms 1.05x
5120 32768 2048 1.585 ms 1.613 ms 0.98x

0.92-1.06x across the range. Swapping in cuBLAS would buy nothing.

3. Larger prefill chunks do not amortise the reconstruct further: no change

--max-num-batched-tokens 8192 --long-prefill-token-threshold 8192: PP 5,043 / 5,120 tok/s versus 5,050 / 5,146 at defaults - within run-to-run noise, because the scheduler already places these prompts in a single chunk, so reconstruct is already paid once per prompt. That lever is closed.

4. The remaining gap is structural, and I would rather state it than chase it

At m=2048 a gate_proj-shaped call costs 1.20 ms of which the GEMM is 0.90 ms; the other ~0.30 ms is Hadamard + reconstruct. gate+up+down = 3.69 ms per layer, x64 layers = 236 ms, an MLP-only ceiling of 8.7k tok/s (we measure 5.6k end to end). A perfect fused kernel making reconstruct free would give 173 ms, i.e. 11.8k tok/s MLP-only, call it 7-8k end to end. On this box official FP8 serves 10,667 tok/s and NVFP4 14,528 tok/s, and neither pays a decode step at all.

So: prefill parity with FP8 is not reachable for a 4-bit-class trellis format by dispatch or tuning changes in this runtime. It needs dequant fused into the GEMM epilogue (Marlin-style) - a new kernel, out of scope for this PR. This PR takes the 2.1-2.3x that is available for a threshold and a scratch buffer, and I am documenting the rest as a measured limit rather than an open TODO.

5. Also pushed here

Priming now uses 0.05 * randn instead of a zero arena (kept in sync with #314). Measured on the eager-vs-graph decode-parity harness: it does not change near-tie drift (25/32 vs 24/32, identical logprob deltas), and unquantised BF16 drifts the same amount (24/32), so that drift is ambient to the graph path rather than caused by either patch - details in #314.

Method and receipts: docs/26-prefill-attribution.md, microbenchmark in tools/gemm_cmp.py, bench driver in tools/bench.py.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant