Skip to content

finegrained_fp8 models are silently mis-dequantized when loaded on CPU if a quantized weight has partial blocks #50603

Open

Description

@YancyLii

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:

  1. 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 of weight_block_size (external
    per-block quantization tooling emits such ceil-shaped grids by
    construction).
  2. 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"), and Fp8Dequantize derives
    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.
  3. 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_weight is 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):

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

  1. its-amann commented on Nov 11, 2026

    @its-amann

    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

  2. davrens101 commented on Nov 12, 2026

    @davrens101

    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_one derives the block size by equal division at src/transformers/integrations/finegrained_fp8.py:1152 (block_m = rows // scale_rows) and raises ValueError when the shape is not divisible by the scale grid (line 1148).
    • The partial-block-aware path _dequantize_fp8_weight (lines 285-303) uses repeat_interleave + [..., :rows, :] and a rem branch — i.e. the correct ceil-grid semantics.

    Reproducer (CPU-only, no downloads; same as the one in the issue body):

    import json, tempfile, warnings
    from pathlib import Path
    import torch
    from safetensors.torch import save_file
    from transformers import LlamaConfig, LlamaForCausalLM
    
    FP8_MAX = 448.0
    def quantize_reference(w):
        rows, cols = w.shape
        gr, gc = (rows + 127) // 128, (cols + 127) // 128
        codes = torch.zeros_like(w); scales = torch.zeros(gr, gc); deq = torch.zeros_like(w)
        for i in range(gr):
            for j in range(gc):
                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
                deq[sl] = codes[sl].to(torch.float8_e4m3fn).float() * mult
        return codes.to(torch.float8_e4m3fn), scales, deq
    
    def build(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 = {}, {}
        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
            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
    
    def load(ckpt, ground):
        m = LlamaForCausalLM.from_pretrained(str(ckpt), torch_dtype=torch.bfloat16)
        n = 'model.layers.0.mlp.gate_proj.weight'
        return int((m.state_dict()[n].float() - ground[n].to(torch.bfloat16).float()).abs().gt(0).sum())
    
    with tempfile.TemporaryDirectory() as td:
        root = Path(td)
        d, g = build(root, 256)
        print('hidden 256 (divisible): wrong elements =', load(d, g))
        d, g = build(root, 200)
        print('hidden 200 (partial):   wrong elements =', load(d, g))

    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.md I can only fix an issue I opened that a maintainer has labeled triaged, 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.

  3. davrens101 commented on Nov 12, 2026

    @davrens101

    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.

    import torch
    from transformers.integrations.finegrained_fp8 import Fp8Dequantize
    w = torch.ones(130, 128).to(torch.float8_e4m3fn)
    s = torch.tensor([[1.0], [2.0]])  # ceil(130/128)=2 row blocks: rows 0-127 -> 1.0, rows 128-129 -> 2.0
    out = Fp8Dequantize(None)._dequantize_one(w, s, output_dtype=torch.float32)
    print("row 65:", out[65, 0].item(), "(expected 1.0)")
    print("wrong rows:", int((out[:, 0] != torch.tensor([1.0] * 128 + [2.0] * 2)).sum()), "of 130")

    Output:

    row 65: 2.0 (expected 1.0)
    wrong rows: 63 of 130
    

    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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions