Conversation
The fp8 KV read routed every prefill to the scalar CUDA-core flash kernel, 3.7x slower than the same kernel on a bf16 cache, because FA-2 admits only bf16 q/KV/out while the fp8 store presents f32. Record the dense per-layer dequant shape, the two ladder levers it replaces and why, and the operator gate that stays owed. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:opencode-go/deepseek-v4.1-flash [pi]
The fp8 KV-cache read sent every prefill to the scalar CUDA-core flash kernel at f32 q/out, because the model presents f32 for a non-bf16 store and the vendored FA-2 admission requires bf16 q, bf16 KV and bf16 out. That is the whole 3.5x: nsys showed 25.1s of 36s in PagedFlashKernel<float, unsigned char, ...> at 8k, while the bf16 store ran the FA-2 split-KV kernel at 2.9ms/layer. Dequantize the fp8 cache ONCE per layer into a dense bf16 scratch and run the normal bf16 dispatch on it. The scratch is one block per request with block_size = max_seq and an identity block table, so the kernel's paged address IS the dense address and no attention kernel changes. The model presents bf16 for an fp8 store through the new shared `KvCachePresentsBf16` (the scratch is bf16), which admits FA-2 with zero cast kernels; the CUDA path is model-agnostic, so any model that gains an fp8 store inherits it. VT_ATTN_FP8_DENSE=0 restores the per-read dequant for a same-binary A/B. 32k prefill (no spec, 2048-token chunks, host embedding): 34.9s / 938 tok/s, against 36.4s / 901 for the bf16 store and 39.9s / 822 for llama.cpp's q8_0 KV, at 20.5 GiB against 22.5. 8k: 7.6s / 1000 tok/s. Decode with MTP n=3: 38.6 tok/s against 37.4 before. The op-level parity case pins the fp8 cache against the f32 reference at 1.9e-6 max abs err (the exact dequantized values); all 33 paged-attention cases pass. benchmarks/paged_attn_prefill_ab.cpp and its CMake target land with it: one vt::PagedAttention call per arm, so the kernel cost is separated from the engine. These numbers are the author's run on the local sm_120a card with host embedding on; the operator's same-binary A/B under lease stays owed and is recorded in the spec. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:opencode-go/deepseek-v4.1-flash [pi]
localai-org-maint-bot
left a comment
There was a problem hiding this comment.
The new dense-scratch path leaks device memory on exceptions. In LaunchPagedFp8Out, the first cudaMallocAsync allocates dense, then the identity-table allocation, copy, launch checks, and LaunchPaged can all throw. Both frees occur only at the end of the success path. For example, if the identity-table allocation fails after the much larger dense allocation succeeds, Check throws and no owner frees dense. A failed request can therefore retain its scratch for the process lifetime and leave less memory for later requests.
Please use stream-aware scope guards/RAII for both allocations immediately after each successful allocation. Cover an injected failure after the first allocation, plus one after both allocations, and verify both buffers are released without masking the original exception.
The new benchmark also says every arm is verified against a bf16 reference with maximum absolute error, but it runs only the selected arm, prints a checksum, and returns zero without a comparison. Please implement the stated comparison or correct that claim; a checksum alone does not establish attention parity.
I read the complete diff at a40ba50. These are static findings; CUDA parity, failure injection, and the model speed/memory gate remain unexecuted here. @mudler @richiejp
The upstream review found that `LaunchPagedFp8Out` freed the dense bf16 scratch and the identity table only at the end of the success path. The identity allocation, the identity copy, the dequant launch, the bf16 dispatch it feeds, and the vectors between them can all throw, so a failed request held both buffers for the process lifetime. Each allocation is now owned by a stream-aware scope guard from the moment it succeeds; the guard frees on the same stream, so the free stays ordered behind the work that reads the buffer, and it never throws, so the original exception is not replaced. The success path keeps its explicit checked frees through `release()`. A new case in `test_ops_paged_attn` injects the two failures a healthy device cannot produce on demand (the identity allocation, and the identity copy after both allocations succeeded) through a test-only seam, and checks that the CUDA memory pool's used bytes return to their pre-call value after the unwind and that the propagated message is the failing `Check`. The same review found the A/B benchmark claimed a bf16 parity comparison it did not perform. The header now states what the binary does (one arm, a checksum, no parity gate) and points at `test_ops_paged_attn.cpp`, which holds the f32-reference gate. Verified in an MSVC 17.14 x64 Release CPU-only build: `test_ops_paged_attn` compiles (the new case links through its CPU no-op stubs and skips at runtime without a CUDA backend) and `vllm_paged_attn_prefill_ab` builds. Execution of the injected case needs a CUDA device; the CUDA lane is its gate. Refs ISSUE-LOCAL-01M3QG4WWC3X0C84M9PWQZAJ10 FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: opencode-go:deepseek-v4.1-flash [pi]
|
Repairs pushed at Scratch ownership. The dense bf16 scratch and the identity table are now each Coverage. A new case in Benchmark claim. The header no longer claims a bf16 parity comparison it does Evidence: MSVC 17.14 x64 Release CPU-only — |
|
Addressed in
Both injected failure points you asked for are covered by Verified on Linux with CUDA + CUTLASS at arch MUTATION of the guarantee that case exists for — disable the scope guard's free and rebuild Restored, the case is 4/4 green again. The happy path is confirmed end to end too: with the scratch guards in place, fp8 KV generates the same 8 tokens as bf16 on the official |
## Row `LOAD-MODELOPT-NVFP4-BORROW` — one row per PR. Spec: `.agents/specs/nvfp4-one-owner-per-resident.md`. ## Before starting - Issue/PR search: no open issue described this leak. Issue #1647 (ModelOpt borrow) is a different defect in the same row and closed in flow. Filed `ISSUE-LOCAL-01M3QDT6A8JEM8WNJTPR16MXHR`. - Pull request shape selected at row claim: one PR for the spec and the implementation. - Row: `LOAD-MODELOPT-NVFP4-BORROW` in `.agents/engine-matrix.md`; the row spec `load-modelopt-nvfp4-borrow.md` owns `ResidentNvfp4`'s upload and adoption path. - Anchors inspected: `Nvfp4Weight` (`qwen3_5_weights.h:672-712`), `OwnedTensor::d_dev` (`:195`), both `ResidentNvfp4` copies (`dense_nvfp4_gemm.h:319`, `qwen3_5.cpp:1449`), and every release site (`dense_nvfp4_gemm.h:448,667`; `qwen3_5.cpp:2952,3132,6890`; `laguna.cpp:650`). ## What changed `Nvfp4Weight` published one device allocation on two owning handles: the type-specific `d_packed`/`d_scale` and the generic `packed.d_dev`/`scale.d_dev` slot that `AdoptDeviceBytesAsHost` keys on. Every Marlin repack builder reset only the type-specific pair, so the `d_dev` alias kept a full packed+scale device copy alive for the process lifetime — once per repacked weight, and once per expert per projection on an MoE model. That undoes the repack's whole point ("then FREE the fp4 originals") on exactly the 24 GiB cards the NVFP4 arms serve from. The change makes `d_dev` the one owner. The type-specific members are deleted (a remaining reader fails to compile), both upload copies publish into `d_dev` directly, and `Nvfp4Weight::ReleaseResident()` is the single release: it drops an adopted host twin view first (that view holds the same block on a host-addressable device), then resets both slots. `scripts/check-fp4-resident-consistency.py`, which pinned the old two-handle contract, is updated in the same change: its publication clause now requires a backend-owned allocation and a new clause requires the release to reset BOTH slots with the twin drop first. ## Evidence RED, before the fix (the new case, against unmodified `main`): ``` $ ./build/tests/test_load_direct_upload -tc="fp4 resident: releasing the resident frees both device buffers" CHECK( b.frees == 2 ) is NOT correct! values: CHECK( 0 == 2 ) test cases: 1 | 0 passed | 1 failed | 16 skipped ``` The invariant is that a release through the weight's own resident state frees both buffers. Pre-fix it freed zero: the surviving `packed.d_dev` alias owned the block. GREEN, after the fix: ``` $ ./build/tests/test_load_direct_upload test cases: 17 | 17 passed | 0 failed | 0 skipped assertions: 206 | 206 passed | 0 failed ``` Checker, including its mutation suite: ``` $ python3 scripts/check-fp4-resident-consistency.py OK: 2 ResidentNvfp4 definition(s) across 2 file(s) count the upload, copy, publish d_dev, and adopt — per buffer, for both packed and scale — and ReleaseResident() resets both d_dev slots. $ python3 -m unittest tests.scripts.test_check_fp4_resident_consistency Ran 53 tests ... OK ``` The new release clause goes red under each live-tree mutation of `qwen3_5_weights.h`: drop `packed.d_dev.reset()`, drop `scale.d_dev.reset()`, drop either adopted-twin drop, move a twin drop after its reset; and the publication clause under `= nullptr` or a bare alias of another object's `d_dev`. - [x] `python3 scripts/check-agent-record.py` OK; commit style and trailer contracts OK for both commits - [x] tests that cover this change: `tests/vllm/test_load_direct_upload.cpp`, `tests/scripts/test_check_fp4_resident_consistency.py` - [x] public docs unchanged (no user-facing fact changed) - [x] full `ctest --test-dir build`: **795 of 809 pass**, and every one of the 14 failures reproduces on a clean `origin/main` (`b45a94273`) build: `test_model_loader_gguf`, `test_gliner2_e2e`, `test_gdn_v_head_permute`, `test_deepseek_v4_exl3_loader`, `test_qwen4_exp_layer_loop`, `test_glm4_moe_lite_paged_engine`, `test_bench`, `test_bench_kv_cache_dtype`, `test_bench_eos_chat_template`, `test_capi`, `test_qwen3_dense_async_serving`, `test_qwen3_paged_engine` (each run as a base binary, exit 1), plus `test_cpu_threadpool` (segfault/abort 3/3 on base) and `test_serve_low_tools` (fails on base). `test_serve_deepseek_v4_mm` hangs in `accept()` on this box and is `Not Run` in the base ctest. - [ ] full `scripts/agent-preflight.sh` — RED ON THIS BOX AT BASE. Each failing checker was re-run on a clean `origin/main` worktree and fails there too; none of the 35 red gates touches a file this PR changes. ## End-to-end A/B on the official NVFP4 safetensors checkpoint The unit gate proves the release frees; this proves the release MATTERS on a real artifact. `ukisai/Swift-1.5-Qwen3.8-27b-NVFP4` (21.94 GB, 5 shards, public and ungated), the ModelOpt NVFP4 + FP8 checkpoint the local GGUF is converted from, run on the local `sm_120a` card (RTX PRO 4000 Blackwell, 24 GiB) under the file mutex, same command, same binary build recipe, only the branch differs: ``` $ VT_LOAD_STATS=1 ./build-cuda/examples/vllm-cli --model /tmp/models/Swift-1.5-Qwen3.8-27b-NVFP4 \ --device cuda --kv-cache-dtype fp8 --max-num-seqs 1 --kv-cache-memory 2000000000 \ --max-tokens 8 --prompt "The capital of France is" --temperature 0 ``` | Tree | Result | |---|---| | **This branch (fix)** | exit 0: generated 8 tokens (`" Paris.\nThe capital of Germany is"`), 1.064 tok/s, `device_upload=12.044 GiB` | | `main` + the unrelated PermuteVHeads CUDA kernel (PR #3359) | **exit 1: `engine-fatal: EngineCore busy loop threw: vt cuda: marlin_repack: malloc bqt: out of memory`** at `device_upload=8.619 GiB` | | `main` + the fp8-KV prefill change (PR #3360) | **the same OOM**, same `device_upload=8.619 GiB` | Two independent trees without the fix die in the NVFP4 Marlin repack; the fix reaches the same repack and finishes. That is the leak: `BuildMarlinDenseResident` repacks a weight and frees the fp4 originals only through the type-specific handle, and the surviving `d_dev` alias keeps a full packed+scale copy alive per repacked weight — so the first forward runs out of card. The repack's own comment says the free is the point ("we do this repack lazily on first forward and then FREE the fp4 originals"), and on a 24 GiB card a second copy is the difference between serving and OOM. ## Speed claims - [x] This PR makes NO speed claim. It frees a leaked allocation; no timing was taken and none is implied. ## Honest gaps - No end-to-end GiB/RSS number. The gate proves the release frees both buffers through the fake backend; it does not measure a repacking serve's process RSS. - The mutation evidence is author-run. A fresh reviewer over the immutable head still owes its own mutation pass. - `scripts/agent-preflight.sh` on this box is red at base (35 gates): record drift in `.agents/completed/` and `docs/benchmarks/`, supported-models drift, the oracle pin mismatch (`e126687a9a` vs `a7c23ac96d` in `upstream-sync.md`), and one environment FAIL (`file` is not installed for the release-archive test). Every one reproduces on clean `main`, and none touches a changed file. Refs ISSUE-LOCAL-01M3QDT6A8JEM8WNJTPR16MXHR FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:opencode-go/deepseek-v4.1-flash [pi] --------- Co-authored-by: dev <dev@local>
Row
KV-FP8— one row per PR. Spec:.agents/specs/fp8-kv-prefill-dense-dequant.md.Before starting
KV-FP8W2 (#1593)landed the fp8 store and read-dequant for correctness; no issue covered the
PREFILL performance half. Filed
ISSUE-LOCAL-01M3QG4WWC3X0C84M9PWQZAJ10.implementation.
cuda_paged_attn.cu:148-197(the W2 fp8 read and itsper-element dequant),
:2909,:2954(the dispatch split that names thescalar arm),
kv_cache_route.h:40,73-78, and the 33-case suite.What changed
The fp8 KV-cache read sent every prefill to the scalar CUDA-core flash kernel at
f32 q/out, because the model presents f32 for a non-bf16 store and the vendored
FA-2 admission requires bf16 q, bf16 KV and bf16 out. nsys attributes the whole
gap: 25.1 s of 36 s in
PagedFlashKernel<float, unsigned char, …>at 8k, against2.9 ms/layer for the bf16 store on the FA-2 split-KV kernel.
The fp8 cache is now dequantized ONCE per layer into a dense bf16 scratch, and
the normal bf16 dispatch runs on it. The scratch is one block per request with
block_size = max_seqand an identity block table, so the paged address IS thedense address and no attention kernel changes. A new shared
KvCachePresentsBf16presents bf16 for an fp8 store, which admits FA-2 with zerocast kernels; the CUDA path is model-agnostic, so any model that gains an fp8
store inherits it.
VT_ATTN_FP8_DENSE=0restores the per-read dequant for asame-binary A/B.
benchmarks/paged_attn_prefill_ab.cpp(new target) is theisolated sweep: one
vt::PagedAttentioncall per arm, so kernel cost isseparated from engine cost.
Two earlier shapes of the same lever are recorded as rejected rather than
deleted, because both are plausible and neither survives the numbers:
(8k 28.0 -> 9.1 s) — the per-element dequant stays on every re-stream;
__nv_cvt_fp8_to_halfrawstaging conversion — bit-identical andcheap, but still per re-stream.
Evidence
Build:
-DVLLM_CPP_CUDA=ON -DVLLM_CPP_CUTLASS_DIR=<local CUTLASS 4.x> -DVLLM_CPP_CUDA_ARCHITECTURES=120aon the localsm_120acard (RTX PRO 4000Blackwell, 24 GiB). The 1.87755e-06 is the 1.9e-6 the commit body records — the
exact dequantized values, tighter than the bf16-compute envelope.
The new case pins the fp8 cache against the f32 reference at 1.9e-6 max abs err —
the exact dequantized values, tighter than the bf16-compute envelope — with the
other 33 cases unchanged.
The e2e numbers in the commit body are the AUTHOR's run on the local
sm_120acard, 27B NVFP4 arm, 2048-token chunks, no spec,
VT_HOST_EMBEDDING=1, with thebf16 store and llama.cpp's q8_0 KV as denominators on the same box:
They are recorded as the shape's shape evidence, NOT as a gate: the operator's
same-binary A/B under lease is owed.
scripts/agent-preflight.shisRED AT BASE on this box (35 gates) and each failing gate was re-run on a
clean
origin/mainworktree and fails there tootests/vt/test_ops_paged_attn.cppVT_ATTN_FP8_DENSEis a kernel-internal A/B knob,not a user-facing surface
Integration run on the official checkpoint
This branch alone cannot run the 27B artifact: without #3355's NVFP4 ownership
fix the first forward dies in
marlin_repack: malloc bqt: out of memory(theleak is characterized in PR #3355). The runs below therefore use a scratch tree
with #3355, #3356 and this change merged (not pushed), which is how the arm was
exercised end to end on the local
sm_120a24 GiB card:"The capital of Germany is")with the same prompt, both exit 0.
--max-tokens 2: fp8 5.063 s vs bf16 4.996 s(284.4 vs 288.2 prefill tok/s) — equal within noise at this length. The
commit body's 938-vs-901 tok/s is a 32k-chunked-prefill measurement; this short
run does not reproduce it and is NOT offered as a speed result.
This is a smoke test of the arm, not this PR's gate; the gate is the op parity
above.
Review follow-up, verified on Linux CUDA
The review's findings were fixed in
b2f195e19and re-verified here. Linux,-DVLLM_CPP_CUDA=ON -DVLLM_CPP_CUTLASS_DIR=<local> -DVLLM_CPP_CUDA_ARCHITECTURES=120a:MUTATION of the guarantee the new case exists for — disable the scope guard's
free and rebuild
cuda_paged_attn.cu:Both injected failures (the identity allocation, and the identity copy after
both allocations succeeded) are covered, the original exception propagates, and
the pool-used-bytes assertion is what catches the leak when the guards are
removed.
The happy path is confirmed end to end on the official checkpoint in the
integration run above: fp8 KV generates the same 8 tokens as bf16 with host
embedding on (
device_upload=9.676 GiB, exit 0).Speed claims
are the author's, on a local card. The operator A/B under
${GPU_LOCK}andthe
docs/BENCHMARKS.mdentry are owed and named in the spec.Honest gaps
docs/BENCHMARKS.mdentry, and nodgx:gpu0reproduction. The numbers are one card, one artifact.
cudaMallocAsyncunder thestream; its own cost is inside the measured prefill time, not separated from
it (the bench exists so a reviewer can separate them).
scripts/agent-preflight.shis red at base (record drift, thee126687a9avsa7c23ac96doracle pin mismatch,check-test-registration'sCMake probe, the missing
filetool); every failing gate reproduces on cleanmain.Refs ISSUE-LOCAL-01M3QG4WWC3X0C84M9PWQZAJ10
FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:opencode-go/deepseek-v4.1-flash [pi]