Instructions to use safffrron/25M2111-Week02-Track2-40-Submission01 with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- Transformers
How to use safffrron/25M2111-Week02-Track2-40-Submission01 with Transformers:
# Use a pipeline as a high-level helper from transformers import pipeline pipe = pipeline("text-generation", model="safffrron/25M2111-Week02-Track2-40-Submission01")# Load model directly from transformers import AutoModel model = AutoModel.from_pretrained("safffrron/25M2111-Week02-Track2-40-Submission01", device_map="auto") - Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- vLLM
How to use safffrron/25M2111-Week02-Track2-40-Submission01 with vLLM:
Install from pip and serve model
# Install vLLM from pip: pip install vllm # Start the vLLM server: vllm serve "safffrron/25M2111-Week02-Track2-40-Submission01" # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:8000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "safffrron/25M2111-Week02-Track2-40-Submission01", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker
docker model run hf.co/safffrron/25M2111-Week02-Track2-40-Submission01
- SGLang
How to use safffrron/25M2111-Week02-Track2-40-Submission01 with SGLang:
Install from pip and serve model
# Install SGLang from pip: pip install sglang # Start the SGLang server: python3 -m sglang.launch_server \ --model-path "safffrron/25M2111-Week02-Track2-40-Submission01" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "safffrron/25M2111-Week02-Track2-40-Submission01", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }'Use Docker images
docker run --gpus all \ --shm-size 32g \ -p 30000:30000 \ -v ~/.cache/huggingface:/root/.cache/huggingface \ --env "HF_TOKEN=<secret>" \ --ipc=host \ lmsysorg/sglang:latest \ python3 -m sglang.launch_server \ --model-path "safffrron/25M2111-Week02-Track2-40-Submission01" \ --host 0.0.0.0 \ --port 30000 # Call the server using curl (OpenAI-compatible API): curl -X POST "http://localhost:30000/v1/completions" \ -H "Content-Type: application/json" \ --data '{ "model": "safffrron/25M2111-Week02-Track2-40-Submission01", "prompt": "Once upon a time,", "max_tokens": 512, "temperature": 0.5 }' - Docker Model Runner
How to use safffrron/25M2111-Week02-Track2-40-Submission01 with Docker Model Runner:
docker model run hf.co/safffrron/25M2111-Week02-Track2-40-Submission01
Upload folder using huggingface_hub
Browse files- src/eaimath/__init__.py +6 -0
- src/eaimath/adaptive_artifact.py +423 -0
- src/eaimath/answers.py +173 -0
- src/eaimath/artifact.py +686 -0
- src/eaimath/artifact_refine.py +248 -0
- src/eaimath/buckets.py +91 -0
- src/eaimath/data.py +304 -0
- src/eaimath/delta_sharing.py +144 -0
- src/eaimath/embedding_predictor.py +129 -0
- src/eaimath/evaluate.py +222 -0
- src/eaimath/extreme_quant.py +582 -0
- src/eaimath/gauge.py +136 -0
- src/eaimath/generate.py +162 -0
- src/eaimath/gptq_backend.py +40 -0
- src/eaimath/ledger.py +224 -0
- src/eaimath/model.py +440 -0
- src/eaimath/pack.py +397 -0
- src/eaimath/peft_compat.py +54 -0
- src/eaimath/quantize.py +233 -0
- src/eaimath/rate_distortion.py +84 -0
- src/eaimath/sharing.py +196 -0
- src/eaimath/vllm_backend.py +144 -0
- src/eaimath/vocab.py +161 -0
- src/eaimath/workspace.py +40 -0
src/eaimath/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CS6013 Efficient AI — compression recipe for Qwen3.5-4B on the math domain."""
|
| 2 |
+
|
| 3 |
+
from .model import DEFAULT_MODEL
|
| 4 |
+
|
| 5 |
+
__all__ = ["DEFAULT_MODEL"]
|
| 6 |
+
__version__ = "0.1.0"
|
src/eaimath/adaptive_artifact.py
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Physical packing for the Round-14 block-adaptive Bucket-C representation.
|
| 2 |
+
|
| 3 |
+
The research builder selects one bit width per output-row block, but evaluates
|
| 4 |
+
the resulting values as an ordinary BF16 Hugging Face checkpoint. This module
|
| 5 |
+
turns that selection into the actual submission object: densely packed integer
|
| 6 |
+
codes, FP16 group scales, one uint8 width selector per adaptive block, and BF16
|
| 7 |
+
passthrough tensors for protected parameters.
|
| 8 |
+
|
| 9 |
+
Unlike the older nested Bucket-B artifact, ``state_dict`` here is deliberately a
|
| 10 |
+
flat mapping of names to tensors. The course starter's size audit counts only
|
| 11 |
+
immediate tensor values, so this layout makes its tensor-payload report equal to
|
| 12 |
+
the exact representation accounting instead of accidentally reporting zero.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import hashlib
|
| 18 |
+
import json
|
| 19 |
+
import math
|
| 20 |
+
import re
|
| 21 |
+
import time
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Any, Mapping
|
| 24 |
+
|
| 25 |
+
import torch
|
| 26 |
+
|
| 27 |
+
from .model import load_model, save_checkpoint
|
| 28 |
+
from .pack import pack_tensor, unpack_tensor
|
| 29 |
+
from .quantize import EXCLUDED, QuantSpec, quantize_dequantize
|
| 30 |
+
|
| 31 |
+
FORMAT = "eaimath-block-adaptive-c-v1"
|
| 32 |
+
EXCLUDED_RE = re.compile(EXCLUDED)
|
| 33 |
+
PROTECTED_RE = re.compile(r"A_log|dt_bias|conv1d|in_proj_a|in_proj_b|norm|bias")
|
| 34 |
+
ADAPTIVE_RE = re.compile(r"linear_attn|self_attn|\.mlp\.")
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def tensor_payload_bytes(tensors: Mapping[str, torch.Tensor]) -> int:
|
| 38 |
+
"""Bytes counted by the starter evaluator's immediate state-dict audit."""
|
| 39 |
+
return sum(int(value.numel()) * int(value.element_size()) for value in tensors.values())
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def sha256_file(path: str | Path, chunk_size: int = 8 * 1024 * 1024) -> str:
|
| 43 |
+
digest = hashlib.sha256()
|
| 44 |
+
with Path(path).open("rb") as handle:
|
| 45 |
+
while chunk := handle.read(chunk_size):
|
| 46 |
+
digest.update(chunk)
|
| 47 |
+
return digest.hexdigest()
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _is_adaptive(name: str, tensor: torch.Tensor) -> bool:
|
| 51 |
+
return (
|
| 52 |
+
tensor.ndim == 2
|
| 53 |
+
and name.endswith(".weight")
|
| 54 |
+
and not PROTECTED_RE.search(name)
|
| 55 |
+
and bool(ADAPTIVE_RE.search(name))
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _store_entry(
|
| 60 |
+
entry: dict[str, Any],
|
| 61 |
+
tensors: dict[str, torch.Tensor],
|
| 62 |
+
prefix: str,
|
| 63 |
+
) -> dict[str, Any]:
|
| 64 |
+
"""Move entry tensors into the flat payload and retain JSON-safe metadata."""
|
| 65 |
+
metadata: dict[str, Any] = {}
|
| 66 |
+
tensor_keys: dict[str, str] = {}
|
| 67 |
+
for field, value in entry.items():
|
| 68 |
+
if isinstance(value, torch.Tensor):
|
| 69 |
+
key = f"{prefix}.{field}"
|
| 70 |
+
if key in tensors:
|
| 71 |
+
raise KeyError(f"duplicate artifact tensor key: {key}")
|
| 72 |
+
tensors[key] = value.detach().cpu().contiguous()
|
| 73 |
+
tensor_keys[field] = key
|
| 74 |
+
elif isinstance(value, tuple):
|
| 75 |
+
metadata[field] = list(value)
|
| 76 |
+
else:
|
| 77 |
+
metadata[field] = value
|
| 78 |
+
metadata["tensor_keys"] = tensor_keys
|
| 79 |
+
return metadata
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _load_entry(metadata: Mapping[str, Any], tensors: Mapping[str, torch.Tensor]) -> dict[str, Any]:
|
| 83 |
+
entry = {key: value for key, value in metadata.items() if key != "tensor_keys"}
|
| 84 |
+
for field, key in metadata.get("tensor_keys", {}).items():
|
| 85 |
+
if key not in tensors:
|
| 86 |
+
raise KeyError(f"artifact tensor is missing: {key}")
|
| 87 |
+
entry[field] = tensors[key]
|
| 88 |
+
if "shape" in entry:
|
| 89 |
+
entry["shape"] = tuple(entry["shape"])
|
| 90 |
+
return entry
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def _row_ids(widths: torch.Tensor, bit_width: int, rows: int, block: int) -> torch.Tensor:
|
| 94 |
+
selected: list[int] = []
|
| 95 |
+
for block_index, width in enumerate(widths.tolist()):
|
| 96 |
+
if int(width) != bit_width:
|
| 97 |
+
continue
|
| 98 |
+
start = block_index * block
|
| 99 |
+
selected.extend(range(start, min(rows, start + block)))
|
| 100 |
+
return torch.tensor(selected, dtype=torch.int64)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def pack_block_adaptive_state(
|
| 104 |
+
state_dict: Mapping[str, torch.Tensor],
|
| 105 |
+
allocation_report: Mapping[str, Any],
|
| 106 |
+
*,
|
| 107 |
+
reference_state_dict: Mapping[str, torch.Tensor] | None = None,
|
| 108 |
+
scale_normalization: str = "none",
|
| 109 |
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
| 110 |
+
"""Pack a block-adaptive BF16 state dict and verify exact byte accounting.
|
| 111 |
+
|
| 112 |
+
``state_dict`` should be the unquantized learned source. When supplied,
|
| 113 |
+
``reference_state_dict`` is the expanded fake-quant checkpoint used during
|
| 114 |
+
research evaluation; the reported repack error then proves that physical
|
| 115 |
+
codes reproduce that evaluated model without applying a second quantizer.
|
| 116 |
+
"""
|
| 117 |
+
if allocation_report.get("format") != FORMAT:
|
| 118 |
+
raise ValueError(
|
| 119 |
+
f"expected allocation format {FORMAT!r}, got {allocation_report.get('format')!r}"
|
| 120 |
+
)
|
| 121 |
+
selected_bits = allocation_report.get("selected_bits")
|
| 122 |
+
if not isinstance(selected_bits, dict) or not selected_bits:
|
| 123 |
+
raise ValueError("allocation report has no selected_bits mapping")
|
| 124 |
+
block = int(allocation_report.get("row_block", 0))
|
| 125 |
+
group_size = int(allocation_report.get("group_size", 0))
|
| 126 |
+
if block <= 0 or group_size <= 0:
|
| 127 |
+
raise ValueError(f"invalid block/group sizes: {block}/{group_size}")
|
| 128 |
+
|
| 129 |
+
tensors: dict[str, torch.Tensor] = {}
|
| 130 |
+
entries: dict[str, dict[str, Any]] = {}
|
| 131 |
+
aliases: dict[str, str] = {}
|
| 132 |
+
seen: dict[int, str] = {}
|
| 133 |
+
reported_names: set[str] = set()
|
| 134 |
+
max_abs_error = 0.0
|
| 135 |
+
max_rmse = 0.0
|
| 136 |
+
max_abs_scale_error = 0.0
|
| 137 |
+
max_scale_rmse = 0.0
|
| 138 |
+
worst_tensor = ""
|
| 139 |
+
canonical_index = 0
|
| 140 |
+
|
| 141 |
+
for name, tensor in state_dict.items():
|
| 142 |
+
if not isinstance(tensor, torch.Tensor) or EXCLUDED_RE.search(name):
|
| 143 |
+
continue
|
| 144 |
+
pointer = tensor.data_ptr()
|
| 145 |
+
if pointer and pointer in seen:
|
| 146 |
+
aliases[name] = seen[pointer]
|
| 147 |
+
continue
|
| 148 |
+
if pointer:
|
| 149 |
+
seen[pointer] = name
|
| 150 |
+
widths_raw = selected_bits.get(name)
|
| 151 |
+
if widths_raw is None:
|
| 152 |
+
raise KeyError(
|
| 153 |
+
f"text tensor {name!r} has no allocation; refusing uncharged passthrough"
|
| 154 |
+
)
|
| 155 |
+
widths = torch.tensor([int(value) for value in widths_raw], dtype=torch.uint8)
|
| 156 |
+
if widths.numel() == 0:
|
| 157 |
+
raise ValueError(f"tensor {name!r} has an empty width selection")
|
| 158 |
+
reported_names.add(name)
|
| 159 |
+
prefix = f"t{canonical_index:04d}"
|
| 160 |
+
canonical_index += 1
|
| 161 |
+
|
| 162 |
+
adaptive = _is_adaptive(name, tensor)
|
| 163 |
+
expected_blocks = math.ceil(tensor.shape[0] / block) if adaptive else 1
|
| 164 |
+
if widths.numel() != expected_blocks:
|
| 165 |
+
raise ValueError(
|
| 166 |
+
f"width count for {name} is {widths.numel()}, expected {expected_blocks}"
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
if adaptive:
|
| 170 |
+
selector_key = f"{prefix}.selector"
|
| 171 |
+
tensors[selector_key] = widths.contiguous()
|
| 172 |
+
groups: dict[str, dict[str, Any]] = {}
|
| 173 |
+
reconstructed = torch.empty_like(tensor, device="cpu")
|
| 174 |
+
for bit_width in sorted(set(int(value) for value in widths.tolist())):
|
| 175 |
+
ids = _row_ids(widths, bit_width, tensor.shape[0], block)
|
| 176 |
+
selected = tensor.detach().cpu().index_select(0, ids)
|
| 177 |
+
packed = pack_tensor(
|
| 178 |
+
selected,
|
| 179 |
+
bit_width,
|
| 180 |
+
group_size=group_size,
|
| 181 |
+
scale_normalization=scale_normalization,
|
| 182 |
+
)
|
| 183 |
+
groups[str(bit_width)] = {
|
| 184 |
+
"rows": int(ids.numel()),
|
| 185 |
+
"entry": _store_entry(packed, tensors, f"{prefix}.b{bit_width}"),
|
| 186 |
+
}
|
| 187 |
+
unpacked = unpack_tensor(packed).to(reconstructed.dtype)
|
| 188 |
+
reconstructed.index_copy_(0, ids, unpacked)
|
| 189 |
+
if bit_width < 16:
|
| 190 |
+
ideal = quantize_dequantize(
|
| 191 |
+
selected,
|
| 192 |
+
QuantSpec(".*", bit_width, group_size),
|
| 193 |
+
)
|
| 194 |
+
scale_delta = ideal.float() - unpacked.float()
|
| 195 |
+
if scale_delta.numel():
|
| 196 |
+
max_abs_scale_error = max(
|
| 197 |
+
max_abs_scale_error, float(scale_delta.abs().max())
|
| 198 |
+
)
|
| 199 |
+
max_scale_rmse = max(
|
| 200 |
+
max_scale_rmse,
|
| 201 |
+
float(scale_delta.square().mean().sqrt()),
|
| 202 |
+
)
|
| 203 |
+
entries[name] = {
|
| 204 |
+
"kind": "adaptive_rows",
|
| 205 |
+
"shape": list(tensor.shape),
|
| 206 |
+
"block": block,
|
| 207 |
+
"selector_key": selector_key,
|
| 208 |
+
"groups": groups,
|
| 209 |
+
}
|
| 210 |
+
else:
|
| 211 |
+
bit_width = int(widths[0])
|
| 212 |
+
packed = pack_tensor(
|
| 213 |
+
tensor.detach().cpu(),
|
| 214 |
+
bit_width,
|
| 215 |
+
group_size=group_size,
|
| 216 |
+
scale_normalization=scale_normalization,
|
| 217 |
+
)
|
| 218 |
+
entries[name] = {
|
| 219 |
+
"kind": "tensor",
|
| 220 |
+
"bits": bit_width,
|
| 221 |
+
"entry": _store_entry(packed, tensors, prefix),
|
| 222 |
+
}
|
| 223 |
+
reconstructed = unpack_tensor(packed)
|
| 224 |
+
if bit_width < 16:
|
| 225 |
+
ideal = quantize_dequantize(
|
| 226 |
+
tensor.detach().cpu(),
|
| 227 |
+
QuantSpec(".*", bit_width, group_size),
|
| 228 |
+
)
|
| 229 |
+
scale_delta = ideal.float() - reconstructed.float()
|
| 230 |
+
if scale_delta.numel():
|
| 231 |
+
max_abs_scale_error = max(
|
| 232 |
+
max_abs_scale_error, float(scale_delta.abs().max())
|
| 233 |
+
)
|
| 234 |
+
max_scale_rmse = max(
|
| 235 |
+
max_scale_rmse,
|
| 236 |
+
float(scale_delta.square().mean().sqrt()),
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
comparison = tensor
|
| 240 |
+
if reference_state_dict is not None:
|
| 241 |
+
if name not in reference_state_dict:
|
| 242 |
+
raise KeyError(f"reference checkpoint is missing text tensor {name!r}")
|
| 243 |
+
comparison = reference_state_dict[name]
|
| 244 |
+
if tuple(comparison.shape) != tuple(tensor.shape):
|
| 245 |
+
raise ValueError(
|
| 246 |
+
f"reference shape for {name} is {tuple(comparison.shape)}, "
|
| 247 |
+
f"expected {tuple(tensor.shape)}"
|
| 248 |
+
)
|
| 249 |
+
delta = comparison.detach().float().cpu() - reconstructed.float().cpu()
|
| 250 |
+
abs_error = float(delta.abs().max()) if delta.numel() else 0.0
|
| 251 |
+
rmse = float(delta.square().mean().sqrt()) if delta.numel() else 0.0
|
| 252 |
+
if abs_error > max_abs_error:
|
| 253 |
+
max_abs_error = abs_error
|
| 254 |
+
worst_tensor = name
|
| 255 |
+
max_rmse = max(max_rmse, rmse)
|
| 256 |
+
|
| 257 |
+
unused = set(selected_bits) - reported_names
|
| 258 |
+
if unused:
|
| 259 |
+
preview = ", ".join(sorted(unused)[:8])
|
| 260 |
+
raise KeyError(f"allocation contains {len(unused)} absent tensors: {preview}")
|
| 261 |
+
|
| 262 |
+
actual_bytes = tensor_payload_bytes(tensors)
|
| 263 |
+
charged_bytes = int(allocation_report.get("total_bytes", -1))
|
| 264 |
+
if actual_bytes != charged_bytes:
|
| 265 |
+
raise ValueError(
|
| 266 |
+
"physical tensor bytes do not match allocation: "
|
| 267 |
+
f"packed={actual_bytes:,}, charged={charged_bytes:,}"
|
| 268 |
+
)
|
| 269 |
+
selector_bytes = sum(
|
| 270 |
+
tensor.numel() * tensor.element_size()
|
| 271 |
+
for key, tensor in tensors.items()
|
| 272 |
+
if key.endswith(".selector")
|
| 273 |
+
)
|
| 274 |
+
expected_selectors = int(allocation_report.get("selector_bytes", -1))
|
| 275 |
+
if selector_bytes != expected_selectors:
|
| 276 |
+
raise ValueError(
|
| 277 |
+
f"selector bytes {selector_bytes:,} do not match report {expected_selectors:,}"
|
| 278 |
+
)
|
| 279 |
+
|
| 280 |
+
report = {
|
| 281 |
+
"format": FORMAT,
|
| 282 |
+
"canonical_tensors": len(entries),
|
| 283 |
+
"aliases": len(aliases),
|
| 284 |
+
"flat_payload_tensors": len(tensors),
|
| 285 |
+
"tensor_bytes": actual_bytes,
|
| 286 |
+
"selector_bytes": selector_bytes,
|
| 287 |
+
"max_abs_repack_error": max_abs_error,
|
| 288 |
+
"max_repack_rmse": max_rmse,
|
| 289 |
+
"max_abs_scale_storage_error": max_abs_scale_error,
|
| 290 |
+
"max_scale_storage_rmse": max_scale_rmse,
|
| 291 |
+
"worst_tensor": worst_tensor,
|
| 292 |
+
"error_reference": (
|
| 293 |
+
"expanded_fake_quant" if reference_state_dict is not None else "packing_source"
|
| 294 |
+
),
|
| 295 |
+
"row_block": block,
|
| 296 |
+
"group_size": group_size,
|
| 297 |
+
"scale_normalization": scale_normalization,
|
| 298 |
+
"source_budget": int(allocation_report.get("budget", charged_bytes)),
|
| 299 |
+
"source_margin": int(allocation_report.get("margin_bytes", 0)),
|
| 300 |
+
}
|
| 301 |
+
payload = {
|
| 302 |
+
"format": FORMAT,
|
| 303 |
+
"model": allocation_report.get("model"),
|
| 304 |
+
"state_dict": tensors,
|
| 305 |
+
"entries": entries,
|
| 306 |
+
"aliases": aliases,
|
| 307 |
+
"report": report,
|
| 308 |
+
}
|
| 309 |
+
return payload, report
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def unpack_block_adaptive_state(payload: Mapping[str, Any]) -> dict[str, torch.Tensor]:
|
| 313 |
+
"""Restore every packed text tensor without loading the base architecture."""
|
| 314 |
+
if payload.get("format") != FORMAT:
|
| 315 |
+
raise ValueError(f"unsupported block-adaptive artifact: {payload.get('format')!r}")
|
| 316 |
+
tensors = payload.get("state_dict")
|
| 317 |
+
entries = payload.get("entries")
|
| 318 |
+
if not isinstance(tensors, dict) or not isinstance(entries, dict):
|
| 319 |
+
raise ValueError("artifact is missing flat state_dict or entries metadata")
|
| 320 |
+
restored: dict[str, torch.Tensor] = {}
|
| 321 |
+
for name, metadata in entries.items():
|
| 322 |
+
kind = metadata.get("kind")
|
| 323 |
+
if kind == "tensor":
|
| 324 |
+
value = unpack_tensor(_load_entry(metadata["entry"], tensors))
|
| 325 |
+
elif kind == "adaptive_rows":
|
| 326 |
+
shape = tuple(int(value) for value in metadata["shape"])
|
| 327 |
+
widths = tensors[metadata["selector_key"]].to(torch.uint8)
|
| 328 |
+
value = torch.empty(shape, dtype=torch.bfloat16)
|
| 329 |
+
for bit_text, group_metadata in metadata["groups"].items():
|
| 330 |
+
bit_width = int(bit_text)
|
| 331 |
+
ids = _row_ids(widths, bit_width, shape[0], int(metadata["block"]))
|
| 332 |
+
if ids.numel() != int(group_metadata["rows"]):
|
| 333 |
+
raise ValueError(f"row count mismatch while restoring {name} at {bit_width} bits")
|
| 334 |
+
rows = unpack_tensor(_load_entry(group_metadata["entry"], tensors))
|
| 335 |
+
value.index_copy_(0, ids, rows.to(value.dtype))
|
| 336 |
+
else:
|
| 337 |
+
raise ValueError(f"unknown entry kind for {name}: {kind!r}")
|
| 338 |
+
restored[name] = value
|
| 339 |
+
for alias, canonical in payload.get("aliases", {}).items():
|
| 340 |
+
if canonical not in restored:
|
| 341 |
+
raise KeyError(f"alias target is absent: {alias} -> {canonical}")
|
| 342 |
+
restored[alias] = restored[canonical]
|
| 343 |
+
return restored
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def save_block_adaptive_artifact(payload: Mapping[str, Any], path: str | Path) -> dict[str, Any]:
|
| 347 |
+
output = Path(path)
|
| 348 |
+
if output.exists():
|
| 349 |
+
raise FileExistsError(f"refusing to overwrite artifact: {output}")
|
| 350 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 351 |
+
started = time.time()
|
| 352 |
+
torch.save(dict(payload), output)
|
| 353 |
+
report = dict(payload.get("report", {}))
|
| 354 |
+
report.update(
|
| 355 |
+
{
|
| 356 |
+
"disk_bytes": output.stat().st_size,
|
| 357 |
+
"sha256": sha256_file(output),
|
| 358 |
+
"save_seconds": round(time.time() - started, 3),
|
| 359 |
+
}
|
| 360 |
+
)
|
| 361 |
+
output.with_suffix(output.suffix + ".json").write_text(json.dumps(report, indent=2))
|
| 362 |
+
return report
|
| 363 |
+
|
| 364 |
+
|
| 365 |
+
def load_block_adaptive_artifact(path: str | Path) -> dict[str, Any]:
|
| 366 |
+
payload = torch.load(Path(path), map_location="cpu", weights_only=False)
|
| 367 |
+
if not isinstance(payload, dict) or payload.get("format") != FORMAT:
|
| 368 |
+
raise ValueError(f"unsupported block-adaptive artifact in {path}")
|
| 369 |
+
actual = tensor_payload_bytes(payload.get("state_dict", {}))
|
| 370 |
+
expected = int(payload.get("report", {}).get("tensor_bytes", -1))
|
| 371 |
+
if actual != expected:
|
| 372 |
+
raise ValueError(f"artifact tensor bytes changed: {actual:,} != {expected:,}")
|
| 373 |
+
return payload
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
def restore_block_adaptive_artifact(
|
| 377 |
+
model_name: str,
|
| 378 |
+
artifact_path: str | Path,
|
| 379 |
+
output_path: str | Path,
|
| 380 |
+
*,
|
| 381 |
+
cache_dir: str | None = None,
|
| 382 |
+
) -> dict[str, Any]:
|
| 383 |
+
"""Expand a physical artifact into the BF16 HF checkpoint used by evaluation."""
|
| 384 |
+
out = Path(output_path)
|
| 385 |
+
if out.exists() and (not out.is_dir() or any(out.iterdir())):
|
| 386 |
+
raise FileExistsError(f"refusing to overwrite restore target: {out}")
|
| 387 |
+
started = time.time()
|
| 388 |
+
payload = load_block_adaptive_artifact(artifact_path)
|
| 389 |
+
restored = unpack_block_adaptive_state(payload)
|
| 390 |
+
model = load_model(
|
| 391 |
+
model_name,
|
| 392 |
+
dtype="bfloat16",
|
| 393 |
+
device_map=None,
|
| 394 |
+
cache_dir=cache_dir,
|
| 395 |
+
multimodal=True,
|
| 396 |
+
)
|
| 397 |
+
targets = model.state_dict()
|
| 398 |
+
with torch.no_grad():
|
| 399 |
+
for name, target in targets.items():
|
| 400 |
+
if EXCLUDED_RE.search(name):
|
| 401 |
+
target.zero_()
|
| 402 |
+
continue
|
| 403 |
+
if name not in restored:
|
| 404 |
+
raise KeyError(f"artifact did not restore text tensor: {name}")
|
| 405 |
+
value = restored[name]
|
| 406 |
+
if tuple(value.shape) != tuple(target.shape):
|
| 407 |
+
raise ValueError(
|
| 408 |
+
f"shape mismatch for {name}: {tuple(value.shape)} != {tuple(target.shape)}"
|
| 409 |
+
)
|
| 410 |
+
target.copy_(value.to(dtype=target.dtype))
|
| 411 |
+
copied = save_checkpoint(model, out, source_model=model_name, cache_dir=cache_dir)
|
| 412 |
+
report = {
|
| 413 |
+
"format": FORMAT,
|
| 414 |
+
"artifact": str(artifact_path),
|
| 415 |
+
"artifact_sha256": sha256_file(artifact_path),
|
| 416 |
+
"tensor_bytes": int(payload["report"]["tensor_bytes"]),
|
| 417 |
+
"restored_text_tensors": len(restored),
|
| 418 |
+
"zeroed_excluded_tensors": sum(1 for name in targets if EXCLUDED_RE.search(name)),
|
| 419 |
+
"auxiliary_files": copied,
|
| 420 |
+
"restore_seconds": round(time.time() - started, 1),
|
| 421 |
+
}
|
| 422 |
+
(out / "block_adaptive_restore.json").write_text(json.dumps(report, indent=2))
|
| 423 |
+
return report
|
src/eaimath/answers.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Extraction and equivalence checking for free-form math answers.
|
| 2 |
+
|
| 3 |
+
The hidden leaderboard eval is math, so answers are LaTeX expressions inside
|
| 4 |
+
``\\boxed{...}`` rather than multiple-choice letters. Extraction therefore needs
|
| 5 |
+
real brace matching (``\\boxed{\\frac{1}{2}}`` breaks any naive regex), and
|
| 6 |
+
comparison needs symbolic equivalence (``0.5`` == ``\\frac{1}{2}`` == ``1/2``).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import re
|
| 12 |
+
from fractions import Fraction
|
| 13 |
+
|
| 14 |
+
BOXED_TOKENS = ("\\boxed", "\\fbox")
|
| 15 |
+
|
| 16 |
+
# Applied in order when falling back to string comparison.
|
| 17 |
+
_STRIP_WRAPPERS = (
|
| 18 |
+
(r"\\left", ""),
|
| 19 |
+
(r"\\right", ""),
|
| 20 |
+
(r"\\!", ""),
|
| 21 |
+
(r"\\,", ""),
|
| 22 |
+
(r"\\;", ""),
|
| 23 |
+
(r"\\ ", " "),
|
| 24 |
+
(r"\\dfrac", r"\\frac"),
|
| 25 |
+
(r"\\tfrac", r"\\frac"),
|
| 26 |
+
(r"\\cdot", "*"),
|
| 27 |
+
(r"\\times", "*"),
|
| 28 |
+
(r"\^\{\\circ\}", ""),
|
| 29 |
+
(r"\^\\circ", ""),
|
| 30 |
+
(r"\\%", ""),
|
| 31 |
+
(r"\\\$", ""),
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def extract_boxed(text: str) -> str | None:
|
| 36 |
+
"""Return the contents of the LAST ``\\boxed{...}`` in ``text``.
|
| 37 |
+
|
| 38 |
+
Uses brace-depth matching so nested LaTeX survives. Returns ``None`` when no
|
| 39 |
+
boxed expression is present or the braces never close (a truncated
|
| 40 |
+
generation), which the caller should treat as an unparsed answer.
|
| 41 |
+
"""
|
| 42 |
+
if not text:
|
| 43 |
+
return None
|
| 44 |
+
|
| 45 |
+
start_idx = -1
|
| 46 |
+
token_len = 0
|
| 47 |
+
for token in BOXED_TOKENS:
|
| 48 |
+
idx = text.rfind(token)
|
| 49 |
+
if idx > start_idx:
|
| 50 |
+
start_idx, token_len = idx, len(token)
|
| 51 |
+
if start_idx == -1:
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
i = start_idx + token_len
|
| 55 |
+
while i < len(text) and text[i].isspace():
|
| 56 |
+
i += 1
|
| 57 |
+
if i >= len(text):
|
| 58 |
+
return None
|
| 59 |
+
|
| 60 |
+
# `\boxed 42` (no braces) — take the next non-space run.
|
| 61 |
+
if text[i] != "{":
|
| 62 |
+
match = re.match(r"[^\s$\\]+", text[i:])
|
| 63 |
+
return match.group(0) if match else None
|
| 64 |
+
|
| 65 |
+
depth = 0
|
| 66 |
+
content_start = i + 1
|
| 67 |
+
while i < len(text):
|
| 68 |
+
if text[i] == "{":
|
| 69 |
+
depth += 1
|
| 70 |
+
elif text[i] == "}":
|
| 71 |
+
depth -= 1
|
| 72 |
+
if depth == 0:
|
| 73 |
+
return text[content_start:i]
|
| 74 |
+
i += 1
|
| 75 |
+
return None # unclosed brace -> truncated mid-answer
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def extract_answer(text: str) -> str | None:
|
| 79 |
+
"""Extract a final answer, preferring ``\\boxed{}`` with light fallbacks."""
|
| 80 |
+
boxed = extract_boxed(text)
|
| 81 |
+
if boxed is not None:
|
| 82 |
+
return boxed.strip()
|
| 83 |
+
|
| 84 |
+
# Fallback: an explicit "answer is X" on one of the last few lines.
|
| 85 |
+
tail = [ln.strip() for ln in text.strip().splitlines() if ln.strip()][-5:]
|
| 86 |
+
pattern = re.compile(
|
| 87 |
+
r"(?:final\s+answer|answer)\s*(?:is|:)\s*\$?([^\s$.,]+)", re.IGNORECASE
|
| 88 |
+
)
|
| 89 |
+
for line in reversed(tail):
|
| 90 |
+
match = pattern.search(line)
|
| 91 |
+
if match:
|
| 92 |
+
return match.group(1).strip()
|
| 93 |
+
return None
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def normalize(expr: str) -> str:
|
| 97 |
+
"""Aggressively normalize a LaTeX answer for string comparison."""
|
| 98 |
+
if expr is None:
|
| 99 |
+
return ""
|
| 100 |
+
out = expr.strip()
|
| 101 |
+
out = re.sub(r"^\$+|\$+$", "", out).strip()
|
| 102 |
+
out = re.sub(r"^\\\[|\\\]$", "", out).strip()
|
| 103 |
+
for pattern, repl in _STRIP_WRAPPERS:
|
| 104 |
+
out = re.sub(pattern, repl, out)
|
| 105 |
+
out = re.sub(r"\\text\{([^}]*)\}", r"\1", out)
|
| 106 |
+
out = re.sub(r"\\mathrm\{([^}]*)\}", r"\1", out)
|
| 107 |
+
# 1,234,567 -> 1234567 (thousands separators only, not tuples)
|
| 108 |
+
if re.fullmatch(r"-?\d{1,3}(,\d{3})+(\.\d+)?", out):
|
| 109 |
+
out = out.replace(",", "")
|
| 110 |
+
out = out.replace(" ", "").rstrip(".")
|
| 111 |
+
# Trailing ".0" and redundant leading "+"
|
| 112 |
+
out = re.sub(r"^\+", "", out)
|
| 113 |
+
if re.fullmatch(r"-?\d+\.0+", out):
|
| 114 |
+
out = out.split(".")[0]
|
| 115 |
+
return out
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def _as_number(expr: str) -> float | None:
|
| 119 |
+
"""Best-effort numeric value for simple ints, decimals and \\frac{a}{b}."""
|
| 120 |
+
text = normalize(expr)
|
| 121 |
+
try:
|
| 122 |
+
return float(Fraction(text))
|
| 123 |
+
except (ValueError, ZeroDivisionError):
|
| 124 |
+
pass
|
| 125 |
+
frac = re.fullmatch(r"\\frac\{(-?[\d.]+)\}\{(-?[\d.]+)\}", text)
|
| 126 |
+
if frac:
|
| 127 |
+
try:
|
| 128 |
+
denom = float(frac.group(2))
|
| 129 |
+
return float(frac.group(1)) / denom if denom else None
|
| 130 |
+
except (ValueError, ZeroDivisionError):
|
| 131 |
+
return None
|
| 132 |
+
return None
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
_MATH_VERIFY_STATE: dict[str, object] = {}
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
def _math_verify():
|
| 139 |
+
"""Lazily import math_verify; cache the failure so we only warn once."""
|
| 140 |
+
if "fn" not in _MATH_VERIFY_STATE:
|
| 141 |
+
try:
|
| 142 |
+
from math_verify import parse, verify
|
| 143 |
+
|
| 144 |
+
_MATH_VERIFY_STATE["fn"] = (parse, verify)
|
| 145 |
+
except Exception: # pragma: no cover - depends on optional install
|
| 146 |
+
_MATH_VERIFY_STATE["fn"] = None
|
| 147 |
+
return _MATH_VERIFY_STATE["fn"]
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
def answers_match(predicted: str | None, gold: str) -> bool:
|
| 151 |
+
"""True when ``predicted`` is mathematically equivalent to ``gold``.
|
| 152 |
+
|
| 153 |
+
Tries symbolic equivalence via ``math_verify`` first, then numeric
|
| 154 |
+
comparison, then normalized string equality. Each stage is a superset of
|
| 155 |
+
what the next can catch, so a False here means all three disagreed.
|
| 156 |
+
"""
|
| 157 |
+
if predicted is None:
|
| 158 |
+
return False
|
| 159 |
+
|
| 160 |
+
checker = _math_verify()
|
| 161 |
+
if checker is not None:
|
| 162 |
+
parse, verify = checker
|
| 163 |
+
try:
|
| 164 |
+
if verify(parse(f"${gold}$"), parse(f"${predicted}$")):
|
| 165 |
+
return True
|
| 166 |
+
except Exception:
|
| 167 |
+
pass # malformed LaTeX -> fall through to the cheaper checks
|
| 168 |
+
|
| 169 |
+
pred_num, gold_num = _as_number(predicted), _as_number(gold)
|
| 170 |
+
if pred_num is not None and gold_num is not None:
|
| 171 |
+
return abs(pred_num - gold_num) < 1e-6
|
| 172 |
+
|
| 173 |
+
return normalize(predicted) == normalize(gold)
|
src/eaimath/artifact.py
ADDED
|
@@ -0,0 +1,686 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Submission-shaped packing and restoration for the Round 9 Bucket-B artifact.
|
| 2 |
+
|
| 3 |
+
The calibrated GPTQ checkpoint is an inference diagnostic, not the object the
|
| 4 |
+
course grades: modules skipped by GPTQ are still BF16 in that directory. This
|
| 5 |
+
module dequantizes the diagnostic values once, repacks every text parameter at
|
| 6 |
+
the target mixed precision, stores only selected embedding rows, and restores a
|
| 7 |
+
normal BF16 Hugging Face checkpoint.
|
| 8 |
+
|
| 9 |
+
The artifact deliberately preserves original tokenizer ids. The supplied
|
| 10 |
+
evaluator copies the original tokenizer into the restored directory, so a dense
|
| 11 |
+
id remap would be overwritten and would make prompts invalid.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import lzma
|
| 18 |
+
import re
|
| 19 |
+
import tempfile
|
| 20 |
+
import time
|
| 21 |
+
import zlib
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import Any, Iterable
|
| 24 |
+
|
| 25 |
+
import numpy as np
|
| 26 |
+
import torch
|
| 27 |
+
|
| 28 |
+
from .embedding_predictor import predict_token_rows
|
| 29 |
+
from .model import DEFAULT_MODEL, load_model, load_tokenizer, save_checkpoint
|
| 30 |
+
from .pack import (
|
| 31 |
+
pack_bits,
|
| 32 |
+
pack_rows,
|
| 33 |
+
pack_tensor,
|
| 34 |
+
state_dict_bytes,
|
| 35 |
+
unpack_bits,
|
| 36 |
+
unpack_adaptive_codebook,
|
| 37 |
+
unpack_rows,
|
| 38 |
+
unpack_tensor,
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
FORMAT = "eaimath-r09-v1"
|
| 42 |
+
ZLIB_MAGIC = b"EAIMATH_ZLIB_V1\n"
|
| 43 |
+
LZMA_MAGIC = b"EAIMATH_LZMA_V1\n"
|
| 44 |
+
EXCLUDED = re.compile(r"visual|vision_tower|(^|\.)mtp\.")
|
| 45 |
+
PROTECTED = re.compile(r"A_log|dt_bias|conv1d|in_proj_a|in_proj_b|norm|bias")
|
| 46 |
+
EMBED = re.compile(r"embed_tokens|lm_head")
|
| 47 |
+
LINEAR_ATTN = re.compile(r"linear_attn")
|
| 48 |
+
FULL_ATTN = re.compile(r"self_attn")
|
| 49 |
+
MLP = re.compile(r"\.mlp\.")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def component_and_bits(
|
| 53 |
+
name: str,
|
| 54 |
+
recipe_bits: dict[str, int] | None = None,
|
| 55 |
+
) -> tuple[str, int]:
|
| 56 |
+
"""Return component and stored width; protected parameters always stay BF16."""
|
| 57 |
+
bits = recipe_bits or {
|
| 58 |
+
"mlp": 3,
|
| 59 |
+
"linear_attn": 3,
|
| 60 |
+
"full_attn": 8,
|
| 61 |
+
"embed": 8,
|
| 62 |
+
}
|
| 63 |
+
if PROTECTED.search(name):
|
| 64 |
+
return "protected", 16
|
| 65 |
+
if EMBED.search(name):
|
| 66 |
+
return "embed", bits["embed"]
|
| 67 |
+
if LINEAR_ATTN.search(name):
|
| 68 |
+
return "linear_attn", bits["linear_attn"]
|
| 69 |
+
if FULL_ATTN.search(name):
|
| 70 |
+
return "full_attn", bits["full_attn"]
|
| 71 |
+
if MLP.search(name):
|
| 72 |
+
return "mlp", bits["mlp"]
|
| 73 |
+
return "other", 16
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _tensor_error(original: torch.Tensor, restored: torch.Tensor) -> tuple[float, float]:
|
| 77 |
+
if original.numel() == 0:
|
| 78 |
+
return 0.0, 0.0
|
| 79 |
+
delta = original.detach().float().cpu() - restored.detach().float().cpu()
|
| 80 |
+
return float(delta.abs().max()), float(delta.square().mean().sqrt())
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def read_trace_corpus(
|
| 84 |
+
paths: Iterable[Path],
|
| 85 |
+
) -> tuple[list[str], list[str], dict[str, int]]:
|
| 86 |
+
"""Read verified train-only problems and completions without mixing their roles."""
|
| 87 |
+
problems: list[str] = []
|
| 88 |
+
completions: list[str] = []
|
| 89 |
+
stats = {"rows": 0, "usable": 0, "bad_json": 0, "characters": 0}
|
| 90 |
+
for path in paths:
|
| 91 |
+
if not path.is_file():
|
| 92 |
+
raise FileNotFoundError(f"vocabulary corpus does not exist: {path}")
|
| 93 |
+
with path.open(encoding="utf-8") as handle:
|
| 94 |
+
for line in handle:
|
| 95 |
+
stats["rows"] += 1
|
| 96 |
+
try:
|
| 97 |
+
row = json.loads(line)
|
| 98 |
+
except json.JSONDecodeError:
|
| 99 |
+
stats["bad_json"] += 1
|
| 100 |
+
continue
|
| 101 |
+
problem = str(row.get("problem") or "").strip()
|
| 102 |
+
completion = str(row.get("completion") or row.get("response") or "").strip()
|
| 103 |
+
if not problem or not completion:
|
| 104 |
+
continue
|
| 105 |
+
if row.get("correct") is False or row.get("finished") is False:
|
| 106 |
+
continue
|
| 107 |
+
problems.append(problem)
|
| 108 |
+
completions.append(completion)
|
| 109 |
+
stats["usable"] += 1
|
| 110 |
+
stats["characters"] += len(problem) + len(completion)
|
| 111 |
+
if stats["usable"] < 128 or stats["characters"] < 1_000_000:
|
| 112 |
+
raise ValueError(
|
| 113 |
+
"vocabulary ranking needs at least 128 verified traces and 1M characters; "
|
| 114 |
+
f"found {stats['usable']} traces / {stats['characters']:,} characters"
|
| 115 |
+
)
|
| 116 |
+
return problems, completions, stats
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def read_trace_texts(paths: Iterable[Path]) -> tuple[list[str], dict[str, int]]:
|
| 120 |
+
"""Read disjoint training problems and verified completions for vocab ranking."""
|
| 121 |
+
problems, completions, stats = read_trace_corpus(paths)
|
| 122 |
+
return problems + completions, stats
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _decode_gptq_qzeros(module) -> torch.Tensor:
|
| 126 |
+
"""Decode GPTQ v2 zero points without relying on a kernel-private helper.
|
| 127 |
+
|
| 128 |
+
Three-bit GPTQ does not have an integer ``pack_factor``: 32 values span
|
| 129 |
+
three 32-bit words, with values 10 and 21 crossing word boundaries.
|
| 130 |
+
GPTQModel's TorchLinear streaming helper uses the ordinary pack-factor path
|
| 131 |
+
and therefore cannot decode this layout. Its reference dequantizer has the
|
| 132 |
+
special case reproduced here.
|
| 133 |
+
"""
|
| 134 |
+
qzeros = module.qzeros.detach().cpu()
|
| 135 |
+
bits = int(module.bits)
|
| 136 |
+
word_bits = int(module.pack_dtype_bits)
|
| 137 |
+
maxq = int(module.maxq)
|
| 138 |
+
|
| 139 |
+
if bits in {2, 4, 8}:
|
| 140 |
+
shifts = torch.arange(0, word_bits, bits, dtype=torch.int32).reshape(1, 1, -1)
|
| 141 |
+
zeros = torch.bitwise_right_shift(qzeros.unsqueeze(2), shifts)
|
| 142 |
+
zeros = torch.bitwise_and(zeros, maxq)
|
| 143 |
+
elif bits == 3:
|
| 144 |
+
if word_bits != 32 or qzeros.shape[1] % 3:
|
| 145 |
+
raise RuntimeError(
|
| 146 |
+
"unsupported 3-bit GPTQ zero layout: "
|
| 147 |
+
f"word_bits={word_bits}, qzeros={tuple(qzeros.shape)}"
|
| 148 |
+
)
|
| 149 |
+
shifts = torch.tensor(
|
| 150 |
+
[
|
| 151 |
+
[0, 3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 0],
|
| 152 |
+
[0, 1, 4, 7, 10, 13, 16, 19, 22, 25, 28, 31],
|
| 153 |
+
[0, 2, 5, 8, 11, 14, 17, 20, 23, 26, 29, 0],
|
| 154 |
+
],
|
| 155 |
+
dtype=torch.int32,
|
| 156 |
+
).reshape(1, 1, 3, 12)
|
| 157 |
+
zeros = qzeros.reshape(qzeros.shape[0], qzeros.shape[1] // 3, 3, 1)
|
| 158 |
+
zeros = torch.bitwise_right_shift(zeros.expand(-1, -1, -1, 12), shifts)
|
| 159 |
+
zeros[:, :, 0, 10] = (zeros[:, :, 0, 10] & 0x3) | (
|
| 160 |
+
(zeros[:, :, 1, 0] << 2) & 0x4
|
| 161 |
+
)
|
| 162 |
+
zeros[:, :, 1, 11] = (zeros[:, :, 1, 11] & 0x1) | (
|
| 163 |
+
(zeros[:, :, 2, 0] << 1) & 0x6
|
| 164 |
+
)
|
| 165 |
+
zeros = torch.bitwise_and(zeros, maxq)
|
| 166 |
+
zeros = torch.cat(
|
| 167 |
+
[zeros[:, :, 0, :11], zeros[:, :, 1, 1:12], zeros[:, :, 2, 1:11]],
|
| 168 |
+
dim=2,
|
| 169 |
+
)
|
| 170 |
+
else:
|
| 171 |
+
raise RuntimeError(f"unsupported GPTQ zero-point width: {bits}")
|
| 172 |
+
|
| 173 |
+
expected = int(module.scales.numel())
|
| 174 |
+
if zeros.numel() != expected:
|
| 175 |
+
raise RuntimeError(
|
| 176 |
+
"decoded GPTQ zero-point count does not match scales: "
|
| 177 |
+
f"zeros={zeros.numel()}, scales={expected}"
|
| 178 |
+
)
|
| 179 |
+
return zeros.reshape(module.scales.shape).to(torch.int64)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def _capture_gptq_entry(module) -> tuple[dict[str, Any], float, torch.Tensor]:
|
| 183 |
+
"""Store GPTQ's exact codes/scales instead of re-quantizing its values."""
|
| 184 |
+
dequantized = module.dequantize_weight().detach().cpu()
|
| 185 |
+
scales = module.scales.detach().cpu().contiguous()
|
| 186 |
+
g_idx = module.g_idx.detach().cpu().long()
|
| 187 |
+
expected_shape = (module.in_features, module.out_features)
|
| 188 |
+
if tuple(dequantized.shape) != expected_shape or g_idx.numel() != module.in_features:
|
| 189 |
+
raise RuntimeError(
|
| 190 |
+
"GPTQ auto-padding was not removed by dequantization: "
|
| 191 |
+
f"weight={tuple(dequantized.shape)}, g_idx={g_idx.numel()}, "
|
| 192 |
+
f"expected={expected_shape}"
|
| 193 |
+
)
|
| 194 |
+
zeros = _decode_gptq_qzeros(module)
|
| 195 |
+
scale_rows = scales.float().index_select(0, g_idx)
|
| 196 |
+
zero_rows = zeros.index_select(0, g_idx)
|
| 197 |
+
codes = torch.round(dequantized.float() / scale_rows + zero_rows.float()).to(torch.int64)
|
| 198 |
+
codes.clamp_(0, 2**module.bits - 1)
|
| 199 |
+
reconstructed = scales.index_select(0, g_idx) * (
|
| 200 |
+
codes - zero_rows
|
| 201 |
+
).to(scales.dtype)
|
| 202 |
+
target = dequantized.to(torch.bfloat16)
|
| 203 |
+
max_error = float((reconstructed.to(torch.bfloat16) - target).float().abs().max())
|
| 204 |
+
|
| 205 |
+
sequential = torch.div(
|
| 206 |
+
torch.arange(module.in_features, dtype=torch.int64),
|
| 207 |
+
module.group_size,
|
| 208 |
+
rounding_mode="floor",
|
| 209 |
+
).clamp_max(scales.shape[0] - 1)
|
| 210 |
+
zero_flat = zeros.reshape(-1)
|
| 211 |
+
uniform_zero = bool(torch.all(zero_flat == zero_flat[0]))
|
| 212 |
+
entry: dict[str, Any] = {
|
| 213 |
+
"kind": "gptq_codes",
|
| 214 |
+
"packed": torch.from_numpy(pack_bits(codes.numpy().astype(np.uint32), module.bits)),
|
| 215 |
+
"scale": scales,
|
| 216 |
+
"shape": (module.out_features, module.in_features),
|
| 217 |
+
"code_shape": tuple(codes.shape),
|
| 218 |
+
"bits": int(module.bits),
|
| 219 |
+
"group": int(module.group_size),
|
| 220 |
+
"sym": bool(module.sym),
|
| 221 |
+
"dtype": "torch.bfloat16",
|
| 222 |
+
"zero_value": int(zero_flat[0]) if uniform_zero else None,
|
| 223 |
+
}
|
| 224 |
+
if not uniform_zero:
|
| 225 |
+
entry["zero_packed"] = torch.from_numpy(
|
| 226 |
+
pack_bits(zeros.numpy().astype(np.uint32), module.bits)
|
| 227 |
+
)
|
| 228 |
+
entry["zero_shape"] = tuple(zeros.shape)
|
| 229 |
+
if not torch.equal(g_idx, sequential):
|
| 230 |
+
entry["g_idx"] = g_idx.to(torch.int32)
|
| 231 |
+
return entry, max_error, target.T.contiguous()
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def unpack_gptq_entry(entry: dict[str, Any]) -> torch.Tensor:
|
| 235 |
+
"""Expand an exact code/scale GPTQ entry to its BF16 linear weight."""
|
| 236 |
+
in_features, out_features = tuple(entry["code_shape"])
|
| 237 |
+
count = in_features * out_features
|
| 238 |
+
codes = unpack_bits(entry["packed"].numpy(), entry["bits"], count)
|
| 239 |
+
codes_tensor = torch.from_numpy(codes.astype(np.int16)).reshape(in_features, out_features)
|
| 240 |
+
scales = entry["scale"]
|
| 241 |
+
if entry.get("g_idx") is None:
|
| 242 |
+
g_idx = torch.div(
|
| 243 |
+
torch.arange(in_features, dtype=torch.int64),
|
| 244 |
+
entry["group"],
|
| 245 |
+
rounding_mode="floor",
|
| 246 |
+
).clamp_max(scales.shape[0] - 1)
|
| 247 |
+
else:
|
| 248 |
+
g_idx = entry["g_idx"].long()
|
| 249 |
+
if entry.get("zero_value") is not None:
|
| 250 |
+
zero_rows = int(entry["zero_value"])
|
| 251 |
+
else:
|
| 252 |
+
zero_shape = tuple(entry["zero_shape"])
|
| 253 |
+
zero_count = int(np.prod(zero_shape))
|
| 254 |
+
zeros = unpack_bits(entry["zero_packed"].numpy(), entry["bits"], zero_count)
|
| 255 |
+
zero_tensor = torch.from_numpy(zeros.astype(np.int16)).reshape(zero_shape)
|
| 256 |
+
zero_rows = zero_tensor.index_select(0, g_idx)
|
| 257 |
+
values = scales.index_select(0, g_idx) * (
|
| 258 |
+
codes_tensor - zero_rows
|
| 259 |
+
).to(scales.dtype)
|
| 260 |
+
return values.T.contiguous().to(torch.bfloat16)
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def dequantize_gptq_model(checkpoint: str | Path):
|
| 264 |
+
"""Capture exact GPTQ entries, then replace linears for a normal state dict."""
|
| 265 |
+
import torch.nn as nn
|
| 266 |
+
from gptqmodel import GPTQModel
|
| 267 |
+
from gptqmodel.nn_modules.qlinear import BaseQuantLinear
|
| 268 |
+
from gptqmodel.nn_modules.qlinear.torch import TorchLinear
|
| 269 |
+
from gptqmodel.utils.backend import BACKEND
|
| 270 |
+
|
| 271 |
+
loaded = GPTQModel.load(
|
| 272 |
+
str(checkpoint),
|
| 273 |
+
backend=BACKEND.GPTQ_TORCH,
|
| 274 |
+
device="cpu",
|
| 275 |
+
trust_remote_code=True,
|
| 276 |
+
)
|
| 277 |
+
model = loaded.model
|
| 278 |
+
named = dict(model.named_modules())
|
| 279 |
+
replaced = 0
|
| 280 |
+
exact_weights: dict[str, dict[str, Any]] = {}
|
| 281 |
+
capture_error = 0.0
|
| 282 |
+
for name, module in list(model.named_modules()):
|
| 283 |
+
if isinstance(module, BaseQuantLinear) and not isinstance(module, TorchLinear):
|
| 284 |
+
raise TypeError(
|
| 285 |
+
f"{name} loaded as {type(module).__name__}; the submission packer "
|
| 286 |
+
"requires BACKEND.GPTQ_TORCH"
|
| 287 |
+
)
|
| 288 |
+
if not isinstance(module, TorchLinear):
|
| 289 |
+
continue
|
| 290 |
+
exact_entry, error, weight = _capture_gptq_entry(module)
|
| 291 |
+
exact_weights[f"{name}.weight"] = exact_entry
|
| 292 |
+
capture_error = max(capture_error, error)
|
| 293 |
+
replacement = nn.Linear(
|
| 294 |
+
module.in_features,
|
| 295 |
+
module.out_features,
|
| 296 |
+
bias=module.bias is not None,
|
| 297 |
+
device="cpu",
|
| 298 |
+
dtype=torch.bfloat16,
|
| 299 |
+
)
|
| 300 |
+
replacement.weight = nn.Parameter(weight)
|
| 301 |
+
if module.bias is not None:
|
| 302 |
+
replacement.bias = nn.Parameter(module.bias.detach().cpu().to(torch.bfloat16))
|
| 303 |
+
parent_name, child_name = name.rsplit(".", 1)
|
| 304 |
+
setattr(named[parent_name], child_name, replacement)
|
| 305 |
+
named[name] = replacement
|
| 306 |
+
replaced += 1
|
| 307 |
+
if replaced == 0:
|
| 308 |
+
raise RuntimeError("no GPTQ TorchLinear modules were found in the source checkpoint")
|
| 309 |
+
return loaded, model, replaced, exact_weights, capture_error
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def pack_model_state(
|
| 313 |
+
state_dict: dict[str, torch.Tensor],
|
| 314 |
+
keep_ids: list[int],
|
| 315 |
+
*,
|
| 316 |
+
group_size: int = 128,
|
| 317 |
+
exact_entries: dict[str, dict[str, Any]] | None = None,
|
| 318 |
+
recipe_bits: dict[str, int] | None = None,
|
| 319 |
+
recipe_name: str = "m3l3a8e8",
|
| 320 |
+
) -> tuple[dict[str, Any], dict[str, Any]]:
|
| 321 |
+
"""Pack unique text tensors and validate every local round trip."""
|
| 322 |
+
packed: dict[str, dict[str, Any]] = {}
|
| 323 |
+
aliases: dict[str, str] = {}
|
| 324 |
+
seen: dict[int, str] = {}
|
| 325 |
+
groups: dict[str, dict[str, float | int]] = {}
|
| 326 |
+
max_abs_error = 0.0
|
| 327 |
+
max_rmse = 0.0
|
| 328 |
+
worst_tensor = ""
|
| 329 |
+
keep_tensor = torch.tensor(keep_ids, dtype=torch.int64)
|
| 330 |
+
exact_entries = exact_entries or {}
|
| 331 |
+
|
| 332 |
+
for index, (name, tensor) in enumerate(state_dict.items(), 1):
|
| 333 |
+
if EXCLUDED.search(name) or not isinstance(tensor, torch.Tensor):
|
| 334 |
+
continue
|
| 335 |
+
pointer = tensor.data_ptr()
|
| 336 |
+
if pointer and pointer in seen:
|
| 337 |
+
aliases[name] = seen[pointer]
|
| 338 |
+
continue
|
| 339 |
+
if pointer:
|
| 340 |
+
seen[pointer] = name
|
| 341 |
+
|
| 342 |
+
component, bits = component_and_bits(name, recipe_bits)
|
| 343 |
+
if name in exact_entries:
|
| 344 |
+
entry = exact_entries[name]
|
| 345 |
+
restored = unpack_gptq_entry(entry)
|
| 346 |
+
original = tensor.detach().cpu()
|
| 347 |
+
elif component == "embed" and tensor.ndim == 2 and tensor.shape[0] > len(keep_ids):
|
| 348 |
+
entry = pack_rows(tensor, keep_tensor, bits, group_size=group_size)
|
| 349 |
+
restored = unpack_rows(entry, fill="zero").index_select(0, keep_tensor)
|
| 350 |
+
original = tensor.detach().cpu().index_select(0, keep_tensor)
|
| 351 |
+
else:
|
| 352 |
+
entry = pack_tensor(tensor, bits, group_size=group_size)
|
| 353 |
+
restored = unpack_tensor(entry)
|
| 354 |
+
original = tensor.detach().cpu()
|
| 355 |
+
abs_error, rmse = _tensor_error(original, restored)
|
| 356 |
+
if abs_error > max_abs_error:
|
| 357 |
+
max_abs_error = abs_error
|
| 358 |
+
worst_tensor = name
|
| 359 |
+
max_rmse = max(max_rmse, rmse)
|
| 360 |
+
packed[name] = entry
|
| 361 |
+
|
| 362 |
+
group = groups.setdefault(
|
| 363 |
+
component,
|
| 364 |
+
{"tensors": 0, "source_parameters": 0, "stored_bytes": 0, "bits": bits},
|
| 365 |
+
)
|
| 366 |
+
group["tensors"] = int(group["tensors"]) + 1
|
| 367 |
+
group["source_parameters"] = int(group["source_parameters"]) + tensor.numel()
|
| 368 |
+
group["stored_bytes"] = int(group["stored_bytes"]) + state_dict_bytes({name: entry})
|
| 369 |
+
if index % 100 == 0:
|
| 370 |
+
print(f" packed through state tensor {index}/{len(state_dict)}: {name}")
|
| 371 |
+
|
| 372 |
+
if not packed:
|
| 373 |
+
raise RuntimeError("source state dict yielded no text tensors")
|
| 374 |
+
if not any(entry["kind"].endswith("_rows") for entry in packed.values()):
|
| 375 |
+
raise RuntimeError("embedding row packing was not applied")
|
| 376 |
+
missing_exact = sorted(set(exact_entries) - set(packed))
|
| 377 |
+
if missing_exact:
|
| 378 |
+
raise RuntimeError(f"{len(missing_exact)} captured GPTQ weights were not packed: {missing_exact[:3]}")
|
| 379 |
+
|
| 380 |
+
report = {
|
| 381 |
+
"format": FORMAT,
|
| 382 |
+
"recipe": recipe_name,
|
| 383 |
+
"recipe_bits": recipe_bits,
|
| 384 |
+
"group_size": group_size,
|
| 385 |
+
"vocab_size": len(keep_ids),
|
| 386 |
+
"packed_tensors": len(packed),
|
| 387 |
+
"aliases": len(aliases),
|
| 388 |
+
"tensor_bytes": state_dict_bytes(packed),
|
| 389 |
+
"groups": groups,
|
| 390 |
+
"max_abs_repack_error": max_abs_error,
|
| 391 |
+
"max_repack_rmse": max_rmse,
|
| 392 |
+
"worst_tensor": worst_tensor,
|
| 393 |
+
}
|
| 394 |
+
payload = {
|
| 395 |
+
"format": FORMAT,
|
| 396 |
+
"recipe": recipe_name,
|
| 397 |
+
"group_size": group_size,
|
| 398 |
+
"keep_ids": keep_tensor.to(torch.int32),
|
| 399 |
+
"state_dict": packed,
|
| 400 |
+
"aliases": aliases,
|
| 401 |
+
"report": report,
|
| 402 |
+
}
|
| 403 |
+
return payload, report
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
def add_input_embedding_extension(
|
| 407 |
+
payload: dict[str, Any],
|
| 408 |
+
source_weight: torch.Tensor,
|
| 409 |
+
input_ids: Iterable[int],
|
| 410 |
+
*,
|
| 411 |
+
bits: int,
|
| 412 |
+
group_size: int = 128,
|
| 413 |
+
) -> dict[str, Any]:
|
| 414 |
+
"""Store extra input-only rows while retaining the smaller output allow-set."""
|
| 415 |
+
output_ids = set(int(value) for value in payload["keep_ids"].tolist())
|
| 416 |
+
input_set = set(int(value) for value in input_ids)
|
| 417 |
+
if not output_ids.issubset(input_set):
|
| 418 |
+
raise ValueError("input vocabulary must contain every output token id")
|
| 419 |
+
extra = sorted(input_set - output_ids)
|
| 420 |
+
if not extra:
|
| 421 |
+
raise ValueError("input extension has no rows beyond the output vocabulary")
|
| 422 |
+
row_names = [
|
| 423 |
+
name
|
| 424 |
+
for name, entry in payload["state_dict"].items()
|
| 425 |
+
if entry["kind"] in {"quant_rows", "raw_rows"}
|
| 426 |
+
]
|
| 427 |
+
if len(row_names) != 1:
|
| 428 |
+
raise ValueError(f"expected one canonical row-packed embedding, found {row_names}")
|
| 429 |
+
entry = pack_rows(
|
| 430 |
+
source_weight,
|
| 431 |
+
torch.tensor(extra, dtype=torch.int64),
|
| 432 |
+
bits,
|
| 433 |
+
group_size=group_size,
|
| 434 |
+
)
|
| 435 |
+
extension = {
|
| 436 |
+
"target": row_names[0],
|
| 437 |
+
"entry": entry,
|
| 438 |
+
"bits": bits,
|
| 439 |
+
"output_vocab_size": len(output_ids),
|
| 440 |
+
"input_vocab_size": len(input_set),
|
| 441 |
+
"extra_rows": len(extra),
|
| 442 |
+
}
|
| 443 |
+
payload["input_embedding_extension"] = extension
|
| 444 |
+
extension_bytes = state_dict_bytes({"input_embedding_extension": entry})
|
| 445 |
+
payload["report"]["tensor_bytes"] += extension_bytes
|
| 446 |
+
payload["report"]["output_vocab_size"] = len(output_ids)
|
| 447 |
+
payload["report"]["input_vocab_size"] = len(input_set)
|
| 448 |
+
payload["report"]["input_extension_bits"] = bits
|
| 449 |
+
payload["report"]["input_extension_rows"] = len(extra)
|
| 450 |
+
payload["report"]["input_extension_bytes"] = extension_bytes
|
| 451 |
+
return extension
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
def save_artifact(
|
| 455 |
+
payload: dict[str, Any],
|
| 456 |
+
output_path: Path,
|
| 457 |
+
*,
|
| 458 |
+
lossless_codec: str = "none",
|
| 459 |
+
zlib_level: int = 6,
|
| 460 |
+
lzma_preset: int = 6,
|
| 461 |
+
) -> dict[str, Any]:
|
| 462 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 463 |
+
if output_path.exists():
|
| 464 |
+
raise FileExistsError(f"refusing to overwrite existing artifact: {output_path}")
|
| 465 |
+
if lossless_codec not in {"none", "zlib", "lzma"}:
|
| 466 |
+
raise ValueError(f"unsupported lossless artifact codec: {lossless_codec}")
|
| 467 |
+
raw_disk_bytes = None
|
| 468 |
+
if lossless_codec == "none":
|
| 469 |
+
torch.save(payload, output_path)
|
| 470 |
+
else:
|
| 471 |
+
temporary_path: Path | None = None
|
| 472 |
+
try:
|
| 473 |
+
with tempfile.NamedTemporaryFile(
|
| 474 |
+
dir=output_path.parent,
|
| 475 |
+
prefix=f".{output_path.name}.",
|
| 476 |
+
suffix=".raw",
|
| 477 |
+
delete=False,
|
| 478 |
+
) as handle:
|
| 479 |
+
temporary_path = Path(handle.name)
|
| 480 |
+
torch.save(payload, temporary_path)
|
| 481 |
+
raw_disk_bytes = temporary_path.stat().st_size
|
| 482 |
+
compressor = (
|
| 483 |
+
zlib.compressobj(level=zlib_level)
|
| 484 |
+
if lossless_codec == "zlib"
|
| 485 |
+
else lzma.LZMACompressor(preset=lzma_preset)
|
| 486 |
+
)
|
| 487 |
+
magic = ZLIB_MAGIC if lossless_codec == "zlib" else LZMA_MAGIC
|
| 488 |
+
with temporary_path.open("rb") as source, output_path.open("xb") as target:
|
| 489 |
+
target.write(magic)
|
| 490 |
+
while block := source.read(8 * 1024 * 1024):
|
| 491 |
+
target.write(compressor.compress(block))
|
| 492 |
+
target.write(compressor.flush())
|
| 493 |
+
except Exception:
|
| 494 |
+
if output_path.exists():
|
| 495 |
+
output_path.unlink()
|
| 496 |
+
raise
|
| 497 |
+
finally:
|
| 498 |
+
if temporary_path is not None and temporary_path.exists():
|
| 499 |
+
temporary_path.unlink()
|
| 500 |
+
disk_bytes = output_path.stat().st_size
|
| 501 |
+
report = {
|
| 502 |
+
**payload["report"],
|
| 503 |
+
"artifact_path": str(output_path),
|
| 504 |
+
"disk_bytes": disk_bytes,
|
| 505 |
+
"lossless_codec": lossless_codec,
|
| 506 |
+
}
|
| 507 |
+
if raw_disk_bytes is not None:
|
| 508 |
+
report.update(
|
| 509 |
+
{
|
| 510 |
+
"raw_artifact_bytes": raw_disk_bytes,
|
| 511 |
+
"lossless_savings_bytes": raw_disk_bytes - disk_bytes,
|
| 512 |
+
"codec_level": zlib_level if lossless_codec == "zlib" else lzma_preset,
|
| 513 |
+
}
|
| 514 |
+
)
|
| 515 |
+
if lossless_codec == "zlib":
|
| 516 |
+
report["zlib_level"] = zlib_level
|
| 517 |
+
else:
|
| 518 |
+
report["lzma_preset"] = lzma_preset
|
| 519 |
+
output_path.with_suffix(output_path.suffix + ".json").write_text(json.dumps(report, indent=2))
|
| 520 |
+
return report
|
| 521 |
+
|
| 522 |
+
|
| 523 |
+
def load_artifact(path: str | Path) -> dict[str, Any]:
|
| 524 |
+
artifact = Path(path)
|
| 525 |
+
with artifact.open("rb") as source:
|
| 526 |
+
magic = source.read(len(ZLIB_MAGIC))
|
| 527 |
+
if magic in {ZLIB_MAGIC, LZMA_MAGIC}:
|
| 528 |
+
decompressor = zlib.decompressobj() if magic == ZLIB_MAGIC else lzma.LZMADecompressor()
|
| 529 |
+
# Keep the restored stream beside the artifact so a multi-GB load
|
| 530 |
+
# cannot unexpectedly exhaust a small system /tmp partition.
|
| 531 |
+
with tempfile.TemporaryFile(dir=artifact.parent) as restored:
|
| 532 |
+
while block := source.read(8 * 1024 * 1024):
|
| 533 |
+
restored.write(decompressor.decompress(block))
|
| 534 |
+
if magic == ZLIB_MAGIC:
|
| 535 |
+
restored.write(decompressor.flush())
|
| 536 |
+
if not decompressor.eof:
|
| 537 |
+
codec = "zlib" if magic == ZLIB_MAGIC else "lzma"
|
| 538 |
+
raise ValueError(f"truncated {codec} artifact: {path}")
|
| 539 |
+
restored.seek(0)
|
| 540 |
+
payload = torch.load(restored, map_location="cpu", weights_only=False)
|
| 541 |
+
else:
|
| 542 |
+
source.seek(0)
|
| 543 |
+
payload = torch.load(source, map_location="cpu", weights_only=False)
|
| 544 |
+
if not isinstance(payload, dict) or payload.get("format") != FORMAT:
|
| 545 |
+
raise ValueError(f"unsupported artifact format in {path}")
|
| 546 |
+
return payload
|
| 547 |
+
|
| 548 |
+
|
| 549 |
+
def restore_artifact(
|
| 550 |
+
model_name: str,
|
| 551 |
+
artifact_path: str | Path,
|
| 552 |
+
output_path: str | Path,
|
| 553 |
+
*,
|
| 554 |
+
embedding_fill: str = "zero",
|
| 555 |
+
cache_dir: str | None = None,
|
| 556 |
+
) -> dict[str, Any]:
|
| 557 |
+
"""Expand the artifact into the evaluator's required full BF16 checkpoint."""
|
| 558 |
+
if embedding_fill not in {"zero", "mean", "token", "base"}:
|
| 559 |
+
raise ValueError("embedding_fill must be zero, mean, token, or base")
|
| 560 |
+
out = Path(output_path)
|
| 561 |
+
if out.exists():
|
| 562 |
+
if not out.is_dir() or any(out.iterdir()):
|
| 563 |
+
raise FileExistsError(f"refusing to overwrite restore target: {out}")
|
| 564 |
+
|
| 565 |
+
payload = load_artifact(artifact_path)
|
| 566 |
+
started = time.time()
|
| 567 |
+
model = load_model(
|
| 568 |
+
model_name,
|
| 569 |
+
dtype="bfloat16",
|
| 570 |
+
device_map=None,
|
| 571 |
+
cache_dir=cache_dir,
|
| 572 |
+
multimodal=True,
|
| 573 |
+
)
|
| 574 |
+
targets = model.state_dict()
|
| 575 |
+
restored_names: set[str] = set()
|
| 576 |
+
token_prediction = None
|
| 577 |
+
if embedding_fill == "token":
|
| 578 |
+
predictor = payload.get("embedding_predictor")
|
| 579 |
+
if predictor is None:
|
| 580 |
+
raise ValueError("artifact has no token-string embedding predictor")
|
| 581 |
+
tokenizer = load_tokenizer(model_name, cache_dir=cache_dir)
|
| 582 |
+
token_prediction = predict_token_rows(predictor, tokenizer)
|
| 583 |
+
|
| 584 |
+
with torch.no_grad():
|
| 585 |
+
for name, entry in payload["state_dict"].items():
|
| 586 |
+
if name not in targets:
|
| 587 |
+
raise KeyError(f"artifact tensor is absent from base architecture: {name}")
|
| 588 |
+
if entry["kind"] == "gptq_codes":
|
| 589 |
+
value = unpack_gptq_entry(entry)
|
| 590 |
+
elif entry["kind"] == "adaptive_codebook":
|
| 591 |
+
value = unpack_adaptive_codebook(entry)
|
| 592 |
+
elif entry["kind"] in {"quant_rows", "raw_rows"}:
|
| 593 |
+
if embedding_fill == "base":
|
| 594 |
+
selected_entry = {
|
| 595 |
+
**entry,
|
| 596 |
+
"kind": "quant" if entry["kind"] == "quant_rows" else "raw",
|
| 597 |
+
}
|
| 598 |
+
selected = unpack_tensor(selected_entry)
|
| 599 |
+
value = targets[name].detach().cpu().clone()
|
| 600 |
+
value.index_copy_(0, entry["row_ids"].long(), selected)
|
| 601 |
+
elif embedding_fill == "token":
|
| 602 |
+
if token_prediction is None or tuple(token_prediction.shape) != tuple(
|
| 603 |
+
entry["full_shape"]
|
| 604 |
+
):
|
| 605 |
+
raise RuntimeError(
|
| 606 |
+
"token prediction shape does not match row-packed embedding: "
|
| 607 |
+
f"prediction={None if token_prediction is None else tuple(token_prediction.shape)}, "
|
| 608 |
+
f"expected={tuple(entry['full_shape'])}"
|
| 609 |
+
)
|
| 610 |
+
selected_entry = {
|
| 611 |
+
**entry,
|
| 612 |
+
"kind": "quant" if entry["kind"] == "quant_rows" else "raw",
|
| 613 |
+
}
|
| 614 |
+
selected = unpack_tensor(selected_entry)
|
| 615 |
+
value = token_prediction.clone()
|
| 616 |
+
value.index_copy_(0, entry["row_ids"].long(), selected)
|
| 617 |
+
else:
|
| 618 |
+
value = unpack_rows(entry, fill=embedding_fill)
|
| 619 |
+
else:
|
| 620 |
+
value = unpack_tensor(entry)
|
| 621 |
+
extension = payload.get("input_embedding_extension")
|
| 622 |
+
if extension is not None and extension.get("target") == name:
|
| 623 |
+
extension_entry = extension["entry"]
|
| 624 |
+
dense_entry = {
|
| 625 |
+
**extension_entry,
|
| 626 |
+
"kind": "quant"
|
| 627 |
+
if extension_entry["kind"] == "quant_rows"
|
| 628 |
+
else "raw",
|
| 629 |
+
}
|
| 630 |
+
extra_values = unpack_tensor(dense_entry)
|
| 631 |
+
value.index_copy_(0, extension_entry["row_ids"].long(), extra_values)
|
| 632 |
+
targets[name].copy_(value.to(dtype=targets[name].dtype))
|
| 633 |
+
restored_names.add(name)
|
| 634 |
+
|
| 635 |
+
for alias, canonical in payload["aliases"].items():
|
| 636 |
+
if alias not in targets or canonical not in targets:
|
| 637 |
+
raise KeyError(f"artifact alias cannot be resolved: {alias} -> {canonical}")
|
| 638 |
+
if targets[alias].data_ptr() != targets[canonical].data_ptr():
|
| 639 |
+
targets[alias].copy_(targets[canonical])
|
| 640 |
+
restored_names.add(alias)
|
| 641 |
+
|
| 642 |
+
missing_text = [
|
| 643 |
+
name
|
| 644 |
+
for name in targets
|
| 645 |
+
if not EXCLUDED.search(name) and name not in restored_names
|
| 646 |
+
]
|
| 647 |
+
if missing_text:
|
| 648 |
+
preview = ", ".join(missing_text[:8])
|
| 649 |
+
raise RuntimeError(f"artifact did not restore {len(missing_text)} text tensors: {preview}")
|
| 650 |
+
|
| 651 |
+
copied = save_checkpoint(model, out, source_model=model_name, cache_dir=cache_dir)
|
| 652 |
+
keep = set(int(token_id) for token_id in payload["keep_ids"].tolist())
|
| 653 |
+
original_vocab = max(
|
| 654 |
+
int(entry["full_shape"][0])
|
| 655 |
+
for entry in payload["state_dict"].values()
|
| 656 |
+
if entry["kind"] in {"quant_rows", "raw_rows"}
|
| 657 |
+
)
|
| 658 |
+
suppressed = [token_id for token_id in range(original_vocab) if token_id not in keep]
|
| 659 |
+
try:
|
| 660 |
+
generation_config = model.generation_config
|
| 661 |
+
generation_config.suppress_tokens = suppressed
|
| 662 |
+
generation_config.save_pretrained(out)
|
| 663 |
+
except Exception as exc: # noqa: BLE001 - explicit manifest still records the failure
|
| 664 |
+
raise RuntimeError(f"could not save suppress_tokens generation config: {exc}") from exc
|
| 665 |
+
|
| 666 |
+
report = {
|
| 667 |
+
"format": FORMAT,
|
| 668 |
+
"artifact": str(artifact_path),
|
| 669 |
+
"model_name": model_name,
|
| 670 |
+
"embedding_fill": embedding_fill,
|
| 671 |
+
"submission_valid": embedding_fill in {"zero", "mean", "token"},
|
| 672 |
+
"restored_text_tensors": len(restored_names),
|
| 673 |
+
"suppressed_tokens": len(suppressed),
|
| 674 |
+
"kept_tokens": len(keep),
|
| 675 |
+
"input_vocab_size": payload.get("report", {}).get("input_vocab_size", len(keep)),
|
| 676 |
+
"auxiliary_files": copied,
|
| 677 |
+
"restore_seconds": round(time.time() - started, 1),
|
| 678 |
+
}
|
| 679 |
+
(out / "artifact_restore.json").write_text(json.dumps(report, indent=2))
|
| 680 |
+
(out / "keep_ids.json").write_text(json.dumps(sorted(keep)))
|
| 681 |
+
return report
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
def default_model() -> str:
|
| 685 |
+
"""Small indirection retained for the standalone submission wrapper."""
|
| 686 |
+
return DEFAULT_MODEL
|
src/eaimath/artifact_refine.py
ADDED
|
@@ -0,0 +1,248 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Physical code-grid refinement helpers for the Bucket-B artifact."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import copy
|
| 6 |
+
import math
|
| 7 |
+
import re
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
import numpy as np
|
| 11 |
+
import torch
|
| 12 |
+
|
| 13 |
+
from .artifact import unpack_gptq_entry
|
| 14 |
+
from .pack import pack_bits, unpack_bits, unpack_tensor
|
| 15 |
+
|
| 16 |
+
MLP = re.compile(r"\.mlp\.")
|
| 17 |
+
GDN = re.compile(r"linear_attn")
|
| 18 |
+
FULL_ATTN = re.compile(r"self_attn")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def resolved_recipe_from_report(report: dict[str, Any]) -> str:
|
| 22 |
+
"""Recover the exact payload recipe from a physical artifact sidecar.
|
| 23 |
+
|
| 24 |
+
Early Round-21 sidecars inherited the base report's recipe even though the
|
| 25 |
+
payload recipe correctly included its refinement suffix. The refinement
|
| 26 |
+
fields are complete, so existing artifacts can be validated without being
|
| 27 |
+
rebuilt or decompressed an extra time.
|
| 28 |
+
"""
|
| 29 |
+
recipe = str(report.get("recipe") or "")
|
| 30 |
+
refinement = report.get("refinement")
|
| 31 |
+
if refinement == "frozen_grid_adapter_projection_v1":
|
| 32 |
+
if "+grid-" in recipe:
|
| 33 |
+
return recipe
|
| 34 |
+
profile = report.get("refinement_profile")
|
| 35 |
+
alpha = report.get("refinement_alpha")
|
| 36 |
+
if not recipe or profile not in {"all", "attention", "gdn"} or alpha is None:
|
| 37 |
+
raise ValueError("incomplete frozen-grid recipe metadata")
|
| 38 |
+
return f"{recipe}+grid-{profile}-a{float(alpha):g}"
|
| 39 |
+
if refinement == "dual_adaptive_codebook_v1":
|
| 40 |
+
if "+aaac-" in recipe:
|
| 41 |
+
return recipe
|
| 42 |
+
mode = report.get("refinement_mode")
|
| 43 |
+
if not recipe or mode not in {"activation", "weight"}:
|
| 44 |
+
raise ValueError("incomplete adaptive-codebook recipe metadata")
|
| 45 |
+
return f"{recipe}+aaac-gdn-{mode}"
|
| 46 |
+
if refinement == "stochastic_grid_adapter_projection_v1":
|
| 47 |
+
if "+stochastic-" in recipe:
|
| 48 |
+
return recipe
|
| 49 |
+
profile = report.get("refinement_profile")
|
| 50 |
+
gain = report.get("refinement_gain")
|
| 51 |
+
seed = report.get("refinement_seed")
|
| 52 |
+
if not recipe or profile not in {"all", "attention", "gdn"} or gain is None or seed is None:
|
| 53 |
+
raise ValueError("incomplete stochastic-grid recipe metadata")
|
| 54 |
+
return f"{recipe}+stochastic-{profile}-g{float(gain):g}-s{int(seed)}"
|
| 55 |
+
if not recipe:
|
| 56 |
+
raise ValueError("artifact sidecar has no recipe")
|
| 57 |
+
return recipe
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def component(name: str) -> str | None:
|
| 61 |
+
if MLP.search(name):
|
| 62 |
+
return "mlp"
|
| 63 |
+
if GDN.search(name):
|
| 64 |
+
return "gdn"
|
| 65 |
+
if FULL_ATTN.search(name):
|
| 66 |
+
return "full_attn"
|
| 67 |
+
return None
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def profile_matches(name: str, profile: str) -> bool:
|
| 71 |
+
value = component(name)
|
| 72 |
+
if profile == "all":
|
| 73 |
+
return value is not None
|
| 74 |
+
if profile == "attention":
|
| 75 |
+
return value in {"gdn", "full_attn"}
|
| 76 |
+
if profile == "gdn":
|
| 77 |
+
return value == "gdn"
|
| 78 |
+
raise ValueError(f"unknown projection profile: {profile}")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _gptq_codes_and_zeros(entry: dict[str, Any]) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
|
| 82 |
+
in_features, out_features = tuple(entry["code_shape"])
|
| 83 |
+
count = in_features * out_features
|
| 84 |
+
codes = unpack_bits(entry["packed"].numpy(), entry["bits"], count)
|
| 85 |
+
codes_tensor = torch.from_numpy(codes.astype(np.int64)).reshape(in_features, out_features)
|
| 86 |
+
scales = entry["scale"].float()
|
| 87 |
+
if entry.get("g_idx") is None:
|
| 88 |
+
g_idx = torch.div(
|
| 89 |
+
torch.arange(in_features, dtype=torch.int64),
|
| 90 |
+
int(entry["group"]),
|
| 91 |
+
rounding_mode="floor",
|
| 92 |
+
).clamp_max(scales.shape[0] - 1)
|
| 93 |
+
else:
|
| 94 |
+
g_idx = entry["g_idx"].long()
|
| 95 |
+
if entry.get("zero_value") is not None:
|
| 96 |
+
zeros = torch.full_like(scales, int(entry["zero_value"]), dtype=torch.int64)
|
| 97 |
+
else:
|
| 98 |
+
zero_shape = tuple(entry["zero_shape"])
|
| 99 |
+
zero_count = int(np.prod(zero_shape))
|
| 100 |
+
values = unpack_bits(entry["zero_packed"].numpy(), entry["bits"], zero_count)
|
| 101 |
+
zeros = torch.from_numpy(values.astype(np.int64)).reshape(zero_shape)
|
| 102 |
+
return codes_tensor, scales.index_select(0, g_idx), zeros.index_select(0, g_idx)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def project_gptq_entry(
|
| 106 |
+
entry: dict[str, Any], adapted: torch.Tensor, alpha: float
|
| 107 |
+
) -> tuple[dict[str, Any], dict[str, float | int]]:
|
| 108 |
+
base = unpack_gptq_entry(entry).float()
|
| 109 |
+
target = base + alpha * (adapted.detach().float().cpu() - base)
|
| 110 |
+
old_codes, scale_rows, zero_rows = _gptq_codes_and_zeros(entry)
|
| 111 |
+
maxq = 2 ** int(entry["bits"]) - 1
|
| 112 |
+
new_codes = torch.round(target.T / scale_rows.clamp_min(1e-12) + zero_rows).clamp(0, maxq)
|
| 113 |
+
new_codes = new_codes.to(torch.int64)
|
| 114 |
+
projected = (scale_rows * (new_codes - zero_rows).float()).T
|
| 115 |
+
changed = new_codes.ne(old_codes)
|
| 116 |
+
updated = copy.copy(entry)
|
| 117 |
+
updated["packed"] = torch.from_numpy(
|
| 118 |
+
pack_bits(new_codes.numpy().astype(np.uint32), int(entry["bits"]))
|
| 119 |
+
)
|
| 120 |
+
return updated, {
|
| 121 |
+
"weights": int(new_codes.numel()),
|
| 122 |
+
"changed_codes": int(changed.sum()),
|
| 123 |
+
"target_delta_rmse": float((target - base).square().mean().sqrt()),
|
| 124 |
+
"projection_rmse": float((target - projected).square().mean().sqrt()),
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def project_quant_entry(
|
| 129 |
+
entry: dict[str, Any], adapted: torch.Tensor, alpha: float
|
| 130 |
+
) -> tuple[dict[str, Any], dict[str, float | int]]:
|
| 131 |
+
base = unpack_tensor(entry).float()
|
| 132 |
+
target = base + alpha * (adapted.detach().float().cpu() - base)
|
| 133 |
+
bits, group = int(entry["bits"]), int(entry["group"])
|
| 134 |
+
count = int(np.prod(entry["shape"]))
|
| 135 |
+
old = unpack_bits(entry["packed"].numpy(), bits, count)
|
| 136 |
+
old_codes = torch.from_numpy(old.astype(np.int64)).reshape(-1, group)
|
| 137 |
+
scale = entry["scale"].float().reshape(-1, 1)
|
| 138 |
+
scale = scale * math.ldexp(1.0, -int(entry.get("scale_exponent", 0)))
|
| 139 |
+
flat = target.reshape(-1, group)
|
| 140 |
+
if entry["symmetric"]:
|
| 141 |
+
qmax = 2 ** (bits - 1) - 1
|
| 142 |
+
new_codes = torch.round(flat / scale.clamp_min(1e-12)) + qmax + 1
|
| 143 |
+
new_codes = new_codes.clamp(0, 2**bits - 1).to(torch.int64)
|
| 144 |
+
projected = (new_codes - qmax - 1).float() * scale
|
| 145 |
+
else:
|
| 146 |
+
zero = entry["zero"].float().reshape(-1, 1)
|
| 147 |
+
new_codes = (torch.round(flat / scale.clamp_min(1e-12)) + zero).clamp(0, 2**bits - 1)
|
| 148 |
+
new_codes = new_codes.to(torch.int64)
|
| 149 |
+
projected = (new_codes.float() - zero) * scale
|
| 150 |
+
updated = copy.copy(entry)
|
| 151 |
+
updated["packed"] = torch.from_numpy(pack_bits(new_codes.numpy().astype(np.uint32), bits))
|
| 152 |
+
return updated, {
|
| 153 |
+
"weights": int(new_codes.numel()),
|
| 154 |
+
"changed_codes": int(new_codes.ne(old_codes).sum()),
|
| 155 |
+
"target_delta_rmse": float((target - base).square().mean().sqrt()),
|
| 156 |
+
"projection_rmse": float((target - projected.reshape_as(target)).square().mean().sqrt()),
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def project_entry(
|
| 161 |
+
entry: dict[str, Any], adapted: torch.Tensor, alpha: float
|
| 162 |
+
) -> tuple[dict[str, Any], dict[str, float | int]]:
|
| 163 |
+
if entry.get("kind") == "gptq_codes":
|
| 164 |
+
return project_gptq_entry(entry, adapted, alpha)
|
| 165 |
+
if entry.get("kind") == "quant":
|
| 166 |
+
return project_quant_entry(entry, adapted, alpha)
|
| 167 |
+
raise ValueError(f"entry kind cannot be grid-projected: {entry.get('kind')!r}")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _stochastic_integer_round(desired: torch.Tensor, *, generator: torch.Generator) -> torch.Tensor:
|
| 171 |
+
"""Unbiased stochastic rounding to integers with a deterministic generator."""
|
| 172 |
+
lower = torch.floor(desired)
|
| 173 |
+
probability = desired - lower
|
| 174 |
+
draw = torch.rand(desired.shape, generator=generator, dtype=torch.float32)
|
| 175 |
+
return lower + draw.lt(probability)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def stochastic_project_gptq_entry(
|
| 179 |
+
entry: dict[str, Any], adapted: torch.Tensor, alpha: float, seed: int
|
| 180 |
+
) -> tuple[dict[str, Any], dict[str, float | int]]:
|
| 181 |
+
base = unpack_gptq_entry(entry).float()
|
| 182 |
+
delta = adapted.detach().float().cpu() - base
|
| 183 |
+
target = base + alpha * delta
|
| 184 |
+
old_codes, scale_rows, zero_rows = _gptq_codes_and_zeros(entry)
|
| 185 |
+
desired = old_codes.float() + alpha * delta.T / scale_rows.clamp_min(1e-12)
|
| 186 |
+
generator = torch.Generator().manual_seed(seed)
|
| 187 |
+
maxq = 2 ** int(entry["bits"]) - 1
|
| 188 |
+
new_codes = _stochastic_integer_round(desired, generator=generator)
|
| 189 |
+
new_codes = new_codes.clamp(0, maxq).to(torch.int64)
|
| 190 |
+
projected = (scale_rows * (new_codes - zero_rows).float()).T
|
| 191 |
+
changed = new_codes.ne(old_codes)
|
| 192 |
+
updated = copy.copy(entry)
|
| 193 |
+
updated["packed"] = torch.from_numpy(
|
| 194 |
+
pack_bits(new_codes.numpy().astype(np.uint32), int(entry["bits"]))
|
| 195 |
+
)
|
| 196 |
+
return updated, {
|
| 197 |
+
"weights": int(new_codes.numel()),
|
| 198 |
+
"changed_codes": int(changed.sum()),
|
| 199 |
+
"absolute_code_steps": int((new_codes - old_codes).abs().sum()),
|
| 200 |
+
"target_delta_rmse": float((target - base).square().mean().sqrt()),
|
| 201 |
+
"projection_rmse": float((target - projected).square().mean().sqrt()),
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def stochastic_project_quant_entry(
|
| 206 |
+
entry: dict[str, Any], adapted: torch.Tensor, alpha: float, seed: int
|
| 207 |
+
) -> tuple[dict[str, Any], dict[str, float | int]]:
|
| 208 |
+
base = unpack_tensor(entry).float()
|
| 209 |
+
delta = adapted.detach().float().cpu() - base
|
| 210 |
+
target = base + alpha * delta
|
| 211 |
+
bits, group = int(entry["bits"]), int(entry["group"])
|
| 212 |
+
count = int(np.prod(entry["shape"]))
|
| 213 |
+
old = unpack_bits(entry["packed"].numpy(), bits, count)
|
| 214 |
+
old_codes = torch.from_numpy(old.astype(np.int64)).reshape(-1, group)
|
| 215 |
+
scale = entry["scale"].float().reshape(-1, 1)
|
| 216 |
+
scale = scale * math.ldexp(1.0, -int(entry.get("scale_exponent", 0)))
|
| 217 |
+
flat_delta = delta.reshape(-1, group)
|
| 218 |
+
if entry["symmetric"]:
|
| 219 |
+
qmax = 2 ** (bits - 1) - 1
|
| 220 |
+
zero = qmax + 1
|
| 221 |
+
else:
|
| 222 |
+
zero = entry["zero"].float().reshape(-1, 1)
|
| 223 |
+
desired = old_codes.float() + alpha * flat_delta / scale.clamp_min(1e-12)
|
| 224 |
+
generator = torch.Generator().manual_seed(seed)
|
| 225 |
+
new_codes = _stochastic_integer_round(desired, generator=generator)
|
| 226 |
+
new_codes = new_codes.clamp(0, 2**bits - 1).to(torch.int64)
|
| 227 |
+
projected = (new_codes.float() - zero) * scale
|
| 228 |
+
changed = new_codes.ne(old_codes)
|
| 229 |
+
updated = copy.copy(entry)
|
| 230 |
+
updated["packed"] = torch.from_numpy(pack_bits(new_codes.numpy().astype(np.uint32), bits))
|
| 231 |
+
return updated, {
|
| 232 |
+
"weights": int(new_codes.numel()),
|
| 233 |
+
"changed_codes": int(changed.sum()),
|
| 234 |
+
"absolute_code_steps": int((new_codes - old_codes).abs().sum()),
|
| 235 |
+
"target_delta_rmse": float((target - base).square().mean().sqrt()),
|
| 236 |
+
"projection_rmse": float((target - projected.reshape_as(target)).square().mean().sqrt()),
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def stochastic_project_entry(
|
| 241 |
+
entry: dict[str, Any], adapted: torch.Tensor, alpha: float, seed: int
|
| 242 |
+
) -> tuple[dict[str, Any], dict[str, float | int]]:
|
| 243 |
+
"""Stochastically preserve a sub-grid learned update in physical codes."""
|
| 244 |
+
if entry.get("kind") == "gptq_codes":
|
| 245 |
+
return stochastic_project_gptq_entry(entry, adapted, alpha, seed)
|
| 246 |
+
if entry.get("kind") == "quant":
|
| 247 |
+
return stochastic_project_quant_entry(entry, adapted, alpha, seed)
|
| 248 |
+
raise ValueError(f"entry kind cannot be grid-projected: {entry.get('kind')!r}")
|
src/eaimath/buckets.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Submission buckets — three per week, scored on accuracy *within* a band.
|
| 2 |
+
|
| 3 |
+
The leaderboard ranks by accuracy inside each compression band, not on the
|
| 4 |
+
size/accuracy frontier. That inverts the optimization: there is **no reward for
|
| 5 |
+
being smaller than your bucket allows**, so every submission should sit as close
|
| 6 |
+
to its upper size limit as it can. A 2.0 GB model and a 3.3 GB model compete on
|
| 7 |
+
equal terms in bucket C; the 3.3 GB one simply gets more bits to spend.
|
| 8 |
+
|
| 9 |
+
Three bands × two tracks = six submissions selected at the end of each week.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from dataclasses import dataclass
|
| 15 |
+
|
| 16 |
+
# Conservative denominator: the text-only weight budget we actually compress.
|
| 17 |
+
# If the graders measure against the full HF repo (9.31 GB, vision tower
|
| 18 |
+
# included) every limit below is ~11% more generous -- worth confirming, but
|
| 19 |
+
# aiming at the tighter number can only be safe.
|
| 20 |
+
ORIGINAL_GB = 8.412
|
| 21 |
+
ORIGINAL_GB_FULL_REPO = 9.313
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@dataclass(frozen=True)
|
| 25 |
+
class Bucket:
|
| 26 |
+
name: str
|
| 27 |
+
label: str
|
| 28 |
+
low_pct: float
|
| 29 |
+
high_pct: float
|
| 30 |
+
|
| 31 |
+
@property
|
| 32 |
+
def low_gb(self) -> float:
|
| 33 |
+
return self.low_pct / 100 * ORIGINAL_GB
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def high_gb(self) -> float:
|
| 37 |
+
return self.high_pct / 100 * ORIGINAL_GB
|
| 38 |
+
|
| 39 |
+
@property
|
| 40 |
+
def target_gb(self) -> float:
|
| 41 |
+
"""Where a submission should aim: just under the ceiling."""
|
| 42 |
+
return self.high_gb * 0.98
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def bits_per_weight(self) -> tuple[float, float]:
|
| 46 |
+
return (16 * self.low_pct / 100, 16 * self.high_pct / 100)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
BUCKETS: tuple[Bucket, ...] = (
|
| 50 |
+
Bucket("A", "≤10%", 0.0, 10.0),
|
| 51 |
+
Bucket("B", "10–20%", 10.0, 20.0),
|
| 52 |
+
Bucket("C", "20–40%", 20.0, 40.0),
|
| 53 |
+
)
|
| 54 |
+
|
| 55 |
+
BY_NAME = {b.name: b for b in BUCKETS}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def classify(size_gb: float | None) -> Bucket | None:
|
| 59 |
+
"""Which bucket a checkpoint qualifies for, or None if it is too large."""
|
| 60 |
+
if size_gb is None:
|
| 61 |
+
return None
|
| 62 |
+
pct = 100 * size_gb / ORIGINAL_GB
|
| 63 |
+
for bucket in BUCKETS:
|
| 64 |
+
if bucket.low_pct <= pct < bucket.high_pct:
|
| 65 |
+
return bucket
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def pct_of_original(size_gb: float) -> float:
|
| 70 |
+
return 100 * size_gb / ORIGINAL_GB
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def describe() -> str:
|
| 74 |
+
lines = [
|
| 75 |
+
f"{'bucket':<8}{'band':<10}{'size range':>18}{'bits/weight':>14}{'aim for':>10}",
|
| 76 |
+
"-" * 60,
|
| 77 |
+
]
|
| 78 |
+
for b in BUCKETS:
|
| 79 |
+
lo, hi = b.bits_per_weight
|
| 80 |
+
lines.append(
|
| 81 |
+
f"{b.name:<8}{b.label:<10}{f'{b.low_gb:.2f}–{b.high_gb:.2f} GB':>18}"
|
| 82 |
+
f"{f'{lo:.2f}–{hi:.2f}':>14}{b.target_gb:>9.2f} GB"
|
| 83 |
+
)
|
| 84 |
+
lines.append("")
|
| 85 |
+
lines.append(
|
| 86 |
+
f"Scored on accuracy within a band, so aim at the ceiling — being "
|
| 87 |
+
f"smaller earns nothing.\nDenominator: {ORIGINAL_GB} GB (text-only). "
|
| 88 |
+
f"If graders use the full repo ({ORIGINAL_GB_FULL_REPO} GB) every limit "
|
| 89 |
+
f"is ~11% looser."
|
| 90 |
+
)
|
| 91 |
+
return "\n".join(lines)
|
src/eaimath/data.py
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Math evaluation sets and calibration data.
|
| 2 |
+
|
| 3 |
+
The leaderboard set is hidden and probably postdates the model, so we keep two
|
| 4 |
+
tiers deliberately separate:
|
| 5 |
+
|
| 6 |
+
* **gate** — small, fast, run on every recipe. Cheap signal for iteration.
|
| 7 |
+
* **holdout** — recent competitions we never tune against. The honest estimate.
|
| 8 |
+
|
| 9 |
+
AIME 2024 is deliberately excluded from the holdout: it is measurably
|
| 10 |
+
contaminated (inflating scores 10-20 points over clean contests), so it flatters
|
| 11 |
+
every recipe equally and discriminates between none of them.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
from typing import Any, Iterable, Sequence
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@dataclass
|
| 21 |
+
class MathExample:
|
| 22 |
+
example_id: str
|
| 23 |
+
problem: str
|
| 24 |
+
answer: str
|
| 25 |
+
source: str
|
| 26 |
+
metadata: dict[str, Any] = field(default_factory=dict)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@dataclass(frozen=True)
|
| 30 |
+
class DatasetSpec:
|
| 31 |
+
"""How to pull one benchmark off the Hub.
|
| 32 |
+
|
| 33 |
+
Field names differ between mirrors of the same benchmark, so each role lists
|
| 34 |
+
candidate column names tried in order.
|
| 35 |
+
"""
|
| 36 |
+
|
| 37 |
+
name: str
|
| 38 |
+
hf_id: str
|
| 39 |
+
split: str = "test"
|
| 40 |
+
config: str | None = None
|
| 41 |
+
problem_fields: Sequence[str] = ("problem", "Problem", "question", "Question")
|
| 42 |
+
answer_fields: Sequence[str] = ("answer", "Answer", "solution", "expected_answer")
|
| 43 |
+
tier: str = "gate"
|
| 44 |
+
filters: tuple[tuple[str, str, Any], ...] = ()
|
| 45 |
+
max_examples: int | None = None
|
| 46 |
+
note: str = ""
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
# Three tiers, by how often we run them and how much tuning pressure they can
|
| 50 |
+
# absorb before their numbers stop meaning anything.
|
| 51 |
+
#
|
| 52 |
+
# gate every experiment. Cheap, tuned against freely.
|
| 53 |
+
# checkpoint before a weekly leaderboard submission. Moderate tuning risk.
|
| 54 |
+
# holdout the two graded checkpoints only. NEVER tuned against — these are
|
| 55 |
+
# post-release contests and the closest proxy we have for a hidden
|
| 56 |
+
# eval that "is not available in public domain today".
|
| 57 |
+
REGISTRY: dict[str, DatasetSpec] = {
|
| 58 |
+
"math500_hard": DatasetSpec(
|
| 59 |
+
name="math500_hard",
|
| 60 |
+
hf_id="HuggingFaceH4/MATH-500",
|
| 61 |
+
split="test",
|
| 62 |
+
tier="gate",
|
| 63 |
+
filters=(("level", "gte", 4),),
|
| 64 |
+
max_examples=100,
|
| 65 |
+
note="MATH-500 levels 4-5, deterministic 100-problem subsample. The "
|
| 66 |
+
"fast regression signal: sensitive enough to catch damage, cheap "
|
| 67 |
+
"enough to run on every recipe.",
|
| 68 |
+
),
|
| 69 |
+
"math500": DatasetSpec(
|
| 70 |
+
name="math500",
|
| 71 |
+
hf_id="HuggingFaceH4/MATH-500",
|
| 72 |
+
split="test",
|
| 73 |
+
tier="checkpoint",
|
| 74 |
+
note="Full 500. Largely saturated for this model (~84.5 bf16), so it "
|
| 75 |
+
"detects collapse but not subtle reasoning damage.",
|
| 76 |
+
),
|
| 77 |
+
"aime25": DatasetSpec(
|
| 78 |
+
name="aime25",
|
| 79 |
+
hf_id="MathArena/aime_2025",
|
| 80 |
+
split="train",
|
| 81 |
+
tier="checkpoint",
|
| 82 |
+
note="30 problems. Hard tail — where quantization damage actually shows.",
|
| 83 |
+
),
|
| 84 |
+
"hmmt_feb25": DatasetSpec(
|
| 85 |
+
name="hmmt_feb25",
|
| 86 |
+
hf_id="MathArena/hmmt_feb_2025",
|
| 87 |
+
split="train",
|
| 88 |
+
tier="checkpoint",
|
| 89 |
+
note="30 problems. Reported on the model card (74.0), so we have a "
|
| 90 |
+
"published bf16 reference to validate our harness against.",
|
| 91 |
+
),
|
| 92 |
+
"aime26": DatasetSpec(
|
| 93 |
+
name="aime26",
|
| 94 |
+
hf_id="MathArena/aime_2026",
|
| 95 |
+
split="train",
|
| 96 |
+
tier="holdout",
|
| 97 |
+
note="30 problems, Feb 2026 contest. Post-dates most training data.",
|
| 98 |
+
),
|
| 99 |
+
"hmmt_feb26": DatasetSpec(
|
| 100 |
+
name="hmmt_feb26",
|
| 101 |
+
hf_id="MathArena/hmmt_feb_2026",
|
| 102 |
+
split="train",
|
| 103 |
+
tier="holdout",
|
| 104 |
+
note="33 problems, Feb 2026 contest. Cleanest proxy for the hidden eval.",
|
| 105 |
+
),
|
| 106 |
+
"aime24": DatasetSpec(
|
| 107 |
+
name="aime24",
|
| 108 |
+
hf_id="Maxwell-Jia/AIME_2024",
|
| 109 |
+
split="train",
|
| 110 |
+
tier="diagnostic",
|
| 111 |
+
note="Measurably contaminated — inflates scores 10-20 points over clean "
|
| 112 |
+
"contests. Diagnostic only, never for recipe selection.",
|
| 113 |
+
),
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
# Training pools — problems with known answers, used to generate our own
|
| 117 |
+
# reasoning traces. Disjoint from every eval set above: MATH-500 is drawn from
|
| 118 |
+
# the MATH *test* split, so the MATH train split cannot leak into it.
|
| 119 |
+
TRAIN_REGISTRY: dict[str, DatasetSpec] = {
|
| 120 |
+
"math_train": DatasetSpec(
|
| 121 |
+
name="math_train",
|
| 122 |
+
hf_id="EleutherAI/hendrycks_math",
|
| 123 |
+
split="train",
|
| 124 |
+
config="algebra",
|
| 125 |
+
answer_fields=("solution",), # gold answer is the \boxed{} in the solution
|
| 126 |
+
tier="train",
|
| 127 |
+
note="MATH train split. Pass --config to pick a subject.",
|
| 128 |
+
),
|
| 129 |
+
"openr1": DatasetSpec(
|
| 130 |
+
name="openr1",
|
| 131 |
+
hf_id="open-r1/OpenR1-Math-220k",
|
| 132 |
+
split="train",
|
| 133 |
+
answer_fields=("answer", "solution"),
|
| 134 |
+
tier="train",
|
| 135 |
+
note="220k competition problems with verified answers.",
|
| 136 |
+
),
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
MATH_SUBJECTS = (
|
| 140 |
+
"algebra", "counting_and_probability", "geometry", "intermediate_algebra",
|
| 141 |
+
"number_theory", "prealgebra", "precalculus",
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
SUITES: dict[str, list[str]] = {
|
| 145 |
+
"gate": ["math500_hard"],
|
| 146 |
+
"checkpoint": ["math500", "aime25", "hmmt_feb25"],
|
| 147 |
+
"holdout": ["aime26", "hmmt_feb26"],
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _passes_filters(row: dict[str, Any], filters: Sequence[tuple[str, str, Any]]) -> bool:
|
| 152 |
+
for field_name, op, value in filters:
|
| 153 |
+
actual = row.get(field_name)
|
| 154 |
+
if actual is None:
|
| 155 |
+
return False
|
| 156 |
+
if op == "gte" and not actual >= value:
|
| 157 |
+
return False
|
| 158 |
+
if op == "lte" and not actual <= value:
|
| 159 |
+
return False
|
| 160 |
+
if op == "eq" and actual != value:
|
| 161 |
+
return False
|
| 162 |
+
if op == "in" and actual not in value:
|
| 163 |
+
return False
|
| 164 |
+
return True
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _subsample(examples: list[MathExample], n: int, seed: int = 0) -> list[MathExample]:
|
| 168 |
+
"""Deterministic subsample, stable across runs and machines.
|
| 169 |
+
|
| 170 |
+
Shuffles with a fixed seed rather than taking a prefix, because these sets
|
| 171 |
+
are ordered by subject/difficulty and a prefix would be badly skewed. The
|
| 172 |
+
gate set must be identical across every recipe or the comparison is
|
| 173 |
+
meaningless.
|
| 174 |
+
"""
|
| 175 |
+
import random
|
| 176 |
+
|
| 177 |
+
if len(examples) <= n:
|
| 178 |
+
return examples
|
| 179 |
+
indices = sorted(range(len(examples)))
|
| 180 |
+
random.Random(seed).shuffle(indices)
|
| 181 |
+
return [examples[i] for i in sorted(indices[:n])]
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _resolve_field(row: dict[str, Any], candidates: Iterable[str]) -> str | None:
|
| 185 |
+
lowered = {k.lower(): k for k in row}
|
| 186 |
+
for candidate in candidates:
|
| 187 |
+
key = lowered.get(candidate.lower())
|
| 188 |
+
if key is not None and row[key] is not None:
|
| 189 |
+
return str(row[key])
|
| 190 |
+
return None
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def load_dataset_examples(
|
| 194 |
+
spec: DatasetSpec | str,
|
| 195 |
+
limit: int | None = None,
|
| 196 |
+
cache_dir: str | None = None,
|
| 197 |
+
) -> list[MathExample]:
|
| 198 |
+
"""Load one benchmark into ``MathExample`` records.
|
| 199 |
+
|
| 200 |
+
Raises with the observed column names when a field cannot be resolved, so a
|
| 201 |
+
schema change on the Hub produces an actionable error instead of silently
|
| 202 |
+
empty problems.
|
| 203 |
+
"""
|
| 204 |
+
from datasets import load_dataset
|
| 205 |
+
|
| 206 |
+
if isinstance(spec, str):
|
| 207 |
+
table = {**REGISTRY, **TRAIN_REGISTRY}
|
| 208 |
+
if spec not in table:
|
| 209 |
+
raise KeyError(f"Unknown dataset {spec!r}. Known: {sorted(table)}")
|
| 210 |
+
spec = table[spec]
|
| 211 |
+
|
| 212 |
+
kwargs: dict[str, Any] = {"split": spec.split}
|
| 213 |
+
if spec.config:
|
| 214 |
+
kwargs["name"] = spec.config
|
| 215 |
+
if cache_dir:
|
| 216 |
+
kwargs["cache_dir"] = cache_dir
|
| 217 |
+
|
| 218 |
+
dataset = load_dataset(spec.hf_id, **kwargs)
|
| 219 |
+
|
| 220 |
+
examples: list[MathExample] = []
|
| 221 |
+
for i, row in enumerate(dataset):
|
| 222 |
+
if not _passes_filters(row, spec.filters):
|
| 223 |
+
continue
|
| 224 |
+
problem = _resolve_field(row, spec.problem_fields)
|
| 225 |
+
answer = _resolve_field(row, spec.answer_fields)
|
| 226 |
+
if problem is None or answer is None:
|
| 227 |
+
raise ValueError(
|
| 228 |
+
f"{spec.name}: could not resolve problem/answer fields. "
|
| 229 |
+
f"Available columns: {sorted(row)}. "
|
| 230 |
+
f"Tried problem={list(spec.problem_fields)}, answer={list(spec.answer_fields)}."
|
| 231 |
+
)
|
| 232 |
+
if "\\boxed" in answer:
|
| 233 |
+
from .answers import extract_boxed
|
| 234 |
+
|
| 235 |
+
boxed = extract_boxed(answer)
|
| 236 |
+
if boxed is None:
|
| 237 |
+
continue # unparseable gold: drop rather than train on it
|
| 238 |
+
answer = boxed
|
| 239 |
+
|
| 240 |
+
examples.append(
|
| 241 |
+
MathExample(
|
| 242 |
+
example_id=f"{spec.name}:{i}",
|
| 243 |
+
problem=problem,
|
| 244 |
+
answer=answer,
|
| 245 |
+
source=spec.name,
|
| 246 |
+
metadata={
|
| 247 |
+
k: row[k]
|
| 248 |
+
for k in ("level", "subject", "type", "url", "id", "problem_idx")
|
| 249 |
+
if k in row
|
| 250 |
+
},
|
| 251 |
+
)
|
| 252 |
+
)
|
| 253 |
+
|
| 254 |
+
# Spec cap first (defines the canonical set), then the ad-hoc --limit.
|
| 255 |
+
if spec.max_examples is not None:
|
| 256 |
+
examples = _subsample(examples, spec.max_examples)
|
| 257 |
+
if limit is not None:
|
| 258 |
+
examples = examples[:limit]
|
| 259 |
+
return examples
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def load_suite(
|
| 263 |
+
names: Sequence[str],
|
| 264 |
+
limit: int | None = None,
|
| 265 |
+
cache_dir: str | None = None,
|
| 266 |
+
) -> list[MathExample]:
|
| 267 |
+
"""Load and concatenate several benchmarks. ``limit`` applies per dataset.
|
| 268 |
+
|
| 269 |
+
Accepts tier names (``gate``/``checkpoint``/``holdout``) as shorthand for
|
| 270 |
+
the datasets in that tier.
|
| 271 |
+
"""
|
| 272 |
+
resolved: list[str] = []
|
| 273 |
+
for name in names:
|
| 274 |
+
resolved.extend(SUITES[name] if name in SUITES else [name])
|
| 275 |
+
|
| 276 |
+
out: list[MathExample] = []
|
| 277 |
+
for name in resolved:
|
| 278 |
+
out.extend(load_dataset_examples(name, limit=limit, cache_dir=cache_dir))
|
| 279 |
+
return out
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
def describe_registry() -> str:
|
| 283 |
+
lines = []
|
| 284 |
+
for tier in ("gate", "checkpoint", "holdout", "diagnostic"):
|
| 285 |
+
members = [s for s in REGISTRY.values() if s.tier == tier]
|
| 286 |
+
if not members:
|
| 287 |
+
continue
|
| 288 |
+
lines.append(f"[{tier}]")
|
| 289 |
+
for spec in members:
|
| 290 |
+
cap = f" (capped at {spec.max_examples})" if spec.max_examples else ""
|
| 291 |
+
lines.append(f" {spec.name:<14} {spec.hf_id}{cap}")
|
| 292 |
+
lines.append(f" {'':<14} {spec.note}")
|
| 293 |
+
return "\n".join(lines)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
+
MATH_PROMPT = (
|
| 297 |
+
"Solve the following math problem. Put your final answer inside "
|
| 298 |
+
"\\boxed{{}} on the last line.\n\n"
|
| 299 |
+
"Problem:\n{problem}"
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
def build_prompt(example: MathExample) -> str:
|
| 304 |
+
return MATH_PROMPT.format(problem=example.problem.strip())
|
src/eaimath/delta_sharing.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cross-layer MLP sharing with low-rank, layer-specific weight deltas.
|
| 2 |
+
|
| 3 |
+
Hard tying erased every layer's identity and scored zero in Round 8. This
|
| 4 |
+
module keeps one full MLP per contiguous Middle-Cycle group and approximates
|
| 5 |
+
each follower as ``shared_base + U @ V.T``. It is the directly testable
|
| 6 |
+
initialization used by DeltaLLM-style recovery and the same broad idea as Basis
|
| 7 |
+
Sharing: shared storage plus small layer-specific coefficients.
|
| 8 |
+
|
| 9 |
+
The evaluation checkpoint expands the deltas back to ordinary dense weights so
|
| 10 |
+
vLLM needs no custom kernel. The report separately counts the compressed
|
| 11 |
+
representation; a submission decompressor would perform the same expansion.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
import torch
|
| 19 |
+
|
| 20 |
+
from .sharing import MLP_PROJECTIONS, find_decoder_layers, sharing_groups
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def low_rank_delta(
|
| 24 |
+
anchor: torch.Tensor,
|
| 25 |
+
target: torch.Tensor,
|
| 26 |
+
rank: int,
|
| 27 |
+
*,
|
| 28 |
+
device: str,
|
| 29 |
+
niter: int = 1,
|
| 30 |
+
seed: int = 0,
|
| 31 |
+
) -> tuple[torch.Tensor, dict[str, float]]:
|
| 32 |
+
"""Return ``anchor + rank-r(target-anchor)`` and reconstruction metrics."""
|
| 33 |
+
if rank < 1 or rank > min(target.shape):
|
| 34 |
+
raise ValueError(f"rank {rank} is invalid for matrix {tuple(target.shape)}")
|
| 35 |
+
torch.manual_seed(seed)
|
| 36 |
+
if torch.cuda.is_available():
|
| 37 |
+
torch.cuda.manual_seed_all(seed)
|
| 38 |
+
|
| 39 |
+
base = anchor.detach().to(device=device, dtype=torch.float32)
|
| 40 |
+
wanted = target.detach().to(device=device, dtype=torch.float32)
|
| 41 |
+
delta = wanted - base
|
| 42 |
+
delta_norm = torch.linalg.vector_norm(delta)
|
| 43 |
+
target_norm = torch.linalg.vector_norm(wanted)
|
| 44 |
+
|
| 45 |
+
# Oversampling materially improves randomized SVD at almost no storage cost;
|
| 46 |
+
# only the requested rank is retained in the representation.
|
| 47 |
+
q = min(rank + 8, min(delta.shape))
|
| 48 |
+
u, s, v = torch.svd_lowrank(delta, q=q, niter=niter)
|
| 49 |
+
approximation = (u[:, :rank] * s[:rank]) @ v[:, :rank].T
|
| 50 |
+
residual = delta - approximation
|
| 51 |
+
residual_norm = torch.linalg.vector_norm(residual)
|
| 52 |
+
restored = (base + approximation).to(dtype=target.dtype, device="cpu")
|
| 53 |
+
|
| 54 |
+
metrics = {
|
| 55 |
+
"relative_delta_error": float(residual_norm / delta_norm.clamp_min(1e-12)),
|
| 56 |
+
"relative_weight_error": float(residual_norm / target_norm.clamp_min(1e-12)),
|
| 57 |
+
"delta_norm": float(delta_norm),
|
| 58 |
+
"residual_norm": float(residual_norm),
|
| 59 |
+
}
|
| 60 |
+
del base, wanted, delta, approximation, residual, u, s, v
|
| 61 |
+
if str(device).startswith("cuda"):
|
| 62 |
+
torch.cuda.empty_cache()
|
| 63 |
+
return restored, metrics
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def apply_delta_sharing(
|
| 67 |
+
model: torch.nn.Module,
|
| 68 |
+
factor: int,
|
| 69 |
+
rank: int,
|
| 70 |
+
*,
|
| 71 |
+
device: str,
|
| 72 |
+
niter: int = 1,
|
| 73 |
+
seed: int = 0,
|
| 74 |
+
) -> dict[str, Any]:
|
| 75 |
+
"""Approximate follower MLPs in place and count the compressed form."""
|
| 76 |
+
layers = find_decoder_layers(model)
|
| 77 |
+
groups = sharing_groups(len(layers), factor)
|
| 78 |
+
dense_mlp_params = sum(
|
| 79 |
+
getattr(layer.mlp, projection).weight.numel()
|
| 80 |
+
for layer in layers
|
| 81 |
+
for projection in MLP_PROJECTIONS
|
| 82 |
+
)
|
| 83 |
+
basis_layers: set[int] = {0, 1, len(layers) - 1}
|
| 84 |
+
follower_layers: set[int] = set()
|
| 85 |
+
rows: list[dict[str, Any]] = []
|
| 86 |
+
delta_params = 0
|
| 87 |
+
|
| 88 |
+
with torch.no_grad():
|
| 89 |
+
for group_index, group in enumerate(groups):
|
| 90 |
+
if len(group) < 2:
|
| 91 |
+
basis_layers.update(group)
|
| 92 |
+
continue
|
| 93 |
+
anchor_index = group[0]
|
| 94 |
+
basis_layers.add(anchor_index)
|
| 95 |
+
for follower_index in group[1:]:
|
| 96 |
+
follower_layers.add(follower_index)
|
| 97 |
+
for projection_index, projection in enumerate(MLP_PROJECTIONS):
|
| 98 |
+
anchor = getattr(layers[anchor_index].mlp, projection).weight
|
| 99 |
+
follower = getattr(layers[follower_index].mlp, projection).weight
|
| 100 |
+
restored, metrics = low_rank_delta(
|
| 101 |
+
anchor,
|
| 102 |
+
follower,
|
| 103 |
+
rank,
|
| 104 |
+
device=device,
|
| 105 |
+
niter=niter,
|
| 106 |
+
seed=seed + group_index * 10_000 + follower_index * 10 + projection_index,
|
| 107 |
+
)
|
| 108 |
+
follower.copy_(restored)
|
| 109 |
+
delta_params += rank * (follower.shape[0] + follower.shape[1])
|
| 110 |
+
rows.append(
|
| 111 |
+
{
|
| 112 |
+
"group": group_index,
|
| 113 |
+
"anchor_layer": anchor_index,
|
| 114 |
+
"follower_layer": follower_index,
|
| 115 |
+
"projection": projection,
|
| 116 |
+
"shape": list(follower.shape),
|
| 117 |
+
**metrics,
|
| 118 |
+
}
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
basis_params = sum(
|
| 122 |
+
getattr(layers[index].mlp, projection).weight.numel()
|
| 123 |
+
for index in sorted(basis_layers)
|
| 124 |
+
for projection in MLP_PROJECTIONS
|
| 125 |
+
)
|
| 126 |
+
represented = basis_params + delta_params
|
| 127 |
+
return {
|
| 128 |
+
"factor": factor,
|
| 129 |
+
"rank": rank,
|
| 130 |
+
"niter": niter,
|
| 131 |
+
"groups": [group for group in groups if len(group) > 1],
|
| 132 |
+
"basis_layers": sorted(basis_layers),
|
| 133 |
+
"follower_layers": sorted(follower_layers),
|
| 134 |
+
"dense_mlp_params": dense_mlp_params,
|
| 135 |
+
"basis_params": basis_params,
|
| 136 |
+
"delta_params": delta_params,
|
| 137 |
+
"represented_mlp_params": represented,
|
| 138 |
+
"mlp_parameter_fraction": represented / dense_mlp_params,
|
| 139 |
+
"bf16_representation_bytes": represented * 2,
|
| 140 |
+
"max_relative_weight_error": max(row["relative_weight_error"] for row in rows),
|
| 141 |
+
"mean_relative_weight_error": sum(row["relative_weight_error"] for row in rows) / len(rows),
|
| 142 |
+
"max_relative_delta_error": max(row["relative_delta_error"] for row in rows),
|
| 143 |
+
"rows": rows,
|
| 144 |
+
}
|
src/eaimath/embedding_predictor.py
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tiny deterministic predictor for vocabulary rows omitted from an artifact.
|
| 2 |
+
|
| 3 |
+
The predictor borrows the idea of intra-frame prediction from codecs: a token's
|
| 4 |
+
string is side information already present in the tokenizer, so only a small
|
| 5 |
+
linear dictionary has to be stored. Exact retained rows are scattered over the
|
| 6 |
+
prediction during restoration.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import math
|
| 12 |
+
from typing import Any
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
|
| 16 |
+
from .pack import pack_tensor, state_dict_bytes, unpack_tensor
|
| 17 |
+
|
| 18 |
+
FEATURE_DIM = 272
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _token_strings(tokenizer: Any, ids: list[int]) -> list[str]:
|
| 22 |
+
tokens = tokenizer.convert_ids_to_tokens(ids)
|
| 23 |
+
if isinstance(tokens, str):
|
| 24 |
+
tokens = [tokens]
|
| 25 |
+
return ["" if token is None else str(token) for token in tokens]
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def token_string_features(tokenizer: Any, ids: torch.Tensor) -> torch.Tensor:
|
| 29 |
+
"""Return tokenizer-derived features without storing anything per token.
|
| 30 |
+
|
| 31 |
+
Layout: 256 normalized UTF-8 byte counts, eight log-length buckets, four
|
| 32 |
+
first-byte classes and four last-byte classes. The last two blocks retain a
|
| 33 |
+
little order information while keeping the dictionary under one megabyte.
|
| 34 |
+
"""
|
| 35 |
+
flat_ids = ids.detach().cpu().long().reshape(-1)
|
| 36 |
+
strings = _token_strings(tokenizer, [int(value) for value in flat_ids.tolist()])
|
| 37 |
+
features = torch.zeros((len(strings), FEATURE_DIM), dtype=torch.float32)
|
| 38 |
+
for row, token in enumerate(strings):
|
| 39 |
+
encoded = token.encode("utf-8", errors="replace") or b"\x00"
|
| 40 |
+
byte_ids = torch.tensor(list(encoded), dtype=torch.int64)
|
| 41 |
+
counts = torch.bincount(byte_ids, minlength=256).float()
|
| 42 |
+
features[row, :256] = counts / counts.square().sum().sqrt().clamp_min(1.0)
|
| 43 |
+
length_bucket = min(7, int(math.log2(max(1, len(encoded)))))
|
| 44 |
+
features[row, 256 + length_bucket] = 1.0
|
| 45 |
+
features[row, 264 + encoded[0] // 64] = 1.0
|
| 46 |
+
features[row, 268 + encoded[-1] // 64] = 1.0
|
| 47 |
+
return features
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def _sample_ids(vocab_size: int, sample_size: int, offset: float = 0.0) -> torch.Tensor:
|
| 51 |
+
count = min(vocab_size, sample_size)
|
| 52 |
+
if count == vocab_size:
|
| 53 |
+
return torch.arange(vocab_size, dtype=torch.int64)
|
| 54 |
+
positions = (torch.arange(count, dtype=torch.float64) + offset) * vocab_size / count
|
| 55 |
+
return positions.floor().clamp_max(vocab_size - 1).long().unique()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def fit_token_predictor(
|
| 59 |
+
weight: torch.Tensor,
|
| 60 |
+
tokenizer: Any,
|
| 61 |
+
*,
|
| 62 |
+
sample_size: int = 65_536,
|
| 63 |
+
heldout_size: int = 4_096,
|
| 64 |
+
ridge: float = 1e-2,
|
| 65 |
+
device: str = "cpu",
|
| 66 |
+
) -> tuple[dict[str, Any], dict[str, float | int | str]]:
|
| 67 |
+
"""Fit and INT8-pack a ridge dictionary for a full embedding matrix."""
|
| 68 |
+
if weight.ndim != 2 or weight.shape[0] < 2:
|
| 69 |
+
raise ValueError(f"expected a vocabulary matrix, found {tuple(weight.shape)}")
|
| 70 |
+
if ridge <= 0:
|
| 71 |
+
raise ValueError("ridge must be positive")
|
| 72 |
+
train_ids = _sample_ids(weight.shape[0], sample_size)
|
| 73 |
+
x = token_string_features(tokenizer, train_ids).to(device)
|
| 74 |
+
y = weight.detach().cpu().index_select(0, train_ids).float().to(device)
|
| 75 |
+
gram = x.T @ x
|
| 76 |
+
gram.diagonal().add_(ridge)
|
| 77 |
+
basis = torch.linalg.solve(gram, x.T @ y).cpu()
|
| 78 |
+
|
| 79 |
+
heldout_ids = _sample_ids(weight.shape[0], heldout_size, offset=0.5)
|
| 80 |
+
# Exclude any collision with the evenly spaced training grid.
|
| 81 |
+
train_set = set(int(value) for value in train_ids.tolist())
|
| 82 |
+
heldout_list = [int(value) for value in heldout_ids.tolist() if int(value) not in train_set]
|
| 83 |
+
if not heldout_list:
|
| 84 |
+
heldout_list = [int(train_ids[-1])]
|
| 85 |
+
heldout_ids = torch.tensor(heldout_list, dtype=torch.int64)
|
| 86 |
+
hx = token_string_features(tokenizer, heldout_ids)
|
| 87 |
+
target = weight.detach().cpu().index_select(0, heldout_ids).float()
|
| 88 |
+
prediction = hx @ basis
|
| 89 |
+
cosine = torch.nn.functional.cosine_similarity(prediction, target, dim=1).mean()
|
| 90 |
+
denominator = target.square().mean().sqrt().clamp_min(1e-12)
|
| 91 |
+
relative_rmse = (prediction - target).square().mean().sqrt() / denominator
|
| 92 |
+
|
| 93 |
+
packed_basis = pack_tensor(basis, bits=8, group_size=256)
|
| 94 |
+
entry: dict[str, Any] = {
|
| 95 |
+
"kind": "token_string_ridge_v1",
|
| 96 |
+
"feature_dim": FEATURE_DIM,
|
| 97 |
+
"basis": packed_basis,
|
| 98 |
+
"vocab_size": int(weight.shape[0]),
|
| 99 |
+
"hidden_size": int(weight.shape[1]),
|
| 100 |
+
"ridge": float(ridge),
|
| 101 |
+
"sample_size": int(train_ids.numel()),
|
| 102 |
+
}
|
| 103 |
+
report: dict[str, float | int | str] = {
|
| 104 |
+
"kind": entry["kind"],
|
| 105 |
+
"stored_bytes": state_dict_bytes({"basis": packed_basis}),
|
| 106 |
+
"sample_size": int(train_ids.numel()),
|
| 107 |
+
"heldout_size": int(heldout_ids.numel()),
|
| 108 |
+
"heldout_mean_cosine": float(cosine),
|
| 109 |
+
"heldout_relative_rmse": float(relative_rmse),
|
| 110 |
+
}
|
| 111 |
+
return entry, report
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def predict_token_rows(
|
| 115 |
+
entry: dict[str, Any],
|
| 116 |
+
tokenizer: Any,
|
| 117 |
+
*,
|
| 118 |
+
batch_size: int = 8_192,
|
| 119 |
+
) -> torch.Tensor:
|
| 120 |
+
"""Decode a predictor into the original dense vocabulary matrix."""
|
| 121 |
+
if entry.get("kind") != "token_string_ridge_v1":
|
| 122 |
+
raise ValueError(f"unsupported token predictor: {entry.get('kind')!r}")
|
| 123 |
+
basis = unpack_tensor(entry["basis"]).float()
|
| 124 |
+
rows = []
|
| 125 |
+
for start in range(0, int(entry["vocab_size"]), batch_size):
|
| 126 |
+
stop = min(int(entry["vocab_size"]), start + batch_size)
|
| 127 |
+
ids = torch.arange(start, stop, dtype=torch.int64)
|
| 128 |
+
rows.append((token_string_features(tokenizer, ids) @ basis).to(torch.bfloat16))
|
| 129 |
+
return torch.cat(rows, dim=0)
|
src/eaimath/evaluate.py
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The math eval loop, and the metrics that actually discriminate recipes.
|
| 2 |
+
|
| 3 |
+
Accuracy alone is not enough. Published measurements on this exact model family
|
| 4 |
+
show W4A16 retaining ~99% of MATH-500 while losing ~11-20 points of AIME, and
|
| 5 |
+
show quantization roughly doubling the truncation rate on AIME under a fixed
|
| 6 |
+
token cap. The mechanism is that low-bit weights perturb high-entropy
|
| 7 |
+
"branching" tokens, the model rambles, and it never emits its closing tag.
|
| 8 |
+
|
| 9 |
+
So every run reports three things:
|
| 10 |
+
|
| 11 |
+
* ``accuracy`` — did it get the answer right
|
| 12 |
+
* ``truncation_rate`` — did it run out of budget instead of stopping
|
| 13 |
+
* ``mean_generated_tokens`` / ``think_close_rate`` — is CoT inflating
|
| 14 |
+
|
| 15 |
+
A recipe that holds accuracy while inflating tokens is not safe; it is a recipe
|
| 16 |
+
that will collapse the moment the grader's token cap is tighter than ours.
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import statistics
|
| 23 |
+
from dataclasses import asdict, dataclass
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Any, Sequence
|
| 26 |
+
|
| 27 |
+
from .answers import answers_match, extract_answer
|
| 28 |
+
from .data import MathExample, build_prompt
|
| 29 |
+
from .generate import GenerationOutput, generate
|
| 30 |
+
|
| 31 |
+
THINK_CLOSE_TAG = "</think>"
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@dataclass
|
| 35 |
+
class MathPrediction:
|
| 36 |
+
example_id: str
|
| 37 |
+
source: str
|
| 38 |
+
gold: str
|
| 39 |
+
predicted: str | None
|
| 40 |
+
correct: bool
|
| 41 |
+
finished: bool
|
| 42 |
+
think_closed: bool
|
| 43 |
+
num_generated_tokens: int
|
| 44 |
+
num_prompt_tokens: int
|
| 45 |
+
response: str
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def score_generation(
|
| 49 |
+
example: MathExample, output: GenerationOutput
|
| 50 |
+
) -> MathPrediction:
|
| 51 |
+
predicted = extract_answer(output.text)
|
| 52 |
+
return MathPrediction(
|
| 53 |
+
example_id=example.example_id,
|
| 54 |
+
source=example.source,
|
| 55 |
+
gold=example.answer,
|
| 56 |
+
predicted=predicted,
|
| 57 |
+
correct=answers_match(predicted, example.answer),
|
| 58 |
+
finished=output.finished,
|
| 59 |
+
# A thinking model that never closes its tag has looped, even if it
|
| 60 |
+
# somehow stopped afterwards.
|
| 61 |
+
think_closed=THINK_CLOSE_TAG in output.text,
|
| 62 |
+
num_generated_tokens=output.num_generated_tokens,
|
| 63 |
+
num_prompt_tokens=output.num_prompt_tokens,
|
| 64 |
+
response=output.text,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def summarize(predictions: Sequence[MathPrediction]) -> dict[str, Any]:
|
| 69 |
+
n = len(predictions)
|
| 70 |
+
if n == 0:
|
| 71 |
+
return {"num_examples": 0}
|
| 72 |
+
|
| 73 |
+
lengths = [p.num_generated_tokens for p in predictions]
|
| 74 |
+
finished = [p for p in predictions if p.finished]
|
| 75 |
+
truncated = [p for p in predictions if not p.finished]
|
| 76 |
+
|
| 77 |
+
return {
|
| 78 |
+
"num_examples": n,
|
| 79 |
+
"accuracy": sum(p.correct for p in predictions) / n,
|
| 80 |
+
"parse_rate": sum(p.predicted is not None for p in predictions) / n,
|
| 81 |
+
# The headline risk metric: budget exhaustion, not wrong answers.
|
| 82 |
+
"truncation_rate": len(truncated) / n,
|
| 83 |
+
"think_close_rate": sum(p.think_closed for p in predictions) / n,
|
| 84 |
+
"mean_generated_tokens": statistics.mean(lengths),
|
| 85 |
+
"median_generated_tokens": statistics.median(lengths),
|
| 86 |
+
"max_generated_tokens": max(lengths),
|
| 87 |
+
# Splitting accuracy by termination separates "reasoned badly" from
|
| 88 |
+
# "never got to answer" — they need different fixes.
|
| 89 |
+
"accuracy_when_finished": (
|
| 90 |
+
sum(p.correct for p in finished) / len(finished) if finished else None
|
| 91 |
+
),
|
| 92 |
+
"accuracy_when_truncated": (
|
| 93 |
+
sum(p.correct for p in truncated) / len(truncated) if truncated else None
|
| 94 |
+
),
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def summarize_by_source(predictions: Sequence[MathPrediction]) -> dict[str, Any]:
|
| 99 |
+
sources = sorted({p.source for p in predictions})
|
| 100 |
+
return {s: summarize([p for p in predictions if p.source == s]) for s in sources}
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def run_eval(
|
| 104 |
+
model,
|
| 105 |
+
tokenizer,
|
| 106 |
+
examples: Sequence[MathExample],
|
| 107 |
+
*,
|
| 108 |
+
max_new_tokens: int = 65536,
|
| 109 |
+
temperature: float = 0.0,
|
| 110 |
+
top_p: float = 0.95,
|
| 111 |
+
top_k: int = 20,
|
| 112 |
+
presence_penalty: float = 0.0,
|
| 113 |
+
repetition_penalty: float = 1.0,
|
| 114 |
+
batch_size: int = 8,
|
| 115 |
+
enable_thinking: bool | None = None,
|
| 116 |
+
) -> list[MathPrediction]:
|
| 117 |
+
outputs = generate(
|
| 118 |
+
model,
|
| 119 |
+
tokenizer,
|
| 120 |
+
[build_prompt(ex) for ex in examples],
|
| 121 |
+
max_new_tokens=max_new_tokens,
|
| 122 |
+
temperature=temperature,
|
| 123 |
+
top_p=top_p,
|
| 124 |
+
top_k=top_k,
|
| 125 |
+
repetition_penalty=repetition_penalty,
|
| 126 |
+
batch_size=batch_size,
|
| 127 |
+
enable_thinking=enable_thinking,
|
| 128 |
+
desc="math eval",
|
| 129 |
+
)
|
| 130 |
+
return [score_generation(ex, out) for ex, out in zip(examples, outputs)]
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def run_eval_vllm(
|
| 134 |
+
model_path: str,
|
| 135 |
+
tokenizer,
|
| 136 |
+
examples: Sequence[MathExample],
|
| 137 |
+
*,
|
| 138 |
+
max_new_tokens: int = 65536,
|
| 139 |
+
temperature: float = 0.0,
|
| 140 |
+
top_p: float = 0.95,
|
| 141 |
+
top_k: int = 20,
|
| 142 |
+
presence_penalty: float = 0.0,
|
| 143 |
+
repetition_penalty: float = 1.0,
|
| 144 |
+
enable_thinking: bool | None = None,
|
| 145 |
+
gpu_memory_utilization: float = 0.90,
|
| 146 |
+
allowed_token_ids: Sequence[int] | None = None,
|
| 147 |
+
llm=None,
|
| 148 |
+
) -> tuple[list[MathPrediction], object]:
|
| 149 |
+
"""Same scoring, vLLM engine. Returns the engine so it can be reused."""
|
| 150 |
+
from .vllm_backend import generate_vllm
|
| 151 |
+
|
| 152 |
+
outputs, llm = generate_vllm(
|
| 153 |
+
model_path,
|
| 154 |
+
tokenizer,
|
| 155 |
+
[build_prompt(ex) for ex in examples],
|
| 156 |
+
max_new_tokens=max_new_tokens,
|
| 157 |
+
temperature=temperature,
|
| 158 |
+
top_p=top_p,
|
| 159 |
+
top_k=top_k,
|
| 160 |
+
presence_penalty=presence_penalty,
|
| 161 |
+
repetition_penalty=repetition_penalty,
|
| 162 |
+
enable_thinking=enable_thinking,
|
| 163 |
+
gpu_memory_utilization=gpu_memory_utilization,
|
| 164 |
+
allowed_token_ids=allowed_token_ids,
|
| 165 |
+
llm=llm,
|
| 166 |
+
)
|
| 167 |
+
return [score_generation(ex, out) for ex, out in zip(examples, outputs)], llm
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def save_results(
|
| 171 |
+
output_dir: str | Path,
|
| 172 |
+
*,
|
| 173 |
+
run_name: str,
|
| 174 |
+
config: dict[str, Any],
|
| 175 |
+
predictions: Sequence[MathPrediction],
|
| 176 |
+
) -> Path:
|
| 177 |
+
"""Write ``summary.json`` (tracked) and ``generations.jsonl`` (gitignored)."""
|
| 178 |
+
out = Path(output_dir) / run_name
|
| 179 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 180 |
+
|
| 181 |
+
summary = {
|
| 182 |
+
"run_name": run_name,
|
| 183 |
+
"config": config,
|
| 184 |
+
"overall": summarize(predictions),
|
| 185 |
+
"by_source": summarize_by_source(predictions),
|
| 186 |
+
}
|
| 187 |
+
(out / "summary.json").write_text(json.dumps(summary, indent=2))
|
| 188 |
+
|
| 189 |
+
with (out / "generations.jsonl").open("w") as fh:
|
| 190 |
+
for prediction in predictions:
|
| 191 |
+
fh.write(json.dumps(asdict(prediction)) + "\n")
|
| 192 |
+
|
| 193 |
+
return out
|
| 194 |
+
|
| 195 |
+
|
| 196 |
+
def format_summary(summary: dict[str, Any]) -> str:
|
| 197 |
+
overall = summary["overall"]
|
| 198 |
+
if not overall.get("num_examples"):
|
| 199 |
+
return "no examples evaluated"
|
| 200 |
+
|
| 201 |
+
lines = [
|
| 202 |
+
f" examples {overall['num_examples']}",
|
| 203 |
+
f" accuracy {overall['accuracy']:.3f}",
|
| 204 |
+
f" parse rate {overall['parse_rate']:.3f}",
|
| 205 |
+
f" TRUNCATION RATE {overall['truncation_rate']:.3f} <- budget exhaustion",
|
| 206 |
+
f" think-close rate {overall['think_close_rate']:.3f}",
|
| 207 |
+
f" mean gen tokens {overall['mean_generated_tokens']:.0f}",
|
| 208 |
+
f" median gen tokens {overall['median_generated_tokens']:.0f}",
|
| 209 |
+
f" max gen tokens {overall['max_generated_tokens']}",
|
| 210 |
+
]
|
| 211 |
+
if overall.get("accuracy_when_finished") is not None:
|
| 212 |
+
lines.append(f" acc | finished {overall['accuracy_when_finished']:.3f}")
|
| 213 |
+
if overall.get("accuracy_when_truncated") is not None:
|
| 214 |
+
lines.append(f" acc | truncated {overall['accuracy_when_truncated']:.3f}")
|
| 215 |
+
|
| 216 |
+
lines.append("")
|
| 217 |
+
for source, stats in summary["by_source"].items():
|
| 218 |
+
lines.append(
|
| 219 |
+
f" [{source}] n={stats['num_examples']} acc={stats['accuracy']:.3f} "
|
| 220 |
+
f"trunc={stats['truncation_rate']:.3f} tok={stats['mean_generated_tokens']:.0f}"
|
| 221 |
+
)
|
| 222 |
+
return "\n".join(lines)
|
src/eaimath/extreme_quant.py
ADDED
|
@@ -0,0 +1,582 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Training-free extreme weight compression primitives for Bucket A.
|
| 2 |
+
|
| 3 |
+
The functions in this module produce dense BF16 values for ordinary Hugging
|
| 4 |
+
Face/vLLM evaluation while accounting for the compact representation that
|
| 5 |
+
would be stored in a submission artifact. They deliberately avoid custom
|
| 6 |
+
inference kernels: Round 11 asks which representation still reasons, not which
|
| 7 |
+
kernel is fastest.
|
| 8 |
+
|
| 9 |
+
Three representation families are implemented:
|
| 10 |
+
|
| 11 |
+
* binary and ternary scalar grids with one BF16 scale per group;
|
| 12 |
+
* binary grids with a fixed number of exact sparse residuals per group;
|
| 13 |
+
* residual vector codebooks (an intentionally small AQLM-style screen).
|
| 14 |
+
|
| 15 |
+
The vector-codebook implementation is not claimed to reproduce AQLM. AQLM
|
| 16 |
+
jointly optimizes block outputs and codebooks; this is a deterministic,
|
| 17 |
+
weight-only cold-start test that tells us whether paying for that optimization
|
| 18 |
+
is justified on Qwen3.5's hybrid architecture.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import hashlib
|
| 24 |
+
import math
|
| 25 |
+
import re
|
| 26 |
+
from dataclasses import asdict, dataclass, replace
|
| 27 |
+
from typing import Any, Sequence
|
| 28 |
+
|
| 29 |
+
import torch
|
| 30 |
+
|
| 31 |
+
from .model import BUDGET_GROUPS
|
| 32 |
+
from .quantize import EXCLUDED, PROTECTED, QuantSpec, quantize_dequantize
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@dataclass(frozen=True)
|
| 36 |
+
class ExtremeSpec:
|
| 37 |
+
kind: str
|
| 38 |
+
group_size: int = 256
|
| 39 |
+
outliers: int = 0
|
| 40 |
+
vector_dim: int = 8
|
| 41 |
+
codebooks: int = 0
|
| 42 |
+
codebook_size: int = 16
|
| 43 |
+
kmeans_iterations: int = 4
|
| 44 |
+
kmeans_samples: int = 32_768
|
| 45 |
+
assign_chunk: int = 65_536
|
| 46 |
+
rank: int = 0
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _component(name: str) -> str:
|
| 50 |
+
for group, pattern in BUDGET_GROUPS:
|
| 51 |
+
if re.search(pattern, name):
|
| 52 |
+
return group
|
| 53 |
+
return "other"
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _seed_for(name: str, seed: int) -> int:
|
| 57 |
+
digest = hashlib.sha256(f"{seed}:{name}".encode()).digest()
|
| 58 |
+
return int.from_bytes(digest[:8], "little") % (2**31)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _grouped(weight: torch.Tensor, group_size: int) -> tuple[torch.Tensor, tuple[int, ...]]:
|
| 62 |
+
shape = tuple(weight.shape)
|
| 63 |
+
width = shape[-1]
|
| 64 |
+
if group_size <= 0 or width % group_size:
|
| 65 |
+
group_size = width
|
| 66 |
+
return weight.reshape(-1, group_size).float(), shape
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def _normalized_fwht(values: torch.Tensor) -> torch.Tensor:
|
| 70 |
+
"""Orthonormal Walsh-Hadamard transform over the last dimension.
|
| 71 |
+
|
| 72 |
+
The transform is its own inverse and fixed by shape, so a submission stores
|
| 73 |
+
no transform matrix. This is the transform-coding analogue of using a DCT
|
| 74 |
+
before scalar quantization, but every operation is an add/subtract and all
|
| 75 |
+
power-of-two Qwen group widths are supported.
|
| 76 |
+
"""
|
| 77 |
+
width = values.shape[-1]
|
| 78 |
+
if width < 1 or width & (width - 1):
|
| 79 |
+
raise ValueError(f"Hadamard width must be a power of two, found {width}")
|
| 80 |
+
# ``float().contiguous()`` can alias an already-contiguous float input.
|
| 81 |
+
# The butterfly is in-place, so clone to keep this a pure transform.
|
| 82 |
+
output = values.float().contiguous().clone()
|
| 83 |
+
stride = 1
|
| 84 |
+
while stride < width:
|
| 85 |
+
blocks = output.reshape(-1, width // (2 * stride), 2, stride)
|
| 86 |
+
left = blocks[:, :, 0, :].clone()
|
| 87 |
+
right = blocks[:, :, 1, :].clone()
|
| 88 |
+
blocks[:, :, 0, :] = left + right
|
| 89 |
+
blocks[:, :, 1, :] = left - right
|
| 90 |
+
output = blocks.reshape(values.shape)
|
| 91 |
+
stride *= 2
|
| 92 |
+
return output / math.sqrt(width)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def _hadamard_quantize(weight: torch.Tensor, spec: ExtremeSpec) -> tuple[torch.Tensor, int]:
|
| 96 |
+
"""Transform-code independent groups and invert to dense evaluation values."""
|
| 97 |
+
base_kind = spec.kind.removeprefix("hadamard_")
|
| 98 |
+
if base_kind == spec.kind:
|
| 99 |
+
raise ValueError(f"not a Hadamard spec: {spec.kind}")
|
| 100 |
+
groups, shape = _grouped(weight, spec.group_size)
|
| 101 |
+
if groups.shape[-1] & (groups.shape[-1] - 1):
|
| 102 |
+
raise ValueError("Hadamard transform requires a power-of-two effective group")
|
| 103 |
+
coefficients = _normalized_fwht(groups)
|
| 104 |
+
base_spec = replace(spec, kind=base_kind, group_size=groups.shape[-1])
|
| 105 |
+
if base_kind == "binary":
|
| 106 |
+
coded = _binary(coefficients, base_spec)
|
| 107 |
+
codebook_values = 0
|
| 108 |
+
elif base_kind == "ternary":
|
| 109 |
+
coded = _ternary(coefficients, base_spec)
|
| 110 |
+
codebook_values = 0
|
| 111 |
+
elif base_kind == "sparse_binary":
|
| 112 |
+
coded = _sparse_binary(coefficients, base_spec)
|
| 113 |
+
codebook_values = 0
|
| 114 |
+
elif base_kind.startswith("int"):
|
| 115 |
+
bits = int(base_kind.removeprefix("int"))
|
| 116 |
+
coded = quantize_dequantize(
|
| 117 |
+
coefficients,
|
| 118 |
+
QuantSpec(pattern=".*", bits=bits, group_size=groups.shape[-1]),
|
| 119 |
+
)
|
| 120 |
+
codebook_values = 0
|
| 121 |
+
else:
|
| 122 |
+
raise ValueError(f"unsupported Hadamard coefficient codec: {base_kind}")
|
| 123 |
+
restored = _normalized_fwht(coded).reshape(shape).to(weight.dtype)
|
| 124 |
+
return restored, codebook_values
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _binary(weight: torch.Tensor, spec: ExtremeSpec) -> torch.Tensor:
|
| 128 |
+
groups, shape = _grouped(weight, spec.group_size)
|
| 129 |
+
scale = groups.abs().mean(dim=1, keepdim=True).clamp_min(1e-8)
|
| 130 |
+
signs = torch.where(groups >= 0, 1.0, -1.0)
|
| 131 |
+
return (signs * scale).reshape(shape).to(weight.dtype)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def _ternary(weight: torch.Tensor, spec: ExtremeSpec) -> torch.Tensor:
|
| 135 |
+
"""MSE-select a symmetric ternary threshold independently per group."""
|
| 136 |
+
groups, shape = _grouped(weight, spec.group_size)
|
| 137 |
+
magnitude = groups.abs()
|
| 138 |
+
mean_abs = magnitude.mean(dim=1, keepdim=True).clamp_min(1e-8)
|
| 139 |
+
best = torch.zeros_like(groups)
|
| 140 |
+
best_error = torch.full(
|
| 141 |
+
(groups.shape[0], 1), float("inf"), device=groups.device, dtype=groups.dtype
|
| 142 |
+
)
|
| 143 |
+
# A compact grid is enough for a diagnostic and avoids training on eval data.
|
| 144 |
+
for multiplier in (0.35, 0.45, 0.55, 0.65, 0.75, 0.90):
|
| 145 |
+
mask = magnitude >= mean_abs * multiplier
|
| 146 |
+
count = mask.sum(dim=1, keepdim=True).clamp_min(1)
|
| 147 |
+
scale = (magnitude * mask).sum(dim=1, keepdim=True) / count
|
| 148 |
+
candidate = torch.sign(groups) * mask * scale
|
| 149 |
+
error = (groups - candidate).square().mean(dim=1, keepdim=True)
|
| 150 |
+
improve = error < best_error
|
| 151 |
+
best = torch.where(improve, candidate, best)
|
| 152 |
+
best_error = torch.where(improve, error, best_error)
|
| 153 |
+
return best.reshape(shape).to(weight.dtype)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def _activation_weighted_ternary(
|
| 157 |
+
weight: torch.Tensor,
|
| 158 |
+
spec: ExtremeSpec,
|
| 159 |
+
importance: torch.Tensor | None,
|
| 160 |
+
) -> torch.Tensor:
|
| 161 |
+
"""Ternarize against diagonal activation covariance instead of weight MSE."""
|
| 162 |
+
if importance is None or weight.ndim != 2 or importance.numel() != weight.shape[-1]:
|
| 163 |
+
return _ternary(weight, replace(spec, kind="ternary"))
|
| 164 |
+
groups, shape = _grouped(weight, spec.group_size)
|
| 165 |
+
group = groups.shape[-1]
|
| 166 |
+
coordinate_weight = importance.detach().float().clamp_min(0)
|
| 167 |
+
coordinate_weight = coordinate_weight / coordinate_weight.mean().clamp_min(1e-12)
|
| 168 |
+
weights = coordinate_weight.reshape(1, -1).expand(shape[0], -1).reshape(-1, group)
|
| 169 |
+
magnitude = groups.abs()
|
| 170 |
+
mean_abs = (magnitude * weights).sum(dim=1, keepdim=True) / weights.sum(
|
| 171 |
+
dim=1, keepdim=True
|
| 172 |
+
).clamp_min(1e-12)
|
| 173 |
+
best = torch.zeros_like(groups)
|
| 174 |
+
best_error = torch.full(
|
| 175 |
+
(groups.shape[0], 1), float("inf"), device=groups.device, dtype=groups.dtype
|
| 176 |
+
)
|
| 177 |
+
for multiplier in (0.25, 0.35, 0.45, 0.55, 0.65, 0.75, 0.90, 1.10):
|
| 178 |
+
mask = magnitude >= mean_abs * multiplier
|
| 179 |
+
selected_weight = weights * mask
|
| 180 |
+
scale = (magnitude * selected_weight).sum(dim=1, keepdim=True) / selected_weight.sum(
|
| 181 |
+
dim=1, keepdim=True
|
| 182 |
+
).clamp_min(1e-12)
|
| 183 |
+
candidate = torch.sign(groups) * mask * scale
|
| 184 |
+
error = ((groups - candidate).square() * weights).sum(dim=1, keepdim=True)
|
| 185 |
+
improve = error < best_error
|
| 186 |
+
best = torch.where(improve, candidate, best)
|
| 187 |
+
best_error = torch.where(improve, error, best_error)
|
| 188 |
+
return best.reshape(shape).to(weight.dtype)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def _sigma_delta_ternary(
|
| 192 |
+
weight: torch.Tensor,
|
| 193 |
+
spec: ExtremeSpec,
|
| 194 |
+
importance: torch.Tensor | None,
|
| 195 |
+
) -> torch.Tensor:
|
| 196 |
+
"""First-order error-feedback ternary in activation-energy order.
|
| 197 |
+
|
| 198 |
+
Coordinates with lower observed energy are visited first, allowing their
|
| 199 |
+
quantization residue to influence later, more important coordinates. The
|
| 200 |
+
traversal is reconstructed from the same train-only sensitivity vector, so
|
| 201 |
+
it requires no stored permutation or side information.
|
| 202 |
+
"""
|
| 203 |
+
if importance is None or weight.ndim != 2 or importance.numel() != weight.shape[-1]:
|
| 204 |
+
return _ternary(weight, replace(spec, kind="ternary"))
|
| 205 |
+
groups, shape = _grouped(weight, spec.group_size)
|
| 206 |
+
group = groups.shape[-1]
|
| 207 |
+
column_groups = shape[-1] // group
|
| 208 |
+
energy = importance.detach().float().clamp_min(0).reshape(column_groups, group)
|
| 209 |
+
order = energy.argsort(dim=1).repeat(shape[0], 1).to(groups.device)
|
| 210 |
+
ordinary = _ternary(weight, replace(spec, kind="ternary")).reshape_as(groups).float()
|
| 211 |
+
scale = ordinary.abs().amax(dim=1).clamp_min(1e-8)
|
| 212 |
+
output = torch.zeros_like(groups)
|
| 213 |
+
feedback = torch.zeros(groups.shape[0], device=groups.device, dtype=groups.dtype)
|
| 214 |
+
rows = torch.arange(groups.shape[0], device=groups.device)
|
| 215 |
+
for step in range(group):
|
| 216 |
+
index = order[:, step]
|
| 217 |
+
value = groups[rows, index] + feedback
|
| 218 |
+
quantized = torch.where(
|
| 219 |
+
value > scale * 0.5,
|
| 220 |
+
scale,
|
| 221 |
+
torch.where(value < -scale * 0.5, -scale, torch.zeros_like(value)),
|
| 222 |
+
)
|
| 223 |
+
output[rows, index] = quantized
|
| 224 |
+
feedback = value - quantized
|
| 225 |
+
return output.reshape(shape).to(weight.dtype)
|
| 226 |
+
|
| 227 |
+
|
| 228 |
+
def _structured_residual_ternary(
|
| 229 |
+
weight: torch.Tensor,
|
| 230 |
+
spec: ExtremeSpec,
|
| 231 |
+
*,
|
| 232 |
+
seed: int,
|
| 233 |
+
) -> tuple[torch.Tensor, int]:
|
| 234 |
+
"""Preserve a low-rank subspace and ternarize only the residual."""
|
| 235 |
+
if weight.ndim != 2 or spec.rank < 1:
|
| 236 |
+
raise ValueError("srr_ternary requires a matrix and rank >= 1")
|
| 237 |
+
rank = min(spec.rank, min(weight.shape))
|
| 238 |
+
target = weight.float()
|
| 239 |
+
devices = [target.device.index or 0] if target.is_cuda else []
|
| 240 |
+
with torch.random.fork_rng(devices=devices):
|
| 241 |
+
torch.manual_seed(seed)
|
| 242 |
+
if target.is_cuda:
|
| 243 |
+
torch.cuda.manual_seed_all(seed)
|
| 244 |
+
u, singular, v = torch.svd_lowrank(
|
| 245 |
+
target,
|
| 246 |
+
q=min(rank + 4, min(target.shape)),
|
| 247 |
+
niter=2,
|
| 248 |
+
)
|
| 249 |
+
left = u[:, :rank] * singular[:rank]
|
| 250 |
+
right = v[:, :rank].T
|
| 251 |
+
residual = target - left @ right
|
| 252 |
+
coded = _ternary(residual, replace(spec, kind="ternary"))
|
| 253 |
+
# BF16 left/right factors are the complete side information.
|
| 254 |
+
factor_values = rank * (weight.shape[0] + weight.shape[1])
|
| 255 |
+
return (coded.float() + left @ right).to(weight.dtype), factor_values
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _sparse_binary(weight: torch.Tensor, spec: ExtremeSpec) -> torch.Tensor:
|
| 259 |
+
"""Binary base plus exact, fixed-rate residual exceptions in each group."""
|
| 260 |
+
groups, shape = _grouped(weight, spec.group_size)
|
| 261 |
+
if not 0 < spec.outliers < groups.shape[1]:
|
| 262 |
+
raise ValueError("sparse_binary requires 0 < outliers < group_size")
|
| 263 |
+
|
| 264 |
+
first_scale = groups.abs().mean(dim=1, keepdim=True).clamp_min(1e-8)
|
| 265 |
+
first = torch.where(groups >= 0, first_scale, -first_scale)
|
| 266 |
+
indices = (groups - first).abs().topk(spec.outliers, dim=1).indices
|
| 267 |
+
residual_mask = torch.zeros_like(groups, dtype=torch.bool)
|
| 268 |
+
residual_mask.scatter_(1, indices, True)
|
| 269 |
+
|
| 270 |
+
base_mask = ~residual_mask
|
| 271 |
+
scale = (
|
| 272 |
+
(groups.abs() * base_mask).sum(dim=1, keepdim=True)
|
| 273 |
+
/ base_mask.sum(dim=1, keepdim=True).clamp_min(1)
|
| 274 |
+
).clamp_min(1e-8)
|
| 275 |
+
output = torch.where(groups >= 0, scale, -scale)
|
| 276 |
+
output[residual_mask] = groups[residual_mask]
|
| 277 |
+
return output.reshape(shape).to(weight.dtype)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _assign(points: torch.Tensor, centers: torch.Tensor, chunk: int) -> torch.Tensor:
|
| 281 |
+
assignments = []
|
| 282 |
+
center_norm = centers.square().sum(dim=1).unsqueeze(0)
|
| 283 |
+
for start in range(0, points.shape[0], chunk):
|
| 284 |
+
block = points[start : start + chunk]
|
| 285 |
+
distances = block.square().sum(dim=1, keepdim=True) + center_norm
|
| 286 |
+
distances.addmm_(block, centers.T, beta=1.0, alpha=-2.0)
|
| 287 |
+
assignments.append(distances.argmin(dim=1))
|
| 288 |
+
return torch.cat(assignments)
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
def _fit_codebook(
|
| 292 |
+
points: torch.Tensor,
|
| 293 |
+
*,
|
| 294 |
+
size: int,
|
| 295 |
+
iterations: int,
|
| 296 |
+
max_samples: int,
|
| 297 |
+
chunk: int,
|
| 298 |
+
seed: int,
|
| 299 |
+
) -> torch.Tensor:
|
| 300 |
+
if points.shape[0] < size:
|
| 301 |
+
raise ValueError(f"need at least {size} vectors, found {points.shape[0]}")
|
| 302 |
+
generator = torch.Generator(device="cpu")
|
| 303 |
+
generator.manual_seed(seed)
|
| 304 |
+
if points.shape[0] > max_samples:
|
| 305 |
+
# randperm(points.shape[0]) would allocate gigabytes for the largest
|
| 306 |
+
# MLP matrices. Sampling with replacement keeps this bounded at O(sample).
|
| 307 |
+
sample_idx = torch.randint(
|
| 308 |
+
points.shape[0], (max_samples,), generator=generator
|
| 309 |
+
)
|
| 310 |
+
sample = points.index_select(0, sample_idx.to(points.device))
|
| 311 |
+
else:
|
| 312 |
+
sample = points
|
| 313 |
+
init_idx = torch.randperm(sample.shape[0], generator=generator)[:size]
|
| 314 |
+
centers = sample.index_select(0, init_idx.to(sample.device)).clone()
|
| 315 |
+
|
| 316 |
+
for _ in range(iterations):
|
| 317 |
+
assignment = _assign(sample, centers, chunk)
|
| 318 |
+
sums = torch.zeros_like(centers)
|
| 319 |
+
sums.index_add_(0, assignment, sample)
|
| 320 |
+
counts = torch.bincount(assignment, minlength=size).to(sample.dtype).unsqueeze(1)
|
| 321 |
+
nonempty = counts.squeeze(1) > 0
|
| 322 |
+
centers[nonempty] = sums[nonempty] / counts[nonempty]
|
| 323 |
+
return centers
|
| 324 |
+
|
| 325 |
+
|
| 326 |
+
def _vector_additive(
|
| 327 |
+
weight: torch.Tensor, spec: ExtremeSpec, *, seed: int
|
| 328 |
+
) -> tuple[torch.Tensor, int]:
|
| 329 |
+
if spec.codebooks < 1:
|
| 330 |
+
raise ValueError("vector quantization requires at least one codebook")
|
| 331 |
+
if spec.codebook_size < 2 or spec.codebook_size & (spec.codebook_size - 1):
|
| 332 |
+
raise ValueError("codebook_size must be a power of two")
|
| 333 |
+
|
| 334 |
+
original_shape = tuple(weight.shape)
|
| 335 |
+
flat = weight.float().reshape(-1)
|
| 336 |
+
padding = (-flat.numel()) % spec.vector_dim
|
| 337 |
+
if padding:
|
| 338 |
+
flat = torch.nn.functional.pad(flat, (0, padding))
|
| 339 |
+
points = flat.reshape(-1, spec.vector_dim)
|
| 340 |
+
residual = points.clone()
|
| 341 |
+
reconstructed = torch.zeros_like(points)
|
| 342 |
+
codebook_values = 0
|
| 343 |
+
|
| 344 |
+
for book in range(spec.codebooks):
|
| 345 |
+
centers = _fit_codebook(
|
| 346 |
+
residual,
|
| 347 |
+
size=spec.codebook_size,
|
| 348 |
+
iterations=spec.kmeans_iterations,
|
| 349 |
+
max_samples=spec.kmeans_samples,
|
| 350 |
+
chunk=spec.assign_chunk,
|
| 351 |
+
seed=seed + book * 104_729,
|
| 352 |
+
)
|
| 353 |
+
assignment = _assign(residual, centers, spec.assign_chunk)
|
| 354 |
+
contribution = centers.index_select(0, assignment)
|
| 355 |
+
reconstructed.add_(contribution)
|
| 356 |
+
residual.sub_(contribution)
|
| 357 |
+
codebook_values += centers.numel()
|
| 358 |
+
|
| 359 |
+
restored = reconstructed.reshape(-1)[: weight.numel()].reshape(original_shape)
|
| 360 |
+
return restored.to(weight.dtype), codebook_values
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def _representation_bits(
|
| 364 |
+
numel: int, spec: ExtremeSpec, *, codebook_values: int = 0
|
| 365 |
+
) -> int:
|
| 366 |
+
if spec.kind.startswith("hadamard_"):
|
| 367 |
+
# The fixed normalized transform is reconstructed from group width and
|
| 368 |
+
# costs no payload bytes. Only its coefficient codec is charged.
|
| 369 |
+
return _representation_bits(
|
| 370 |
+
numel,
|
| 371 |
+
replace(spec, kind=spec.kind.removeprefix("hadamard_")),
|
| 372 |
+
codebook_values=codebook_values,
|
| 373 |
+
)
|
| 374 |
+
if spec.kind == "aw_ternary":
|
| 375 |
+
return _representation_bits(
|
| 376 |
+
numel, replace(spec, kind="ternary"), codebook_values=codebook_values
|
| 377 |
+
)
|
| 378 |
+
if spec.kind == "sigma_ternary":
|
| 379 |
+
return _representation_bits(
|
| 380 |
+
numel, replace(spec, kind="ternary"), codebook_values=codebook_values
|
| 381 |
+
)
|
| 382 |
+
if spec.kind == "binary":
|
| 383 |
+
groups = math.ceil(numel / spec.group_size)
|
| 384 |
+
return numel + groups * 16
|
| 385 |
+
if spec.kind == "ternary":
|
| 386 |
+
# Five base-3 digits fit in one byte. Account in independently packed groups.
|
| 387 |
+
groups = math.ceil(numel / spec.group_size)
|
| 388 |
+
trit_bytes = groups * math.ceil(spec.group_size / 5)
|
| 389 |
+
return trit_bytes * 8 + groups * 16
|
| 390 |
+
if spec.kind == "sparse_binary":
|
| 391 |
+
groups = math.ceil(numel / spec.group_size)
|
| 392 |
+
position_bits = math.ceil(math.log2(spec.group_size))
|
| 393 |
+
return numel + groups * (16 + spec.outliers * (16 + position_bits))
|
| 394 |
+
if spec.kind == "vector":
|
| 395 |
+
vectors = math.ceil(numel / spec.vector_dim)
|
| 396 |
+
index_bits = int(math.log2(spec.codebook_size))
|
| 397 |
+
return vectors * index_bits * spec.codebooks + codebook_values * 16
|
| 398 |
+
if spec.kind.startswith("int"):
|
| 399 |
+
bits = int(spec.kind.removeprefix("int"))
|
| 400 |
+
groups = math.ceil(numel / spec.group_size)
|
| 401 |
+
return numel * bits + groups * 16
|
| 402 |
+
if spec.kind == "bf16":
|
| 403 |
+
return numel * 16
|
| 404 |
+
raise ValueError(f"unknown extreme quantizer kind: {spec.kind}")
|
| 405 |
+
|
| 406 |
+
|
| 407 |
+
def quantize_tensor(
|
| 408 |
+
weight: torch.Tensor,
|
| 409 |
+
spec: ExtremeSpec,
|
| 410 |
+
*,
|
| 411 |
+
seed: int,
|
| 412 |
+
importance: torch.Tensor | None = None,
|
| 413 |
+
) -> tuple[torch.Tensor, int, dict[str, Any]]:
|
| 414 |
+
factor_values = 0
|
| 415 |
+
if spec.kind.startswith("hadamard_"):
|
| 416 |
+
restored, codebook_values = _hadamard_quantize(weight, spec)
|
| 417 |
+
elif spec.kind == "binary":
|
| 418 |
+
restored = _binary(weight, spec)
|
| 419 |
+
codebook_values = 0
|
| 420 |
+
elif spec.kind == "ternary":
|
| 421 |
+
restored = _ternary(weight, spec)
|
| 422 |
+
codebook_values = 0
|
| 423 |
+
elif spec.kind == "aw_ternary":
|
| 424 |
+
restored = _activation_weighted_ternary(weight, spec, importance)
|
| 425 |
+
codebook_values = 0
|
| 426 |
+
elif spec.kind == "sigma_ternary":
|
| 427 |
+
restored = _sigma_delta_ternary(weight, spec, importance)
|
| 428 |
+
codebook_values = 0
|
| 429 |
+
elif spec.kind == "srr_ternary":
|
| 430 |
+
restored, factor_values = _structured_residual_ternary(weight, spec, seed=seed)
|
| 431 |
+
codebook_values = 0
|
| 432 |
+
elif spec.kind == "sparse_binary":
|
| 433 |
+
restored = _sparse_binary(weight, spec)
|
| 434 |
+
codebook_values = 0
|
| 435 |
+
elif spec.kind == "vector":
|
| 436 |
+
restored, codebook_values = _vector_additive(weight, spec, seed=seed)
|
| 437 |
+
elif spec.kind.startswith("int"):
|
| 438 |
+
bits = int(spec.kind.removeprefix("int"))
|
| 439 |
+
restored = quantize_dequantize(
|
| 440 |
+
weight, QuantSpec(pattern=".*", bits=bits, group_size=spec.group_size)
|
| 441 |
+
)
|
| 442 |
+
codebook_values = 0
|
| 443 |
+
elif spec.kind == "bf16":
|
| 444 |
+
restored = weight
|
| 445 |
+
codebook_values = 0
|
| 446 |
+
else:
|
| 447 |
+
raise ValueError(f"unknown extreme quantizer kind: {spec.kind}")
|
| 448 |
+
|
| 449 |
+
delta = weight.float() - restored.float()
|
| 450 |
+
denominator = weight.float().square().sum().sqrt().clamp_min(1e-12)
|
| 451 |
+
info = {
|
| 452 |
+
"relative_weight_error": float(delta.square().sum().sqrt() / denominator),
|
| 453 |
+
"max_abs_error": float(delta.abs().max()),
|
| 454 |
+
"codebook_values": codebook_values,
|
| 455 |
+
"factor_values": factor_values,
|
| 456 |
+
"activation_weighted": spec.kind == "aw_ternary" and importance is not None,
|
| 457 |
+
"error_feedback": spec.kind == "sigma_ternary" and importance is not None,
|
| 458 |
+
}
|
| 459 |
+
if spec.kind == "srr_ternary":
|
| 460 |
+
bits = _representation_bits(
|
| 461 |
+
weight.numel(), replace(spec, kind="ternary"), codebook_values=0
|
| 462 |
+
) + factor_values * 16
|
| 463 |
+
else:
|
| 464 |
+
bits = _representation_bits(weight.numel(), spec, codebook_values=codebook_values)
|
| 465 |
+
return restored, bits, info
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
def apply_extreme_recipe(
|
| 469 |
+
model: torch.nn.Module,
|
| 470 |
+
specs: dict[str, ExtremeSpec],
|
| 471 |
+
*,
|
| 472 |
+
device: str,
|
| 473 |
+
stored_vocab_rows: int,
|
| 474 |
+
stored_vocab_ids: Sequence[int] | None = None,
|
| 475 |
+
input_second_moments: dict[str, torch.Tensor] | None = None,
|
| 476 |
+
seed: int = 6013,
|
| 477 |
+
) -> dict[str, Any]:
|
| 478 |
+
"""Quantize the text model in place and return physical representation accounting."""
|
| 479 |
+
excluded = re.compile(EXCLUDED)
|
| 480 |
+
protected = re.compile(PROTECTED.pattern)
|
| 481 |
+
seen: set[int] = set()
|
| 482 |
+
tensors: list[dict[str, Any]] = []
|
| 483 |
+
component_bits: dict[str, int] = {}
|
| 484 |
+
component_params: dict[str, int] = {}
|
| 485 |
+
total_bits = 0
|
| 486 |
+
total_counted_params = 0
|
| 487 |
+
weighted_squared_error = 0.0
|
| 488 |
+
weighted_squared_norm = 0.0
|
| 489 |
+
vocab_ids = None
|
| 490 |
+
sensitivity_hits = 0
|
| 491 |
+
if stored_vocab_ids is not None:
|
| 492 |
+
vocab_ids = torch.tensor(sorted(set(int(value) for value in stored_vocab_ids)))
|
| 493 |
+
if vocab_ids.numel() != stored_vocab_rows:
|
| 494 |
+
raise ValueError(
|
| 495 |
+
"stored_vocab_ids must contain exactly stored_vocab_rows unique ids: "
|
| 496 |
+
f"ids={vocab_ids.numel()}, rows={stored_vocab_rows}"
|
| 497 |
+
)
|
| 498 |
+
|
| 499 |
+
with torch.no_grad():
|
| 500 |
+
for name, param in model.named_parameters():
|
| 501 |
+
if excluded.search(name):
|
| 502 |
+
continue
|
| 503 |
+
pointer = param.data_ptr()
|
| 504 |
+
if pointer in seen:
|
| 505 |
+
continue
|
| 506 |
+
seen.add(pointer)
|
| 507 |
+
|
| 508 |
+
component = _component(name)
|
| 509 |
+
spec = ExtremeSpec("bf16") if protected.search(name) else specs.get(
|
| 510 |
+
component, ExtremeSpec("bf16")
|
| 511 |
+
)
|
| 512 |
+
target = param.data.to(device) if spec.kind != "bf16" else param.data
|
| 513 |
+
original = target.float()
|
| 514 |
+
module_name = name.removesuffix(".weight")
|
| 515 |
+
importance = None
|
| 516 |
+
if input_second_moments is not None and module_name in input_second_moments:
|
| 517 |
+
importance = input_second_moments[module_name].to(target.device)
|
| 518 |
+
sensitivity_hits += 1
|
| 519 |
+
restored, bits, info = quantize_tensor(
|
| 520 |
+
target,
|
| 521 |
+
spec,
|
| 522 |
+
seed=_seed_for(name, seed),
|
| 523 |
+
importance=importance,
|
| 524 |
+
)
|
| 525 |
+
if component == "embed" and param.ndim == 2 and stored_vocab_rows < param.shape[0]:
|
| 526 |
+
if vocab_ids is None:
|
| 527 |
+
raise ValueError(
|
| 528 |
+
"a compact embedding claim requires exact stored_vocab_ids; "
|
| 529 |
+
"output masking alone does not reconstruct omitted input rows"
|
| 530 |
+
)
|
| 531 |
+
if int(vocab_ids[0]) < 0 or int(vocab_ids[-1]) >= param.shape[0]:
|
| 532 |
+
raise ValueError("stored vocabulary ids are outside the embedding table")
|
| 533 |
+
keep_mask = torch.zeros(param.shape[0], dtype=torch.bool, device=restored.device)
|
| 534 |
+
keep_mask[vocab_ids.to(restored.device)] = True
|
| 535 |
+
restored[~keep_mask] = 0
|
| 536 |
+
if spec.kind != "bf16" or (
|
| 537 |
+
component == "embed" and param.ndim == 2 and stored_vocab_rows < param.shape[0]
|
| 538 |
+
):
|
| 539 |
+
param.data.copy_(restored.to(param.device))
|
| 540 |
+
|
| 541 |
+
counted_params = param.numel()
|
| 542 |
+
# The physical A artifact will store selected original-id rows only.
|
| 543 |
+
# Quantize the full table for an input-compatible diagnostic, but do
|
| 544 |
+
# not charge rows that the compact representation omits.
|
| 545 |
+
if component == "embed" and param.ndim == 2:
|
| 546 |
+
counted_params = min(stored_vocab_rows, param.shape[0]) * param.shape[1]
|
| 547 |
+
ratio = counted_params / param.numel()
|
| 548 |
+
bits = math.ceil(bits * ratio)
|
| 549 |
+
|
| 550 |
+
component_bits[component] = component_bits.get(component, 0) + bits
|
| 551 |
+
component_params[component] = component_params.get(component, 0) + counted_params
|
| 552 |
+
total_bits += bits
|
| 553 |
+
total_counted_params += counted_params
|
| 554 |
+
weighted_squared_error += float((original - restored.float()).square().sum())
|
| 555 |
+
weighted_squared_norm += float(original.square().sum())
|
| 556 |
+
tensors.append(
|
| 557 |
+
{
|
| 558 |
+
"name": name,
|
| 559 |
+
"component": component,
|
| 560 |
+
"shape": list(param.shape),
|
| 561 |
+
"spec": asdict(spec),
|
| 562 |
+
"counted_params": counted_params,
|
| 563 |
+
"representation_bytes": math.ceil(bits / 8),
|
| 564 |
+
**info,
|
| 565 |
+
}
|
| 566 |
+
)
|
| 567 |
+
|
| 568 |
+
return {
|
| 569 |
+
"format": "eaimath-extreme-screen-v1",
|
| 570 |
+
"stored_vocab_rows": stored_vocab_rows,
|
| 571 |
+
"omitted_embedding_rows_zeroed": vocab_ids is not None,
|
| 572 |
+
"total_params_in_representation": total_counted_params,
|
| 573 |
+
"total_bytes": math.ceil(total_bits / 8),
|
| 574 |
+
"total_gb": total_bits / 8e9,
|
| 575 |
+
"effective_bits_per_weight": total_bits / total_counted_params,
|
| 576 |
+
"relative_weight_error": math.sqrt(weighted_squared_error / weighted_squared_norm),
|
| 577 |
+
"component_bytes": {name: math.ceil(bits / 8) for name, bits in component_bits.items()},
|
| 578 |
+
"component_params": component_params,
|
| 579 |
+
"specs": {name: asdict(spec) for name, spec in specs.items()},
|
| 580 |
+
"sensitivity_tensors_used": sensitivity_hits,
|
| 581 |
+
"tensors": tensors,
|
| 582 |
+
}
|
src/eaimath/gauge.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exact MLP gauge transformations used as quantization preconditioners.
|
| 2 |
+
|
| 3 |
+
For a gated MLP ``down(silu(gate(x)) * up(x))``, every intermediate channel has
|
| 4 |
+
an exact positive scaling symmetry: multiply one row of ``up`` by ``c`` and the
|
| 5 |
+
matching column of ``down`` by ``1/c``. Gauge fixing chooses ``c`` so the two
|
| 6 |
+
channel norms are balanced before low-bit compression. The factors are fused
|
| 7 |
+
into the weights, so neither the compact artifact nor inference stores them.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import math
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
import torch
|
| 16 |
+
import torch.nn.functional as F
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def balance_up_down(
|
| 20 |
+
up: torch.Tensor,
|
| 21 |
+
down: torch.Tensor,
|
| 22 |
+
*,
|
| 23 |
+
min_scale: float = 1 / 16,
|
| 24 |
+
max_scale: float = 16,
|
| 25 |
+
) -> tuple[torch.Tensor, torch.Tensor, dict[str, Any]]:
|
| 26 |
+
"""Return a function-equivalent, norm-balanced pair of MLP weights."""
|
| 27 |
+
if up.ndim != 2 or down.ndim != 2 or up.shape[0] != down.shape[1]:
|
| 28 |
+
raise ValueError(f"incompatible up/down shapes: {tuple(up.shape)}, {tuple(down.shape)}")
|
| 29 |
+
up32 = up.float()
|
| 30 |
+
down32 = down.float()
|
| 31 |
+
up_rms = up32.square().mean(dim=1).sqrt().clamp_min(1e-12)
|
| 32 |
+
down_rms = down32.square().mean(dim=0).sqrt().clamp_min(1e-12)
|
| 33 |
+
scale = torch.sqrt(down_rms / up_rms).clamp(min_scale, max_scale)
|
| 34 |
+
balanced_up = up32 * scale[:, None]
|
| 35 |
+
balanced_down = down32 / scale[None, :]
|
| 36 |
+
before_ratio = torch.maximum(up_rms / down_rms, down_rms / up_rms)
|
| 37 |
+
after_up = balanced_up.square().mean(dim=1).sqrt().clamp_min(1e-12)
|
| 38 |
+
after_down = balanced_down.square().mean(dim=0).sqrt().clamp_min(1e-12)
|
| 39 |
+
after_ratio = torch.maximum(after_up / after_down, after_down / after_up)
|
| 40 |
+
quantiles = torch.tensor([0.01, 0.5, 0.99], device=scale.device)
|
| 41 |
+
scale_quantiles = torch.quantile(scale, quantiles).cpu().tolist()
|
| 42 |
+
report = {
|
| 43 |
+
"channels": scale.numel(),
|
| 44 |
+
"scale_min": float(scale.min()),
|
| 45 |
+
"scale_p01": scale_quantiles[0],
|
| 46 |
+
"scale_median": scale_quantiles[1],
|
| 47 |
+
"scale_p99": scale_quantiles[2],
|
| 48 |
+
"scale_max": float(scale.max()),
|
| 49 |
+
"mean_norm_ratio_before": float(before_ratio.mean()),
|
| 50 |
+
"mean_norm_ratio_after": float(after_ratio.mean()),
|
| 51 |
+
"clamped_low": int((scale == min_scale).sum()),
|
| 52 |
+
"clamped_high": int((scale == max_scale).sum()),
|
| 53 |
+
"payload_bytes": 0,
|
| 54 |
+
}
|
| 55 |
+
return balanced_up.to(up.dtype), balanced_down.to(down.dtype), report
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def gated_mlp_output(
|
| 59 |
+
inputs: torch.Tensor,
|
| 60 |
+
gate: torch.Tensor,
|
| 61 |
+
up: torch.Tensor,
|
| 62 |
+
down: torch.Tensor,
|
| 63 |
+
) -> torch.Tensor:
|
| 64 |
+
return (F.silu(inputs @ gate.T) * (inputs @ up.T)) @ down.T
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def apply_mlp_gauge(
|
| 68 |
+
model: torch.nn.Module,
|
| 69 |
+
*,
|
| 70 |
+
device: str,
|
| 71 |
+
verification_vectors: int = 2,
|
| 72 |
+
seed: int = 6013,
|
| 73 |
+
) -> dict[str, Any]:
|
| 74 |
+
"""Fuse the exact up/down gauge into every dense text MLP in ``model``."""
|
| 75 |
+
rows = []
|
| 76 |
+
generator = torch.Generator(device="cpu")
|
| 77 |
+
generator.manual_seed(seed)
|
| 78 |
+
with torch.no_grad():
|
| 79 |
+
for name, module in model.named_modules():
|
| 80 |
+
if "visual" in name or "mtp" in name:
|
| 81 |
+
continue
|
| 82 |
+
if not all(hasattr(module, attr) for attr in ("gate_proj", "up_proj", "down_proj")):
|
| 83 |
+
continue
|
| 84 |
+
gate_param = module.gate_proj.weight
|
| 85 |
+
up_param = module.up_proj.weight
|
| 86 |
+
down_param = module.down_proj.weight
|
| 87 |
+
gate = gate_param.detach().to(device)
|
| 88 |
+
up = up_param.detach().to(device)
|
| 89 |
+
down = down_param.detach().to(device)
|
| 90 |
+
inputs = torch.randn(
|
| 91 |
+
verification_vectors,
|
| 92 |
+
up.shape[1],
|
| 93 |
+
generator=generator,
|
| 94 |
+
dtype=torch.float32,
|
| 95 |
+
).to(device=device, dtype=up.dtype)
|
| 96 |
+
before = gated_mlp_output(inputs, gate, up, down).float()
|
| 97 |
+
balanced_up, balanced_down, report = balance_up_down(up, down)
|
| 98 |
+
after = gated_mlp_output(inputs, gate, balanced_up, balanced_down).float()
|
| 99 |
+
denominator = before.square().sum().sqrt().clamp_min(1e-12)
|
| 100 |
+
report.update(
|
| 101 |
+
{
|
| 102 |
+
"module": name,
|
| 103 |
+
"up_shape": list(up.shape),
|
| 104 |
+
"down_shape": list(down.shape),
|
| 105 |
+
"verification_relative_l2": float(
|
| 106 |
+
(before - after).square().sum().sqrt() / denominator
|
| 107 |
+
),
|
| 108 |
+
"verification_max_abs": float((before - after).abs().max()),
|
| 109 |
+
}
|
| 110 |
+
)
|
| 111 |
+
up_param.copy_(balanced_up.to(up_param.device, dtype=up_param.dtype))
|
| 112 |
+
down_param.copy_(balanced_down.to(down_param.device, dtype=down_param.dtype))
|
| 113 |
+
if getattr(module.up_proj, "bias", None) is not None:
|
| 114 |
+
bias = module.up_proj.bias
|
| 115 |
+
# Qwen uses bias-free MLPs, but preserve the symmetry for any
|
| 116 |
+
# compatible checkpoint that does carry an up-projection bias.
|
| 117 |
+
up_rms = up.float().square().mean(dim=1).sqrt().clamp_min(1e-12)
|
| 118 |
+
down_rms = down.float().square().mean(dim=0).sqrt().clamp_min(1e-12)
|
| 119 |
+
scale = torch.sqrt(down_rms / up_rms).clamp(1 / 16, 16)
|
| 120 |
+
bias.mul_(scale.to(bias.device, dtype=bias.dtype))
|
| 121 |
+
rows.append(report)
|
| 122 |
+
|
| 123 |
+
if not rows:
|
| 124 |
+
raise RuntimeError("no dense gate/up/down MLP modules were found")
|
| 125 |
+
squared = sum(row["verification_relative_l2"] ** 2 for row in rows)
|
| 126 |
+
return {
|
| 127 |
+
"format": "eaimath-mlp-gauge-v1",
|
| 128 |
+
"method": "exact positive up/down channel gauge balancing",
|
| 129 |
+
"modules": len(rows),
|
| 130 |
+
"payload_bytes": 0,
|
| 131 |
+
"rms_verification_relative_l2": math.sqrt(squared / len(rows)),
|
| 132 |
+
"max_verification_relative_l2": max(
|
| 133 |
+
row["verification_relative_l2"] for row in rows
|
| 134 |
+
),
|
| 135 |
+
"rows": rows,
|
| 136 |
+
}
|
src/eaimath/generate.py
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Batched generation with the bookkeeping our metrics need.
|
| 2 |
+
|
| 3 |
+
``transformers.generate`` gives back padded token ids and nothing about *why*
|
| 4 |
+
each sequence stopped. For a thinking model that distinction is the whole game:
|
| 5 |
+
a sequence that ran out of budget is a different failure from one that finished
|
| 6 |
+
and got the answer wrong, and only the first is fixable by shortening CoT.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from dataclasses import dataclass
|
| 12 |
+
from typing import Sequence
|
| 13 |
+
|
| 14 |
+
import torch
|
| 15 |
+
from tqdm.auto import tqdm
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@dataclass
|
| 19 |
+
class GenerationOutput:
|
| 20 |
+
text: str
|
| 21 |
+
num_prompt_tokens: int
|
| 22 |
+
num_generated_tokens: int
|
| 23 |
+
finished: bool # True = emitted EOS; False = hit max_new_tokens
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def build_chat_prompts(tokenizer, prompts: Sequence[str], enable_thinking: bool | None = None):
|
| 27 |
+
"""Apply the chat template, passing ``enable_thinking`` only if supported."""
|
| 28 |
+
kwargs = {"tokenize": False, "add_generation_prompt": True}
|
| 29 |
+
if enable_thinking is not None:
|
| 30 |
+
kwargs["enable_thinking"] = enable_thinking
|
| 31 |
+
|
| 32 |
+
messages = [[{"role": "user", "content": p}] for p in prompts]
|
| 33 |
+
try:
|
| 34 |
+
return [tokenizer.apply_chat_template(m, **kwargs) for m in messages]
|
| 35 |
+
except TypeError:
|
| 36 |
+
kwargs.pop("enable_thinking", None)
|
| 37 |
+
return [tokenizer.apply_chat_template(m, **kwargs) for m in messages]
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@torch.inference_mode()
|
| 41 |
+
def generate(
|
| 42 |
+
model,
|
| 43 |
+
tokenizer,
|
| 44 |
+
prompts: Sequence[str],
|
| 45 |
+
*,
|
| 46 |
+
max_new_tokens: int = 65536,
|
| 47 |
+
temperature: float = 0.0,
|
| 48 |
+
top_p: float = 0.95,
|
| 49 |
+
top_k: int = 20,
|
| 50 |
+
repetition_penalty: float = 1.0,
|
| 51 |
+
batch_size: int = 8,
|
| 52 |
+
enable_thinking: bool | None = None,
|
| 53 |
+
desc: str = "generating",
|
| 54 |
+
) -> list[GenerationOutput]:
|
| 55 |
+
chat_texts = build_chat_prompts(tokenizer, prompts, enable_thinking)
|
| 56 |
+
do_sample = temperature > 0
|
| 57 |
+
eos_ids = _eos_token_ids(model, tokenizer)
|
| 58 |
+
|
| 59 |
+
gen_kwargs = {
|
| 60 |
+
"max_new_tokens": max_new_tokens,
|
| 61 |
+
"pad_token_id": tokenizer.pad_token_id,
|
| 62 |
+
"do_sample": do_sample,
|
| 63 |
+
}
|
| 64 |
+
# HF has no presence_penalty; repetition_penalty is the available analogue.
|
| 65 |
+
if repetition_penalty != 1.0:
|
| 66 |
+
gen_kwargs["repetition_penalty"] = repetition_penalty
|
| 67 |
+
if do_sample:
|
| 68 |
+
gen_kwargs.update(temperature=temperature, top_p=top_p, top_k=top_k)
|
| 69 |
+
|
| 70 |
+
results: list[GenerationOutput] = []
|
| 71 |
+
progress = tqdm(total=len(chat_texts), desc=desc, unit="ex")
|
| 72 |
+
index = 0
|
| 73 |
+
current_batch = batch_size
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
while index < len(chat_texts):
|
| 77 |
+
batch = chat_texts[index : index + current_batch]
|
| 78 |
+
try:
|
| 79 |
+
results.extend(
|
| 80 |
+
_run_batch(model, tokenizer, batch, gen_kwargs, eos_ids)
|
| 81 |
+
)
|
| 82 |
+
except Exception as exc:
|
| 83 |
+
# A shared box means free VRAM changes under us, and long-CoT KV
|
| 84 |
+
# cache grows with max_new_tokens. Back off rather than losing
|
| 85 |
+
# an hour of completed work -- but only for genuine OOM; anything
|
| 86 |
+
# else is a real bug and must surface.
|
| 87 |
+
if not _is_oom(exc) or current_batch == 1:
|
| 88 |
+
raise
|
| 89 |
+
torch.cuda.empty_cache()
|
| 90 |
+
current_batch = max(1, current_batch // 2)
|
| 91 |
+
progress.write(f" OOM -> retrying at batch size {current_batch}")
|
| 92 |
+
continue
|
| 93 |
+
index += len(batch)
|
| 94 |
+
progress.update(len(batch))
|
| 95 |
+
finally:
|
| 96 |
+
progress.close()
|
| 97 |
+
|
| 98 |
+
return results
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
_OOM_TYPES = tuple(
|
| 102 |
+
err
|
| 103 |
+
for err in (
|
| 104 |
+
getattr(torch, "OutOfMemoryError", None),
|
| 105 |
+
getattr(torch.cuda, "OutOfMemoryError", None),
|
| 106 |
+
)
|
| 107 |
+
if err is not None
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _is_oom(exc: BaseException) -> bool:
|
| 112 |
+
"""True for out-of-memory failures only, across torch versions."""
|
| 113 |
+
if _OOM_TYPES and isinstance(exc, _OOM_TYPES):
|
| 114 |
+
return True
|
| 115 |
+
return isinstance(exc, RuntimeError) and "out of memory" in str(exc).lower()
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
@torch.inference_mode()
|
| 119 |
+
def _run_batch(model, tokenizer, batch, gen_kwargs, eos_ids) -> list[GenerationOutput]:
|
| 120 |
+
inputs = tokenizer(batch, return_tensors="pt", padding=True, truncation=False)
|
| 121 |
+
inputs = {k: v.to(model.device) for k, v in inputs.items()}
|
| 122 |
+
prompt_len = inputs["input_ids"].shape[1]
|
| 123 |
+
|
| 124 |
+
outputs = model.generate(**inputs, **gen_kwargs)
|
| 125 |
+
|
| 126 |
+
out: list[GenerationOutput] = []
|
| 127 |
+
for row, prompt_tokens in zip(outputs, inputs["attention_mask"].sum(dim=1).tolist()):
|
| 128 |
+
generated = row[prompt_len:]
|
| 129 |
+
num_generated, finished = _measure_completion(generated, eos_ids)
|
| 130 |
+
out.append(
|
| 131 |
+
GenerationOutput(
|
| 132 |
+
text=tokenizer.decode(generated[:num_generated], skip_special_tokens=True),
|
| 133 |
+
num_prompt_tokens=int(prompt_tokens),
|
| 134 |
+
num_generated_tokens=num_generated,
|
| 135 |
+
finished=finished,
|
| 136 |
+
)
|
| 137 |
+
)
|
| 138 |
+
return out
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _eos_token_ids(model, tokenizer) -> set[int]:
|
| 142 |
+
ids: set[int] = set()
|
| 143 |
+
config_eos = getattr(getattr(model, "generation_config", None), "eos_token_id", None)
|
| 144 |
+
for source in (config_eos, tokenizer.eos_token_id):
|
| 145 |
+
if isinstance(source, int):
|
| 146 |
+
ids.add(source)
|
| 147 |
+
elif isinstance(source, (list, tuple)):
|
| 148 |
+
ids.update(int(i) for i in source)
|
| 149 |
+
return ids
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def _measure_completion(generated: torch.Tensor, eos_ids: set[int]) -> tuple[int, bool]:
|
| 153 |
+
"""Length up to and including the first EOS, and whether one was found.
|
| 154 |
+
|
| 155 |
+
Sequences that finish early are right-padded by ``generate``, so scanning for
|
| 156 |
+
the first EOS is what separates a real stop from budget exhaustion.
|
| 157 |
+
"""
|
| 158 |
+
if eos_ids:
|
| 159 |
+
for position, token in enumerate(generated.tolist()):
|
| 160 |
+
if token in eos_ids:
|
| 161 |
+
return position + 1, True
|
| 162 |
+
return int(generated.shape[0]), False
|
src/eaimath/gptq_backend.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""GPTQModel loading adapter for the existing Hugging Face evaluation loop.
|
| 2 |
+
|
| 3 |
+
GPTQModel's public object deliberately wraps the underlying Transformers model.
|
| 4 |
+
It exposes ``generate`` but does not promise every attribute that
|
| 5 |
+
``eaimath.generate`` uses (notably ``device`` and ``generation_config``). This
|
| 6 |
+
small adapter keeps that compatibility boundary in one place.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import Any
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class GPTQGenerationAdapter:
|
| 17 |
+
"""Expose GPTQModel through the subset of ``PreTrainedModel`` we evaluate."""
|
| 18 |
+
|
| 19 |
+
def __init__(self, wrapped: Any, device: str):
|
| 20 |
+
self.wrapped = wrapped
|
| 21 |
+
self.device = torch.device(device)
|
| 22 |
+
inner = getattr(wrapped, "model", None)
|
| 23 |
+
self.generation_config = getattr(inner, "generation_config", None)
|
| 24 |
+
|
| 25 |
+
def generate(self, *args, **kwargs):
|
| 26 |
+
return self.wrapped.generate(*args, **kwargs)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def load_gptq_for_generation(model_path: str, device: str) -> GPTQGenerationAdapter:
|
| 30 |
+
"""Load a packed GPTQModel checkpoint on one device for deterministic eval."""
|
| 31 |
+
try:
|
| 32 |
+
from gptqmodel import GPTQModel
|
| 33 |
+
except ImportError as exc:
|
| 34 |
+
raise RuntimeError(
|
| 35 |
+
"The gptqmodel backend is not installed. Run "
|
| 36 |
+
"`./scripts/setup_round8.sh` in the project environment."
|
| 37 |
+
) from exc
|
| 38 |
+
|
| 39 |
+
wrapped = GPTQModel.load(model_path, device=device)
|
| 40 |
+
return GPTQGenerationAdapter(wrapped, device)
|
src/eaimath/ledger.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""The results ledger: one row per checkpoint, not per experiment.
|
| 2 |
+
|
| 3 |
+
Storage is **one small file per checkpoint**, under `experiments/records/`.
|
| 4 |
+
|
| 5 |
+
This is the third design. A single JSON array conflicted whenever two machines
|
| 6 |
+
recorded anything. Append-only JSONL was better but still conflicted, because
|
| 7 |
+
both sides append at the same end-of-file position — verified with
|
| 8 |
+
`git merge-file`, which reported a conflict even though the two added rows were
|
| 9 |
+
different. One file per checkpoint is the version that actually works: two
|
| 10 |
+
machines recording different checkpoints create different files, which git
|
| 11 |
+
merges without even noticing. Recording the *same* checkpoint twice still
|
| 12 |
+
conflicts, and should — that is a real collision.
|
| 13 |
+
|
| 14 |
+
`RESULTS.md` is a **build artifact and is gitignored**, fully derived from these
|
| 15 |
+
records. Rebuild or print it with `python scripts/results.py`.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
RECORDS = Path("experiments/records") # one JSON file per checkpoint
|
| 25 |
+
RESULTS_MD = Path("RESULTS.md") # derived, gitignored
|
| 26 |
+
|
| 27 |
+
# Commentary lives in code so nobody has to hand-edit a generated file.
|
| 28 |
+
# The reference every result is quoted against. It must be a row that a run
|
| 29 |
+
# directory can produce: the previous reference claimed 0.950 at rp=1.10, matched
|
| 30 |
+
# no run on any machine, and disagreed with both measurements that did exist.
|
| 31 |
+
BASELINE = "bf16_rp120"
|
| 32 |
+
|
| 33 |
+
CURATED_NOTES: dict[str, str] = {
|
| 34 |
+
"bf16_rp120": "**REFERENCE** bf16 at its actual optimum, rp=1.20. acc|finished 1.000",
|
| 35 |
+
"bf16_rp110": "bf16 at rp=1.10 — identical to w16 on all three metrics, so the "
|
| 36 |
+
"quantize/save/load path is exactly lossless",
|
| 37 |
+
"bf16-greedy-nopenalty": "same weights, no penalty — decoding is worth ~+30 pts",
|
| 38 |
+
"w16": "identity check: quantize/save/eval path is lossless",
|
| 39 |
+
"mlp4": "Red Hat's recipe (GDN untouched). No loss vs w16.",
|
| 40 |
+
"mlp4_attn4": "full_attn@4 costs 21 pts for 0.44 GB — worst trade found",
|
| 41 |
+
"mlp4_lin8": "linear_attn@8 free on top of attn@4 — GDN survives INT8",
|
| 42 |
+
"all4_lin8": "embed@4 looked free here (−3 pts); round 4 revised this",
|
| 43 |
+
"mlp4_lin4": "linear_attn@4 cliff — later shown to be a full_attn@4 artifact",
|
| 44 |
+
"all4": "acc|finished 1.000 — the loss is pure truncation",
|
| 45 |
+
"mlp3_lin8": "mlp@3 cliff (RTN; untested with a calibrated quantizer)",
|
| 46 |
+
"mlp3_lin4": "98% truncated, yet acc|finished still 1.000",
|
| 47 |
+
"all3": "collapsed — this is the RTN row of the published recovery ladder",
|
| 48 |
+
"mlp2_lin8": "distinct failure: gibberish, stops at 1k tokens, 0% parse",
|
| 49 |
+
"m4l16a16e4": "embed@4 free here; GDN and attention untouched",
|
| 50 |
+
"m4l8a16e4": "linear_attn@8 free AGAIN (+0.010) for −1.00 GB",
|
| 51 |
+
"m4l8a8e8": "3.14 GB / 0.890 at rp=1.10",
|
| 52 |
+
"m4l8a8e4": "acc|finished 0.988 — the 8 pt gap was all truncation",
|
| 53 |
+
"m4l4a8e4": "GDN@4 at rp=1.10",
|
| 54 |
+
"m3l8a8e4": "MLP cliff between 4 and 3 bits (RTN)",
|
| 55 |
+
"m4l8a8e4_rp12": "2.83 GB at w16 accuracy — rp=1.20 not 1.10, zero bytes",
|
| 56 |
+
"m4l8a8e4_g64": "g=64 does not help (−0.030) and costs +0.06 GB",
|
| 57 |
+
"m4l8a16e4_g64": "g=64 does not help (−0.080)",
|
| 58 |
+
"m4l8a8e8_g64": "g=64 flat (+0.010) for +0.07 GB — finer groups rejected",
|
| 59 |
+
"m4l6a8e4_g64": "linear_attn@6 at g=64",
|
| 60 |
+
"m5l6a8e4_rp12": "**BEST OVERALL** 2.86 GB / 0.950 = bf16 exactly, 2.94x, fewer tokens than bf16",
|
| 61 |
+
"m5l6a8e8_rp12": "3.18 GB / 0.950 — same score as embed@4 above: embed is free once mlp is 5",
|
| 62 |
+
"m4l8a8e8_rp12": "0.890 → 0.920 from rp alone; mlp@4 caps out here regardless of the other bits",
|
| 63 |
+
"m4l6a8e8_rp12": "2.89 GB / 0.920 — superseded by m5l6a8e4: fewer bytes AND 3 pts more",
|
| 64 |
+
"m4l5a8e4_rp12": "2.45 GB / 0.860 — GDN survives 5 bits",
|
| 65 |
+
"m4l6a8e4_rp12": "2.57 GB / 0.840 — embed@4 costs ~8 pts vs embed@8 here",
|
| 66 |
+
"m4l4a8e4_rp12": "0.670 → 0.750 purely from retuning the penalty",
|
| 67 |
+
# Round 7 — the overnight sweep on buckets A and B.
|
| 68 |
+
"all3_rp13": "rp=1.30 made truncation WORSE (1.000) — past the useful range",
|
| 69 |
+
"all3_rp14": "trunc 0.900, still 0.000 — finishing more does not mean answering",
|
| 70 |
+
"all3_rp15": "trunc 0.660 and tokens −23%, accuracy STILL 0.000. Law 1 breaks here",
|
| 71 |
+
"m3l6a8e8_rp12": "mlp@3, everything else healthy: **0.300** — damaged, NOT dead",
|
| 72 |
+
"m3l6a8e8_rp135": "same weights at rp=1.35: 0.300 → 0.030. The penalty optimum has a ceiling",
|
| 73 |
+
"m3l3a8e8_rp12": "0.000 — adding GDN@3 on top of mlp@3 is what actually kills it",
|
| 74 |
+
"m3l3a8e8_g64_rp12": "g=64 does not rescue 3-bit either — 0.000",
|
| 75 |
+
"m4l6a8e4_rp12_sh2": "2x MLP sharing, no recovery: 0.000. Even the gentle factor collapses",
|
| 76 |
+
"m4l6a8e4_rp12_sh4": "4x MLP sharing, no recovery: 0.000 at trunc 0.46 — finishes, wrong",
|
| 77 |
+
"m5l6a8e4_rp12_v64k": "INVALID — probe read the wrong JSON key and kept only ~4k tokens",
|
| 78 |
+
"m5l6a8e4_rp12_v32k": "INVALID — same bug as the 64k row",
|
| 79 |
+
# Round 8 — calibrated GPTQ and clean BF16 sharing controls.
|
| 80 |
+
"gptq_mlp3_gar_rp12": "GPTQ MLP@3 with GAR: 0.870; 3-bit weights are healthy under calibration",
|
| 81 |
+
"gptq_mlp3_descact_rp12": "GPTQ MLP@3 with desc_act: ties GAR at 0.870",
|
| 82 |
+
"gptq_m3l4a8e8_gar_rp12": "**BEST GPTQ PROBE** 2.36 GB / 0.910; only 3 pts below re-measured bf16",
|
| 83 |
+
"gptq_m3l4a6e4_gar_rp12": "1.97 GB / 0.860; +30k vocab projects to 1.678 GB but leaves only 4 MB slack",
|
| 84 |
+
"gptq_m3l3a8e8_gar_rp12": "**B PRECURSOR** exact RTN recipe rose 0.000→0.860; +30k vocab projects to 1.664 GB",
|
| 85 |
+
"w16_sh2_mean_rp12": "BF16 2x MLP sharing, mean init: 0.000. Quantization was not the cause",
|
| 86 |
+
"w16_sh2_first_rp12": "BF16 2x MLP sharing, first init: finishes 77%, still 0.000",
|
| 87 |
+
"w16_sh4_mean_rp12": "BF16 4x MLP sharing, mean init: 0.000; untrained sharing closed",
|
| 88 |
+
# Round 9 — physical Bucket-B artifact and embedding-row controls.
|
| 89 |
+
"b0_zero_rp12": "**PHYSICAL B CANDIDATE** 1.664 GB / 0.780; exact GPTQ codes + 30k stored rows",
|
| 90 |
+
"b0_zero_checkpoint_rp12": "same physical B0 on n=560: 0.786, acc|finished 0.971",
|
| 91 |
+
"b0_zero_nomask_n30_rp12": "n=30 deployment control without an explicit output keep-set: ties zero fill",
|
| 92 |
+
"b0_mean_n30_rp12": "submission-valid mean omitted-row fill: no gain over zero on n=30",
|
| 93 |
+
"b0_base_n30_rp12": "INVALID diagnostic: original omitted rows improve 0.767→0.833 on n=30",
|
| 94 |
+
# Round 10 — B vocabulary/decoding plateau and A delta-sharing capacity.
|
| 95 |
+
"r10_b1_pf30_rp115": "30k problem-first physical B, rp=1.15: pure termination loss",
|
| 96 |
+
"r10_b1_pf30_rp120": "30k problem-first physical B, rp=1.20; no material gain over Round 9",
|
| 97 |
+
"r10_b1_pf30_rp125": "30k physical B over-penalized: 0.800→0.670",
|
| 98 |
+
"r10_b2_pf36_rp115": "36k problem-first physical B, rp=1.15",
|
| 99 |
+
"r10_b2_pf36_rp120": "**ROUND 10 GATE WINNER** 1.680 GB / 0.810; must defer to n=560",
|
| 100 |
+
"r10_b2_pf36_rp125": "lower truncation, but acc|finished collapses 0.988→0.860",
|
| 101 |
+
"r10_b2_pf36_checkpoint_rp120": "**PHYSICAL B FRONTIER** n=560 0.787: one problem above Round 9, effectively tied",
|
| 102 |
+
"r10_a_delta_f2_r64_n30": "factor 2/rank 64 delta sharing: finishes 43%, still 0/30",
|
| 103 |
+
"r10_a_delta_f8_r64_n30": "factor 8/rank 64 delta sharing: 100% truncation, 0/30",
|
| 104 |
+
"r10_a_delta_f8_r32_n30": "factor 8/rank 32 delta sharing: 100% truncation, 0/30",
|
| 105 |
+
"r10_a_delta_f8_r16_n30": "factor 8/rank 16 size endpoint: 100% truncation, 0/30",
|
| 106 |
+
# Round 12 — learned cross-band transfer and creative controls.
|
| 107 |
+
"r12_short2500_c_checkpoint": "**NEW C FRONTIER** short2500 + m5l6a8e4: n=560 0.916",
|
| 108 |
+
"r12_balanced6000_c_checkpoint": "balanced learned source: healthy 0.914, below short2500",
|
| 109 |
+
"r12_balanced6000_gauge_c_checkpoint": "gauge cuts truncation but trades conditional correctness; accuracy stays 0.914",
|
| 110 |
+
"r12_short2500_gauge_c_checkpoint": "exact SwiGLU gauge control: 0.909 vs ordinary short C 0.916",
|
| 111 |
+
"r12_hadamard_c_checkpoint": "fixed-transform codec: 0.887; rejected",
|
| 112 |
+
"r12_paro_int4_checkpoint": "public Paro method control: 0.886 and repository outside C",
|
| 113 |
+
"r12_short2500_b_zero_checkpoint": "short-source physical B zero fill: n=560 0.814",
|
| 114 |
+
"r12_short2500_b_token_checkpoint": "**NEW B FRONTIER** token predictor: n=560 0.834 vs zero 0.814",
|
| 115 |
+
}
|
| 116 |
+
|
| 117 |
+
HEADER = """# Results ledger
|
| 118 |
+
|
| 119 |
+
One row per **checkpoint**. Sweeps and ablations live in `experiments/`.
|
| 120 |
+
|
| 121 |
+
**Generated file — do not edit.** Rebuild with `python scripts/results.py`.
|
| 122 |
+
Source of truth is one file per checkpoint under `experiments/records/`, so two
|
| 123 |
+
machines recording different results never touch the same file.
|
| 124 |
+
|
| 125 |
+
Eval: `gate` (MATH-500 levels 4-5, n=100), 32,768-token cap, greedy. Size is the
|
| 126 |
+
compressed size from the quantization recipe; `pack.py` verifies those are real
|
| 127 |
+
bytes to 0.0%.
|
| 128 |
+
|
| 129 |
+
Noise floor ~±5 points unpaired — use `scripts/05_compare_runs.py` for paired
|
| 130 |
+
McNemar before believing a small delta.
|
| 131 |
+
|
| 132 |
+
"""
|
| 133 |
+
|
| 134 |
+
COLUMNS = [
|
| 135 |
+
("checkpoint", "checkpoint", "s"),
|
| 136 |
+
("size GB", "size_gb", ".2f"),
|
| 137 |
+
("bpw", "bits_per_weight", ".2f"),
|
| 138 |
+
("acc", "accuracy", ".3f"),
|
| 139 |
+
("trunc", "truncation_rate", ".3f"),
|
| 140 |
+
("mean tok", "mean_generated_tokens", ".0f"),
|
| 141 |
+
("notes", "notes", "s"),
|
| 142 |
+
]
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def load() -> list[dict[str, Any]]:
|
| 146 |
+
"""Every recorded checkpoint, largest first."""
|
| 147 |
+
if not RECORDS.exists():
|
| 148 |
+
return []
|
| 149 |
+
rows = [json.loads(p.read_text()) for p in sorted(RECORDS.glob("*.json"))]
|
| 150 |
+
return sorted(rows, key=lambda r: -(r.get("size_gb") or 0))
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def _safe_name(checkpoint: str) -> str:
|
| 154 |
+
return "".join(c if c.isalnum() or c in "-_" else "_" for c in checkpoint)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def record(
|
| 158 |
+
checkpoint: str,
|
| 159 |
+
*,
|
| 160 |
+
size_gb: float,
|
| 161 |
+
bits_per_weight: float,
|
| 162 |
+
summary: dict[str, Any],
|
| 163 |
+
notes: str = "",
|
| 164 |
+
run_name: str = "",
|
| 165 |
+
suite: str = "gate",
|
| 166 |
+
) -> None:
|
| 167 |
+
"""Write one checkpoint's result to its own file.
|
| 168 |
+
|
| 169 |
+
``suite`` is part of the identity, not decoration. The same recipe evaluated
|
| 170 |
+
on `gate` (n=100) and on `checkpoint` (n=560) produces two different numbers,
|
| 171 |
+
and without this they collide on one filename -- the second silently
|
| 172 |
+
replacing the first in a table whose header promises every row is `gate`.
|
| 173 |
+
Callers must fold the suite into ``checkpoint`` for anything but `gate`.
|
| 174 |
+
"""
|
| 175 |
+
RECORDS.mkdir(parents=True, exist_ok=True)
|
| 176 |
+
row = {
|
| 177 |
+
"checkpoint": checkpoint,
|
| 178 |
+
"suite": suite,
|
| 179 |
+
"size_gb": size_gb,
|
| 180 |
+
"bits_per_weight": bits_per_weight,
|
| 181 |
+
"accuracy": summary.get("accuracy"),
|
| 182 |
+
"truncation_rate": summary.get("truncation_rate"),
|
| 183 |
+
"mean_generated_tokens": summary.get("mean_generated_tokens"),
|
| 184 |
+
"notes": notes,
|
| 185 |
+
"run_name": run_name,
|
| 186 |
+
}
|
| 187 |
+
(RECORDS / f"{_safe_name(checkpoint)}.json").write_text(json.dumps(row, indent=2))
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
def render(rows: list[dict[str, Any]]) -> str:
|
| 191 |
+
head = "| " + " | ".join(name for name, *_ in COLUMNS) + " |"
|
| 192 |
+
align = "|" + "|".join(":--" if f == "s" else "--:" for _, _, f in COLUMNS) + "|"
|
| 193 |
+
lines = [HEADER, head, align]
|
| 194 |
+
|
| 195 |
+
for row in rows:
|
| 196 |
+
cells = []
|
| 197 |
+
for _, key, fmt in COLUMNS:
|
| 198 |
+
value = row.get(key)
|
| 199 |
+
if key == "notes":
|
| 200 |
+
cells.append(CURATED_NOTES.get(row["checkpoint"], str(value or "")) or "—")
|
| 201 |
+
elif value is None:
|
| 202 |
+
cells.append("—")
|
| 203 |
+
elif fmt == "s":
|
| 204 |
+
cells.append(str(value))
|
| 205 |
+
else:
|
| 206 |
+
cells.append(f"{value:{fmt}}")
|
| 207 |
+
lines.append("| " + " | ".join(cells) + " |")
|
| 208 |
+
|
| 209 |
+
baseline = next((r for r in rows if r["checkpoint"] == BASELINE), None)
|
| 210 |
+
if baseline and baseline.get("accuracy") is not None:
|
| 211 |
+
lines += [
|
| 212 |
+
"",
|
| 213 |
+
f"Baseline: **bf16 = {baseline['accuracy']:.3f} acc, "
|
| 214 |
+
f"{baseline['truncation_rate']:.3f} trunc, "
|
| 215 |
+
f"{baseline['mean_generated_tokens']:.0f} tokens, "
|
| 216 |
+
f"{baseline['size_gb']:.2f} GB** "
|
| 217 |
+
f"(`{BASELINE}`, greedy + repetition_penalty=1.20 — bf16's actual optimum).",
|
| 218 |
+
]
|
| 219 |
+
return "\n".join(lines) + "\n"
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
def write_results_md() -> Path:
|
| 223 |
+
RESULTS_MD.write_text(render(load()))
|
| 224 |
+
return RESULTS_MD
|
src/eaimath/model.py
ADDED
|
@@ -0,0 +1,440 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Loading, inspecting and slimming the Qwen3.5-4B checkpoint.
|
| 2 |
+
|
| 3 |
+
Qwen3.5-4B is a hybrid multimodal model — 24 Gated DeltaNet (linear attention)
|
| 4 |
+
layers interleaved with 8 full-attention layers, plus a 24-block vision tower
|
| 5 |
+
and a multi-token-prediction head. For a text-only math benchmark the vision
|
| 6 |
+
tower and MTP head are never executed, so they are ~455M parameters (9.7% of the
|
| 7 |
+
checkpoint) of pure dead weight.
|
| 8 |
+
|
| 9 |
+
Exact class names and module paths for this architecture vary across
|
| 10 |
+
transformers releases, so everything here introspects the loaded module tree
|
| 11 |
+
rather than hardcoding paths. ``describe_model`` exists to dump ground truth
|
| 12 |
+
before we commit to any of it.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import os
|
| 18 |
+
import re
|
| 19 |
+
import shutil
|
| 20 |
+
from dataclasses import dataclass, field
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
import torch
|
| 25 |
+
|
| 26 |
+
DEFAULT_MODEL = "Qwen/Qwen3.5-4B"
|
| 27 |
+
|
| 28 |
+
# First matching pattern wins, so order matters: `visual`/`mtp` must precede the
|
| 29 |
+
# generic `mlp`/`norm` patterns since those submodules contain MLPs too.
|
| 30 |
+
BUDGET_GROUPS: tuple[tuple[str, str], ...] = (
|
| 31 |
+
("vision", r"visual|vision"),
|
| 32 |
+
("mtp", r"(^|\.)mtp\."),
|
| 33 |
+
("embed", r"embed_tokens|lm_head"),
|
| 34 |
+
("linear_attn", r"linear_attn"),
|
| 35 |
+
("full_attn", r"self_attn"),
|
| 36 |
+
("mlp", r"\.mlp\."),
|
| 37 |
+
("norm", r"norm"),
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
# Tiny but precision-critical: the SSM recurrence dynamics. Keeping every one of
|
| 41 |
+
# these in fp32 costs ~3 MB, and quantizing them is what makes low-bit SSM
|
| 42 |
+
# quantization collapse.
|
| 43 |
+
SSM_SENSITIVE = r"A_log|dt_bias|conv1d|in_proj_a|in_proj_b"
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@dataclass
|
| 47 |
+
class GroupStats:
|
| 48 |
+
name: str
|
| 49 |
+
num_tensors: int = 0
|
| 50 |
+
num_params: int = 0 # unique storage only
|
| 51 |
+
num_bytes: int = 0 # unique storage only
|
| 52 |
+
raw_params: int = 0 # counting tied aliases twice
|
| 53 |
+
num_aliases: int = 0
|
| 54 |
+
examples: list[str] = field(default_factory=list)
|
| 55 |
+
|
| 56 |
+
def add(self, name: str, tensor: torch.Tensor, *, counted: bool) -> None:
|
| 57 |
+
self.num_tensors += 1
|
| 58 |
+
self.raw_params += tensor.numel()
|
| 59 |
+
if counted:
|
| 60 |
+
self.num_params += tensor.numel()
|
| 61 |
+
self.num_bytes += tensor.numel() * tensor.element_size()
|
| 62 |
+
else:
|
| 63 |
+
self.num_aliases += 1
|
| 64 |
+
if len(self.examples) < 3:
|
| 65 |
+
self.examples.append(name + (" (tied alias)" if not counted else ""))
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _group_for(name: str) -> str:
|
| 69 |
+
for group, pattern in BUDGET_GROUPS:
|
| 70 |
+
if re.search(pattern, name):
|
| 71 |
+
return group
|
| 72 |
+
return "other"
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def parameter_budget(state_dict: dict[str, torch.Tensor]) -> dict[str, Any]:
|
| 76 |
+
"""Group every tensor by component and report params / bytes per group.
|
| 77 |
+
|
| 78 |
+
Tied weights (Qwen3.5 ties ``lm_head`` to ``embed_tokens``) appear twice in a
|
| 79 |
+
``state_dict`` while sharing one allocation. Counting both would overstate
|
| 80 |
+
the checkpoint by 636M params / 1.27 GB here — and checkpoint bytes is the
|
| 81 |
+
metric we are graded on — so aliases are detected by storage pointer and
|
| 82 |
+
counted once.
|
| 83 |
+
"""
|
| 84 |
+
groups: dict[str, GroupStats] = {}
|
| 85 |
+
seen_storage: dict[int, str] = {}
|
| 86 |
+
aliases: list[dict[str, str]] = []
|
| 87 |
+
|
| 88 |
+
for name, tensor in state_dict.items():
|
| 89 |
+
if not isinstance(tensor, torch.Tensor):
|
| 90 |
+
continue
|
| 91 |
+
pointer = tensor.data_ptr()
|
| 92 |
+
is_alias = pointer != 0 and pointer in seen_storage
|
| 93 |
+
if is_alias:
|
| 94 |
+
aliases.append({"name": name, "aliases": seen_storage[pointer]})
|
| 95 |
+
else:
|
| 96 |
+
seen_storage[pointer] = name
|
| 97 |
+
groups.setdefault(_group_for(name), GroupStats(_group_for(name))).add(
|
| 98 |
+
name, tensor, counted=not is_alias
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
total_params = sum(g.num_params for g in groups.values())
|
| 102 |
+
total_bytes = sum(g.num_bytes for g in groups.values())
|
| 103 |
+
return {
|
| 104 |
+
"total_params": total_params,
|
| 105 |
+
"total_bytes": total_bytes,
|
| 106 |
+
"total_gb": total_bytes / 1e9,
|
| 107 |
+
"raw_params_with_aliases": sum(g.raw_params for g in groups.values()),
|
| 108 |
+
"tied_aliases": aliases,
|
| 109 |
+
"groups": {
|
| 110 |
+
name: {
|
| 111 |
+
"num_tensors": g.num_tensors,
|
| 112 |
+
"num_params": g.num_params,
|
| 113 |
+
"num_bytes": g.num_bytes,
|
| 114 |
+
"gb": g.num_bytes / 1e9,
|
| 115 |
+
"num_aliases": g.num_aliases,
|
| 116 |
+
"pct_params": 100 * g.num_params / total_params if total_params else 0.0,
|
| 117 |
+
"examples": g.examples,
|
| 118 |
+
}
|
| 119 |
+
for name, g in sorted(groups.items(), key=lambda kv: -kv[1].num_params)
|
| 120 |
+
},
|
| 121 |
+
}
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def checkpoint_size_bytes(state_dict: dict[str, torch.Tensor]) -> int:
|
| 125 |
+
"""Unique bytes in a state_dict — the number the leaderboard scores."""
|
| 126 |
+
return parameter_budget(state_dict)["total_bytes"]
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
def format_budget(budget: dict[str, Any]) -> str:
|
| 130 |
+
lines = [
|
| 131 |
+
f"{'component':<16}{'params':>16}{'% params':>11}{'GB':>9}{'tensors':>9}",
|
| 132 |
+
"-" * 61,
|
| 133 |
+
]
|
| 134 |
+
for name, stats in budget["groups"].items():
|
| 135 |
+
suffix = f" ({stats['num_aliases']} tied)" if stats["num_aliases"] else ""
|
| 136 |
+
lines.append(
|
| 137 |
+
f"{name:<16}{stats['num_params']:>16,}{stats['pct_params']:>10.1f}%"
|
| 138 |
+
f"{stats['gb']:>9.3f}{stats['num_tensors']:>9}{suffix}"
|
| 139 |
+
)
|
| 140 |
+
lines.append("-" * 61)
|
| 141 |
+
lines.append(
|
| 142 |
+
f"{'TOTAL':<16}{budget['total_params']:>16,}{100.0:>10.1f}%"
|
| 143 |
+
f"{budget['total_gb']:>9.3f}"
|
| 144 |
+
)
|
| 145 |
+
if budget.get("tied_aliases"):
|
| 146 |
+
excess = budget["raw_params_with_aliases"] - budget["total_params"]
|
| 147 |
+
lines.append(
|
| 148 |
+
f" ({len(budget['tied_aliases'])} tied alias tensor(s) counted once; "
|
| 149 |
+
f"naive state_dict sum would overstate by {excess:,} params)"
|
| 150 |
+
)
|
| 151 |
+
return "\n".join(lines)
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
def load_model(
|
| 155 |
+
model_id: str = DEFAULT_MODEL,
|
| 156 |
+
dtype: str = "bfloat16",
|
| 157 |
+
device_map: str | None = "auto",
|
| 158 |
+
cache_dir: str | None = None,
|
| 159 |
+
device: str | None = None,
|
| 160 |
+
multimodal: bool = False,
|
| 161 |
+
):
|
| 162 |
+
"""Load the model, trying each plausible auto-class for this architecture.
|
| 163 |
+
|
| 164 |
+
On transformers >= 5, ``AutoModelForCausalLM`` resolves Qwen3.5 to
|
| 165 |
+
``Qwen3_5ForCausalLM`` and materializes only the language model — the vision
|
| 166 |
+
tower and MTP head are never allocated, so the 455M-param text-only saving
|
| 167 |
+
happens for free at load time.
|
| 168 |
+
|
| 169 |
+
Pass ``device`` to pin the whole model to one GPU. At 8.4 GB it fits on any
|
| 170 |
+
of ours, and ``device_map="auto"`` across several GPUs would pipeline-shard
|
| 171 |
+
it instead, adding cross-device hops on every layer for no benefit.
|
| 172 |
+
"""
|
| 173 |
+
import transformers
|
| 174 |
+
|
| 175 |
+
torch_dtype = {
|
| 176 |
+
"bfloat16": torch.bfloat16,
|
| 177 |
+
"float16": torch.float16,
|
| 178 |
+
"float32": torch.float32,
|
| 179 |
+
}[dtype]
|
| 180 |
+
|
| 181 |
+
kwargs: dict[str, Any] = {
|
| 182 |
+
"dtype": torch_dtype,
|
| 183 |
+
"trust_remote_code": True,
|
| 184 |
+
}
|
| 185 |
+
if device is not None:
|
| 186 |
+
kwargs["device_map"] = {"": device}
|
| 187 |
+
elif device_map is not None:
|
| 188 |
+
kwargs["device_map"] = device_map
|
| 189 |
+
if cache_dir:
|
| 190 |
+
kwargs["cache_dir"] = cache_dir
|
| 191 |
+
|
| 192 |
+
candidates = [
|
| 193 |
+
"AutoModelForCausalLM",
|
| 194 |
+
"AutoModelForImageTextToText",
|
| 195 |
+
"AutoModelForVision2Seq",
|
| 196 |
+
"AutoModel",
|
| 197 |
+
]
|
| 198 |
+
if multimodal:
|
| 199 |
+
# vLLM only registers Qwen3_5ForConditionalGeneration and rejects a
|
| 200 |
+
# text-only Qwen3_5TextConfig (vllm#39231). Any checkpoint we intend to
|
| 201 |
+
# evaluate with vLLM must therefore keep the multimodal wrapper, even
|
| 202 |
+
# though the vision tower is never executed for a math prompt.
|
| 203 |
+
candidates = [
|
| 204 |
+
"Qwen3_5ForConditionalGeneration",
|
| 205 |
+
"AutoModelForImageTextToText",
|
| 206 |
+
"AutoModelForVision2Seq",
|
| 207 |
+
"AutoModelForCausalLM",
|
| 208 |
+
]
|
| 209 |
+
errors: list[str] = []
|
| 210 |
+
for class_name in candidates:
|
| 211 |
+
auto_class = getattr(transformers, class_name, None)
|
| 212 |
+
if auto_class is None:
|
| 213 |
+
continue
|
| 214 |
+
try:
|
| 215 |
+
model = auto_class.from_pretrained(model_id, **kwargs)
|
| 216 |
+
model.eval()
|
| 217 |
+
print(f"[model] loaded {model_id} via {class_name}")
|
| 218 |
+
return model
|
| 219 |
+
except Exception as exc: # noqa: BLE001 - we want the full error list
|
| 220 |
+
errors.append(f" {class_name}: {type(exc).__name__}: {exc}")
|
| 221 |
+
|
| 222 |
+
raise RuntimeError(
|
| 223 |
+
f"Could not load {model_id} with any auto-class.\n" + "\n".join(errors)
|
| 224 |
+
)
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def load_tokenizer(model_id: str = DEFAULT_MODEL, cache_dir: str | None = None):
|
| 228 |
+
from transformers import AutoTokenizer
|
| 229 |
+
|
| 230 |
+
kwargs: dict[str, Any] = {"trust_remote_code": True}
|
| 231 |
+
if cache_dir:
|
| 232 |
+
kwargs["cache_dir"] = cache_dir
|
| 233 |
+
tokenizer = AutoTokenizer.from_pretrained(model_id, **kwargs)
|
| 234 |
+
if tokenizer.pad_token is None:
|
| 235 |
+
tokenizer.pad_token = tokenizer.eos_token
|
| 236 |
+
tokenizer.padding_side = "left" # required for correct batched generation
|
| 237 |
+
return tokenizer
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
def estimate_memory(
|
| 241 |
+
model: torch.nn.Module, batch_size: int, seq_len: int
|
| 242 |
+
) -> dict[str, float]:
|
| 243 |
+
"""Predict peak VRAM for a generation run, so OOM is caught before it costs an hour.
|
| 244 |
+
|
| 245 |
+
Only the 8 full-attention layers hold a growing KV cache; the 24 Gated
|
| 246 |
+
DeltaNet layers keep a fixed-size recurrent state regardless of length,
|
| 247 |
+
which is why this model's cache is far cheaper than a dense transformer's.
|
| 248 |
+
"""
|
| 249 |
+
config = getattr(model, "config", None)
|
| 250 |
+
config = getattr(config, "text_config", config)
|
| 251 |
+
|
| 252 |
+
layer_types = getattr(config, "layer_types", None)
|
| 253 |
+
num_layers = getattr(config, "num_hidden_layers", 32)
|
| 254 |
+
if layer_types:
|
| 255 |
+
num_full = sum(1 for t in layer_types if "full" in str(t))
|
| 256 |
+
else:
|
| 257 |
+
interval = getattr(config, "full_attention_interval", 4)
|
| 258 |
+
num_full = num_layers // interval
|
| 259 |
+
|
| 260 |
+
kv_heads = getattr(config, "num_key_value_heads", 4)
|
| 261 |
+
head_dim = getattr(config, "head_dim", 256)
|
| 262 |
+
dtype_size = 2
|
| 263 |
+
|
| 264 |
+
kv_bytes_per_token = num_full * kv_heads * head_dim * 2 * dtype_size
|
| 265 |
+
kv_bytes = kv_bytes_per_token * seq_len * batch_size
|
| 266 |
+
|
| 267 |
+
# DynamicCache grows by torch.cat on every decode step: allocate (n+1),
|
| 268 |
+
# copy, free n. Peak is therefore ~2x the steady-state cache, which is
|
| 269 |
+
# exactly the "ladder" you see climbing in nvtop until it OOMs.
|
| 270 |
+
# expandable_segments lets the allocator grow a segment in place, cutting
|
| 271 |
+
# the transient sharply -- but we still budget for it.
|
| 272 |
+
growth_factor = 1.4 if "expandable_segments" in os.environ.get(
|
| 273 |
+
"PYTORCH_CUDA_ALLOC_CONF", ""
|
| 274 |
+
) else 2.0
|
| 275 |
+
kv_peak_bytes = kv_bytes * growth_factor
|
| 276 |
+
|
| 277 |
+
# Gated DeltaNet recurrent state: [v_heads, k_dim, v_dim] per layer, length-independent.
|
| 278 |
+
v_heads = getattr(config, "linear_num_value_heads", 32)
|
| 279 |
+
k_dim = getattr(config, "linear_key_head_dim", 128)
|
| 280 |
+
v_dim = getattr(config, "linear_value_head_dim", 128)
|
| 281 |
+
state_bytes = (num_layers - num_full) * v_heads * k_dim * v_dim * 4 * batch_size
|
| 282 |
+
|
| 283 |
+
weight_bytes = sum(p.numel() * p.element_size() for p in model.parameters())
|
| 284 |
+
|
| 285 |
+
return {
|
| 286 |
+
"weights_gb": weight_bytes / 1e9,
|
| 287 |
+
"kv_cache_gb": kv_bytes / 1e9,
|
| 288 |
+
"kv_peak_gb": kv_peak_bytes / 1e9,
|
| 289 |
+
"ssm_state_gb": state_bytes / 1e9,
|
| 290 |
+
"steady_gb": (weight_bytes + kv_bytes + state_bytes) / 1e9,
|
| 291 |
+
# What the preflight must check against: peak, not steady state.
|
| 292 |
+
"total_gb": (weight_bytes + kv_peak_bytes + state_bytes) / 1e9,
|
| 293 |
+
"kv_bytes_per_token": kv_bytes_per_token,
|
| 294 |
+
"growth_factor": growth_factor,
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
def free_vram_gb(device: str | torch.device) -> tuple[float, float]:
|
| 299 |
+
"""(free, total) GB on ``device`` — reflects other users' jobs on a shared box."""
|
| 300 |
+
free, total = torch.cuda.mem_get_info(torch.device(device))
|
| 301 |
+
return free / 1e9, total / 1e9
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def describe_model(model: torch.nn.Module, max_depth: int = 3) -> dict[str, Any]:
|
| 305 |
+
"""Summarize the module tree — the ground truth we build stripping on."""
|
| 306 |
+
tree: list[dict[str, Any]] = []
|
| 307 |
+
for name, module in model.named_modules():
|
| 308 |
+
depth = name.count(".")
|
| 309 |
+
if name and depth <= max_depth:
|
| 310 |
+
n_params = sum(p.numel() for p in module.parameters(recurse=True))
|
| 311 |
+
tree.append(
|
| 312 |
+
{
|
| 313 |
+
"path": name,
|
| 314 |
+
"class": type(module).__name__,
|
| 315 |
+
"depth": depth,
|
| 316 |
+
"num_params": n_params,
|
| 317 |
+
}
|
| 318 |
+
)
|
| 319 |
+
|
| 320 |
+
state_dict = model.state_dict()
|
| 321 |
+
sensitive = [n for n in state_dict if re.search(SSM_SENSITIVE, n)]
|
| 322 |
+
return {
|
| 323 |
+
"model_class": type(model).__name__,
|
| 324 |
+
"num_parameters": sum(p.numel() for p in model.parameters()),
|
| 325 |
+
"module_tree": tree,
|
| 326 |
+
"budget": parameter_budget(state_dict),
|
| 327 |
+
"ssm_sensitive_tensors": {
|
| 328 |
+
"count": len(sensitive),
|
| 329 |
+
"num_params": sum(state_dict[n].numel() for n in sensitive),
|
| 330 |
+
"names": sensitive[:20],
|
| 331 |
+
},
|
| 332 |
+
"state_dict_prefixes": sorted(
|
| 333 |
+
{".".join(n.split(".")[:2]) for n in state_dict}
|
| 334 |
+
),
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def find_submodule(model: torch.nn.Module, pattern: str) -> list[str]:
|
| 339 |
+
"""Paths of modules whose name matches ``pattern`` and whose parent does not."""
|
| 340 |
+
regex = re.compile(pattern)
|
| 341 |
+
hits = [name for name, _ in model.named_modules() if name and regex.search(name)]
|
| 342 |
+
# Keep only the shallowest match on each branch.
|
| 343 |
+
return [h for h in hits if not any(h.startswith(o + ".") for o in hits)]
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
def strip_unused_modules(
|
| 347 |
+
model: torch.nn.Module,
|
| 348 |
+
drop_vision: bool = True,
|
| 349 |
+
drop_mtp: bool = True,
|
| 350 |
+
) -> dict[str, Any]:
|
| 351 |
+
"""Delete text-irrelevant submodules in place; return what was removed.
|
| 352 |
+
|
| 353 |
+
The vision tower and MTP head together are ~455M params. A text-only math
|
| 354 |
+
eval never executes either: the ViT has no image inputs, and the MTP head is
|
| 355 |
+
a speculative-decoding draft head that HF ``generate()`` does not call.
|
| 356 |
+
"""
|
| 357 |
+
removed: list[dict[str, Any]] = []
|
| 358 |
+
targets: list[str] = []
|
| 359 |
+
if drop_vision:
|
| 360 |
+
targets.append(r"(^|\.)(visual|vision_tower)$")
|
| 361 |
+
if drop_mtp:
|
| 362 |
+
targets.append(r"(^|\.)mtp$")
|
| 363 |
+
|
| 364 |
+
for pattern in targets:
|
| 365 |
+
for path in find_submodule(model, pattern):
|
| 366 |
+
parent = model
|
| 367 |
+
parts = path.split(".")
|
| 368 |
+
for part in parts[:-1]:
|
| 369 |
+
parent = getattr(parent, part)
|
| 370 |
+
child = getattr(parent, parts[-1], None)
|
| 371 |
+
if child is None:
|
| 372 |
+
continue
|
| 373 |
+
n_params = sum(p.numel() for p in child.parameters(recurse=True))
|
| 374 |
+
setattr(parent, parts[-1], None)
|
| 375 |
+
removed.append({"path": path, "num_params": n_params})
|
| 376 |
+
|
| 377 |
+
# transformers >= 5 loads Qwen3.5 through Qwen3_5ForCausalLM, which never
|
| 378 |
+
# allocates the vision tower or MTP head. Finding nothing to remove is the
|
| 379 |
+
# expected outcome there, not a failure.
|
| 380 |
+
already_text_only = not removed and not find_submodule(model, r"visual|vision_tower|mtp")
|
| 381 |
+
|
| 382 |
+
return {
|
| 383 |
+
"removed": removed,
|
| 384 |
+
"num_params_removed": sum(r["num_params"] for r in removed),
|
| 385 |
+
"bytes_removed_bf16": 2 * sum(r["num_params"] for r in removed),
|
| 386 |
+
"already_text_only": already_text_only,
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
# Files the model repo carries that `save_pretrained` does not reproduce.
|
| 391 |
+
# vLLM's multimodal path builds an image processor even for a text-only prompt,
|
| 392 |
+
# so a checkpoint missing `preprocessor_config.json` fails to load outright.
|
| 393 |
+
AUXILIARY_FILES = (
|
| 394 |
+
"preprocessor_config.json",
|
| 395 |
+
"video_preprocessor_config.json",
|
| 396 |
+
"chat_template.jinja",
|
| 397 |
+
)
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def save_checkpoint(
|
| 401 |
+
model: torch.nn.Module,
|
| 402 |
+
out_dir: str | Path,
|
| 403 |
+
source_model: str = DEFAULT_MODEL,
|
| 404 |
+
cache_dir: str | None = None,
|
| 405 |
+
) -> list[str]:
|
| 406 |
+
"""Write a checkpoint that vLLM and transformers can both load.
|
| 407 |
+
|
| 408 |
+
``model.save_pretrained`` emits weights + config, and the tokenizer covers
|
| 409 |
+
vocab/merges, but the processor and chat-template files come from the source
|
| 410 |
+
repo and must be copied across explicitly.
|
| 411 |
+
"""
|
| 412 |
+
out = Path(out_dir)
|
| 413 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 414 |
+
model.save_pretrained(out)
|
| 415 |
+
load_tokenizer(source_model, cache_dir=cache_dir).save_pretrained(out)
|
| 416 |
+
|
| 417 |
+
copied: list[str] = []
|
| 418 |
+
source_dir = Path(source_model)
|
| 419 |
+
for filename in AUXILIARY_FILES:
|
| 420 |
+
target = out / filename
|
| 421 |
+
if target.exists():
|
| 422 |
+
copied.append(filename)
|
| 423 |
+
continue
|
| 424 |
+
try:
|
| 425 |
+
if source_dir.is_dir():
|
| 426 |
+
candidate = source_dir / filename
|
| 427 |
+
if not candidate.is_file():
|
| 428 |
+
continue
|
| 429 |
+
shutil.copy(candidate, target)
|
| 430 |
+
else:
|
| 431 |
+
from huggingface_hub import hf_hub_download
|
| 432 |
+
|
| 433 |
+
downloaded = hf_hub_download(
|
| 434 |
+
source_model, filename, cache_dir=cache_dir
|
| 435 |
+
)
|
| 436 |
+
shutil.copy(downloaded, target)
|
| 437 |
+
copied.append(filename)
|
| 438 |
+
except Exception as exc: # noqa: BLE001 - optional files; report and continue
|
| 439 |
+
print(f"[save_checkpoint] could not copy {filename}: {exc}")
|
| 440 |
+
return copied
|
src/eaimath/pack.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Real bit-packing — turning our analytic sizes into actual bytes on disk.
|
| 2 |
+
|
| 3 |
+
Everything so far has used *simulated* quantization: weights rounded onto a
|
| 4 |
+
coarse grid but stored back as bf16, with the compressed size computed
|
| 5 |
+
analytically from the recipe. That is the right tool for research — it isolates
|
| 6 |
+
accuracy effects without committing to a format — but a submission is scored on
|
| 7 |
+
bytes that actually exist.
|
| 8 |
+
|
| 9 |
+
This module closes that gap. `pack_tensor` stores an n-bit quantized tensor as
|
| 10 |
+
a flat `uint8` buffer plus fp16 scales, so `numel * element_size` over the saved
|
| 11 |
+
state dict equals the number we have been reporting. `unpack_tensor` inverts it.
|
| 12 |
+
|
| 13 |
+
Sub-byte widths are packed densely (two 4-bit values per byte, eight 3-bit
|
| 14 |
+
values per three bytes, and so on) rather than padded to a byte, because padding
|
| 15 |
+
would silently inflate 3-bit to 4-bit and make our size claims wrong.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import math
|
| 21 |
+
from typing import Any
|
| 22 |
+
|
| 23 |
+
import numpy as np
|
| 24 |
+
import torch
|
| 25 |
+
|
| 26 |
+
# Packing 2.26B values at once would materialise a ~9 GB bit array, so the
|
| 27 |
+
# general path works in chunks.
|
| 28 |
+
CHUNK = 1 << 24
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _pack_uint8_chunked(values: np.ndarray, bits: int) -> np.ndarray:
|
| 32 |
+
out = []
|
| 33 |
+
for start in range(0, values.size, CHUNK):
|
| 34 |
+
chunk = values[start : start + CHUNK]
|
| 35 |
+
bit_planes = ((chunk[:, None] >> np.arange(bits - 1, -1, -1)) & 1).astype(np.uint8)
|
| 36 |
+
out.append(np.packbits(bit_planes.reshape(-1)))
|
| 37 |
+
return np.concatenate(out) if out else np.zeros(0, dtype=np.uint8)
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _unpack_uint8_chunked(packed: np.ndarray, bits: int, count: int) -> np.ndarray:
|
| 41 |
+
bit_stream = np.unpackbits(packed)[: count * bits].reshape(count, bits)
|
| 42 |
+
weights = (1 << np.arange(bits - 1, -1, -1)).astype(np.uint32)
|
| 43 |
+
return (bit_stream * weights).sum(axis=1).astype(np.uint32)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def pack_bits(values: np.ndarray, bits: int) -> np.ndarray:
|
| 47 |
+
"""Pack unsigned integers of width ``bits`` into a dense uint8 buffer."""
|
| 48 |
+
flat = np.ascontiguousarray(values.reshape(-1)).astype(np.uint32)
|
| 49 |
+
if bits == 8:
|
| 50 |
+
return flat.astype(np.uint8)
|
| 51 |
+
if bits == 4:
|
| 52 |
+
# Fast path: two nibbles per byte. Pad to even length.
|
| 53 |
+
padded = np.pad(flat, (0, flat.size % 2)).astype(np.uint8)
|
| 54 |
+
return (padded[0::2] << 4 | padded[1::2]).astype(np.uint8)
|
| 55 |
+
if bits == 16:
|
| 56 |
+
return flat.astype(np.uint16).view(np.uint8)
|
| 57 |
+
# Preserve widths above eight bits. The general bit-plane packer accepts
|
| 58 |
+
# uint32 values; narrowing here silently wrapped 9--15 bit codes.
|
| 59 |
+
return _pack_uint8_chunked(flat, bits)
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def unpack_bits(packed: np.ndarray, bits: int, count: int) -> np.ndarray:
|
| 63 |
+
if bits == 8:
|
| 64 |
+
return packed[:count].astype(np.uint32)
|
| 65 |
+
if bits == 4:
|
| 66 |
+
high, low = packed >> 4, packed & 0x0F
|
| 67 |
+
return np.stack([high, low], axis=1).reshape(-1)[:count].astype(np.uint32)
|
| 68 |
+
if bits == 16:
|
| 69 |
+
return packed.view(np.uint16)[:count].astype(np.uint32)
|
| 70 |
+
return _unpack_uint8_chunked(packed, bits, count)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _store_scale(
|
| 74 |
+
scale: torch.Tensor,
|
| 75 |
+
normalization: str,
|
| 76 |
+
) -> tuple[torch.Tensor, int]:
|
| 77 |
+
"""Encode a positive scale field in two bytes per group.
|
| 78 |
+
|
| 79 |
+
``pow2`` is a block-floating representation: one exact power-of-two
|
| 80 |
+
exponent is shared by the tensor entry, while every group retains an FP16
|
| 81 |
+
mantissa. It costs the same tensor bytes as raw FP16 scales but prevents
|
| 82 |
+
the tiny scales used by 9--12 bit weights from falling into FP16's
|
| 83 |
+
subnormal range.
|
| 84 |
+
"""
|
| 85 |
+
if normalization == "none":
|
| 86 |
+
return scale.reshape(-1).to(torch.float16), 0
|
| 87 |
+
if normalization != "pow2":
|
| 88 |
+
raise ValueError(
|
| 89 |
+
f"unsupported scale normalization {normalization!r}; expected none or pow2"
|
| 90 |
+
)
|
| 91 |
+
maximum = float(scale.detach().abs().max()) if scale.numel() else 0.0
|
| 92 |
+
exponent = 0 if maximum <= 0.0 else -math.floor(math.log2(maximum))
|
| 93 |
+
# A normalized maximum in [1, 2) is comfortably inside FP16's normal
|
| 94 |
+
# range. The exponent is metadata (one small integer per entry), while
|
| 95 |
+
# the charged scale stream remains one FP16 value per group.
|
| 96 |
+
normalized = scale * math.ldexp(1.0, exponent)
|
| 97 |
+
return normalized.reshape(-1).to(torch.float16), int(exponent)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def pack_tensor(
|
| 101 |
+
weight: torch.Tensor,
|
| 102 |
+
bits: int,
|
| 103 |
+
group_size: int = 128,
|
| 104 |
+
symmetric: bool = True,
|
| 105 |
+
scale_normalization: str = "none",
|
| 106 |
+
) -> dict[str, Any]:
|
| 107 |
+
"""Quantize and pack one tensor into buffers whose bytes are the real cost."""
|
| 108 |
+
if bits >= 16:
|
| 109 |
+
return {"kind": "raw", "data": weight.to(torch.bfloat16).cpu()}
|
| 110 |
+
|
| 111 |
+
shape = tuple(weight.shape)
|
| 112 |
+
group = group_size if group_size > 0 and shape[-1] % group_size == 0 else shape[-1]
|
| 113 |
+
flat = weight.detach().float().cpu().reshape(-1, group)
|
| 114 |
+
|
| 115 |
+
if symmetric:
|
| 116 |
+
qmax = 2 ** (bits - 1) - 1
|
| 117 |
+
scale = (flat.abs().amax(dim=1, keepdim=True) / qmax).clamp(min=1e-8)
|
| 118 |
+
q = torch.round(flat / scale).clamp(-qmax - 1, qmax)
|
| 119 |
+
codes = (q + (qmax + 1)).to(torch.int64) # shift to unsigned for packing
|
| 120 |
+
zero = None
|
| 121 |
+
else:
|
| 122 |
+
qmax = 2**bits - 1
|
| 123 |
+
wmin, wmax = flat.amin(dim=1, keepdim=True), flat.amax(dim=1, keepdim=True)
|
| 124 |
+
scale = ((wmax - wmin) / qmax).clamp(min=1e-8)
|
| 125 |
+
zero = torch.round(-wmin / scale)
|
| 126 |
+
codes = torch.clamp(torch.round(flat / scale) + zero, 0, qmax).to(torch.int64)
|
| 127 |
+
|
| 128 |
+
packed = pack_bits(codes.numpy().astype(np.uint32), bits)
|
| 129 |
+
stored_scale, scale_exponent = _store_scale(scale, scale_normalization)
|
| 130 |
+
entry = {
|
| 131 |
+
"kind": "quant",
|
| 132 |
+
"packed": torch.from_numpy(packed), # uint8 -> 1 byte each, honestly counted
|
| 133 |
+
"scale": stored_scale,
|
| 134 |
+
"shape": shape,
|
| 135 |
+
"bits": bits,
|
| 136 |
+
"group": group,
|
| 137 |
+
"symmetric": symmetric,
|
| 138 |
+
"dtype": str(weight.dtype),
|
| 139 |
+
"scale_normalization": scale_normalization,
|
| 140 |
+
}
|
| 141 |
+
entry["scale_exponent"] = scale_exponent
|
| 142 |
+
if zero is not None:
|
| 143 |
+
entry["zero"] = zero.reshape(-1).to(torch.float16)
|
| 144 |
+
return entry
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def unpack_tensor(entry: dict[str, Any]) -> torch.Tensor:
|
| 148 |
+
if entry["kind"] == "raw":
|
| 149 |
+
return entry["data"]
|
| 150 |
+
|
| 151 |
+
bits, group, shape = entry["bits"], entry["group"], tuple(entry["shape"])
|
| 152 |
+
count = int(np.prod(shape))
|
| 153 |
+
codes = unpack_bits(entry["packed"].numpy(), bits, count)
|
| 154 |
+
codes = torch.from_numpy(codes.astype(np.int64)).reshape(-1, group)
|
| 155 |
+
scale = entry["scale"].float().reshape(-1, 1)
|
| 156 |
+
scale = scale * math.ldexp(1.0, -int(entry.get("scale_exponent", 0)))
|
| 157 |
+
|
| 158 |
+
if entry["symmetric"]:
|
| 159 |
+
qmax = 2 ** (bits - 1) - 1
|
| 160 |
+
values = (codes - (qmax + 1)).float() * scale
|
| 161 |
+
else:
|
| 162 |
+
values = (codes.float() - entry["zero"].float().reshape(-1, 1)) * scale
|
| 163 |
+
|
| 164 |
+
dtype = getattr(torch, entry["dtype"].replace("torch.", ""))
|
| 165 |
+
return values.reshape(shape).to(dtype)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _nearest_code_indices(values: torch.Tensor, codebook: torch.Tensor) -> torch.Tensor:
|
| 169 |
+
"""Nearest scalar-codebook entry without materialising a huge 3-D tensor."""
|
| 170 |
+
best_distance = torch.full_like(values, float("inf"), dtype=torch.float32)
|
| 171 |
+
best_index = torch.zeros_like(values, dtype=torch.int64)
|
| 172 |
+
for index, center in enumerate(codebook):
|
| 173 |
+
distance = (values - center).square()
|
| 174 |
+
replace = distance < best_distance
|
| 175 |
+
best_distance = torch.where(replace, distance, best_distance)
|
| 176 |
+
best_index = torch.where(replace, index, best_index)
|
| 177 |
+
return best_index
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def pack_adaptive_codebook(
|
| 181 |
+
weight: torch.Tensor,
|
| 182 |
+
*,
|
| 183 |
+
bits: int = 4,
|
| 184 |
+
group_size: int = 128,
|
| 185 |
+
importance: torch.Tensor | None = None,
|
| 186 |
+
codebook_count: int = 2,
|
| 187 |
+
iterations: int = 4,
|
| 188 |
+
) -> tuple[dict[str, Any], dict[str, float | int]]:
|
| 189 |
+
"""Pack groups with tiny learned scalar codebooks and sign-bit selectors.
|
| 190 |
+
|
| 191 |
+
The sign of each otherwise-positive FP16 group scale encodes which of two
|
| 192 |
+
codebooks is used. The absolute scale and packed indices are ordinary
|
| 193 |
+
physical tensors, so byte accounting includes every codebook value while
|
| 194 |
+
the selector itself costs no additional storage.
|
| 195 |
+
"""
|
| 196 |
+
if bits != 4:
|
| 197 |
+
raise ValueError("adaptive codebooks currently require four-bit indices")
|
| 198 |
+
if codebook_count != 2:
|
| 199 |
+
raise ValueError("scale-sign encoding requires exactly two codebooks")
|
| 200 |
+
if iterations < 1:
|
| 201 |
+
raise ValueError("iterations must be positive")
|
| 202 |
+
shape = tuple(weight.shape)
|
| 203 |
+
if weight.ndim != 2 or shape[-1] % group_size:
|
| 204 |
+
raise ValueError(
|
| 205 |
+
f"adaptive codebooks need a 2-D matrix divisible by group size; got {shape}"
|
| 206 |
+
)
|
| 207 |
+
device = weight.device
|
| 208 |
+
flat = weight.detach().float().reshape(-1, group_size)
|
| 209 |
+
groups = flat.shape[0]
|
| 210 |
+
if importance is None:
|
| 211 |
+
feature_weight = torch.ones(group_size, device=device, dtype=torch.float32)
|
| 212 |
+
else:
|
| 213 |
+
feature_weight = importance.detach().to(device=device, dtype=torch.float32)
|
| 214 |
+
if feature_weight.numel() != shape[-1]:
|
| 215 |
+
raise ValueError(
|
| 216 |
+
f"importance has {feature_weight.numel()} values, expected {shape[-1]}"
|
| 217 |
+
)
|
| 218 |
+
feature_weight = feature_weight.reshape(-1, group_size).repeat(shape[0], 1)
|
| 219 |
+
feature_weight = feature_weight / feature_weight.mean().clamp_min(1e-12)
|
| 220 |
+
if feature_weight.ndim == 1:
|
| 221 |
+
feature_weight = feature_weight.expand(groups, -1)
|
| 222 |
+
|
| 223 |
+
scale = flat.abs().amax(dim=1).clamp_min(1e-8)
|
| 224 |
+
normalized = flat / scale[:, None]
|
| 225 |
+
shape_score = normalized.abs().mean(dim=1)
|
| 226 |
+
split = shape_score.median()
|
| 227 |
+
partitions = (shape_score > split).long()
|
| 228 |
+
levels = 2**bits
|
| 229 |
+
quantiles = torch.linspace(0, 1, levels + 2, device=device)[1:-1]
|
| 230 |
+
books = []
|
| 231 |
+
for book in range(codebook_count):
|
| 232 |
+
values = normalized[partitions == book].reshape(-1)
|
| 233 |
+
if values.numel() < levels:
|
| 234 |
+
values = normalized.reshape(-1)
|
| 235 |
+
# Bound initialization cost for unusually large matrices while keeping
|
| 236 |
+
# deterministic coverage across their complete flattened range.
|
| 237 |
+
if values.numel() > 2_000_000:
|
| 238 |
+
stride = max(1, values.numel() // 2_000_000)
|
| 239 |
+
values = values[::stride][:2_000_000]
|
| 240 |
+
books.append(torch.quantile(values, quantiles).sort().values)
|
| 241 |
+
codebooks = torch.stack(books)
|
| 242 |
+
|
| 243 |
+
selector = partitions
|
| 244 |
+
indices = torch.zeros_like(flat, dtype=torch.int64)
|
| 245 |
+
for _ in range(iterations):
|
| 246 |
+
errors = []
|
| 247 |
+
candidate_indices = []
|
| 248 |
+
for book in range(codebook_count):
|
| 249 |
+
codes = _nearest_code_indices(flat / scale[:, None], codebooks[book])
|
| 250 |
+
reconstructed = codebooks[book][codes] * scale[:, None]
|
| 251 |
+
errors.append(((flat - reconstructed).square() * feature_weight).sum(dim=1))
|
| 252 |
+
candidate_indices.append(codes)
|
| 253 |
+
selector = torch.stack(errors, dim=1).argmin(dim=1)
|
| 254 |
+
indices = torch.where(
|
| 255 |
+
selector[:, None].eq(0), candidate_indices[0], candidate_indices[1]
|
| 256 |
+
)
|
| 257 |
+
chosen = codebooks[selector[:, None], indices]
|
| 258 |
+
numerator = (feature_weight * flat * chosen).sum(dim=1)
|
| 259 |
+
denominator = (feature_weight * chosen.square()).sum(dim=1).clamp_min(1e-12)
|
| 260 |
+
scale = (numerator / denominator).abs().clamp_min(1e-8)
|
| 261 |
+
normalized = flat / scale[:, None]
|
| 262 |
+
for book in range(codebook_count):
|
| 263 |
+
selected_groups = selector.eq(book)
|
| 264 |
+
if not bool(selected_groups.any()):
|
| 265 |
+
continue
|
| 266 |
+
selected_codes = indices[selected_groups].reshape(-1)
|
| 267 |
+
selected_values = normalized[selected_groups].reshape(-1)
|
| 268 |
+
selected_weights = feature_weight[selected_groups].reshape(-1)
|
| 269 |
+
sums = torch.zeros(levels, device=device).scatter_add_(
|
| 270 |
+
0, selected_codes, selected_values * selected_weights
|
| 271 |
+
)
|
| 272 |
+
counts = torch.zeros(levels, device=device).scatter_add_(
|
| 273 |
+
0, selected_codes, selected_weights
|
| 274 |
+
)
|
| 275 |
+
updated = torch.where(
|
| 276 |
+
counts > 0, sums / counts.clamp_min(1e-12), codebooks[book]
|
| 277 |
+
)
|
| 278 |
+
codebooks[book] = updated.sort().values.clamp(-1.5, 1.5)
|
| 279 |
+
|
| 280 |
+
# Recompute once after the final codebook update and then charge the exact
|
| 281 |
+
# FP16 scale/codebook representation used by the decoder.
|
| 282 |
+
errors = []
|
| 283 |
+
candidate_indices = []
|
| 284 |
+
for book in range(codebook_count):
|
| 285 |
+
codes = _nearest_code_indices(flat / scale[:, None], codebooks[book])
|
| 286 |
+
reconstructed = codebooks[book][codes] * scale[:, None]
|
| 287 |
+
errors.append(((flat - reconstructed).square() * feature_weight).sum(dim=1))
|
| 288 |
+
candidate_indices.append(codes)
|
| 289 |
+
selector = torch.stack(errors, dim=1).argmin(dim=1)
|
| 290 |
+
indices = torch.where(selector[:, None].eq(0), candidate_indices[0], candidate_indices[1])
|
| 291 |
+
signed_scale = scale.to(torch.float16)
|
| 292 |
+
signed_scale = torch.where(selector.bool(), -signed_scale.abs(), signed_scale.abs())
|
| 293 |
+
stored_books = codebooks.to(torch.float16)
|
| 294 |
+
packed = pack_bits(indices.detach().cpu().numpy().astype(np.uint32), bits)
|
| 295 |
+
entry: dict[str, Any] = {
|
| 296 |
+
"kind": "adaptive_codebook",
|
| 297 |
+
"packed": torch.from_numpy(packed),
|
| 298 |
+
"scale": signed_scale.detach().cpu(),
|
| 299 |
+
"codebooks": stored_books.detach().cpu(),
|
| 300 |
+
"shape": shape,
|
| 301 |
+
"bits": bits,
|
| 302 |
+
"group": group_size,
|
| 303 |
+
"dtype": str(weight.dtype),
|
| 304 |
+
"selector_encoding": "scale_sign",
|
| 305 |
+
}
|
| 306 |
+
restored = unpack_adaptive_codebook(entry).to(device=device, dtype=torch.float32)
|
| 307 |
+
delta = weight.detach().float() - restored
|
| 308 |
+
weighted = feature_weight * delta.reshape(-1, group_size).square()
|
| 309 |
+
report = {
|
| 310 |
+
"groups": int(groups),
|
| 311 |
+
"selector_one_groups": int(selector.sum()),
|
| 312 |
+
"codebook_bytes": int(stored_books.numel() * stored_books.element_size()),
|
| 313 |
+
"weighted_sse": float(weighted.sum()),
|
| 314 |
+
"weighted_mse": float(weighted.mean()),
|
| 315 |
+
"rmse": float(delta.square().mean().sqrt()),
|
| 316 |
+
"max_abs_error": float(delta.abs().max()),
|
| 317 |
+
}
|
| 318 |
+
return entry, report
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def unpack_adaptive_codebook(entry: dict[str, Any]) -> torch.Tensor:
|
| 322 |
+
if entry.get("kind") != "adaptive_codebook":
|
| 323 |
+
raise ValueError(f"not an adaptive-codebook entry: {entry.get('kind')!r}")
|
| 324 |
+
bits, group, shape = int(entry["bits"]), int(entry["group"]), tuple(entry["shape"])
|
| 325 |
+
count = int(np.prod(shape))
|
| 326 |
+
codes = unpack_bits(entry["packed"].numpy(), bits, count)
|
| 327 |
+
codes_tensor = torch.from_numpy(codes.astype(np.int64)).reshape(-1, group)
|
| 328 |
+
signed_scale = entry["scale"].float().reshape(-1)
|
| 329 |
+
selector = torch.signbit(signed_scale).long()
|
| 330 |
+
scale = signed_scale.abs().reshape(-1, 1)
|
| 331 |
+
books = entry["codebooks"].float()
|
| 332 |
+
chosen = books[selector[:, None], codes_tensor]
|
| 333 |
+
values = chosen * scale
|
| 334 |
+
dtype = getattr(torch, entry["dtype"].replace("torch.", ""))
|
| 335 |
+
return values.reshape(shape).to(dtype)
|
| 336 |
+
|
| 337 |
+
|
| 338 |
+
def pack_rows(
|
| 339 |
+
weight: torch.Tensor,
|
| 340 |
+
row_ids: torch.Tensor,
|
| 341 |
+
bits: int,
|
| 342 |
+
group_size: int = 128,
|
| 343 |
+
symmetric: bool = True,
|
| 344 |
+
) -> dict[str, Any]:
|
| 345 |
+
"""Pack selected rows while retaining the full tensor shape in metadata.
|
| 346 |
+
|
| 347 |
+
The original token ids are preserved. This matters for the course evaluator,
|
| 348 |
+
which restores the original tokenizer after decompression. At restore time the
|
| 349 |
+
omitted rows can be filled deterministically and the selected rows scattered
|
| 350 |
+
back to their original ids.
|
| 351 |
+
"""
|
| 352 |
+
if weight.ndim != 2:
|
| 353 |
+
raise ValueError(f"row packing requires a matrix, got shape {tuple(weight.shape)}")
|
| 354 |
+
ids = row_ids.detach().to(device="cpu", dtype=torch.int64).sort().values
|
| 355 |
+
if ids.numel() == 0:
|
| 356 |
+
raise ValueError("row_ids must not be empty")
|
| 357 |
+
if int(ids[0]) < 0 or int(ids[-1]) >= weight.shape[0]:
|
| 358 |
+
raise ValueError(
|
| 359 |
+
f"row ids [{int(ids[0])}, {int(ids[-1])}] outside 0..{weight.shape[0] - 1}"
|
| 360 |
+
)
|
| 361 |
+
if torch.unique_consecutive(ids).numel() != ids.numel():
|
| 362 |
+
raise ValueError("row_ids contains duplicates")
|
| 363 |
+
|
| 364 |
+
selected = weight.detach().cpu().index_select(0, ids)
|
| 365 |
+
entry = pack_tensor(selected, bits, group_size=group_size, symmetric=symmetric)
|
| 366 |
+
entry["kind"] = "quant_rows" if entry["kind"] == "quant" else "raw_rows"
|
| 367 |
+
entry["full_shape"] = tuple(weight.shape)
|
| 368 |
+
entry["row_ids"] = ids.to(torch.int32)
|
| 369 |
+
return entry
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def unpack_rows(entry: dict[str, Any], fill: str = "zero") -> torch.Tensor:
|
| 373 |
+
"""Expand a row-packed matrix with a deterministic omitted-row fill."""
|
| 374 |
+
if entry["kind"] not in {"quant_rows", "raw_rows"}:
|
| 375 |
+
raise ValueError(f"not a row-packed entry: {entry.get('kind')!r}")
|
| 376 |
+
dense_entry = {**entry, "kind": "quant" if entry["kind"] == "quant_rows" else "raw"}
|
| 377 |
+
selected = unpack_tensor(dense_entry)
|
| 378 |
+
full_shape = tuple(entry["full_shape"])
|
| 379 |
+
if fill == "zero":
|
| 380 |
+
restored = torch.zeros(full_shape, dtype=selected.dtype)
|
| 381 |
+
elif fill == "mean":
|
| 382 |
+
mean_row = selected.float().mean(dim=0).to(selected.dtype)
|
| 383 |
+
restored = mean_row.expand(full_shape[0], -1).clone()
|
| 384 |
+
else:
|
| 385 |
+
raise ValueError(f"unsupported row fill {fill!r}; expected zero or mean")
|
| 386 |
+
restored.index_copy_(0, entry["row_ids"].long(), selected)
|
| 387 |
+
return restored
|
| 388 |
+
|
| 389 |
+
|
| 390 |
+
def state_dict_bytes(packed: dict[str, dict[str, Any]]) -> int:
|
| 391 |
+
"""Real bytes of a packed state dict — the number a submission is scored on."""
|
| 392 |
+
total = 0
|
| 393 |
+
for entry in packed.values():
|
| 394 |
+
for value in entry.values():
|
| 395 |
+
if isinstance(value, torch.Tensor):
|
| 396 |
+
total += value.numel() * value.element_size()
|
| 397 |
+
return total
|
src/eaimath/peft_compat.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Narrow PEFT compatibility helpers for a shared quantization environment."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from typing import Any
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def is_runtime_quantized(model: Any) -> bool:
|
| 9 |
+
"""Return whether PEFT should treat ``model`` as a quantized runtime model."""
|
| 10 |
+
if bool(getattr(model, "is_quantized", False)):
|
| 11 |
+
return True
|
| 12 |
+
if getattr(model, "quantization_method", None):
|
| 13 |
+
return True
|
| 14 |
+
config = getattr(model, "config", None)
|
| 15 |
+
return bool(getattr(config, "quantization_config", None))
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
QUANT_DISPATCHERS = (
|
| 19 |
+
"dispatch_eetq",
|
| 20 |
+
"dispatch_aqlm",
|
| 21 |
+
"dispatch_awq",
|
| 22 |
+
"dispatch_gptq",
|
| 23 |
+
"dispatch_hqq",
|
| 24 |
+
"dispatch_inc",
|
| 25 |
+
"dispatch_torchao",
|
| 26 |
+
)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def prepare_dense_lora_dispatch(model: Any, *, dispatch_module: Any | None = None) -> bool:
|
| 30 |
+
"""Bypass PEFT's irrelevant quantized dispatchers for a dense BF16 model.
|
| 31 |
+
|
| 32 |
+
PEFT 0.18 calls every available quantization backend before its ordinary
|
| 33 |
+
``torch.nn.Linear`` fallback. Merely having GPTQModel or TorchAO installed
|
| 34 |
+
can therefore raise an optional-backend compatibility error even though the
|
| 35 |
+
target model is not quantized. Round 12 needs those packages elsewhere, so
|
| 36 |
+
disabling their dispatch functions in this one dense-training process is
|
| 37 |
+
safer than mutating the shared environment.
|
| 38 |
+
"""
|
| 39 |
+
if is_runtime_quantized(model):
|
| 40 |
+
return False
|
| 41 |
+
if dispatch_module is None:
|
| 42 |
+
from peft.tuners.lora import model as dispatch_module
|
| 43 |
+
|
| 44 |
+
def not_applicable(*_args: Any, **_kwargs: Any) -> None:
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
for name in QUANT_DISPATCHERS:
|
| 48 |
+
if not hasattr(dispatch_module, name):
|
| 49 |
+
raise RuntimeError(f"PEFT dense dispatch compatibility is missing {name}")
|
| 50 |
+
setattr(dispatch_module, name, not_applicable)
|
| 51 |
+
# Avoid eager BitsAndBytes imports at the start of PEFT's dispatcher list.
|
| 52 |
+
dispatch_module.is_bnb_available = lambda: False
|
| 53 |
+
dispatch_module.is_bnb_4bit_available = lambda: False
|
| 54 |
+
return True
|
src/eaimath/quantize.py
ADDED
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Group-wise weight quantization with exact size accounting.
|
| 2 |
+
|
| 3 |
+
Simulated ("fake") quantization: weights are quantized then dequantized back to
|
| 4 |
+
bf16, so the saved checkpoint is still a normal HF model that vLLM and
|
| 5 |
+
`transformers` load unchanged. That is not a hack — it mirrors the submission
|
| 6 |
+
pipeline exactly, where ``convert_to_hf_checkpoint`` must emit a full bf16 model.
|
| 7 |
+
The *scored* size is computed analytically from the recipe, not from the bytes on
|
| 8 |
+
disk.
|
| 9 |
+
|
| 10 |
+
This lets us answer the question that decides our compression ceiling — how far
|
| 11 |
+
can each layer group be pushed before long chain-of-thought breaks — without
|
| 12 |
+
first committing to a packed format or a quantization toolchain.
|
| 13 |
+
|
| 14 |
+
Round-to-nearest is deliberate as a starting point: it is the floor, needs no
|
| 15 |
+
calibration data, and is enough to *rank* layer-group sensitivity.
|
| 16 |
+
|
| 17 |
+
But it is a worse floor than we assumed. arXiv 2606.25519 measures CoT *token
|
| 18 |
+
inflation* by quantizer on Qwen3-4B at group size 128, and RTN is the worst of
|
| 19 |
+
the lot: +42.5% at INT4 against GPTQ's +12.0% and rotation-based ParoQuant's
|
| 20 |
+
+4.7%. At INT3 the spread across methods reaches 10x. Since our accuracy is
|
| 21 |
+
gated by truncation, token inflation is the quantity that actually costs us
|
| 22 |
+
points -- so moving off RTN is worth considerably more here than the "1-2
|
| 23 |
+
points" that reconstruction-error framing would suggest.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
import re
|
| 29 |
+
from dataclasses import dataclass
|
| 30 |
+
from typing import Any, Iterable
|
| 31 |
+
|
| 32 |
+
import torch
|
| 33 |
+
|
| 34 |
+
KEEP_BITS = 16 # bf16 passthrough
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@dataclass(frozen=True)
|
| 38 |
+
class QuantSpec:
|
| 39 |
+
"""Quantization for every parameter whose name matches ``pattern``.
|
| 40 |
+
|
| 41 |
+
``group_size`` groups along the input dimension (the last axis); 0 means one
|
| 42 |
+
group per output channel. Smaller groups cost more scale bytes but track the
|
| 43 |
+
weight distribution better.
|
| 44 |
+
"""
|
| 45 |
+
|
| 46 |
+
pattern: str
|
| 47 |
+
bits: int
|
| 48 |
+
group_size: int = 128
|
| 49 |
+
symmetric: bool = True
|
| 50 |
+
|
| 51 |
+
def bits_per_weight(self) -> float:
|
| 52 |
+
"""Effective stored bits per weight, including scale/zero-point overhead."""
|
| 53 |
+
if self.bits >= KEEP_BITS:
|
| 54 |
+
return float(KEEP_BITS)
|
| 55 |
+
if self.group_size <= 0:
|
| 56 |
+
return float(self.bits)
|
| 57 |
+
# fp16 scale per group, plus an int zero-point per group when asymmetric.
|
| 58 |
+
overhead = 16 + (0 if self.symmetric else self.bits)
|
| 59 |
+
return self.bits + overhead / self.group_size
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def quantize_dequantize(
|
| 63 |
+
weight: torch.Tensor, spec: QuantSpec
|
| 64 |
+
) -> torch.Tensor:
|
| 65 |
+
"""Round-trip ``weight`` through ``spec``'s grid, returning the same dtype."""
|
| 66 |
+
if spec.bits >= KEEP_BITS:
|
| 67 |
+
return weight
|
| 68 |
+
|
| 69 |
+
original_dtype, original_shape = weight.dtype, weight.shape
|
| 70 |
+
group_size = spec.group_size if spec.group_size > 0 else original_shape[-1]
|
| 71 |
+
if original_shape[-1] % group_size != 0:
|
| 72 |
+
# Fall back to per-channel rather than silently mis-grouping.
|
| 73 |
+
group_size = original_shape[-1]
|
| 74 |
+
|
| 75 |
+
flat = weight.reshape(-1, group_size).float()
|
| 76 |
+
|
| 77 |
+
if spec.symmetric:
|
| 78 |
+
qmax = 2 ** (spec.bits - 1) - 1
|
| 79 |
+
scale = (flat.abs().amax(dim=1, keepdim=True) / qmax).clamp(min=1e-8)
|
| 80 |
+
q = torch.round(flat / scale).clamp(-qmax - 1, qmax)
|
| 81 |
+
out = q * scale
|
| 82 |
+
else:
|
| 83 |
+
qmax = 2**spec.bits - 1
|
| 84 |
+
wmin = flat.amin(dim=1, keepdim=True)
|
| 85 |
+
wmax = flat.amax(dim=1, keepdim=True)
|
| 86 |
+
scale = ((wmax - wmin) / qmax).clamp(min=1e-8)
|
| 87 |
+
zero = torch.round(-wmin / scale)
|
| 88 |
+
q = torch.clamp(torch.round(flat / scale) + zero, 0, qmax)
|
| 89 |
+
out = (q - zero) * scale
|
| 90 |
+
|
| 91 |
+
return out.reshape(original_shape).to(original_dtype)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
class Recipe:
|
| 95 |
+
"""Ordered specs; first match wins, anything unmatched stays bf16."""
|
| 96 |
+
|
| 97 |
+
def __init__(self, specs: Iterable[QuantSpec]):
|
| 98 |
+
self.specs = list(specs)
|
| 99 |
+
self._compiled = [(re.compile(s.pattern), s) for s in self.specs]
|
| 100 |
+
|
| 101 |
+
def spec_for(self, name: str) -> QuantSpec | None:
|
| 102 |
+
for regex, spec in self._compiled:
|
| 103 |
+
if regex.search(name):
|
| 104 |
+
return spec
|
| 105 |
+
return None
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
# The vision tower and MTP head are never executed by a text-only math eval, and
|
| 109 |
+
# a real submission simply drops them (llama.cpp's text-only conversion of this
|
| 110 |
+
# model does exactly that). They are excluded from both quantization and the size
|
| 111 |
+
# accounting. We nonetheless keep them *present* in the saved checkpoint, because
|
| 112 |
+
# vLLM refuses to load a text-only Qwen3_5 config (vllm#39231).
|
| 113 |
+
EXCLUDED = r"visual|vision_tower|(^|\.)mtp\."
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def apply_recipe(
|
| 117 |
+
model: torch.nn.Module, recipe: Recipe, device: str | None = None
|
| 118 |
+
) -> dict[str, Any]:
|
| 119 |
+
"""Fake-quantize ``model`` in place; return per-group size accounting.
|
| 120 |
+
|
| 121 |
+
Tied tensors are counted once — Qwen3.5 ties ``lm_head`` to ``embed_tokens``,
|
| 122 |
+
and double-counting would overstate the checkpoint by 1.27 GB.
|
| 123 |
+
"""
|
| 124 |
+
excluded_regex = re.compile(EXCLUDED)
|
| 125 |
+
seen_storage: set[int] = set()
|
| 126 |
+
groups: dict[str, dict[str, float]] = {}
|
| 127 |
+
total_bits = 0.0
|
| 128 |
+
total_params = 0
|
| 129 |
+
excluded_params = 0
|
| 130 |
+
|
| 131 |
+
with torch.no_grad():
|
| 132 |
+
for name, param in model.named_parameters():
|
| 133 |
+
if excluded_regex.search(name):
|
| 134 |
+
excluded_params += param.numel()
|
| 135 |
+
continue
|
| 136 |
+
spec = recipe.spec_for(name)
|
| 137 |
+
bits = spec.bits_per_weight() if spec else float(KEEP_BITS)
|
| 138 |
+
|
| 139 |
+
if spec is not None and spec.bits < KEEP_BITS:
|
| 140 |
+
target = param.data.to(device) if device else param.data
|
| 141 |
+
quantized = quantize_dequantize(target, spec)
|
| 142 |
+
param.data.copy_(quantized.to(param.data.device))
|
| 143 |
+
|
| 144 |
+
pointer = param.data_ptr()
|
| 145 |
+
if pointer in seen_storage:
|
| 146 |
+
continue # tied alias: already counted
|
| 147 |
+
seen_storage.add(pointer)
|
| 148 |
+
|
| 149 |
+
label = _group_label(name, spec)
|
| 150 |
+
entry = groups.setdefault(
|
| 151 |
+
label, {"params": 0, "bits_per_weight": bits, "bytes": 0.0}
|
| 152 |
+
)
|
| 153 |
+
entry["params"] += param.numel()
|
| 154 |
+
entry["bytes"] += param.numel() * bits / 8
|
| 155 |
+
total_params += param.numel()
|
| 156 |
+
total_bits += param.numel() * bits
|
| 157 |
+
|
| 158 |
+
total_bytes = total_bits / 8
|
| 159 |
+
return {
|
| 160 |
+
"total_params": total_params,
|
| 161 |
+
"total_bytes": int(total_bytes),
|
| 162 |
+
"total_gb": total_bytes / 1e9,
|
| 163 |
+
"effective_bits_per_weight": total_bits / total_params if total_params else 0.0,
|
| 164 |
+
"compression_vs_bf16": (total_params * 2) / total_bytes if total_bytes else 0.0,
|
| 165 |
+
"groups": groups,
|
| 166 |
+
# Present in the saved checkpoint for vLLM compatibility, excluded from
|
| 167 |
+
# the score because a real submission drops them.
|
| 168 |
+
"excluded_params": excluded_params,
|
| 169 |
+
"excluded_gb_bf16": excluded_params * 2 / 1e9,
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
def _group_label(name: str, spec: QuantSpec | None) -> str:
|
| 174 |
+
from .model import BUDGET_GROUPS
|
| 175 |
+
|
| 176 |
+
for group, pattern in BUDGET_GROUPS:
|
| 177 |
+
if re.search(pattern, name):
|
| 178 |
+
return f"{group}@{spec.bits if spec else KEEP_BITS}b"
|
| 179 |
+
return f"other@{spec.bits if spec else KEEP_BITS}b"
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def format_size_report(report: dict[str, Any]) -> str:
|
| 183 |
+
lines = [
|
| 184 |
+
f"{'group':<22}{'params':>15}{'bpw':>7}{'GB':>9}",
|
| 185 |
+
"-" * 53,
|
| 186 |
+
]
|
| 187 |
+
for label, stats in sorted(report["groups"].items(), key=lambda kv: -kv[1]["params"]):
|
| 188 |
+
lines.append(
|
| 189 |
+
f"{label:<22}{stats['params']:>15,}"
|
| 190 |
+
f"{stats['bits_per_weight']:>7.2f}{stats['bytes'] / 1e9:>9.3f}"
|
| 191 |
+
)
|
| 192 |
+
lines.append("-" * 53)
|
| 193 |
+
lines.append(
|
| 194 |
+
f"{'TOTAL':<22}{report['total_params']:>15,}"
|
| 195 |
+
f"{report['effective_bits_per_weight']:>7.2f}{report['total_gb']:>9.3f}"
|
| 196 |
+
)
|
| 197 |
+
lines.append(f" compression vs bf16: {report['compression_vs_bf16']:.2f}x")
|
| 198 |
+
if report.get("excluded_params"):
|
| 199 |
+
lines.append(
|
| 200 |
+
f" excluded (vision+MTP, present on disk but not scored): "
|
| 201 |
+
f"{report['excluded_params']:,} params / {report['excluded_gb_bf16']:.2f} GB"
|
| 202 |
+
)
|
| 203 |
+
return "\n".join(lines)
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
# The SSM recurrence dynamics must stay high precision: error compounds through
|
| 207 |
+
# the linear recurrence instead of being renormalized each step, and naive
|
| 208 |
+
# low-bit PTQ on these collapses SSM models entirely. They are ~0.02% of
|
| 209 |
+
# parameters, so protecting all of them is nearly free.
|
| 210 |
+
PROTECTED = QuantSpec(
|
| 211 |
+
pattern=r"A_log|dt_bias|conv1d|in_proj_a|in_proj_b|norm|bias",
|
| 212 |
+
bits=KEEP_BITS,
|
| 213 |
+
)
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def build_recipe(
|
| 217 |
+
mlp_bits: int = 16,
|
| 218 |
+
linear_attn_bits: int = 16,
|
| 219 |
+
full_attn_bits: int = 16,
|
| 220 |
+
embed_bits: int = 16,
|
| 221 |
+
group_size: int = 128,
|
| 222 |
+
symmetric: bool = True,
|
| 223 |
+
) -> Recipe:
|
| 224 |
+
"""Per-component recipe. PROTECTED comes first so it always wins."""
|
| 225 |
+
return Recipe(
|
| 226 |
+
[
|
| 227 |
+
PROTECTED,
|
| 228 |
+
QuantSpec(r"embed_tokens|lm_head", embed_bits, group_size, symmetric),
|
| 229 |
+
QuantSpec(r"linear_attn", linear_attn_bits, group_size, symmetric),
|
| 230 |
+
QuantSpec(r"self_attn", full_attn_bits, group_size, symmetric),
|
| 231 |
+
QuantSpec(r"\.mlp\.", mlp_bits, group_size, symmetric),
|
| 232 |
+
]
|
| 233 |
+
)
|
src/eaimath/rate_distortion.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Exact-byte utilities for monotone rate--distortion allocation."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import math
|
| 6 |
+
import heapq
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
@dataclass(frozen=True)
|
| 11 |
+
class Choice:
|
| 12 |
+
bits: int
|
| 13 |
+
bytes: int
|
| 14 |
+
distortion: float
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def quantized_bytes(shape: tuple[int, ...], bits: int, group_size: int = 128) -> int:
|
| 18 |
+
numel = math.prod(shape)
|
| 19 |
+
if bits >= 16 or len(shape) < 2:
|
| 20 |
+
return numel * 2
|
| 21 |
+
group = group_size if group_size > 0 and shape[-1] % group_size == 0 else shape[-1]
|
| 22 |
+
groups = math.ceil(numel / group)
|
| 23 |
+
return math.ceil(numel * bits / 8) + groups * 2
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def allocate(
|
| 27 |
+
options: dict[str, list[Choice]],
|
| 28 |
+
budget: int,
|
| 29 |
+
) -> tuple[dict[str, Choice], int]:
|
| 30 |
+
"""Greedily buy monotone next upgrades by distortion reduction per byte."""
|
| 31 |
+
if not options:
|
| 32 |
+
raise ValueError("no rate--distortion options")
|
| 33 |
+
ordered = {
|
| 34 |
+
name: sorted(rows, key=lambda row: (row.bytes, row.distortion))
|
| 35 |
+
for name, rows in options.items()
|
| 36 |
+
}
|
| 37 |
+
if any(not rows for rows in ordered.values()):
|
| 38 |
+
raise ValueError("every tensor requires at least one choice")
|
| 39 |
+
index = {name: 0 for name in ordered}
|
| 40 |
+
total = sum(rows[0].bytes for rows in ordered.values())
|
| 41 |
+
if total > budget:
|
| 42 |
+
raise ValueError(f"minimum representation is {total:,} bytes, over budget {budget:,}")
|
| 43 |
+
|
| 44 |
+
# Only one next upgrade per item is eligible at a time. The original
|
| 45 |
+
# implementation rebuilt and scanned that full set after buying every
|
| 46 |
+
# individual upgrade: O(items * upgrades), which becomes effectively
|
| 47 |
+
# quadratic for Round 14's 280k row blocks. A heap preserves the exact
|
| 48 |
+
# greedy ordering in O((items + upgrades) log items).
|
| 49 |
+
name_rank = {name: rank for rank, name in enumerate(sorted(ordered))}
|
| 50 |
+
heap: list[tuple[float, float, int, int, str, int]] = []
|
| 51 |
+
|
| 52 |
+
def push_next(name: str) -> None:
|
| 53 |
+
current_index = index[name]
|
| 54 |
+
rows = ordered[name]
|
| 55 |
+
if current_index + 1 >= len(rows):
|
| 56 |
+
return
|
| 57 |
+
current = rows[current_index]
|
| 58 |
+
target = rows[current_index + 1]
|
| 59 |
+
added = target.bytes - current.bytes
|
| 60 |
+
gain = current.distortion - target.distortion
|
| 61 |
+
if added <= 0 or gain <= 0:
|
| 62 |
+
return
|
| 63 |
+
# This is the min-heap inverse of the old max tuple:
|
| 64 |
+
# (gain / added, gain, -added, name).
|
| 65 |
+
heapq.heappush(
|
| 66 |
+
heap,
|
| 67 |
+
(-gain / added, -gain, added, -name_rank[name], name, current_index + 1),
|
| 68 |
+
)
|
| 69 |
+
|
| 70 |
+
for name in ordered:
|
| 71 |
+
push_next(name)
|
| 72 |
+
|
| 73 |
+
while heap:
|
| 74 |
+
_ratio, _gain, added, _rank, winner, target_index = heapq.heappop(heap)
|
| 75 |
+
if total + added > budget:
|
| 76 |
+
# Total never decreases, so this upgrade (and its dependent chain)
|
| 77 |
+
# can never fit later. Smaller independent upgrades remain eligible.
|
| 78 |
+
continue
|
| 79 |
+
total += added
|
| 80 |
+
index[winner] = target_index
|
| 81 |
+
push_next(winner)
|
| 82 |
+
|
| 83 |
+
selected = {name: ordered[name][position] for name, position in index.items()}
|
| 84 |
+
return selected, total
|
src/eaimath/sharing.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MLP layer sharing — buying size structurally instead of with bits.
|
| 2 |
+
|
| 3 |
+
Bucket A (<=0.84 GB) is out of reach by quantization alone. ParetoQ measures 3-
|
| 4 |
+
and 4-bit QAT saturating at 10 B tokens while sub-2-bit needs 30 B, because
|
| 5 |
+
sub-2-bit "breaks the grid" and has to form new representations rather than nudge
|
| 6 |
+
within one. Our token budget is 1-3 B. So the bits have to stay at 3-4 and the
|
| 7 |
+
size has to come from somewhere else.
|
| 8 |
+
|
| 9 |
+
The MLPs are 53.9% of the model (2.26 B of 4.21 B). Tying one MLP block across
|
| 10 |
+
every k-th layer divides that by k while keeping every forward pass intact --
|
| 11 |
+
unlike depth pruning, which deletes the pass as well and collapses this workload
|
| 12 |
+
(GSM8K 79.3 -> 0.9 at 25% removal; AIME24 gone after a single layer).
|
| 13 |
+
|
| 14 |
+
**Why this is measurable tonight with no new inference code.** We tie the
|
| 15 |
+
*Parameters*, so `apply_recipe`'s existing data_ptr dedup counts the block once
|
| 16 |
+
and the size report is the real shared size. Then `untie` clones them back into
|
| 17 |
+
separate storage before saving, so what lands on disk is an ordinary checkpoint
|
| 18 |
+
that vLLM loads unchanged. Accuracy measured on that checkpoint is exactly the
|
| 19 |
+
shared model's accuracy -- the tying is mathematically identical, only the
|
| 20 |
+
storage differs. A submission would ship the deduped form plus an expansion
|
| 21 |
+
step, which is precisely the "compression + decompression" pair the course asks
|
| 22 |
+
for.
|
| 23 |
+
|
| 24 |
+
**What this does NOT do.** There is no recovery training here. Averaging k
|
| 25 |
+
learned blocks into one and running it cold measures the *raw damage*, which is
|
| 26 |
+
the number we need to size the LoRA/distillation budget against -- not a result
|
| 27 |
+
to submit. Expect it to look bad.
|
| 28 |
+
|
| 29 |
+
Middle-Cycle only: layers 0, 1 and the last are left alone. They are
|
| 30 |
+
independently found to be functionally distinct in Qwen3-4B, and tying them is a
|
| 31 |
+
known way to destroy a model for no size win worth having.
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
from __future__ import annotations
|
| 35 |
+
|
| 36 |
+
from typing import Any
|
| 37 |
+
|
| 38 |
+
import torch
|
| 39 |
+
|
| 40 |
+
# The projections inside one MLP block. Named rather than discovered so that a
|
| 41 |
+
# renamed submodule fails loudly instead of silently sharing nothing.
|
| 42 |
+
MLP_PROJECTIONS = ("gate_proj", "up_proj", "down_proj")
|
| 43 |
+
|
| 44 |
+
PROTECTED_HEAD = 2 # never tie layers 0-1
|
| 45 |
+
PROTECTED_TAIL = 1 # never tie the final layer
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def find_decoder_layers(model: torch.nn.Module) -> torch.nn.ModuleList:
|
| 49 |
+
"""The decoder layer list, wherever this transformers release put it.
|
| 50 |
+
|
| 51 |
+
Qwen3.5 is multimodal, so the text stack sits under a `language_model` in
|
| 52 |
+
some releases and directly under `model` in others. Both appear in the
|
| 53 |
+
versions we have run against, so this introspects instead of guessing.
|
| 54 |
+
"""
|
| 55 |
+
candidates: list[torch.nn.ModuleList] = []
|
| 56 |
+
for name, module in model.named_modules():
|
| 57 |
+
if not isinstance(module, torch.nn.ModuleList):
|
| 58 |
+
continue
|
| 59 |
+
if "visual" in name or "vision" in name or ".mtp" in name:
|
| 60 |
+
continue
|
| 61 |
+
if not name.endswith("layers"):
|
| 62 |
+
continue
|
| 63 |
+
first = module[0] if len(module) else None
|
| 64 |
+
if first is not None and hasattr(first, "mlp"):
|
| 65 |
+
candidates.append(module)
|
| 66 |
+
if not candidates:
|
| 67 |
+
raise RuntimeError(
|
| 68 |
+
"Could not locate the decoder layer list. Run "
|
| 69 |
+
"`python scripts/01_inspect_model.py` and check the module tree."
|
| 70 |
+
)
|
| 71 |
+
# The text stack is the longest one that is not the vision tower.
|
| 72 |
+
return max(candidates, key=len)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def sharing_groups(num_layers: int, factor: int) -> list[list[int]]:
|
| 76 |
+
"""Contiguous groups of layer indices to tie, honouring Middle-Cycle."""
|
| 77 |
+
if factor < 2:
|
| 78 |
+
return []
|
| 79 |
+
body = list(range(PROTECTED_HEAD, num_layers - PROTECTED_TAIL))
|
| 80 |
+
return [body[i : i + factor] for i in range(0, len(body), factor)]
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def tie_mlps(
|
| 84 |
+
model: torch.nn.Module, factor: int, *, init: str = "mean"
|
| 85 |
+
) -> dict[str, Any]:
|
| 86 |
+
"""Tie each group of ``factor`` MLP blocks to one shared block, in place.
|
| 87 |
+
|
| 88 |
+
``init='mean'`` averages the group's weights into the shared block;
|
| 89 |
+
``init='first'`` keeps the first block and discards the rest. Mean is the
|
| 90 |
+
default because it is the standard cheap initializer and discarding 3 of 4
|
| 91 |
+
learned blocks is strictly worse. Neither is permutation-aligned merging,
|
| 92 |
+
which is what a real attempt would use -- see PLAN.md W6.
|
| 93 |
+
|
| 94 |
+
Returns accounting: how many MLP params exist after tying, and how many the
|
| 95 |
+
dense model had.
|
| 96 |
+
"""
|
| 97 |
+
if init not in ("mean", "first"):
|
| 98 |
+
raise ValueError(f"init must be 'mean' or 'first', got {init!r}")
|
| 99 |
+
|
| 100 |
+
layers = find_decoder_layers(model)
|
| 101 |
+
groups = sharing_groups(len(layers), factor)
|
| 102 |
+
if not groups:
|
| 103 |
+
return {"factor": factor, "groups": 0, "tied_layers": 0}
|
| 104 |
+
|
| 105 |
+
dense_params = sum(
|
| 106 |
+
p.numel()
|
| 107 |
+
for layer in layers
|
| 108 |
+
for name in MLP_PROJECTIONS
|
| 109 |
+
for p in getattr(layer.mlp, name).parameters()
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
tied_layers = 0
|
| 113 |
+
with torch.no_grad():
|
| 114 |
+
for group in groups:
|
| 115 |
+
if len(group) < 2:
|
| 116 |
+
continue # a trailing singleton shares with nobody
|
| 117 |
+
anchor = layers[group[0]]
|
| 118 |
+
for proj_name in MLP_PROJECTIONS:
|
| 119 |
+
anchor_proj = getattr(anchor.mlp, proj_name)
|
| 120 |
+
if init == "mean":
|
| 121 |
+
stacked = torch.stack([
|
| 122 |
+
getattr(layers[i].mlp, proj_name).weight.data.float()
|
| 123 |
+
for i in group
|
| 124 |
+
])
|
| 125 |
+
anchor_proj.weight.data.copy_(
|
| 126 |
+
stacked.mean(dim=0).to(anchor_proj.weight.dtype)
|
| 127 |
+
)
|
| 128 |
+
# Point every follower at the anchor's Parameter object. Same
|
| 129 |
+
# storage => apply_recipe's data_ptr dedup counts it once.
|
| 130 |
+
for i in group[1:]:
|
| 131 |
+
getattr(layers[i].mlp, proj_name).weight = anchor_proj.weight
|
| 132 |
+
tied_layers += len(group) - 1
|
| 133 |
+
|
| 134 |
+
shared_params = _unique_mlp_params(layers)
|
| 135 |
+
return {
|
| 136 |
+
"factor": factor,
|
| 137 |
+
"init": init,
|
| 138 |
+
"groups": len([g for g in groups if len(g) > 1]),
|
| 139 |
+
"tied_layers": tied_layers,
|
| 140 |
+
"mlp_params_dense": dense_params,
|
| 141 |
+
"mlp_params_shared": shared_params,
|
| 142 |
+
"mlp_params_saved": dense_params - shared_params,
|
| 143 |
+
}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _unique_mlp_params(layers: torch.nn.ModuleList) -> int:
|
| 147 |
+
seen: set[int] = set()
|
| 148 |
+
total = 0
|
| 149 |
+
for layer in layers:
|
| 150 |
+
for proj_name in MLP_PROJECTIONS:
|
| 151 |
+
weight = getattr(layer.mlp, proj_name).weight
|
| 152 |
+
if weight.data_ptr() in seen:
|
| 153 |
+
continue
|
| 154 |
+
seen.add(weight.data_ptr())
|
| 155 |
+
total += weight.numel()
|
| 156 |
+
return total
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
def untie(model: torch.nn.Module) -> int:
|
| 160 |
+
"""Give every tied MLP weight its own storage again.
|
| 161 |
+
|
| 162 |
+
Call this after quantizing and before saving. `safetensors` refuses to write
|
| 163 |
+
tensors that share memory, and transformers' workaround silently drops the
|
| 164 |
+
duplicates -- which would produce a checkpoint with missing keys. Cloning
|
| 165 |
+
costs disk, not accuracy: the values are identical, so the model evaluated
|
| 166 |
+
is exactly the shared one.
|
| 167 |
+
"""
|
| 168 |
+
layers = find_decoder_layers(model)
|
| 169 |
+
seen: set[int] = set()
|
| 170 |
+
cloned = 0
|
| 171 |
+
with torch.no_grad():
|
| 172 |
+
for layer in layers:
|
| 173 |
+
for proj_name in MLP_PROJECTIONS:
|
| 174 |
+
proj = getattr(layer.mlp, proj_name)
|
| 175 |
+
pointer = proj.weight.data_ptr()
|
| 176 |
+
if pointer not in seen:
|
| 177 |
+
seen.add(pointer)
|
| 178 |
+
continue
|
| 179 |
+
proj.weight = torch.nn.Parameter(
|
| 180 |
+
proj.weight.data.clone(), requires_grad=proj.weight.requires_grad
|
| 181 |
+
)
|
| 182 |
+
cloned += 1
|
| 183 |
+
return cloned
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def format_sharing_report(report: dict[str, Any]) -> str:
|
| 187 |
+
if not report.get("groups"):
|
| 188 |
+
return "[sharing] none"
|
| 189 |
+
saved_gb = report["mlp_params_saved"] * 2 / 1e9
|
| 190 |
+
return (
|
| 191 |
+
f"[sharing] {report['factor']}x MLP, init={report['init']}: "
|
| 192 |
+
f"{report['groups']} groups, {report['tied_layers']} layers tied\n"
|
| 193 |
+
f" MLP params {report['mlp_params_dense']:,} -> "
|
| 194 |
+
f"{report['mlp_params_shared']:,} "
|
| 195 |
+
f"(-{report['mlp_params_saved']:,}, -{saved_gb:.2f} GB at bf16)"
|
| 196 |
+
)
|
src/eaimath/vllm_backend.py
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""vLLM generation backend — the fix for our eval throughput problem.
|
| 2 |
+
|
| 3 |
+
The HF path runs a fixed batch until *every* sequence in it stops. With a 43%
|
| 4 |
+
truncation rate and a 32k cap, one long sequence pins the whole batch for 32,768
|
| 5 |
+
steps while the other 15 sit finished and padded. That is why a 50-problem shard
|
| 6 |
+
took ~4 hours.
|
| 7 |
+
|
| 8 |
+
vLLM's continuous batching retires finished sequences immediately and admits new
|
| 9 |
+
ones, and paged attention removes the `torch.cat` cache-regrowth spike entirely.
|
| 10 |
+
For this workload — high variance in output length, long tails — that is worth
|
| 11 |
+
roughly an order of magnitude.
|
| 12 |
+
|
| 13 |
+
Optional dependency: ``pip install -e '.[vllm]'``. The HF backend in
|
| 14 |
+
``generate.py`` stays the reference implementation and always works.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import json
|
| 20 |
+
from pathlib import Path
|
| 21 |
+
from typing import Sequence
|
| 22 |
+
|
| 23 |
+
from .generate import GenerationOutput, build_chat_prompts
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _checkpoint_dtype(model_path: str) -> str:
|
| 27 |
+
"""Use FP16 activations for GPTQ; retain BF16 for the established RTN path."""
|
| 28 |
+
# Official ParoQuant checkpoints use an FP16 activation kernel and can be
|
| 29 |
+
# addressed by a remote Hugging Face id, before a local config exists.
|
| 30 |
+
if "paro" in model_path.lower():
|
| 31 |
+
return "float16"
|
| 32 |
+
root = Path(model_path)
|
| 33 |
+
quantize_config = root / "quantize_config.json"
|
| 34 |
+
if quantize_config.is_file():
|
| 35 |
+
try:
|
| 36 |
+
config = json.loads(quantize_config.read_text())
|
| 37 |
+
if config.get("bits") in (2, 3, 4, 8):
|
| 38 |
+
return "float16"
|
| 39 |
+
except (OSError, json.JSONDecodeError):
|
| 40 |
+
pass
|
| 41 |
+
|
| 42 |
+
model_config = root / "config.json"
|
| 43 |
+
if model_config.is_file():
|
| 44 |
+
try:
|
| 45 |
+
config = json.loads(model_config.read_text())
|
| 46 |
+
quantization = config.get("quantization_config") or {}
|
| 47 |
+
if str(quantization.get("quant_method", "")).lower() in {"gptq", "paroquant"}:
|
| 48 |
+
return "float16"
|
| 49 |
+
except (OSError, json.JSONDecodeError):
|
| 50 |
+
pass
|
| 51 |
+
return "bfloat16"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def is_available() -> bool:
|
| 55 |
+
import importlib.util
|
| 56 |
+
|
| 57 |
+
return importlib.util.find_spec("vllm") is not None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def generate_vllm(
|
| 61 |
+
model_path: str,
|
| 62 |
+
tokenizer,
|
| 63 |
+
prompts: Sequence[str],
|
| 64 |
+
*,
|
| 65 |
+
max_new_tokens: int = 65536,
|
| 66 |
+
temperature: float = 0.0,
|
| 67 |
+
top_p: float = 0.95,
|
| 68 |
+
top_k: int = 20,
|
| 69 |
+
presence_penalty: float = 0.0,
|
| 70 |
+
repetition_penalty: float = 1.0,
|
| 71 |
+
enable_thinking: bool | None = None,
|
| 72 |
+
gpu_memory_utilization: float = 0.90,
|
| 73 |
+
max_model_len: int | None = None,
|
| 74 |
+
seed: int = 0,
|
| 75 |
+
allowed_token_ids: Sequence[int] | None = None,
|
| 76 |
+
llm=None,
|
| 77 |
+
) -> tuple[list[GenerationOutput], object]:
|
| 78 |
+
"""Generate with vLLM, returning the same records as the HF backend.
|
| 79 |
+
|
| 80 |
+
Returns ``(outputs, llm)`` so the engine can be reused across calls — engine
|
| 81 |
+
startup costs ~1-2 min, which matters when sweeping recipes.
|
| 82 |
+
"""
|
| 83 |
+
from vllm import LLM, SamplingParams
|
| 84 |
+
|
| 85 |
+
chat_texts = build_chat_prompts(tokenizer, prompts, enable_thinking)
|
| 86 |
+
|
| 87 |
+
if llm is None:
|
| 88 |
+
longest_prompt = max(len(tokenizer(t)["input_ids"]) for t in chat_texts)
|
| 89 |
+
if max_model_len is None:
|
| 90 |
+
# Headroom over the longest prompt so no request is rejected outright.
|
| 91 |
+
max_model_len = max_new_tokens + longest_prompt + 256
|
| 92 |
+
llm = LLM(
|
| 93 |
+
model=model_path,
|
| 94 |
+
# vLLM 0.19 GPTQ kernels support FP16 activations. The dense base
|
| 95 |
+
# model and our RTN-value checkpoints remain on their proven BF16 path.
|
| 96 |
+
dtype=_checkpoint_dtype(model_path),
|
| 97 |
+
gpu_memory_utilization=gpu_memory_utilization,
|
| 98 |
+
max_model_len=max_model_len,
|
| 99 |
+
trust_remote_code=True,
|
| 100 |
+
seed=seed,
|
| 101 |
+
)
|
| 102 |
+
|
| 103 |
+
sampling_kwargs = dict(
|
| 104 |
+
max_tokens=max_new_tokens,
|
| 105 |
+
temperature=temperature,
|
| 106 |
+
# vLLM ignores top_p/top_k at temperature 0, but passing them when
|
| 107 |
+
# sampling keeps parity with Qwen's recommended thinking-mode settings.
|
| 108 |
+
top_p=top_p if temperature > 0 else 1.0,
|
| 109 |
+
top_k=top_k if temperature > 0 else -1,
|
| 110 |
+
presence_penalty=presence_penalty,
|
| 111 |
+
repetition_penalty=repetition_penalty,
|
| 112 |
+
)
|
| 113 |
+
if allowed_token_ids:
|
| 114 |
+
# Restricting generation to a token subset is how we measure the cost of
|
| 115 |
+
# a trimmed vocabulary without rebuilding the tokenizer. Not every vLLM
|
| 116 |
+
# release accepts it, and an overnight sweep must not die at 3 a.m. over
|
| 117 |
+
# a kwarg -- so fail loudly here, before the engine is built.
|
| 118 |
+
try:
|
| 119 |
+
SamplingParams(**sampling_kwargs, allowed_token_ids=[0])
|
| 120 |
+
except TypeError as exc:
|
| 121 |
+
raise RuntimeError(
|
| 122 |
+
"This vLLM build does not accept SamplingParams(allowed_token_ids=...), "
|
| 123 |
+
"so the vocabulary-restriction probe cannot run here. Drop "
|
| 124 |
+
"--vocab-size to run everything else.\n"
|
| 125 |
+
f" underlying error: {exc}"
|
| 126 |
+
) from exc
|
| 127 |
+
sampling_kwargs["allowed_token_ids"] = list(allowed_token_ids)
|
| 128 |
+
|
| 129 |
+
sampling = SamplingParams(**sampling_kwargs)
|
| 130 |
+
|
| 131 |
+
results: list[GenerationOutput] = []
|
| 132 |
+
for request, chat_text in zip(llm.generate(list(chat_texts), sampling), chat_texts):
|
| 133 |
+
completion = request.outputs[0]
|
| 134 |
+
results.append(
|
| 135 |
+
GenerationOutput(
|
| 136 |
+
text=completion.text,
|
| 137 |
+
num_prompt_tokens=len(request.prompt_token_ids),
|
| 138 |
+
num_generated_tokens=len(completion.token_ids),
|
| 139 |
+
# vLLM reports this directly, so unlike the HF path we do not
|
| 140 |
+
# have to infer termination by scanning for EOS.
|
| 141 |
+
finished=completion.finish_reason != "length",
|
| 142 |
+
)
|
| 143 |
+
)
|
| 144 |
+
return results, llm
|
src/eaimath/vocab.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vocabulary trimming — 285 MB, the single largest untouched lever.
|
| 2 |
+
|
| 3 |
+
The tied embedding is 635.7 M params, 15.1% of the model. A math-only model does
|
| 4 |
+
not need 248,320 tokens: a 13 k vocabulary retains 98.6% coverage on MATH-500 and
|
| 5 |
+
AIME, and math is the easiest domain to trim (far easier than code or
|
| 6 |
+
multilingual).
|
| 7 |
+
|
| 8 |
+
vocab params @4.125 bpw
|
| 9 |
+
248,320 635.7 M 327.8 MB
|
| 10 |
+
32,768 83.9 M 43.3 MB
|
| 11 |
+
|
| 12 |
+
That 285 MB is 34% of the entire bucket-A ceiling. It is also what makes bucket B
|
| 13 |
+
reachable *with a healthy attention stack* -- see `scripts/plan_configs.py`:
|
| 14 |
+
without trimming, fitting under 1.68 GB forces full_attn down to ~3 bits, and
|
| 15 |
+
full_attn is the most fragile component we have measured (-21 points at 4 bits).
|
| 16 |
+
|
| 17 |
+
**Two separate jobs, deliberately separated.**
|
| 18 |
+
|
| 19 |
+
1. `allowed_token_ids` (here) measures the *accuracy cost* of a smaller
|
| 20 |
+
vocabulary with no tokenizer surgery at all: generation is restricted to the
|
| 21 |
+
kept set, so the model produces exactly what a trimmed model could produce.
|
| 22 |
+
This validates the idea before anyone builds the real thing.
|
| 23 |
+
2. Actually rebuilding the tokenizer -- filtering merges so the vocabulary stays
|
| 24 |
+
closed, remapping ids, slicing the embedding -- is the shipping step, and it
|
| 25 |
+
only makes sense once (1) says the cost is acceptable.
|
| 26 |
+
|
| 27 |
+
Doing (1) first also dissolves the zero-logit trap. Zeroing a dropped embedding
|
| 28 |
+
row does not remove the token: its logit becomes exactly 0, which wins the argmax
|
| 29 |
+
whenever every real logit is negative. Masking at sampling time cannot have that
|
| 30 |
+
failure mode.
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
from collections import Counter
|
| 36 |
+
from typing import Any, Iterable, Sequence
|
| 37 |
+
|
| 38 |
+
# Every byte-level fallback token, so any string stays encodable, plus the
|
| 39 |
+
# specials the chat template depends on. Termination is our failure mode and
|
| 40 |
+
# </think> / EOS are the mechanism, so they are never droppable.
|
| 41 |
+
ALWAYS_KEEP_TOKENS = (
|
| 42 |
+
"<|endoftext|>", "<|im_start|>", "<|im_end|>",
|
| 43 |
+
"<think>", "</think>",
|
| 44 |
+
)
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def token_frequencies(texts: Iterable[str], tokenizer) -> Counter:
|
| 48 |
+
"""Token id -> count over a corpus, using the model's own tokenizer."""
|
| 49 |
+
counts: Counter = Counter()
|
| 50 |
+
for text in texts:
|
| 51 |
+
counts.update(tokenizer(text, add_special_tokens=False)["input_ids"])
|
| 52 |
+
return counts
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def build_keep_set(
|
| 56 |
+
counts: Counter,
|
| 57 |
+
tokenizer,
|
| 58 |
+
target_size: int,
|
| 59 |
+
*,
|
| 60 |
+
priority_counts: Counter | None = None,
|
| 61 |
+
coverage_report: bool = True,
|
| 62 |
+
) -> dict[str, Any]:
|
| 63 |
+
"""The ``target_size`` most useful token ids, plus everything unconditional.
|
| 64 |
+
|
| 65 |
+
Byte-fallback tokens are kept whole rather than by frequency: dropping one
|
| 66 |
+
makes some strings unencodable, and the storage is 256 rows.
|
| 67 |
+
"""
|
| 68 |
+
keep: set[int] = set()
|
| 69 |
+
|
| 70 |
+
for token in ALWAYS_KEEP_TOKENS:
|
| 71 |
+
token_id = tokenizer.convert_tokens_to_ids(token)
|
| 72 |
+
if token_id is not None and token_id >= 0:
|
| 73 |
+
keep.add(token_id)
|
| 74 |
+
for token_id in getattr(tokenizer, "all_special_ids", []) or []:
|
| 75 |
+
keep.add(token_id)
|
| 76 |
+
|
| 77 |
+
# Byte-level fallbacks: single-byte pieces the BPE falls back to.
|
| 78 |
+
vocab = tokenizer.get_vocab()
|
| 79 |
+
for token, token_id in vocab.items():
|
| 80 |
+
if len(token) == 1 or (token.startswith("<0x") and token.endswith(">")):
|
| 81 |
+
keep.add(token_id)
|
| 82 |
+
|
| 83 |
+
total = sum(counts.values())
|
| 84 |
+
covered = sum(counts[i] for i in keep if i in counts)
|
| 85 |
+
priority_added = 0
|
| 86 |
+
if priority_counts:
|
| 87 |
+
# Round 9 showed that omitted *input* rows are the clearest recoverable
|
| 88 |
+
# loss. Preserve every token seen in disjoint training problems before
|
| 89 |
+
# the much longer completions consume the frequency budget.
|
| 90 |
+
for token_id, _ in priority_counts.most_common():
|
| 91 |
+
if len(keep) >= target_size:
|
| 92 |
+
break
|
| 93 |
+
if token_id not in keep:
|
| 94 |
+
keep.add(token_id)
|
| 95 |
+
covered += counts[token_id]
|
| 96 |
+
priority_added += 1
|
| 97 |
+
for token_id, count in counts.most_common():
|
| 98 |
+
if len(keep) >= target_size:
|
| 99 |
+
break
|
| 100 |
+
if token_id not in keep:
|
| 101 |
+
keep.add(token_id)
|
| 102 |
+
covered += count
|
| 103 |
+
|
| 104 |
+
if len(keep) < target_size:
|
| 105 |
+
# The corpus did not contain enough distinct tokens to fill the budget.
|
| 106 |
+
# Silently shipping the short set is what turned the first vocabulary
|
| 107 |
+
# probe into a measurement of "restrict the model to 4k tokens drawn
|
| 108 |
+
# from problem statements" -- which unsurprisingly scored 0.010, and
|
| 109 |
+
# said nothing at all about vocabulary trimming.
|
| 110 |
+
#
|
| 111 |
+
# BPE vocabularies are ordered by merge priority, so low ids are the
|
| 112 |
+
# most generally useful pieces. Filling from the bottom is a defensible
|
| 113 |
+
# completion, and the caller is told it happened.
|
| 114 |
+
filled = 0
|
| 115 |
+
for token_id in range(len(vocab)):
|
| 116 |
+
if len(keep) >= target_size:
|
| 117 |
+
break
|
| 118 |
+
if token_id not in keep:
|
| 119 |
+
keep.add(token_id)
|
| 120 |
+
filled += 1
|
| 121 |
+
else:
|
| 122 |
+
filled = 0
|
| 123 |
+
|
| 124 |
+
result: dict[str, Any] = {
|
| 125 |
+
"keep_ids": sorted(keep),
|
| 126 |
+
"kept": len(keep),
|
| 127 |
+
"original_vocab": len(vocab),
|
| 128 |
+
"target_size": target_size,
|
| 129 |
+
"observed_distinct": len(counts),
|
| 130 |
+
"priority_distinct": len(priority_counts or {}),
|
| 131 |
+
"priority_added": priority_added,
|
| 132 |
+
"filled_by_id_order": filled,
|
| 133 |
+
}
|
| 134 |
+
if coverage_report and total:
|
| 135 |
+
result["token_coverage"] = covered / total
|
| 136 |
+
result["dropped_token_mass"] = 1.0 - covered / total
|
| 137 |
+
return result
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
def embedding_savings(
|
| 141 |
+
original_vocab: int, kept: int, hidden_size: int, bits_per_weight: float
|
| 142 |
+
) -> dict[str, float]:
|
| 143 |
+
"""Bytes reclaimed by a trim, for the tied embedding + lm_head (counted once)."""
|
| 144 |
+
dropped_params = (original_vocab - kept) * hidden_size
|
| 145 |
+
return {
|
| 146 |
+
"dropped_rows": original_vocab - kept,
|
| 147 |
+
"dropped_params": dropped_params,
|
| 148 |
+
"saved_bytes": dropped_params * bits_per_weight / 8,
|
| 149 |
+
"saved_mb": dropped_params * bits_per_weight / 8 / 1e6,
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def sampling_kwargs(keep_ids: Sequence[int] | None) -> dict[str, Any]:
|
| 154 |
+
"""SamplingParams kwargs restricting generation to ``keep_ids``.
|
| 155 |
+
|
| 156 |
+
Returns empty when there is nothing to restrict, so callers can splat this
|
| 157 |
+
unconditionally.
|
| 158 |
+
"""
|
| 159 |
+
if not keep_ids:
|
| 160 |
+
return {}
|
| 161 |
+
return {"allowed_token_ids": list(keep_ids)}
|
src/eaimath/workspace.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Workspace layout helpers shared by runners, collectors, and hygiene tools."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from collections.abc import Iterable
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def round_log_files(
|
| 10 |
+
round_name: str,
|
| 11 |
+
patterns: Iterable[str] = (),
|
| 12 |
+
experiments: Path = Path("experiments"),
|
| 13 |
+
) -> list[Path]:
|
| 14 |
+
"""Return round-local logs plus legacy flat/archive locations.
|
| 15 |
+
|
| 16 |
+
Round 15 onward writes logs beside its round. Rounds 8--14 historically
|
| 17 |
+
wrote into ``experiments/logs``; the hygiene tool archives those files
|
| 18 |
+
without making old evidence collectors blind to them.
|
| 19 |
+
"""
|
| 20 |
+
local = experiments / "rounds" / round_name / "logs"
|
| 21 |
+
legacy = experiments / "logs"
|
| 22 |
+
candidates = [local / "runner.log", legacy / f"{round_name}_runner.log"]
|
| 23 |
+
for pattern in patterns:
|
| 24 |
+
candidates.extend(sorted(local.glob(pattern)))
|
| 25 |
+
candidates.extend(sorted(legacy.glob(pattern)))
|
| 26 |
+
|
| 27 |
+
archive = legacy / "archive"
|
| 28 |
+
if archive.is_dir():
|
| 29 |
+
candidates.extend(sorted(archive.rglob(f"{round_name}_runner.log")))
|
| 30 |
+
for pattern in patterns:
|
| 31 |
+
candidates.extend(sorted(archive.rglob(pattern)))
|
| 32 |
+
|
| 33 |
+
unique: list[Path] = []
|
| 34 |
+
seen: set[Path] = set()
|
| 35 |
+
for path in candidates:
|
| 36 |
+
resolved = path.resolve()
|
| 37 |
+
if path.is_file() and resolved not in seen:
|
| 38 |
+
seen.add(resolved)
|
| 39 |
+
unique.append(path)
|
| 40 |
+
return unique
|