Repository navigation
Description
System Info
- transformers version: 5.22.1
- Platform: Linux-5.x-x86_64 (CPU-only)
- Python version: 3.12.15
- Huggingface_hub version: 2.4.0
- Safetensors version: 0.8.0
- Accelerate version: 1.15.0
- PyTorch version: 2.15.0+cpu (CPU build)
- CUDA/GPU: none — CPU-only host, which is exactly the forced-dequantize path
The same wrongness is bit-identical on transformers 5.17.0 (checked at the
Fp8Dequantize._dequantize_one level), and the relevant code is unchanged
on current main (source-checked 2026-11-11: the equal-division derivation
at ~L1152, the loud ValueError at ~L1148, and the partial-aware
_dequantize_fp8_weight helper at ~L285 all coexist).
Who can help?
@SunMarc @MekkCyber (quantization)
Reproduction
Steps to reproduce the behavior:
- On a host without GPU/XPU, load a finegrained_fp8 checkpoint
(quant_method: "fp8", the DeepSeek checkpoint family) whose quantized
linear weights have partial 128-blocks — i.e. any hidden/intermediate
dimension that is not a multiple ofweight_block_size(external
per-block quantization tooling emits such ceil-shaped grids by
construction). - transformers forces
dequantize=True("Using FP8 quantized models
requires a GPU or XPU, we will default to dequantizing the model to
bf16 since no GPU or XPU is available"), andFp8Dequantizederives
the block size from the scale grid by equal division
(block_m = rows // scale_rows), while the format's semantics are
ceil grids with partial edge blocks. - The load succeeds silently and the weights are wrong. On a GPU host
the same checkpoint loads correctly (the quantized path's own
_dequantize_fp8_weightis partial-block aware).
Self-contained script (tiny locally generated Llama checkpoints in the
DeepSeek fp8 format, no downloads; the same script is archived next to
this issue as issue-reproducer.py):
import json
import os
import tempfile
import warnings
from pathlib import Path
warnings.filterwarnings("ignore")
os.environ.setdefault("HF_HUB_OFFLINE", "1")
import torch
from safetensors.torch import load_file, save_file
from transformers import LlamaConfig, LlamaForCausalLM
from transformers.integrations.finegrained_fp8 import (
Fp8Dequantize,
_dequantize_fp8_weight,
)
BLOCK, FP8_MAX = (128, 128), 448.0
def quantize_reference(w):
"""Quantize per the format's reference semantics (ceil grids)."""
rows, cols = w.shape
grid_r, grid_c = (rows + 127) // 128, (cols + 127) // 128
codes = torch.zeros_like(w)
scales = torch.zeros(grid_r, grid_c)
deq = torch.zeros_like(w)
for i in range(grid_r):
for j in range(grid_c):
sl = (slice(i * 128, (i + 1) * 128), slice(j * 128, (j + 1) * 128))
mult = w[sl].abs().max().clamp(min=1e-12) / FP8_MAX
codes[sl] = torch.clamp(w[sl] / mult, -FP8_MAX, FP8_MAX)
scales[i, j] = mult
# post-round truth: what any consumer reads back from the checkpoint
deq[sl] = codes[sl].to(torch.float8_e4m3fn).float() * mult
return codes.to(torch.float8_e4m3fn), scales, deq
def build_checkpoint(root, hidden):
torch.manual_seed(0)
cfg = LlamaConfig(hidden_size=hidden, intermediate_size=512, num_hidden_layers=2,
num_attention_heads=4, num_key_value_heads=4, vocab_size=128,
max_position_embeddings=256, tie_word_embeddings=False,
architectures=["LlamaForCausalLM"])
model = LlamaForCausalLM(cfg).to(torch.bfloat16)
tensors, ground, qnames = {}, {}, []
for name, t in model.state_dict().items():
if name != "lm_head.weight" and any(s in name for s in
("q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj")):
codes, scales, deq = quantize_reference(t.float())
prefix = name[: -len(".weight")]
tensors[name], tensors[prefix + ".weight_scale_inv"] = codes, scales
tensors[prefix + ".activation_scale"] = torch.tensor(1.0)
ground[name] = deq
qnames.append(name)
else:
tensors[name], ground[name] = t.detach().clone(), t
d = root / f"ckpt-h{hidden}"
d.mkdir(parents=True)
save_file(tensors, str(d / "model.safetensors"))
cd = cfg.to_dict()
cd["quantization_config"] = {"quant_method": "fp8", "activation_scheme": "dynamic",
"weight_block_size": [128, 128]}
cd["torch_dtype"] = "bfloat16"
(d / "config.json").write_text(json.dumps(cd, indent=2, sort_keys=True))
return d, ground, qnames
def load_and_compare(ckpt, ground, qnames):
model = LlamaForCausalLM.from_pretrained(str(ckpt), torch_dtype=torch.bfloat16)
sd = model.state_dict()
bad = 0
for n in qnames:
bad += int((sd[n].float() - ground[n].to(torch.bfloat16).float()).abs().gt(0).sum())
return bad
def main():
with tempfile.TemporaryDirectory() as td:
root = Path(td)
# control: every linear dim divisible by the 128 block
d, g, q = build_checkpoint(root, 256)
print("hidden 256 (divisible): wrong elements =", load_and_compare(d, g, q))
# defect: hidden 200 -> partial row blocks, grid (2, .), 200 % 2 == 0
d, g, q = build_checkpoint(root, 200)
print("hidden 200 (partial): wrong elements =", load_and_compare(d, g, q))
# mechanism: the same codes+scales through both in-repo implementations
t = load_file(str(d / "model.safetensors"))
qn = next(n for n in q if n.endswith("q_proj.weight"))
p = qn[: -len(".weight")]
class _Q:
class quantization_config:
scale_fmt = "float"
cpu = Fp8Dequantize(_Q())._dequantize_one(t[qn], t[p + ".weight_scale_inv"],
output_dtype=torch.float32)
helper = _dequantize_fp8_weight(t[qn], t[p + ".weight_scale_inv"], [128, 128],
torch.float32)
gt = g[qn]
print("q_proj (200, 200), grid (2, 2):")
print(" Fp8Dequantize._dequantize_one (CPU load path): max diff",
float((cpu - gt).abs().max()))
print(" _dequantize_fp8_weight (partial-aware): max diff",
float((helper - gt).abs().max()))
if __name__ == "__main__":
main()Actual output (transformers 5.22.1, CPU-only host):
[transformers] Using FP8 quantized models requires a GPU or XPU, we will default to dequantizing the model to bf16 since no GPU or XPU is available
hidden 256 (divisible): wrong elements = 0
hidden 200 (partial): wrong elements = 168915
q_proj (200, 200), grid (2, 2):
Fp8Dequantize._dequantize_one (CPU load path): max diff 0.0129743292927742
_dequantize_fp8_weight (partial-aware): max diff 0.0
All 14 quantized tensors of the hidden_size=200 model are wrong (each
200×200 tensor has 10416/40000 wrong elements): rows 100–127 fall into the
second derived block (100 rows each) although the quantizer's blocks were
[0:128] + [128:200], so they are dequantized with the wrong per-block
scale.
A second variant — a dimension whose size does not divide by its grid
count — fails loudly instead (also wrong: the reference consumer loads
such checkpoints fine):
ValueError from _dequantize_one (e.g.
intermediate_size=131, weight (256, 131), grid (2, 2))
File ".../transformers/integrations/finegrained_fp8.py", line 1149, in _dequantize_one
raise ValueError(
ValueError: Weight shape (256, 131) not divisible by scale grid (2, 2).
Reference semantics for weight_scale_inv grids (why the above checkpoint
is legal): vLLM's fp8 utils dequantizes with
repeat_interleave(block, ...)[:_N, :_K] (partial-block aware), and
transformers' own _dequantize_fp8_weight — used by the quantized path's
backward in the same file — truncates [..., :rows, :] and handles a
rem branch. Only the CPU forced-dequantize path assumes equal blocks.
Expected behavior
Loading the same fp8 checkpoint on a CPU-only host should produce the same
weights as on a GPU host. Concretely, Fp8Dequantize._dequantize_one
should apply the ceil-grid block semantics grid cell i covers rows
[i*128, min((i+1)*128, rows)] — exactly what _dequantize_fp8_weight
already implements — instead of block_m = rows // scale_rows; and a
scale grid inconsistent with ceil(rows / weight_block_size[0]) should
fail loudly rather than being reinterpreted by an arbitrary factorization
(the current code accepts any grid that divides the shape, e.g. a (1, 4)
grid for a 256×256 weight would silently use 64-column blocks).
Activity
its-amann commentedon Nov 11, 2026
i can take this. plan is to make Fp8Dequantize._dequantize_one use the same ceil grid blocks as _dequantize_fp8_weight, raise when the scale grid doesn't match ceil(shape / weight_block_size), and add a CPU load test with partial blocks
davrens101 commentedon Nov 12, 2026
This is the same bug I was going to report, so I'm adding my reproducer here rather than opening a duplicate issue.
I verified the mechanism against current
main(cloned 2026-11-11):Fp8Dequantize._dequantize_onederives the block size by equal division atsrc/transformers/integrations/finegrained_fp8.py:1152(block_m = rows // scale_rows) and raisesValueErrorwhen the shape is not divisible by the scale grid (line 1148)._dequantize_fp8_weight(lines 285-303) usesrepeat_interleave+[..., :rows, :]and arembranch — i.e. the correct ceil-grid semantics.Reproducer (CPU-only, no downloads; same as the one in the issue body):
Expected on CPU-only:
hidden 256 (divisible): wrong elements = 0,hidden 200 (partial): wrong elements = 168915(all 14 quantized tensors wrong; the partial-aware helper gives max diff 0.0).I'm not opening a PR: per
CONTRIBUTING.mdI can only fix an issue I opened that a maintainer has labeledtriaged, so I'll wait for that label.Disclosure: this comment was drafted with the assistance of an AI coding assistant; I have not run the reproducer locally.
davrens101 commentedon Nov 12, 2026
Follow-up: I have now run a smaller, op-level reproducer on current
main(c527bbf, CPU-only, no downloads). My earlier comment said I had not run the checkpoint reproducer, so this one is verified.Output:
Here
130 % 2 == 0, so the divisibility check passes and the block size comes out as 65 instead of 128. Rows 65-127 are silently scaled with the wrong block.@its-amann has said they will take this, so I won't open a competing PR. If that falls through and a maintainer labels the issue
triaged, I'm happy to send the fix.Disclosure: drafted with the assistance of an AI coding assistant. The output above is from a run I did.