fix(FIX-FP8-BLOCKWISE-REFUSAL): refuse block-wise FP8 by name at load (#1166) - #1172
Merged
Conversation
…ntence (#1166) `Qwen/Qwen3.8-27B-FP8` is block-wise (fine-grained 128x128) FP8 and this tree implements per-tensor FP8 only. The load does stop, so this is not a silently-wrong-numerics defect, but it stops on a message that names the wrong thing. Measured live at revision `017b9c7af6b5689d5dd426a76e0bc077eb5ca20a` on 2026-08-17. The config declares `weight_block_size` `[128, 128]` and `activation_scheme` `dynamic`. The safetensors header of `layers-3.safetensors`, read by range request rather than downloaded, gives `self_attn.q_proj.weight` `F8_E4M3` `[12288, 5120]` beside `self_attn.q_proj.weight_scale_inv` `BF16` `[96, 40]`, which is exactly `[12288/128, 5120/128]`, and the shard holds zero `input_scale` tensors. `LoadFp8Raw` asks for `<proj>.weight_scale`, which this checkpoint spells `weight_scale_inv`, so the load ends on `tensor not found`. Nothing is missing from the checkpoint. The reader is sent after a tensor upstream never writes in this mode instead of being told the fine-grained arm is absent. The spec lands before the implementation, as the protocol requires. It records the upstream anchors at the parity pin, the placement decision between the two pre-load refusal sites, and the block-wise arm itself under `## Owed`. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
…#1166) `Qwen/Qwen3.8-27B-FP8` is block-wise (fine-grained 128x128) FP8, and this build implements per-tensor FP8 only. The load already stopped, so this was never wrong numerics. It stopped on the wrong sentence. The dense loader branches on the weight dtype alone (`qwen3_5_dense_weights.cpp:479`), and a block-wise weight really is `F8_E4M3`, so the projection entered the per-tensor arm and `LoadFp8Raw` asked for `<proj>.weight_scale` (`qwen3_5_weights.cpp:458`). A block-wise checkpoint spells that tensor `weight_scale_inv`, so the load died on `tensor not found: ...q_proj.weight_scale`. Nothing was missing from the checkpoint. The reader was sent after a tensor upstream never writes in this mode instead of being told that the fine-grained arm is absent. `RefuseUnsupportedFp8BlockQuant` now reads `quantization_config.weight_block_size` and refuses with a message naming the key, its value, the arm that is missing, the arm that works, where the scale actually lives, and this issue. It is sited in `ModelRegistry::Load` after `Resolve` and before `load_weights`, so an unsupported architecture still reports the architecture, and the guard covers every architecture and the GGUF arm rather than one loader. Mirrors `Fp8Config.from_config` and `Fp8LinearMethod` at the parity pin `5559679229bc961848b121ccdeaa8fa5d79bec98`: `vllm/model_executor/layers/quantization/fp8.py:161` reads the key, `:115-132` validates it, `:297-298` makes it the dispatch, and `:378-379` and `:511` register the block scale as `weight_scale_inv`. Evidence, CPU only, no GPU lease and no checkpoint download. The config and the tensor shapes were measured live at revision `017b9c7af6b5689d5dd426a76e0bc077eb5ca20a` by HTTP range request on the safetensors header. `test_fp8_block_quant` was RED first at 2 of 4 cases and 8 of 19 assertions, and is GREEN at 4 of 4 and 19 of 19. Every case enters through `ModelRegistry::Load` rather than through the predicate, so the reachability mutation applies: deleting the call site compiles cleanly and reds the target at 9 of 19 assertions, and the tree was restored byte-for-byte by sha256. The negative controls are not decorative. One holds that a per-tensor fp8 config still loads past the guard, without which the gate would pass for a refusal that fires on every fp8 checkpoint. One holds that null and empty are not block quant, mirroring the upstream default of None. `docs/FEATURES.md` claimed "Loads BF16/FP8/NVFP4", which overstated what this build reads, and its `fp8 weights` row was an unqualified check mark. Both are narrowed to per-tensor, and the refused arm gets its own row in the quantization table. `docs/USAGE.md` documents the refusal beside the checkpoints it applies to, so a reader who hits the message finds it. Block-wise execution itself stays OWED and is listed under `## Owed` in `.agents/specs/fp8-blockwise-refusal.md`. Issue #1166 stays open after this lands, because this change makes the gap legible and does not close it. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
Base moved while this row was in flight, and the trailer gates SKIP unless origin/main is an ancestor of HEAD. FOLLOWING_AGENTS_PROTOCOL Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [claude-code]
FOLLOWING_AGENTS_PROTOCOL Brings the branch up to `origin/main` so the committed-range and trailer gates examine this tree instead of skipping. A skipped gate reports nothing, and the preflight banner refuses that state even at exit 0. Following-Agents-Protocol: true AI-Assisted: true Assisted-by: AGENT:claude-opus-5 [Claude Code]
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.
Qwen/Qwen3.8-27B-FP8is block-wise (fine-grained 128x128) FP8, and this build implements per-tensor FP8 only. The load already stopped, so this was never wrong numerics. It stopped on the wrong sentence.The dense loader branches on the weight dtype alone (
qwen3_5_dense_weights.cpp:479), and a block-wise weight really isF8_E4M3, so the projection entered the per-tensor arm andLoadFp8Rawasked for<proj>.weight_scale(qwen3_5_weights.cpp:458). A block-wise checkpoint spells that tensorweight_scale_inv, so the load died ontensor not found: ...q_proj.weight_scale. Nothing was missing from the checkpoint. The reader was sent after a tensor upstream never writes in this mode instead of being told that the fine-grained arm is absent.RefuseUnsupportedFp8BlockQuantnow readsquantization_config.weight_block_sizeand refuses with a message naming the key, its value, the arm that is missing, the arm that works, where the scale actually lives, and this issue. It is sited inModelRegistry::LoadafterResolveand beforeload_weights, so an unsupported architecture still reports the architecture, and the guard covers every architecture and the GGUF arm rather than one loader.The verdict, since two other outcomes were plausible
Neither a clean refusal nor a silent misload. The third case, and the distinction decides the severity.
The silent-misload mechanism is real and was checked rather than assumed.
ReadF32Scalar(src/vllm/model_executor/models/qwen3_5_weights.cpp:312-318) bounds its input witht.nbytes >= sizeof(float), a LOWER bound, thenmemcpys the first 4 bytes. A[96, 40]block scale passes that check and reads as block(0,0), whichLoadFp8Transposed(:471, the dequant arm that needs noinput_scale) would then apply to the whole[N, K]weight. That is the shape a token gate cannot see.It does not fire, and the reason is the NAME rather than the shape guard. The lookup asks for
weight_scale, the checkpoint spells itweight_scale_inv, and the resolver (qwen3_5_dense_weights.cpp:682) throws before the scalar read happens. Upstream makes that spelling strictly conditional on block quant ("weight_scale_inv" if self.block_quant else "weight_scale",fp8.py:511), so no upstream block-wise checkpoint reaches it. The hole is therefore latent and unreachable by this route. It is recorded under## Owedrather than fixed, because hardening it is a separable unit of work.Measured, not inferred
config.jsonand one safetensors header read live at revision017b9c7af6b5689d5dd426a76e0bc077eb5ca20aby HTTP range request, with no checkpoint download and no GPU lease.quant_methodfp8weight_block_size[128, 128]activation_schemedynamicself_attn.q_proj.weightF8_E4M3[12288, 5120]self_attn.q_proj.weight_scale_invBF16[96, 40], exactly[12288/128, 5120/128]input_scaletensors in the shardgit grep weight_block_size -- src/ include/returned nothing before this change, and no load path readsweight_scale_inv.Upstream, at the parity pin
Mirrors
Fp8Config.from_configandFp8LinearMethodat5559679229bc961848b121ccdeaa8fa5d79bec98, verified withgit rev-parsein the oracle checkout before citing:vllm/model_executor/layers/quantization/fp8.py:161reads the key,:115-132validates it,:297-298makes it the dispatch, and:378-379and:511register the block scale asweight_scale_inv.Evidence
CPU only. RED first, then green.
test_fp8_block_quantat 2 of 4 cases and 11 of 19 assertions passing, exit 1.RefuseUnsupportedFp8BlockQuant(config)call site compiles cleanly (compile_rc=0, so this is a test failure and not a build failure) and reds the target at 10 of 19 assertions, exit 1. The tree was restored byte-for-byte, verified by sha256525b242f...before and after, withgit status --porcelainempty.scripts/agent-preflight.shexit 0,All gates green., and the real per-block counts sliced at the verdict line before.agents/NOW.mdis appended are 79ok, 0FAIL, 0SKIP.Every case enters through
ModelRegistry::Loadrather than through the predicate, which is why the mutation reds it. A unit test that called the predicate directly would prove the function works and never that a load reaches it.The negative controls are not decorative. One holds that a per-tensor fp8 config still loads past the guard, without which the gate would pass for a refusal that fires on every fp8 checkpoint. One holds that null and empty are not block quant, mirroring the upstream default of
None.Records
docs/FEATURES.mdclaimed "Loads BF16/FP8/NVFP4", which overstated what this build reads, and itsfp8 weightsrow was an unqualified check mark. Both are narrowed to per-tensor, and the refused arm gets its own row in the quantization table.docs/USAGE.mddocuments the refusal beside the checkpoints it applies to, so a reader who hits the message finds it. No checker was weakened and no budget was raised: the diff touches no file underscripts/, andMAX_CELL_CHARS,MAX_ROW_CHARSandUNOWNED_HIGH_WATERare unchanged. Thedocs/FEATURES.mdcell was paid for by shortening the row that owns it..agents/quantization-matrix.mdis deliberately untouched.QUANT-FP8-GENERICalready records the block axis atPARTIALandQUANT-FP8-PB-WOatINVENTORIED, and no row changes lifecycle state here.What stays owed
Block-wise execution itself, meaning reading
weight_scale_inv, applying a 128x128 block scale, and the dynamic per-token activation quant upstream pairs with it. That needs a GPU gate and a checkpoint, and it is listed under## Owedin.agents/specs/fp8-blockwise-refusal.md. Issue #1166 stays open after this lands, because this change makes the gap legible and does not close it.Closes nothing. Tracked by #1166
FOLLOWING_AGENTS_PROTOCOL
Following-Agents-Protocol: true
AI-Assisted: true
Assisted-by: AGENT:claude-opus-5 [claude-code]