File size: 8,982 Bytes
8e4dac5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
"""Group-wise weight quantization with exact size accounting.

Simulated ("fake") quantization: weights are quantized then dequantized back to
bf16, so the saved checkpoint is still a normal HF model that vLLM and
`transformers` load unchanged. That is not a hack — it mirrors the submission
pipeline exactly, where ``convert_to_hf_checkpoint`` must emit a full bf16 model.
The *scored* size is computed analytically from the recipe, not from the bytes on
disk.

This lets us answer the question that decides our compression ceiling — how far
can each layer group be pushed before long chain-of-thought breaks — without
first committing to a packed format or a quantization toolchain.

Round-to-nearest is deliberate as a starting point: it is the floor, needs no
calibration data, and is enough to *rank* layer-group sensitivity.

But it is a worse floor than we assumed. arXiv 2606.25519 measures CoT *token
inflation* by quantizer on Qwen3-4B at group size 128, and RTN is the worst of
the lot: +42.5% at INT4 against GPTQ's +12.0% and rotation-based ParoQuant's
+4.7%. At INT3 the spread across methods reaches 10x. Since our accuracy is
gated by truncation, token inflation is the quantity that actually costs us
points -- so moving off RTN is worth considerably more here than the "1-2
points" that reconstruction-error framing would suggest.
"""

from __future__ import annotations

import re
from dataclasses import dataclass
from typing import Any, Iterable

import torch

KEEP_BITS = 16  # bf16 passthrough


@dataclass(frozen=True)
class QuantSpec:
    """Quantization for every parameter whose name matches ``pattern``.

    ``group_size`` groups along the input dimension (the last axis); 0 means one
    group per output channel. Smaller groups cost more scale bytes but track the
    weight distribution better.
    """

    pattern: str
    bits: int
    group_size: int = 128
    symmetric: bool = True

    def bits_per_weight(self) -> float:
        """Effective stored bits per weight, including scale/zero-point overhead."""
        if self.bits >= KEEP_BITS:
            return float(KEEP_BITS)
        if self.group_size <= 0:
            return float(self.bits)
        # fp16 scale per group, plus an int zero-point per group when asymmetric.
        overhead = 16 + (0 if self.symmetric else self.bits)
        return self.bits + overhead / self.group_size


def quantize_dequantize(
    weight: torch.Tensor, spec: QuantSpec
) -> torch.Tensor:
    """Round-trip ``weight`` through ``spec``'s grid, returning the same dtype."""
    if spec.bits >= KEEP_BITS:
        return weight

    original_dtype, original_shape = weight.dtype, weight.shape
    group_size = spec.group_size if spec.group_size > 0 else original_shape[-1]
    if original_shape[-1] % group_size != 0:
        # Fall back to per-channel rather than silently mis-grouping.
        group_size = original_shape[-1]

    flat = weight.reshape(-1, group_size).float()

    if spec.symmetric:
        qmax = 2 ** (spec.bits - 1) - 1
        scale = (flat.abs().amax(dim=1, keepdim=True) / qmax).clamp(min=1e-8)
        q = torch.round(flat / scale).clamp(-qmax - 1, qmax)
        out = q * scale
    else:
        qmax = 2**spec.bits - 1
        wmin = flat.amin(dim=1, keepdim=True)
        wmax = flat.amax(dim=1, keepdim=True)
        scale = ((wmax - wmin) / qmax).clamp(min=1e-8)
        zero = torch.round(-wmin / scale)
        q = torch.clamp(torch.round(flat / scale) + zero, 0, qmax)
        out = (q - zero) * scale

    return out.reshape(original_shape).to(original_dtype)


class Recipe:
    """Ordered specs; first match wins, anything unmatched stays bf16."""

    def __init__(self, specs: Iterable[QuantSpec]):
        self.specs = list(specs)
        self._compiled = [(re.compile(s.pattern), s) for s in self.specs]

    def spec_for(self, name: str) -> QuantSpec | None:
        for regex, spec in self._compiled:
            if regex.search(name):
                return spec
        return None


# The vision tower and MTP head are never executed by a text-only math eval, and
# a real submission simply drops them (llama.cpp's text-only conversion of this
# model does exactly that). They are excluded from both quantization and the size
# accounting. We nonetheless keep them *present* in the saved checkpoint, because
# vLLM refuses to load a text-only Qwen3_5 config (vllm#39231).
EXCLUDED = r"visual|vision_tower|(^|\.)mtp\."


def apply_recipe(
    model: torch.nn.Module, recipe: Recipe, device: str | None = None
) -> dict[str, Any]:
    """Fake-quantize ``model`` in place; return per-group size accounting.

    Tied tensors are counted once — Qwen3.5 ties ``lm_head`` to ``embed_tokens``,
    and double-counting would overstate the checkpoint by 1.27 GB.
    """
    excluded_regex = re.compile(EXCLUDED)
    seen_storage: set[int] = set()
    groups: dict[str, dict[str, float]] = {}
    total_bits = 0.0
    total_params = 0
    excluded_params = 0

    with torch.no_grad():
        for name, param in model.named_parameters():
            if excluded_regex.search(name):
                excluded_params += param.numel()
                continue
            spec = recipe.spec_for(name)
            bits = spec.bits_per_weight() if spec else float(KEEP_BITS)

            if spec is not None and spec.bits < KEEP_BITS:
                target = param.data.to(device) if device else param.data
                quantized = quantize_dequantize(target, spec)
                param.data.copy_(quantized.to(param.data.device))

            pointer = param.data_ptr()
            if pointer in seen_storage:
                continue  # tied alias: already counted
            seen_storage.add(pointer)

            label = _group_label(name, spec)
            entry = groups.setdefault(
                label, {"params": 0, "bits_per_weight": bits, "bytes": 0.0}
            )
            entry["params"] += param.numel()
            entry["bytes"] += param.numel() * bits / 8
            total_params += param.numel()
            total_bits += param.numel() * bits

    total_bytes = total_bits / 8
    return {
        "total_params": total_params,
        "total_bytes": int(total_bytes),
        "total_gb": total_bytes / 1e9,
        "effective_bits_per_weight": total_bits / total_params if total_params else 0.0,
        "compression_vs_bf16": (total_params * 2) / total_bytes if total_bytes else 0.0,
        "groups": groups,
        # Present in the saved checkpoint for vLLM compatibility, excluded from
        # the score because a real submission drops them.
        "excluded_params": excluded_params,
        "excluded_gb_bf16": excluded_params * 2 / 1e9,
    }


def _group_label(name: str, spec: QuantSpec | None) -> str:
    from .model import BUDGET_GROUPS

    for group, pattern in BUDGET_GROUPS:
        if re.search(pattern, name):
            return f"{group}@{spec.bits if spec else KEEP_BITS}b"
    return f"other@{spec.bits if spec else KEEP_BITS}b"


def format_size_report(report: dict[str, Any]) -> str:
    lines = [
        f"{'group':<22}{'params':>15}{'bpw':>7}{'GB':>9}",
        "-" * 53,
    ]
    for label, stats in sorted(report["groups"].items(), key=lambda kv: -kv[1]["params"]):
        lines.append(
            f"{label:<22}{stats['params']:>15,}"
            f"{stats['bits_per_weight']:>7.2f}{stats['bytes'] / 1e9:>9.3f}"
        )
    lines.append("-" * 53)
    lines.append(
        f"{'TOTAL':<22}{report['total_params']:>15,}"
        f"{report['effective_bits_per_weight']:>7.2f}{report['total_gb']:>9.3f}"
    )
    lines.append(f"  compression vs bf16: {report['compression_vs_bf16']:.2f}x")
    if report.get("excluded_params"):
        lines.append(
            f"  excluded (vision+MTP, present on disk but not scored): "
            f"{report['excluded_params']:,} params / {report['excluded_gb_bf16']:.2f} GB"
        )
    return "\n".join(lines)


# The SSM recurrence dynamics must stay high precision: error compounds through
# the linear recurrence instead of being renormalized each step, and naive
# low-bit PTQ on these collapses SSM models entirely. They are ~0.02% of
# parameters, so protecting all of them is nearly free.
PROTECTED = QuantSpec(
    pattern=r"A_log|dt_bias|conv1d|in_proj_a|in_proj_b|norm|bias",
    bits=KEEP_BITS,
)


def build_recipe(
    mlp_bits: int = 16,
    linear_attn_bits: int = 16,
    full_attn_bits: int = 16,
    embed_bits: int = 16,
    group_size: int = 128,
    symmetric: bool = True,
) -> Recipe:
    """Per-component recipe. PROTECTED comes first so it always wins."""
    return Recipe(
        [
            PROTECTED,
            QuantSpec(r"embed_tokens|lm_head", embed_bits, group_size, symmetric),
            QuantSpec(r"linear_attn", linear_attn_bits, group_size, symmetric),
            QuantSpec(r"self_attn", full_attn_bits, group_size, symmetric),
            QuantSpec(r"\.mlp\.", mlp_bits, group_size, symmetric),
        ]
    )