Repository navigation
Q8KV4 prefill: build for SM107, speed up K1, unify the combine, place partials by locality domain - #21
Open
pgera wants to merge 1 commit into
Open
Q8KV4 prefill: build for SM107, speed up K1, unify the combine, place partials by locality domain#21pgera wants to merge 1 commit into
pgera wants to merge 1 commit into
Conversation
…mbine with locality-domain placement Bring the Q8KV4 paged sparse prefill work from vllm-project/MSA dev (PRs 30 and 38) to nv_dev, adapted to this tree's layout and public contract. Build and dequant - Build the prefill extension for sm_107a (CUDA 13.5 or newer) next to sm_100a/sm_103a; select the dequant path with the shared QMUL4 toolchain probe, now in inference/msa_v1/_build_utils.py and used by the decode attention and decode indexer JITs instead of their private copies. - Dequantize with exact packed-FP16 math where QMUL4 is unavailable (SM107): an E2M1 x E4M3 product has at most five significant bits, so the only rounding is the final E4M3 conversion, as with QMUL4. K1 (attention forward) - Compute each partial-output row's pointer, scale and validity once per group and stream the row's chunks out one 64-column TMEM half at a time. - Prefetch the packed query indices eight groups at a time into a 32-slot metadata ring and deepen the Q ring to eight stages; every loader warp waits for its stage itself. - Skip the causal select for groups whose 128 keys are all visible, and hand the output and stats stages back as soon as the epilogue holds them in registers, before its stores drain. - Use the K=64 kind::f8f6f4 MMA on SM107 for QK and PV (the TS form is a local atom; CUTLASS 4.8 provides only the SS form). - Take the partial-output split stride from the tensor and zero the combine's tile counters for the launch that follows. Combine (K2) - Replace the cp.async combine with one TMA-ring kernel over the flattened varlen rows: 64-row tiles through a four-slot bulk-tensor ring (two slots for interleaved reads on SM107), the STG.128 column permutation undone by XOR shuffles, the LSE tile by one 2-D bulk copy or 4-byte cp.async when the head count is not a multiple of 4. BF16/FP16 partials only; the temperature-LSE and output-scale paths had no caller here. The BF16 and Q8KV8 prefill wrappers use the same kernel. combine.py becomes an inference-local implementation in vendor_manifest.json. Locality-domain placement - Add PrefillWorkspace: the wrapper owns its split partials, striped over the device's memory locality domains by 2 MB page through the CUDA virtual memory API, every split slab padded to the stripe; each combine CTA claims a tile from the domain of its own SM and steals from the other domains once its own are drained. One domain, an older driver or a refused localized allocation fall back to interleaved memory with the same kernels. MM_SPARSE_PREFILL_Q8KV4_LOCALITY_DOMAINS=0 keeps the buffer interleaved. Tests and docs - Direct combine tests (split counts, head counts, dtypes, padded slabs, unaligned LSE slabs, synthetic two-domain claiming), workspace contract tests (growth and reuse, knob, fallback, release with its owner), SM107 test and benchmark gates; READMEs in both languages. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
pgera
force-pushed
the
perf/q8kv4-prefill-rubin
branch
from
October 22, 2026 11:11
a8a3d99 to
59f79ac
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Port the Q8KV4 (E4M3 query, NVFP4 KV) paged sparse prefill work from vllm-project/MSA
dev(PRs 30 and 38)to
nv_dev, adapted to this tree's layout and contract. One commit. Outside the Q8KV4 prefill path only theQMUL4 toolchain probe moves: into
inference/msa_v1/_build_utils.py, shared by the decode attention anddecode indexer JITs.
Changes
Build the prefill for SM107
The extension was gated to SM100 and SM103, and its NVFP4 dequant used the QMUL4 instruction, which SM107 does
not have. Build it for
sm_107aas well (CUDA 13.5 or newer, the same rule as the decode), and choose the dequantpath with the toolchain probe the decode already had, now in the shared build utils: QMUL4 where the compiler
accepts it, otherwise a packed-FP16 multiply. The FP16 path gives the same bits: an E2M1 code times an E4M3 scale has
at most five significant bits, so the product is exact in FP16 and the only rounding is the final conversion to
E4M3, exactly as in QMUL4.
K1: remove the stalls around the MMA loop
K1 runs one CTA per (page, head, query split) and streams the queries through it in groups of 128 rows (8 queries
x 16 heads). Profiles on both GPUs showed the MMA waiting on the epilogue and the loader, not on memory. Four changes
address that; together they make K1 10 to 25 % faster on the formal shapes.
address, its 1/row-sum scale and its validity, then the whole warpgroup synchronized before handing the output
and statistics stages back to the MMA and softmax. Now compute the row state once per row (a thread holds four
rows per group) and reuse it for the row's chunks, and let each thread hand its stages back as soon as it holds
the data in registers, so the MMA refills the stage while the stores drain. One 64-column half of the
accumulator is processed at a time so the epilogue stays inside its 96-register budget.
across the loader warps. Now fetch eight groups of indices in one coalesced warp load into a 32-slot ring, let
every loader warp wait on its own stage, and deepen the Q ring from three to eight stages, so the Q loads run far
enough ahead of the MMA on long sequences.
select when the warp's rows see all 128 keys (a warp-uniform vote), which removes the select from the common case.
instruction. CUTLASS 4.8 provides it only in the shared-memory/shared-memory form, so the form with A in tensor
memory, which PV needs, is a local atom (
sm107_mma_ts.cuh).K1 also reads the partial buffer's split stride from the tensor instead of assuming contiguous slabs, and zeroes the
combine's tile counters at its start; both serve the combine below.
Combine: one streaming kernel for every prefill op
The combine merges, for every (query, head) row, the 16 per-page partial outputs and partial log-sum-exps the
forward wrote. It is a pure read of the partial buffer (4.3 GB at 16K tokens), and the previous kernel, which
loaded with per-thread
cp.asynccopies, was limited by instruction issue rather than by memory on both GPUs.Replace it with a kernel that keeps the memory system busy:
chunk in each split slab, so a tile of one split is a single bulk-tensor (TMA) copy.
tracked by one mbarrier per slot, so loads run ahead of the FP32 accumulation. On SM107, use two slots when the
partial buffer is interleaved across the locality domains; that was measured faster there.
STG.128stores, which is a 4x4 transposeof 32-bit pairs. The previous kernel put the tile through shared memory to restore the column order before the
output store. Now the four lanes that share a group exchange their pairs with two shuffle stages and each lane
issues one coalesced 128-bit store, without the extra shared-memory pass.
slabs are not 16-byte aligned, which TMA requires; then every thread brings its 4-byte entries in with
cp.async.caller in this tree.
The BF16 and Q8KV8 prefill wrappers call the same kernel. The file moves from the training-synced set of
vendor_manifest.jsonto the inference-local list, since it no longer mirrors the training combine.Place the partials by memory locality domain
Rubin, B200 and B300 report two memory locality domains through the CUDA driver API, and a read from the other
domain is slower than a local one. With the partial buffer interleaved across the domains, half of every combine
CTA's reads are remote, and the combine does not reach the local read rate.
Add
PrefillWorkspace, which owns the split partials of a wrapper (one may also serve several wrappers that runin order on one stream, sized up front with
required_bytes). On a device with more than one domain it builds thepartial buffer from 2 MB pages placed in alternating domains through the CUDA 13.4 virtual memory API and pads every
split slab to a multiple of the stripe, so page
pof every slab sits in domainp mod D. The combine deals itstiles to domains by page; each CTA reads its SM id, looks the domain up in a table the extension probes once per
device, claims the next tile of that domain from a per-domain counter, and steals from the other domain once its own
tiles are gone. K1 zeroes the counters at its start, so no memset node is needed and the launch sequence stays K1
then combine. On one domain, an older driver, or a refused localized allocation the workspace falls back to
ordinary device memory and the kernels run unchanged.
MM_SPARSE_PREFILL_Q8KV4_LOCALITY_DOMAINS=0forces thatlayout for comparisons.
Do not port vllm dev's early programmatic-dependent-launch trigger in K1: this tree compiles its
griddepcontrolcalls in and triggers at the end of each CTA's MMA work; vllm dev removed the early trigger again after measuring it.
Validation
Graph replay, real-case smoke, probability contract and shared-source manifest tests, decode attention and decode
indexer contract tests for the shared probe; full 512-case tier (1024 tests, three-run bitwise repeat).
kernel built on the FP16 dequant path (130 passed), and at CuTe DSL 4.5.2, the repository minimum (135 passed);
full 512-case tier 1024 passed. The QMUL4 and FP16 builds produce bitwise identical outputs on the real smoke shapes.
nv_devand untouched by this PR: the decode GQA=8 and decode-indexercontract tests on SM107.
bitwise identical on one part and 23/24 on another, where the single differing element is one BF16 ulp out of
134 M, caused by the SM107 K=64 MMA accumulation order (the K=32 build reproduces the base bitwise; LSE identical).
Performance
Formal metric:
benchmarks/inference/msa_v1/attention/prefill/q8kv4/benchmark.py --suite full(128 productionshapes, CUDA Graph replay of
wrapper.run, cold-cache rotation, representative-weighted mean). Base =nv_dev8b2e229; on Rubin the base also carries the SM107 build enablement (arch gates and the FP16 dequant) so that it runs.
Rubin, HBM 4752 MHz part (SM 2376 MHz, CUDA 13.5 aarch64, CuTe DSL 4.8.0, FP16 dequant)
B300 SXM6 (SM103, CUDA 13.5 toolkit, driver 615.71, CuTe DSL 4.8.1, QMUL4 dequant)
The one case below 1.0x on each GPU is a shape under 25 us, inside the suite's run-to-run noise (two port runs
differ by 1.000x weighted, 0.978x to 1.006x per case).
🤖 Generated with Claude Code