Skip to content

prefill-attn-int8: cast the block id to int64 before the stride multiply (the #86 pattern in a second kernel) - #109

Merged
mhenrichsen merged 1 commit into
syv-ai:mainfrom
cpuchip:prefill-int8-int64-blk
Sep 15, 2026
Merged

mhenrichsen merged 1 commit into
syv-ai:mainfrom
cpuchip:prefill-int8-int64-blk

Conversation

@cpuchip

@cpuchip cpuchip commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

The #86 pattern, in a second kernel. The int8 prefill attention (patches/prefill-attn-int8.patch, prefill_attn_hd256.py) loads the block id from the int32 block table and multiplies it by the cache stride in int32, in all three of its kernels (_k_stats_kernel, _k_quant_kernel, _prefill_attn_kernel). On a pool larger than 2^31 elements divided by the block stride (bf16, block 880: block id 2,383 and up) a request whose blocks sit high in the pool reads the wrong memory. The #86 review said the other Triton kernels in the series were unaudited; this is the sweep's one hit. The int4, KVarN and Mamba kernels already cast.

  1. patches/prefill-attn-int8.patch: blk = blk.to(tl.int64) after each of the three block-table loads. Nothing else in the file changes; the hunk on flash_attn.py is untouched.
  2. bench/test_prefill_attn_bigpool.py: the same synthetic test shape as bench/test_spec_decode_bigpool.py, with a sharper oracle: one request's 20,000 tokens are placed at the top of a 2,600-block pool (blocks 2,577 to 2,599) and again at the bottom (0 to 22) with identical bytes, and the last 2,048-token prefill chunk is run at both placements on the exact-QK path and the int8-QK path; the two outputs must be bit-identical, and both are checked against an fp32 reference. Card-agnostic; needs about 10 GB free.

Measured on two boxes. WSL2, inside the 0.29-series image with the kernel file swapped: before the cast, the top-of-pool run dies with CUDA error: an illegal memory access was encountered; after it, top equals bottom bit for bit on both paths, max error against the reference 0.0002 (exact QK) and 0.0013 (int8 QK). Native 3090, against this branch's tree with a guard that the interpreter had loaded the 239-line zero-cast module: before the cast the top-of-pool run does not crash, it returns nan on both paths while the bottom-of-pool run still matches the reference to 0.0002; after the cast, top equals bottom, max error 0.0002 / 0.0010, exit 0. So the before-arm's tell is not "did it crash": on one card the wrong read is an illegal access and on another it is a silent nan, which is the worse failure, and the test's bit-equality against the bottom placement is what catches both.

Cost of the cast, measured after the first version of this body said it was not. Paired rows under the production launch line (CTX=fast SPEC=dflash2 DFLASH_TOKENS=7 PREFIX_CACHE=1 PREFILL_ATTN=int8 INT8_ACT=int8), fresh prefills of about 24k and 47k tokens, three each, medians, same WSL2 3090, images that differ by this cast and the #91 one: 4579 / 4011 tok/s with the casts against 4615 / 4051 without, so 0.8% / 1.0%, inside the run-to-run spread of that row. On a native 3090 with the cast in, int8 prefill against FA2 on the same boot config with the prefix cache off reads 2244 against 2190 tok/s at n=3 (direction only; separate boots on that box move a row by more than the gap), so the cast does not turn the kernel's gain into a loss.

Reach: as with #86, a 24 GB card never allocates a block id above about 2,383 on this profile, so this cannot fire on the reference 3090 or on this box; it is for the 64 GB cards, where the int8 prefill path (PREFILL_ATTN=int8, on by default in the production launch line) would read past its pool at the same block ids that killed the verify kernel.

Series: in patches/series order against a pristine v0.28.0 checkout, 32 applied, 0 rejected (33 entries, dflash2-backport skipped by design); the regenerated prefill-attn-int8.patch lands with one offset hunk and no fuzz, its new-file hunk 242 lines.

One thing this PR does not touch, found while measuring it: the patch reads VLLM_PREFILL_ATTN with a raw os.environ.get at two sites in flash_attn.py and never registers it, so every boot with PREFILL_ATTN set prints "Unknown vLLM environment variable detected: VLLM_PREFILL_ATTN". That read predates this PR and a rename diff only shows the changed lines, which is why the gate did not flag it here. The 0.29 port (#106) registers it in envs.py and reads it through envs, together with the other launcher-exported knobs that print the same line; say if you want the 0.28 line to carry the same change in this PR or in its own.

What is still unproven: no serving run at high block ids on any card (the same gap #86 left; the synthetic test is the proof); the three kernels' other index arithmetic (pos * stride_k8n, slot * stride_ks) stays int32 by construction because those operands are bounded by the block size and chunk, not the pool, and that is argued from the source, not measured; the cost figure is one box's paired rows and the native row is a direction, not a magnitude.

…ply (the syv-ai#86 pattern, second kernel)

All three kernels in prefill_attn_hd256.py (_k_stats_kernel, _k_quant_kernel, _prefill_attn_kernel) loaded
the block id from the int32 block table and multiplied it by the cache stride in int32, so on a pool larger
than 2^31 / stride_kb blocks (bf16, block 880: block id 2,383 and up) a request high in the pool read the
wrong memory, exactly as syv-ai#86 in the split-KV verify kernel. The syv-ai#86 review called the other kernels
unaudited; this is the sweep's one hit (the int4, KVarN and Mamba kernels already cast).

bench/test_prefill_attn_bigpool.py is the synthetic proof, card-agnostic: one request's 20,000 tokens at the
top of a 2,600-block pool and again at the bottom with identical bytes, the last 2,048-token chunk run at
both placements on the exact-QK and int8-QK paths; outputs must match bit for bit and both are checked
against an fp32 reference. Before the cast the top-of-pool run dies with an illegal memory access; after it,
top equals bottom on both paths, max error 0.0002 (exact QK) and 0.0013 (int8 QK) against the reference.
Unreachable on a 24 GB card, as with syv-ai#86; it is for the 64 GB cards running PREFILL_ATTN=int8.
cpuchip added a commit to cpuchip/qwen38-27b-rtx3090 that referenced this pull request Sep 14, 2026
… fork; the syv-ai#91 and prefill int64 casts

Rebased over syv-ai#54 (patches/series), syv-ai#91, syv-ai#99 and syv-ai#101. patches/series lists the 0.29 set: the two files
0.29.0 retired (vllm-pr54282-draft-gumbel-salt, xgrammar-spec-terminated) leave the list and
offload-mtp-serve.patch (syv-ai#100) joins it after mamba-align-retire-null-gaps. The Dockerfile, README loop,
verify.sh and check_vllm_series.sh apply in series order, at --fuzz 0 as before, and the int8 knob check
asks the live registry.

Every patch file is re-exported from cpuchip/vllm qwen38/0.29 at c68ae347f. Content changes: the split-KV
verify kernel casts the block id to int64 before the stride multiply (syv-ai#86/syv-ai#91, the same three lines the 0.28
series has since e79596b), and the int8 prefill kernel casts at its three block-table loads (the syv-ai#86
pattern in a second kernel; the 0.28 fix is syv-ai#109). triton-prefill-attn-int8.patch is prefill-attn-int8.patch
here as on main. Three files (dflash2-prewarm, dflash2-z-adaptive-emitted, prefill-attn-int8) carried the
whole diff a second time as stray lines above the first file header, a leftover of the import from the old
patch files; the fork commit bodies are prose only now and the preambles follow.
@cpuchip

cpuchip commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

Body updated with two things learned while vetting the 0.29 port, no change to the diff:

  1. The cast's cost is measured now instead of "expected": paired rows under the production launch line, three fresh prefills each at about 24k and 47k tokens, medians 4579 / 4011 tok/s with the casts against 4615 / 4051 without (0.8% / 1.0%, inside that row's spread) on a WSL2 3090; on a native 3090 with the cast in, int8 prefill still reads above FA2 at n=3 with the prefix cache off (2244 against 2190, direction only).
  2. This patch reads VLLM_PREFILL_ATTN with a raw os.environ.get and never registers it, so every boot with PREFILL_ATTN set prints "Unknown vLLM environment variable detected". That predates this PR and is outside its diff; vLLM 0.29.0 port is ready on our fork (patch series, KVarN, #100 and #101 carried); PR when you want it #106's port registers it with the other launcher-exported knobs. Your call whether the 0.28 line gets it here or separately.

@mhenrichsen

Copy link
Copy Markdown
Contributor

Merging. The cast is right, the reproducer discriminates, and it costs nothing on our card.

Unlike #91 (where the first push was test-only), the kernel change is really in the diff here: the new-file hunk header goes 239 -> 242 and three blk = blk.to(tl.int64) lines land, one after each block-table load in _k_stats_kernel, _k_quant_kernel and _prefill_attn_kernel. That is the full coverage: every K/V pointer expression downstream reuses that same blk, and the only other pointer bases index by pos/tok, which are bounded by the chunk rather than the pool. Nothing else in the kernel multiplies a block id.

Reproduced both arms on a native 3090, production stopped, card free before each launch, power limit untouched:

# arm A: installed kernel as it is on main (239 lines, 0 casts), triton + __pycache__ wiped
$ venv/bin/python bench/test_prefill_attn_bigpool.py
int8_qk=False: top==bottom NO; max|ours-ref| bottom 0.0002 top nan  FAIL
int8_qk=True : top==bottom NO; max|ours-ref| bottom 0.0010 top nan  FAIL

# arm B: the same file with your three casts (242 lines), caches wiped again
int8_qk=False: top==bottom yes; max|ours-ref| bottom 0.0002 top 0.0002 OK
int8_qk=True : top==bottom yes; max|ours-ref| bottom 0.0010 top 0.0010 OK

Identical bytes at the top of a 2,600-block pool came back nan (we get nan rather than your illegal access; the test accepts either) and the cast is the thing that flips it. Your test is also the only unit coverage this repo has for prefill_attn_hd256.py, and its call matches the real signature, so neither arm can silently skip.

Cost, since PREFILL_ATTN=int8 is live for us: paired, one process, both module versions loaded, arms alternated per rep, n=15, production prefill geometry (chunk 2048, 24/4 heads, D=256, block 880), two independent runs.

int8_qk=True   L=24000: +0.94% / +0.32%   (run 1 / run 2, median)
int8_qk=True   L=47000: -1.99% / -1.04%
int8_qk=False  L=24000: -3.84% / -4.36%
int8_qk=False  L=47000: -3.40% / -3.08%

The production path is inside +-2% with the sign flipping between shapes in both runs, i.e. the same codegen noise we concluded on #91. The exact-QK path coming out 3-4% faster with a cast added is the tell: read it as Triton codegen luck, not as a speedup this PR delivers. Your WSL2 rows (-0.8% / -1.0%) disagree with ours only in sign, not in magnitude; both say "nothing".

Series integrity against a pristine v0.28.0 clone: bash patches/check_vllm_series.sh <pristine> gives 32 patches applied, 0 offsets, on this branch and on main alike. No new patch file, so patches/series is unchanged. Production restored afterwards: kernel back to md5 36da7ed4, health 200, 250 W.

Three things stated honestly rather than left implied:

  1. Reach. This is unreachable at 24 GB: overflow needs block id > 2^31/(8804256) ~= 2,383, and our pool never gets near that. So this proves the kernel was buggy and your cast fixes it, not that our deployment was ever wrong. It is a fix for 64 GB users on PREFILL_ATTN=int8, and inert-but-free for everyone else.

  2. Correcting your PR body on one point, and it is our error not yours: single-user/start_qwen.sh leaves PREFILL_ATTN empty by default, so int8 prefill is not on in a stock clone. It is on for us only via our own int8-prefill.conf service drop-in.

  3. Your envs.py question: separately, not here. The raw os.environ.get predates this PR; let vLLM 0.29.0 port is ready on our fork (patch series, KVarN, #100 and #101 carried); PR when you want it #106's 0.29 port carry the registration and we will backport a one-liner to the 0.28 line ourselves if the "Unknown vLLM environment variable" line starts annoying us.

Not verified: I hand-installed the three casts into the installed .py to isolate the kernel change, so the full patch -p1 install path is covered only by the series check above; and pass 2 of check_vllm_series.sh does not evaluate cleanly in my staging of the pristine tree, but it fails identically on main, so it carries no signal about this PR. No server boot, no quality battery -- the change is kernel-local and I measured its only entry point in both arms.

#108 forward-references this fix, so it can go in next.

@mhenrichsen
mhenrichsen merged commit a22cfe8 into syv-ai:main Sep 15, 2026
1 check passed
mhenrichsen added a commit to cpuchip/qwen38-27b-rtx3090 that referenced this pull request Sep 15, 2026
The int8 prefill kernel's cast and its bigpool test are not on main yet;
they are PR syv-ai#109. Drop the qualifier when that lands.
mhenrichsen added a commit that referenced this pull request Oct 18, 2026
… (CUDA, sm80/86/89, opt-in) (#325)

* pthfa: vendor the pth-flash-attn int8 per-token-head attention source at ab6bc1c

pthfa/ holds the source of the pth-flash-attn vLLM backend plugin (Apache-2.0)
at one pinned commit: the shipped subset that tools/export_upstream.py writes
(the op library's translation units and headers, test hooks stripped, and the
pth_flash_attn package), plus setup.py, pyproject.toml, tools/build_flags.py,
LICENSE and NOTICE unchanged. Nothing builds or loads it yet.

pthfa/PIN names the commit, pthfa/SHA256SUMS the 32 files, and
pthfa/check_vendor.sh checks them (and, with --source, re-exports the pinned
commit from a checkout and diffs). PATCHES.md says why pthfa/ is outside the
patch series: it is a separate extension, and no vLLM file changes.

Signed-off-by: noon-at-cgn <75719527+noon-at-cgn@users.noreply.github.com>

* pthfa: install.sh, a CPU gate test, and the Docker layer that builds the op library

pthfa/install.sh compiles the op library against the venv's torch for
sm_80/86/89 (SASS only, no PTX), pip-installs the pth_flash_attn package with
its vllm.general_plugins entry point, checks the cubin arch list, and runs
pthfa/tests/gate_test.py. A rerun with the same pin, arch list, torch and nvcc
is a no-op. No GPU and no network.

The gate test checks, against the venv's vLLM: the op library loads without a
GPU; the entry point registers the backend as CUSTOM, idempotently and never
over another plugin's CUSTOM; CUSTOM is in no automatic-selection list, so only
an explicit selection reaches it; the declared limits; that an unsupported
explicit selection fails at load with its reason; and that the speculative
config takes "attention_backend":"CUSTOM" for the drafter.

The Dockerfile builds it in its own layer before COPY . ., so an edit elsewhere
does not recompile it, and runs the gate test again after the patch series and
KVarN are applied. Nothing selects the backend yet.

Signed-off-by: noon-at-cgn <75719527+noon-at-cgn@users.noreply.github.com>

* launchers: PTH_FA=1 puts SPEC=mtp CTX=long on int8 KV with the pth-flash-attn backend

PTH_FA is a single-user knob, default 0. With PTH_FA=1 on SPEC=mtp CTX=long the
launcher passes --attention-backend CUSTOM --kv-cache-dtype int8_per_token_head
instead of the arm's fp8 cache, and adds "attention_backend":"CUSTOM" to the
MTP speculative config, so the drafter runs on the same backend. Nothing else
in the argv changes: max len, seqs, the compilation config and the CUDA-graph
mode stay the arm's own (decode and verify batches are captured FULL).

Refused at startup, with the reason: any other SPEC/CTX; --attention-backend,
--kv-cache-dtype or --speculative-config in EXTRA_ARGS (they would override
it); a KV connector (KV_OFFLOAD_GB, --kv-offloading-size, --kv-transfer-config),
untested on this backend; a venv without the package and its op library
(bash pthfa/install.sh); a value other than 0 or 1. Batch mode warns that it
ignores the knob. The effective-config block names it only when it is 1.

bench/test_pthfa_knob.sh checks all of that against a stub vllm, and that
unset, 0 and empty give the same argv and stderr on every single-user arm.
With PTHFA_BASE_TREE it also compares every arm against the base tree's
launchers byte for byte. It and pthfa/check_vendor.sh run in patch-integrity.

Signed-off-by: noon-at-cgn <75719527+noon-at-cgn@users.noreply.github.com>

* bench: test_pthfa_attn.py checks the pth-flash-attn op on a GPU, with designed skips

The op against an fp64 reference on the cache views the CUSTOM backend passes
it: head sizes 64/128/256, the target's GQA group at TP=1 and TP=2, decode,
MTP verify and chunked prefill, both cache layouts, one and several KV splits,
bf16 and fp16, against the kernel's derived per-element tolerance.

The #86/#109 case: one request's blocks at the top of a 2,600-block pool of
816-token blocks (block ids past 2,383, byte offsets past 2^32), every other
block poisoned so that a wrapped offset shows, at L = 4,000 against the
derived tolerance and at 20,000 and 131,072 against the bigpool test's 0.05.
And a captured launch replayed after seq_lens grew on the device equals the
eager launch bit for bit.

It prints SKIP(by design) and exits 0 with no CUDA device, without the
package, and on a card other than sm_80/86/89. Not run on a GPU yet.

Signed-off-by: noon-at-cgn <75719527+noon-at-cgn@users.noreply.github.com>

* docs: PTH_FA=1 in long-context.md with its launch line and rows, gotcha 63 for its limits

docs/long-context.md gains a section for the route: what it is, the launch
line and the settings it resolves to, verbatim, including the
"attention_backend":"CUSTOM" inside the speculative config, what is refused,
and the measured rows. Every row says whose hardware produced it: two RTX 3080
20 GB at TP=2 on vLLM 0.30.0, stock power, the TRITON_ATTN arm back to back on
the same int8 cache; the capped pair is marked as not back to back. What is
not measured is listed as such, with a placeholder for the A/B on 0.31.0.

Gotcha 63 states the limits (sm80/86/89, head sizes 64/128/256,
int8_per_token_head only, no sliding window, ALiBi, sinks or soft cap, no
tuning knobs) and why the knob is gated to SPEC=mtp CTX=long. The knob table
in single-user/README.md, the README's doc index and docs/docker.md name it.

Signed-off-by: noon-at-cgn <75719527+noon-at-cgn@users.noreply.github.com>

* pthfa: check_vendor.sh --source fails by name on a commit without the export tool

A PIN commit that predates tools/export_upstream.py ended the check with
python's file-not-found and exit 2, which the script documents as a usage
error. It now prints that the commit cannot be the source of this copy and
exits 1, like every other mismatch.

Signed-off-by: noon-at-cgn <75719527+noon-at-cgn@users.noreply.github.com>

* pthfa: the gate test runs on a builder without a GPU driver

The Docker build failed at pthfa/install.sh: the gate test imports
vllm.platforms.cuda for its backend priority lists, and that module imports
vllm._C_stable_libtorch, which links libcuda.so.1. A GPU-less builder (this
repo's own docker-image workflow, a local docker build) has no libcuda, so the
import raised before any check ran.

The priority lists are plain Python. When, and only when, that import fails
for want of libcuda, the test puts a placeholder module in sys.modules for the
extension inside its own process and imports vllm.platforms.cuda again; it
says which way it went. Nothing is written to disk and no stub library is
used. With a driver present the real module is used.

The test also fails by name, instead of with a traceback, when the entry point
is missing or does not register the backend.

Signed-off-by: noon-at-cgn <75719527+noon-at-cgn@users.noreply.github.com>

* docs: the PTH_FA=1 rows on vLLM 0.31.0, measured with this branch's image

The placeholder in docs/long-context.md is replaced by the A/B on this
branch's pin: two RTX 3080 20 GB at TP=2, capped state (220 W), four arms back
to back in one sitting (PTH_FA=1, main's image on TRITON_ATTN int8, this
image with PTH_FA unset as the control, PTH_FA=1 again). 128k TTFT 127.8
against 261.8 s, decode at 128k 86.2 against 44.1 tok/s, 32k TTFT 18.98
against 26.89 s, 8k 5.61 against 6.15 s; MTP tokens per step and GSM8K
unchanged; the control is 0.987-1.005 of main's image on every row. The
caveats stay: one box, sm86 only, not the reference 3090, TP=1 and the
non-sm8x guard not run. Gotcha 63 and the knob table say 0.31.0 is measured.

Signed-off-by: noon-at-cgn <75719527+noon-at-cgn@users.noreply.github.com>

* docs: gotcha 64, prompt_logprobs on a 32k prompt OOMs on vLLM 0.31.0 at GPU_UTIL=0.94

Seen on two RTX 3080 20 GB at TP=2 with the SPEC=mtp CTX=long production
shape: a prompt_logprobs=10 request on a 32k prompt kills the engine with a
CUDA OOM in the V2 runner's prompt-logprob path (1024-token chunks, logits
all-gathered over the vocabulary: 486 MiB wanted, 208 MiB free). It happens on
PTH_FA=1, on TRITON_ATTN int8 and on a FlashAttention bf16-KV reference alike,
so it is the engine and the 0.94 sizing, not this branch; GPU_UTIL=0.90
served the same requests on all three.

Signed-off-by: noon-at-cgn <75719527+noon-at-cgn@users.noreply.github.com>

* pthfa: PIN records the source commit only, without a repository URL

PTH_REPO was read by no script; the only other reference was a link in pthfa/README.md.
check_vendor.sh --source still takes a local checkout that has PTH_COMMIT.

Signed-off-by: noon-at-cgn <75719527+noon-at-cgn@users.noreply.github.com>

* bench: test_pthfa_knob pins FA2_FP8KV=0

On a compute capability 8.6 host an unset FA2_FP8KV resolves to 1 on SPEC=dflash2 CTX=long
since #319, and the stub venv has no fa2 plugin, so that arm refused for a reason unrelated
to PTH_FA.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Signed-off-by: noon-at-cgn <75719527+noon-at-cgn@users.noreply.github.com>
Co-authored-by: Mads Henrichsen <mads@syv.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
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.

2 participants