Repository navigation
[ROCm][DSv4.1][Perf] One-pass dequantize and a gather-sized grid for the compressed K cache - #56720
Conversation
8e2b44a to
3578acb
Compare
|
This pull request has merge conflicts that must be resolved before it can be |
|
Hi @JohnQinAMD can you make your table covers all the way to context length limit 1M? |
3578acb to
e633e8a
Compare
e633e8a to
436296f
Compare
Hi @Fangzhou-Ai — the kernel table already runs to the 1M limit: at a 1,048,576-token gather it is 20359.1 us → 912.0 us, 22.3x. That is the number that scales with context, and it is measured at every power-of-two step from 8k up. Thanks Kernel timeMI355X, one request,
|
436296f to
4069c92
Compare
|
✅ @JohnQinAMD, CI is now available for this PR.
|
daefa9a to
c082ed8
Compare
…thers
dequantize_and_gather_k_cache_triton launched a fixed (num_reqs, 128) grid.
Each worker then walks its slice of the sequence one token at a time, and
every step is a dependent load: block table entry, then the page it points
at, then the token inside it. The loop is bound by that chain's latency
rather than by bandwidth, so the grid has to hold enough of those chains in
flight to cover it.
At batch size 1 that whole grid is 128 workgroups on a 256-CU gfx950, while
a V4.1 prefill gathers the entire compressed prefix once per layer: about
3.1k serial steps per worker at a 399k-token context. A caller that knows a
host-side bound on gather_len now passes it and the grid follows, aiming for
eight steps per worker. The DeepSeek-V4.1 ROCm compressed-KV gather takes
that bound from prefill_seq_lens_cpu, which the metadata already carries, so
nothing new syncs with the device. Callers that pass no bound keep the
previous width, so the CUDA paths are unchanged.
The same file's sliding-window gather keeps the old width on purpose: it
reads at most window_size plus the chunk's query tokens, and in the profile
below its 40 calls total 0.49 ms against the compressed gather's 261.7 ms.
Measured on MI355X TP4, DeepSeek-V4.1-Flash, vllm bench serve with
--random-output-len 8 so the number is prefill. Mean TTFT over 40 prompts at
concurrency 4, reported as the mean of eight repetitions +- their standard
deviation. A repetition replays the previous one's prompts, so the first is
a cold prefill and the rest run against a populated prefix cache, which is
the agentic shape this model serves.
128 workers grid by length
32k, warm cache 214.8 +- 8.5 186.3 +- 8.3 -13.3%
32k, cold 2672.9 2321.9 -13.1%
131k, warm cache 446.9 +- 8.0 320.8 +- 5.3 -28.2%
131k, cold 13453.0 12105.0 -10.0%
The arms do not overlap at 131k: 437-460 ms against 311-328 ms across the
eight repetitions. The gain grows with context because the gather walks the
whole prefix once per layer, and it is larger on a warm cache because a cold
prefill spends most of its time on the newly computed tokens instead.
torch profiler on rank 0, one 399k-token request with 217 newly computed
tokens, shows where it comes from: of 78 gather calls, the 38 that gather a
full compressed prefix drop from a median of 8835 us to 1093 us, and the
kernel falls from 262.2 ms to 32.4 ms of a prefill that goes from 403.7 ms
to 195.5 ms.
A width of 8192 is where this stops paying. Sweeping the grid by hand on a
1M-token gather, with the per-token dequantize already vectorized, 2048 and
8192 workers are within 3% of each other and 65536 buys 2.5%; at 131k a width
of 65536 is 12% *slower* than 2048. The cap is not what bounds the long-context
number.
The grid only partitions tokens across workers: num_workers appears once, as
the stride of the per-token loop, and every store addresses the row of the
token the iteration is on. No atomics, no reduction, one writer per output
row, so the result cannot depend on the width. Gathering the same paged
cache at widths 128, 512, 4096 and 8192 gives byte-identical output, checked
locally against the V4.1 op. Note that the dequantize_and_gather_k_cache
cases in tests/kernels/test_compressor_kv_cache.py exercise the
deepseek_v4 copy of this kernel, not the deepseek_v4_1 one changed here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed-off-by: JohnQinAMD <yanyuan.qin@amd.com>
c082ed8 to
553640e
Compare
_dequantize_and_gather_k_kernel walks each token's 448 fp8 bytes in seven 64-byte quantization blocks, loading a scale byte per block and storing the result per block, then copies the 64 bf16 tail in four chunks of sixteen. The seven blocks tile 448 exactly, so the `qblock_start < fp8_dim` branch and the `offsets < fp8_dim` masks never exclude anything; what they cost is twenty-two memory operations per token where four will do. The MXFP8 record added for the FlashMLA V4.1 layout already reads its token in one load, scales it with a reshape, and stores it once. This gives the fp8_ds_mla record a kernel of the same shape. scale_dim * quant_block is 512 against an fp8 region of 448, so the tail of the tile reads into the token's own bf16 bytes, is scaled by a scale nothing reads, and is dropped by the store mask; the token record is 576 bytes, so the read stays inside it, and a tl.static_assert holds that. It is a second kernel rather than a branch inside the first because the first is shared: deepseek_v41/nvidia/flashmla.py reaches it whenever the MXFP8 and NVFP4 records do not apply. Measuring and shipping a CUDA change is not in this PR's scope, and writing it this way leaves that kernel unedited -- the only line this commit removes is the launch it replaces with a choice between the two. The signatures are identical, so the choice is one expression and pointing CUDA at the one-pass kernel later is deleting it. The cost is a duplicated preamble of twenty-six lines. Output is bit-identical. Gathering the same paged cache both ways over three batch shapes, block sizes and gather lengths gives byte-equal bf16 out, with NaN payloads in the same positions -- random uint8 fill produces e4m3 NaN bit patterns at the expected rate and those compare unequal to themselves, so the comparison treats NaN positions separately. Kernel time on MI355X, measured with torch.profiler over ten calls after five warm-ups, both arms sized by the same grid: 1 request, 8192 tokens 19.2 us -> 7.2 us -63% 1 request, 65536 tokens 117.1 us -> 35.1 us -70% 4 requests, 32768 tokens 223.9 us -> 66.4 us -70% Signed-off-by: JohnQinAMD <yanyuan.qin@amd.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Nothing in the tree exercised `_dequantize_and_gather_k_kernel`: the `dequantize_and_gather_k_cache` cases in this file reach the deepseek_v4 copy, and the V4.1 case above it covers the MXFP8 record, which is a different kernel. The two changes ahead of this one therefore landed with no test. The new case asserts the two properties they rest on. The grid only partitions tokens across workers -- num_workers appears once, as the stride of the per-token loop, and every store addresses the row its iteration is on -- so four different widths over the same paged cache must give byte-equal output. And the rows themselves are checked against a host-side UE8M0 dequantization, which is what catches a wrong tile or a wrong scale in the one-pass form; the RoPE dims are stored verbatim, so those come back exactly. Both dequantization forms are covered on whatever device is present. The wrapper selects the one-pass form on ROCm alone, so without the parametrize each platform would only ever see the form it ships, and the two would never be held against the same reference. The marker is `is_cuda_alike`, not the `is_cuda` its neighbours use: this kernel runs on both, and `is_cuda` is false on ROCm, so the case would have skipped on the platform the two changes were measured on. Signed-off-by: JohnQinAMD <yanyuan.qin@amd.com> Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
553640e to
44fe9e9
Compare
|
/ci run |
|
✅ Triggered Buildkite CI #89687 for commit |
@Fangzhou-Ai this PR is modifying the common path file but mainly add optimized triton kernel that only called by ROCm and not by CUDA path. It has no impact on CUDA platform. |
|
@zyongye can you review the shared part of this PR? It changes We have seen the improvement on MI325X (gfx942) with |
CI selector (shadow): 7 test steps (15 jobs) instead of 33 (46 jobs)Shadow mode: this changes nothing about what CI runs. It shows what the evidence-based selector would pick for this PR, next to today's rules. How it works. Feedback welcome: reply here if it would skip a step this change needs, or runs something unrelated.
Selector would run (7)
Would skip (today's rules run them) (31)
Would add (today's rules do not run them) (5)
AMD mirrors: would skip (30)
AMD mirrors: would add (8)
3 changed files · base |
Signed-off-by: JohnQinAMD <yanyuan.qin@amd.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
/ci run |
|
✅ Triggered Buildkite CI #92926 for commit |
@njhill @zyongye gentle ping on the shared part. The common/ops/cache_utils.py change only takes effect on ROCm: dequantize_and_gather_k_cache gains an optional max_gather_len that defaults to None, which keeps the existing 128-program grid, and the new one-pass kernel is selected only under current_platform.is_rocm(), so CUDA keeps its kernel and grid. Upstream CI is green on the current head, and a re-run there on MI355X keeps the kernel times within 1 % of the description and takes 131k warm TTFT from 336 to 205 ms. |
Move dsv41flash-fp4-mi355x-vllm-agentic-dspark from nightly-rocm100-0cbac6cd to nightly-rocm100-21d93d0d (published 2026-10-06T05:42:44Z, digest sha256:6367ca66a372c36ec0d00ad2f47c848b09620559f6748ab467b11be59488f143), 151 commits ahead. It carries vllm-project/vllm#56720, #57947, #58560, #59731 and #59794 (AITER 0.1.24.post1). Recipe container and master image stay equal; docs and perf-changelog record the new pin. 将 dsv41flash-fp4-mi355x-vllm-agentic-dspark 从 nightly-rocm100-0cbac6cd 重新固定到 nightly-rocm100-21d93d0d(2026-10-06T05:42:44Z 发布,digest 为 sha256:6367ca66a372c36ec0d00ad2f47c848b09620559f6748ab467b11be59488f143), 领先 151 个提交,包含 vllm-project/vllm#56720、#57947、#58560、#59731 与 #59794(AITER 0.1.24.post1)。配方 container 与 master image 保持一致; 文档与 perf-changelog 同步记录新镜像。 Co-authored-by: Cursor <cursoragent@cursor.com>
Purpose
dequantize_and_gather_k_cache_tritonwalks a V4.1 prefill's whole compressed prefix once per layer, and it did so on a fixed(num_reqs, 128)grid with a per-token loop that issued twenty-two memory operations. Two changes here: the grid follows the length being gathered, and a token is dequantized in one pass. They compose — the first raises how many dependent-load chains are in flight, the second cuts the work each chain does.Sizing the grid
Each worker walks its slice of the sequence one token at a time, and every step is a dependent load: block table entry, then the page it points at, then the token inside it. The loop is bound by that chain's latency rather than by bandwidth, so the grid has to hold enough chains in flight to cover it. At batch size 1 the whole grid was 128 workgroups on a 256-CU gfx950, while a V4.1 prefill gathers the entire compressed prefix once per layer: about 3.1k serial steps per worker at a 399k-token context.
A caller that knows a host-side bound on
gather_lennow passes it and the grid follows, aiming for eight steps per worker. The DeepSeek-V4.1 ROCm compressed-KV gather takes that bound fromprefill_seq_lens_cpu, which the metadata already carries, so nothing new syncs with the device. Callers that pass no bound keep the previous width, so the CUDA paths are unchanged. The same file's sliding-window gather keeps the old width on purpose: it reads at mostwindow_sizeplus the chunk's query tokens.Dequantizing in one pass
The per-token loop walked the 448 fp8 bytes in seven 64-byte quantization blocks, loading a scale byte per block and storing per block, then copied the 64 bf16 tail in four chunks of sixteen. The seven blocks tile 448 exactly, so the
qblock_start < fp8_dimbranch and theoffsets < fp8_dimmasks never excluded anything; what they cost is twenty-two memory operations per token where four will do.The MXFP8 record added for the FlashMLA V4.1 layout already reads its token in one load, scales it with a reshape, and stores it once. This gives the
fp8_ds_mlakernel the same shape.scale_dim * quant_blockis 512 against an fp8 region of 448, so the tail of the tile reads into the token's own bf16 bytes, is scaled by a scale nothing reads, and is dropped by the store mask; the token record is 576 bytes, so the read stays inside it, and atl.static_assertholds that.Kernel time
MI355X, one request,
torch.profilerover ten calls after five warm-ups, same inputs on every arm:The grid's own gain settles at −88% from 32k up, because
gather_num_workerscaps at 8192 and a 1M gather wants more; the one-pass dequantize then takes a further 62–71% at every length, since it changes the work per token rather than the parallelism. The combined factor peaks at 131k and eases to 22x beyond it: past the cap the grid stops widening, so the longest contexts keep the per-token win but not the rest of the parallelism one.A width of 8192 is where widening stops paying. Sweeping the grid by hand on a 1M-token gather, with the dequantize already in one pass, 2048 and 8192 workers are within 3% of each other and 65536 buys 2.5%; at 131k a width of 65536 is 12% slower than 2048. The cap is not what bounds the long-context number.
Correctness
The second commit adds a kernel rather than editing one: ROCm gets
_dequantize_and_gather_k_one_pass_kernel, CUDA keeps_dequantize_and_gather_k_kernel. Neither had a test — the existing cases import the deepseek_v4 copy, and the two V4.1 cases cover the 528 B MXFP8 and 288 B NVFP4 records, both SM100-only. The 584 B record these two kernels decode is the only one a ROCm V4.1 server gathers.The third commit covers both kernels, parametrized on the ROCm dispatch so each runs wherever the test lands. It checks that grid width cannot move the output —
num_workersis only the per-token loop stride and each iteration writes its own row, so four widths must be byte-equal — and that the rows match a host-side UE8M0 dequantization, which is what catches a wrong tile or scale.End-to-end
MI355X, TP 4, DeepSeek-V4.1-Flash, vLLM at
af1c01499(this branch's parent, so the arms differ only in this PR's two files), [official MI355X recipe][recipe] config minus its--speculative-config. Two things the recipe omits are needed to boot:--ulimit memlock=-1on the container (the engram table pins 23.6 GiB per rank) and #57036 in every arm, without which the AITER fused-expert path indexes the expert weights with the -1 cudagraph padding sentinel and all ranks fault during the startup profile run.vllm bench serve --random-output-len 8so the number is prefill, 40 prompts per repetition at concurrency 4. A repetition replays the previous one's prompts, so the first is cold and the rest hit the prefix cache. Warm rows are the mean of eight repetitions ± their standard deviation; the whole table was measured twice with identical seeds and agrees to within 2 %.The changes compose at 131k: the grid takes 36.6 %, the one-pass dequantize a further 10.3 %. The 32k cold row is four fresh-server runs per arm and every arm's spread is ~5 %, so it should not be read as a gain — a microbenchmark puts the gather at ~1 % of a cold 32k prefill, against 11 % of a warm one.
Percentiles over the 320 pooled warm requests per cell (
--save-detailed); a single repetition puts only four requests above p90 and swings by 25 %:GSM8K (1319 questions, 5-shot, temperature 0,
tests/evals/gsm8k) with tests/evals/gsm8k/gsm8k_eval.py : 0.9022 on the merge base against 0.9045 with the branch. Both runs report a zero invalid rate.GSM8K via InferenceX's lm-eval config (
infx/evals/gsm8k.yaml, 5-shot, chat template, strict and flexible extraction): 0.9765 / 0.9757 on the unmodified nightly image against 0.9742 / 0.9735 with this PR's two files appliedBoth arms carry the #57036 padding-sentinel fix, without which the server does not start on MI355X.
Notes
The grid width becomes data-dependent. This call site is prefill-only, and
num_workersis read inside the kernel throughtl.num_programs(1), so any width is correct — a replayed capture would use the width it recorded and only lose the speedup. #56686 goes the other way for the candidate-block kernels, which do run inside decode captures.The sliding-window gather in the same file keeps the old width: 40 calls totalling 0.49 ms in the profile above.
deepseek_v4carries a copy of this kernel with the same fixed 128 and is left alone; that path deserves its own numbers.#42504 attacks this kernel from a different angle — gather only the top-k slots the sparse attention reads, instead of dequantizing the whole prefix and discarding most of it. That is the larger win and this does not conflict with it: after this change the gather is still 32.4 ms of a 97.8 ms prefill kernel budget.
🤖 Generated with Claude Code