Skip to content

perf(ple): fuse the n-gram row-id hash into one Triton kernel - #338

Open
dejay2 wants to merge 1 commit into
FlashML-org:mainfrom
dejay2:pr/fused-ple-hash
Open

perf(ple): fuse the n-gram row-id hash into one Triton kernel#338
dejay2 wants to merge 1 commit into
FlashML-org:mainfrom
dejay2:pr/fused-ple-hash

Conversation

@dejay2

@dejay2 dejay2 commented Sep 2, 2026

Copy link
Copy Markdown

What

The Qwen3.8-Flash-Next PLE layer turns each token's n-gram window into
[T, num_ngram_heads] table row ids. Today that is done in torch ops:
NGramEmbedding._window packs the ragged tokens into a [B, ctx+max_len]
buffer, _shift_ignore_eos runs a cummax boundary scan over the whole window
to mark the shifts that would cross a boundary token, and then a per-n-gram
loop does multiply / XOR / remainder / offset.

Every one of those is a tiny elementwise op. A single PLE layer therefore
issues 39 CUDA kernel launches for a few microseconds of actual GPU work,
on the critical path of every decode step — the wall is launch overhead, not
arithmetic.

This PR adds freetoken/kernel/triton/ple_hash.py (ple_row_ids), which is the
same arithmetic in one kernel, one program per token, and dispatches
NGramEmbedding.row_ids to it on CUDA.

Two observations make the one-pass form possible:

  • The cummax-over-the-whole-window boundary scan collapses to an
    ngram_size - 1 step walk. _shift_ignore_eos marks shift s valid at
    position p iff p - s >= 0 and no boundary token sits in [p-s, p-1].
    Only shifts below ngram_size are ever used, so the scan never looks further
    back than that and the predicate can be carried incrementally as the walk
    goes.
  • The packed window never has to be materialized. Token t belongs to request
    req[t] at intra-request offset local[t], so the token s places to its
    left is input_ids[t - s] when local[t] >= s and
    ngram_context[req[t], ctx_len + local[t] - s] otherwise. Out of range on
    the left is the boundary token — exactly what the eos-filled packed window
    gave.

Why it is safe

  • The torch path stays. The old implementation is renamed
    NGramEmbedding.row_ids_reference and is still the CPU path and the oracle
    the kernel is diffed against in tests. Nothing about it changed.
  • Default on only where the reference already ran. row_ids takes the
    fused path only when input_ids is on CUDA and ngram_context,
    layer_multipliers, ngram_heads_vocab_sizes and ngram_heads_offsets are
    all on that same device. CPU stays on the reference.
  • FREETOKEN_PLE_FUSED_HASH=0 puts the hash back on the reference ops
    without a rebuild.
  • Byte-identical, not "close": the tests assert torch.equal against the
    reference on prefill and decode shapes, and after CUDA-graph replays.
    torch.remainder is floored and Triton's % is truncated, so the kernel
    fixes the sign up explicitly.
  • Capture-safe: fixed shapes, every input on device, no host reads, and an
    optional out= destination so a replay writes into a fixed buffer. The
    (request, offset) index the kernel addresses through is memoized per
    (is_decode, shape, device) so a replay reads a stable address; a build that
    happens during capture is deliberately not cached, since its buffers live in
    the graph pool. is_decode is part of that key because a decode of B
    requests and a prefill of one B-token request have the same [T] and mean
    opposite things (one token per request at offset 0, vs B offsets inside one
    request). The memo is bounded at _TOKEN_INDEX_CACHE_SIZE entries.
  • The kernel's two geometry preconditions (ngram_size - 1 context ids, and
    heads_per_ngram heads per n-gram order) are raised as ValueError, not
    asserted — the kernel addresses the context row and the head blocks by them,
    and python -O must not turn a geometry mismatch into an out-of-bounds read.

Measured

RTX 5090, this repo's toy PLE config (ngram_size 3, 4 hash heads), median of
7 x 300 iterations per point; launch count from torch.profiler over one call.

torch reference fused kernel
CUDA launches per call 39 1
decode, B=1 351 us 17 us
decode, B=4 365 us 16 us
decode, B=8 361 us 16 us
prefill, 512 tokens 482 us 16 us

That is per PLE layer per step. (The same change measured on the full
Qwen3.8-Flash-Next serving path on the same box: 415 us -> 17 us.)

Note the multiplier: on Qwen3.8-Flash-Next-NVFP4 ple_layer_ids is [2], so this
is one layer out of 48 and the per-step saving is one layer's worth. Against a
~95 ms decode step on a 4090 with experts streaming from NVMe that is ~0.14 %,
inside run-to-run noise (measured by @MT-z below). This PR does not claim a
tok/s win on that checkpoint; the win is per-layer, and it compounds on any
checkpoint with PLE on several layers or in graph-off small-batch decode.

How it was tested

Windows 11, RTX 5090, CUDA available.

$env:PYTHONPATH = "python;<pytest-lib>"
python -m pytest -q tests/models/qwen4_exp/test_ple.py -p no:cacheprovider -W ignore
result
base (origin/main, a80b4d3) 15 passed, 4 skipped
this branch 26 passed, 4 skipped

The 4 skips are unchanged and pre-existing (3 need FREETOKEN_QWEN4_HF_PYTHON,
1 needs FREETOKEN_QWEN4EXP_MODEL).

Also run, unchanged by this PR:

python -m pytest -q tests/models/qwen4_exp -p no:cacheprovider -W ignore
# base:        86 passed, 52 skipped, 6 failed
# this branch: 97 passed, 52 skipped, 6 failed

The same 6 failures on both: they are all tests/models/qwen4_exp/test_weight.py
and pre-existing on this platform — they need os.O_DIRECT, which Windows does
not have.

New tests (11, in tests/models/qwen4_exp/test_ple.py):

  • test_token_index_addresses_the_same_window_as_the_packed_build — the
    (request, offset) pair names the very cell the packed window would have read.
  • test_token_index_cache_is_keyed_by_shape
  • test_token_index_does_not_confuse_a_decode_with_a_one_request_prefill
  • test_token_index_cache_is_bounded
  • test_fused_hash_is_off_on_cpu
  • test_fused_hash_matches_the_torch_reference[prefill|decode] (CUDA)
  • test_fused_hash_captures_and_replays_in_a_cuda_graph (CUDA) — capture, then
    three replays with fresh ids and context, each checked against the reference.
  • test_fused_hash_env_switch_restores_the_torch_path (CUDA)
  • test_the_fused_hash_refuses_a_geometry_it_cannot_address[ctx|heads]

Not included

This is one topic split out of a larger fork branch. Deliberately left out:

  • the layer-ahead MoE expert prefetch and the small-prefill decode movement
    that shared a commit with this work;
  • the mmap/disk PLE table backend and its pinned staging-buffer ring;
  • anything MTP / speculative-decoding related.

No serving-path behaviour outside NGramEmbedding.row_ids changes; the only
signature change is the new optional out= argument on row_ids, which all
existing callers ignore.

🤖 Generated with Claude Code

https://claude.ai/code/session_01RG8BXfsSZi1nh4wMZnhJQK

The PLE hash builds its row ids from a packed ``[B, ctx+max_len]`` window, a
cummax boundary scan and a per-ngram XOR/multiply/remainder/offset loop. Every
one of those is a tiny elementwise op, so a single PLE layer spends 39 CUDA
launches (~400 us of launch wall) on a few microseconds of GPU work, on the
critical path of every step.

``freetoken.kernel.triton.ple_hash.ple_row_ids`` is the same arithmetic as one
kernel, one program per token:

- the cummax over the whole window collapses to an ``ngram_size-1`` step walk,
  because ``_shift_ignore_eos`` only ever needs shifts below ``ngram_size`` and
  the "no boundary token in between" predicate can be carried incrementally;
- the packed window is never materialized: token ``t`` at intra-request offset
  ``local[t]`` reads ``input_ids[t-s]`` when ``local[t] >= s`` and
  ``ngram_context[req[t], ...]`` otherwise, out of range on the left being the
  boundary token exactly as the eos-filled window was.

``NGramEmbedding.row_ids_reference`` is the old torch-op transcription, kept as
the oracle the kernel is diffed against and as the CPU path; ``row_ids`` picks
the kernel only when every input is on the same CUDA device.
``FREETOKEN_PLE_FUSED_HASH=0`` puts the hash back on the reference.

Fixed shapes, all inputs on device, no host reads, an optional ``out`` buffer:
the hash is capture-safe and replays inside a captured decode step. The
``(request, offset)`` index the kernel addresses through is memoized per
(is_decode, shape, device) so a replay reads a stable address; a build that
happens during capture is not cached, since its buffers live in the graph pool.
``is_decode`` is part of that key because a decode of B requests and a prefill
of one B-token request are the same ``[T]`` and mean opposite things.

Measured on an RTX 5090 (toy config, ngram_size 3, 4 heads), median of 7 x 300
iterations, torch profiler for the launch count:

    launches per call   39 -> 1
    decode B=1         351 us -> 17 us
    decode B=4         365 us -> 16 us
    decode B=8         361 us -> 16 us
    prefill 512 tokens 482 us -> 16 us

Byte-identical to the reference: the new tests assert ``torch.equal`` on both
the prefill and decode shapes, and after three CUDA-graph replays.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RG8BXfsSZi1nh4wMZnhJQK
jomcgi added a commit to jomcgi/FreeToken that referenced this pull request Sep 3, 2026
…cle stat)

Ports from upstream FreeToken, adapted to the tier: FlashML-org#342 lm_head on
sampled rows only (already generalised here via select_lm_head_rows);
FlashML-org#339 the varlen GDN/KDA prefill conv takes max_seq_len from the
scheduler on the Triton fallback (inert when sgl_kernel is installed,
which every install path pins, so no node-4 change); FlashML-org#338 the n-gram
PLE row-id hash as one Triton kernel with a bounded memo that is
bypassed during CUDA graph capture (consumed by the pinned and cached
PLE backends; the disk backend stages from its host hash); FlashML-org#231 the
routing-oracle hit rate on the stats line next to the realised
hot-pair rate, with the baseline reset on a live cache rebuild so the
oracle can never read below realised. FlashML-org#89 (route-density tile
selection) is skipped: its ds_fp4 tile table does not match the NVFP4
kernel's, which needs its own sm_89 sweep.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A88MCbnLtwsFSHmqwuJezY
@MT-z

MT-z commented Sep 3, 2026

Copy link
Copy Markdown

Nice one — I pulled this into my daily branch and it has been serving since. Two things I can add
that are not in the thread yet: a measurement of the kernel on real hash geometry, and an honest
note on how much it buys end to end on the one checkpoint I can test, which is less than the
launch count suggests. Both are from this box.

Setup. RTX 4090 24 GB (sm_89), CUDA toolkit 13.3, RadixArk/Qwen3.8-Flash-Next-NVFP4. This PR
on main @ 03c28d2 plus PR #337 @ a6bd5c0.

The measurement, and it confirms your diagnosis

The FREETOKEN_PLE_FUSED_HASH switch makes this easy to A/B in one process — no serving run, no
GPU contention — so I timed NGramEmbedding.row_ids both ways at the checkpoint's real geometry
(ngram_size 3, heads_per_ngram 8, so 16 hash heads), 300 calls after 30 warm-ups:

case torch ops fused speedup saved
decode bs=1 143.1 µs 12.1 µs 11.8× 131 µs
decode bs=4 145.6 µs 11.7 µs 12.4× 134 µs
prefill, 2048 tokens 187.1 µs 11.8 µs 15.9× 175 µs

The row I would point at is not the speedup, it is that the torch path costs the same ~143 µs at
one token as it does at 2048
. That is your claim, measured: the cost does not scale with work, so
it is launch-bound, not arithmetic-bound. 143 µs over 39 launches is ~3.7 µs each, which is about
what a launch costs on this card. The fused path is ~12 µs flat, which is roughly one launch.

What it buys end to end here: 0.14%, and I would rather say so

ple_layer_ids on this checkpoint is [2]one PLE layer out of 48. So the 131 µs saved per
decode step lands against a 94.8 ms token (10.55 tok/s on this box, MoE experts streaming from an
NVMe tier):

expected gain      0.14 %
run-to-run spread  3.2 %   (10.33 / 10.67 / 10.66 tok/s, same config, same workload)

Noise is ~23× the effect, so I did not run a serving A/B — it could not have resolved this, and a
number produced that way would have been measurement theatre. I mention it because I nearly wrote
the opposite: my first reasoning was that the NVMe disk tier forces --cuda-graph-max-bs 0, so
launch overhead is not amortised by graph capture (7.4 ms/token with graphs vs 118 ms/token without
on this card), and therefore 39 launches pay full price. That is true but incomplete — per-layer
launch counts only matter multiplied by the layer count, and here the multiplier is 1. On a
checkpoint with PLE on many layers this should be a different story.

None of that is an argument against the change. It is 12× on its own terms, it is free, and the
reference path stays as the oracle. I took it.

Correctness

I checked the kernel's arithmetic against row_ids_reference by hand rather than only trusting the
tests, and the two agree: the kernel's cumulative valid product over
(column_shift >= 0) ∧ (packed[column_shift] != EOS) is the same predicate as the reference's
in_segment >= shift, because "no EOS anywhere in [P-s, P-1]" is exactly "every tapped position
P-1 … P-s is in bounds and non-EOS". The where(valid, raw, EOS) * mult[shift] masking
reproduces the reference's per-tap masking, and column = CTX_LEN + local - shift maps 1:1 onto
the reference's P - shift without materialising the packed window. Addressing through
(req, local) instead of the [B, ctx+max_len] buffer is the part that makes the single-program
form possible.

Empirically the equivalence tests pass here in both directions
(test_fused_hash_matches_the_torch_reference prefill and decode), as does the graph
capture/replay test. The row_ids(meta, out=None) signature change is backward compatible for the
sibling callers I exercised.

One failing test, and it is not yours

test_ple.py::test_track_snapshot_equals_a_prefill_stopped_at_the_boundary fails on this branch. It
is pre-existing: I have a baseline taken at main+#337 before applying this PR over 84 test
files, and that test is one of exactly 7 standing failures there. It is in the
commit_ngram_context / conv-state snapshot path, which this diff does not touch, and it is a
torch.equal exact match on GPU floats — the same shape as the other ULP-noise failures in that
baseline. Not a finding against this PR; flagging it only so it does not get attributed here.

Before and after over those 84 files: 7 failures either way, nothing gained, nothing broken.

Happy to run anything else against this — the A/B harness needs no model load, so re-measuring on a
different geometry is cheap if that would help.

Written with AI assistance; every number above was measured on my hardware (RTX 4090, sm_89)
and I can reproduce it.

@dejay2

dejay2 commented Sep 3, 2026

Copy link
Copy Markdown
Author

Good to have a second set of numbers on a different card, and on the real 16-head geometry. The flat ~143 µs from bs=1 up to a 2048-token prefill is the clearest version of the launch-bound argument I have seen, clearer than my own table.

The end-to-end point is fair and I should have spelled it out in the description: ple_layer_ids is [2] on this checkpoint, so per-step it is one layer's worth of launches, and against a ~95 ms token that is in the noise. I never claimed a tok/s win for this checkpoint on its own, but the description reads as if it might, so I will add a line making the one-layer multiplier explicit. Where it should matter more is any checkpoint with PLE on several layers, and the small-batch decode path with graphs off, which is the mode you are in.

On test_track_snapshot_equals_a_prefill_stopped_at_the_boundary: agreed it is not from this diff. The branch only touches ple_hash.py, the row_ids dispatch in ple.py, and the tests; nothing in commit_ngram_context or the conv-state snapshot path changes. It passes here on the 5090, so it looks like the same GPU-float exact-match flakiness as the rest of your baseline.

Thanks for the hand check of the valid-shift predicate. That was the part I most wanted someone else to read.

@gdevenyi

gdevenyi commented Sep 4, 2026

Copy link
Copy Markdown

Tested this on a Qwen3.8-Flash-Next deployment; effect on decode is within noise here, no regression.

main 86214a9 + this PR
single-stream decode 58.6 tok/s 60.0 tok/s
8 concurrent, aggregate 132.8 tok/s 136.8 tok/s
TTFT, ~1k-token prompt 2.03 s 2.02 s

The pinned PLE backend was used, so the fused row_ids path was exercised on every step. Why it does not show: an nsys trace of this box's decode step (about 20 ms at KV=262k, 37% expert residency) is 45% cuBLAS bf16 GEMV over the 8 GiB of dense weights and 23% PCIe expert gather, with the GPU busy ~99% of the step, so removing ~39 tiny launches (~0.1-0.2 ms of launch overhead) lands inside the +-4% run-to-run spread. It should be visible on a setup where the step is launch-bound (small model, high expert residency, fast GPU).

Measured on a 2x RTX 6000 Ada (48 GiB, sm_89, PCIe Gen4, no NVLink) / 2x Xeon Gold 6526Y / 503 GiB box, CUDA 13.3, torch 2.11+cu130, sgl_kernel 0.4.5, model RadixArk/Qwen3.8-Flash-Next-NVFP4 (qwen4_exp, NVFP4 experts + bf16 dense). The PR cherry-picks cleanly onto main 86214a9; both runs also carry a local fix so --moe-cache-auto honours --num-tokens (it OOMs otherwise at this KV size). One GPU, one run each, same flags:

ft serve --model <RadixArk snapshot> --moe-backend offload --ple-backend pinned --num-tokens 262144 \
  --memory-ratio 0.94 --moe-prefill-hit-d2d --max-running-requests 8 --cuda-graph-max-bs 8

Single-stream = median of three 64-vs-256-token completion pairs, aggregate = 8 concurrent 256-token completions, TTFT on a ~1k-token prompt. Run-to-run spread of the baseline on this box is about +-4% single-stream.

@MT-z

MT-z commented Sep 4, 2026

Copy link
Copy Markdown

Borrowing the nsys approach from your comment and running the same trace on this box. It
corrects a figure of mine and turns one of my inferences into a measurement.

row_ids, decode bs=1 torch ops + this PR
kernel launches / call 39.0 1.0
GPU busy / call 44.7 us 1.3 us
wall / call 143 us 12 us

The correction is to the per-launch figure in my earlier comment. "143 us over 39 launches is
~3.7 us each" divided the wall time without removing the GPU's own work; it is
(143 - 44.7) / 39 = 2.5 us. The count itself was right but had been read off the code rather
than measured, and the launch-bound claim had been inferred from the flat curve rather than
observed. Both are now measured, which they should have been the first time.

As a second data point on where it would show: the step here is launch-bound and the change
still does not. The NVMe expert tier forces --cuda-graph-max-bs 0, so launch overhead is not
amortised at all -- 7.4 ms/token with graphs on this card against 118 ms/token with them off --
and the saving is still 0.14% against a 94.8 ms token. What binds here is the multiplier rather
than the step: ple_layer_ids is [2] on this checkpoint, one PLE layer out of 48, so the
131 us is saved once per step rather than 48 times. Launch-bound and per-layer cost usually
travel together; on this checkpoint they came apart. A checkpoint with PLE on many layers would
separate them cleanly, which is not a measurement I can make here.

One note on the tooling, since it cost some time here. With CUDA graphs enabled, nsys records
cudaGraphLaunch once per token and the kernels inside the graph do not appear in
cuda_gpu_kern_sum at all, which puts a decode capture at 555 ms of kernel time over a 40 s
window. --cuda-graph-trace=node is the documented answer; its overhead was large enough here
that the model did not finish loading inside the capture window. --cuda-graph-max-bs 0 worked.

Measured on an RTX 4090 (24 GiB, sm_89) / i9-14900KF / 61 GiB box, driver 595.84, CUDA 13.3,
torch 2.11.0+cu130, triton 3.6.0, sgl_kernel 0.4.5, freetoken 0.1.2, model
RadixArk/Qwen3.8-Flash-Next-NVFP4 (qwen4_exp). This PR on a daily branch off main af71ba4,
also carrying PR #337. The row_ids figures need no served model: FREETOKEN_PLE_FUSED_HASH
switches the path in one process, so both arms run on identical tensors with no GPU contention.

nsys profile --trace=cuda,nvtx --sample=none --cpuctxsw=none python ple_nsys.py
nsys stats --report nvtx_kern_sum --format csv <report>     # launch counts are 'Kern Inst'

Launches and GPU-busy are ten iterations inside one NVTX range, 30 warm-ups outside it; wall
time is the 300-iteration mean from the earlier comment.

The reason is a measurement now rather than an inference, which is what I wanted. Thank you.

Assisted-by: Claude Opus 5

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.

3 participants