Skip to content

Commit 5d5ba21

Browse files
kmbandyclaude
andcommitted
fix(calib): repair uninitialized rotary inv_freq + skip tied lm_head target
inv_freq (non-persistent, no GGUF counterpart) was left uninitialized by meta-build + to_empty -> silent wrong-RoPE on full-attn layers (NaN under use_deterministic_algorithms, which enables fill_uninitialized_memory). reinit_rotary_buffers() rebuilds it in fp32 every run. Also skip lm_head as an ml8 target when tie_word_embeddings=True (served by the tied FP8 token_embd; was quantizing an uninitialized buffer = garbage/NaN, 379s wasted). Verified: deterministic 0.8B calibration now bit-reproducible (187/187 blobs byte-identical across 3 runs), full 150 ML8 + 37 FP8 coverage, 0 Cholesky failures (was 21/151). Adds diag_nan_probe.py + diff_calib_blobs.py. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 3e4effb commit 5d5ba21

3 files changed

Lines changed: 338 additions & 7 deletions

File tree

scripts/calibration/calibrate_ml8_paged.py

Lines changed: 127 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -53,12 +53,13 @@
5353
# ── Deterministic calibration — env half (OPT-IN via ML8_DETERMINISTIC=1; MUST precede `import torch`) ───
5454
# ml8 GPTQ calibration was nondeterministic: the GPU Hessian forward used unforced reduction order
5555
# and GPTQ's sequential error-feedback amplified the low-bit noise into DIFFERENT weight assignments
56-
# run-to-run (~0.6 PPL spread — swamps the ±0.05 levers). These flags pin it bit-identically on the
57-
# linear-attn/MLP path (verified). BUT torch.use_deterministic_algorithms(True) currently breaks
58-
# full-model calibration: from the FIRST full-self-attention layer onward, the SDPA forward under
59-
# the hammer yields a degenerate (NaN-suspected) Hessian → GPTQ Cholesky fails → tensors silently
60-
# skipped → partial-coverage model. Root-cause + fix is deferred (see KG / handoff), so determinism
61-
# is OPT-IN until then; the default path is the known-good nondeterministic calibrator.
56+
# run-to-run (~0.6 PPL spread — swamps the ±0.05 levers). These flags pin it bit-identically.
57+
# VERIFIED 2026-06-02: full-model deterministic calibration is bit-reproducible (187/188 tensors
58+
# byte-identical across two independent runs; the rest are FP8). The earlier "breaks at the first
59+
# self-attn layer" failure was NOT a determinism bug — use_deterministic_algorithms(True) enables
60+
# fill_uninitialized_memory, which surfaced a latent bug: the rotary inv_freq buffer (no GGUF
61+
# counterpart) was left uninitialized by meta+to_empty → NaN-filled → garbage RoPE. Fixed by
62+
# reinit_rotary_buffers() (runs every calibration; also repairs the silent non-deterministic case).
6263
_DETERMINISTIC = os.environ.get("ML8_DETERMINISTIC") == "1"
6364
if _DETERMINISTIC:
6465
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8") # required for deterministic cuBLAS/hipBLAS GEMM
@@ -86,7 +87,7 @@
8687
try: setattr(torch.backends.cuda.matmul, _attr, _val)
8788
except AttributeError: pass
8889
print("[determinism] OPT-IN ENABLED: use_deterministic_algorithms(True) + seeded + pinned GEMM "
89-
"— WARNING: breaks full-model coverage at the first self-attn layer (Cholesky/SDPA bug, WIP)")
90+
"— full-model calibration is bit-reproducible (rotary inv_freq reinit applied post-load)")
9091

9192
# Re-use existing calibration helpers (compute_hessian / gptq_quantize_linear / eval_ppl_wikitext / etc.)
9293
sys.path.insert(0, str(Path(__file__).parent))
@@ -237,7 +238,19 @@ def find_dense_full_targets(model, coverage: str = "ffn"):
237238
yield name, mod, Tier.ML8
238239

239240
# Global ML8 roles (lm_head / eh_proj), if present as nn.Linear.
241+
# Skip lm_head when the model TIES embeddings: a tied lm_head IS token_embd
242+
# (already covered by the FP8 embedding tier), has no independent GGUF tensor
243+
# (the converter correctly emits no output.weight), and meta+to_empty leaves
244+
# its weight UNINITIALIZED — so quantizing it wastes time on silent garbage
245+
# (NaN under determinism) and produces a blob the converter discards anyway.
246+
_cfg = getattr(model, "config", None)
247+
_tied = bool(getattr(getattr(_cfg, "text_config", _cfg), "tie_word_embeddings", False)
248+
or getattr(_cfg, "tie_word_embeddings", False))
240249
for role in _FULL_ML8_GLOBAL_ORDER:
250+
if role == "lm_head" and _tied:
251+
print("[targets] skipping lm_head ML8 target: tie_word_embeddings=True "
252+
"(served by the FP8-tier tied token_embd at inference)")
253+
continue
241254
for name, mod in by_name.items():
242255
if name.rsplit(".", 1)[-1] != role:
243256
continue
@@ -649,6 +662,46 @@ def load_resident_to_model(model: nn.Module, gguf_path: str,
649662
return n_loaded
650663

651664

665+
def reinit_rotary_buffers(model: nn.Module, device: str) -> int:
666+
"""Recompute every text rotary-embedding ``inv_freq`` buffer from config.
667+
668+
inv_freq is a NON-PERSISTENT buffer derived from rope_theta — it has no GGUF
669+
counterpart, so the resident loader (which walks ``named_parameters``) never
670+
fills it, and ``model.to_empty()`` leaves it UNINITIALIZED. The damage is
671+
silent without determinism: uninitialized memory is finite garbage, and since
672+
cos()/sin() clamp to [-1, 1] the forward never NaNs — it just applies WRONG
673+
positional encoding to every full-attention layer (linear-attn layers don't
674+
use RoPE). With ``use_deterministic_algorithms(True)`` that same uninitialized
675+
memory is NaN-filled, so the first RoPE turns q/k into NaN and the whole
676+
forward (and every downstream Hessian) blows up. Both are the SAME bug; the
677+
fix is to rebuild inv_freq the way the module's __init__ does and keep it in
678+
fp32 (HF never downcasts it; our blanket .to(dtype) did). Idempotent — safe to
679+
run on every calibration. Returns the number of rotary modules repaired.
680+
"""
681+
n = 0
682+
for mod in model.modules():
683+
cls = mod.__class__.__name__
684+
if "RotaryEmbedding" not in cls or "Vision" in cls:
685+
continue # text rotary only; vision rotary has a different __init__
686+
if not hasattr(mod, "inv_freq") or getattr(mod, "config", None) is None:
687+
continue
688+
try:
689+
fresh = type(mod)(mod.config, device=torch.device(device))
690+
except Exception as e: # noqa: BLE001 — never let a probe-fix abort calibration
691+
print(f"[rotary-fix] WARN: could not rebuild {cls} inv_freq: {e}")
692+
continue
693+
with torch.no_grad():
694+
# assign (not copy_) so the buffer keeps fresh's fp32 dtype, not the
695+
# bf16 the model-wide .to(dtype) left it as.
696+
mod.inv_freq = fresh.inv_freq.to(device=torch.device(device))
697+
if hasattr(mod, "original_inv_freq") and hasattr(fresh, "original_inv_freq"):
698+
mod.original_inv_freq = fresh.original_inv_freq.to(device=torch.device(device))
699+
if hasattr(fresh, "attention_scaling"):
700+
mod.attention_scaling = fresh.attention_scaling
701+
n += 1
702+
return n
703+
704+
652705
def _detect_moe_n_experts(config) -> int | None:
653706
"""Return n_experts if `config` describes an MoE model, else None.
654707
@@ -1163,6 +1216,73 @@ def main():
11631216
# installed. MUST run before the first forward. ───
11641217
apply_fla_arch_shim(model, args.device)
11651218

1219+
# ─── Repair rotary inv_freq: meta-build + to_empty leaves this non-persistent,
1220+
# non-GGUF buffer uninitialized → silently-wrong RoPE (and a hard NaN under
1221+
# determinism). MUST run before the first forward, on every calibration. ───
1222+
_n_rotary = reinit_rotary_buffers(model, args.device)
1223+
print(f"[rotary-fix] reinitialized inv_freq on {_n_rotary} text rotary module(s)")
1224+
1225+
# ─── NaN probe (opt-in ML8_NAN_PROBE=1): hook every decoder layer + the submodules
1226+
# of the first full_attention layer; on the FIRST forward, print per-module
1227+
# in-nan/out-nan/out-inf/finite-absmax in completion order, then self-remove. Used to
1228+
# pinpoint the determinism-induced NaN in the first full-attn layer (deferred bug). ───
1229+
if os.environ.get("ML8_NAN_PROBE") == "1":
1230+
_dec = [m for _n, m in model.named_modules()
1231+
if m.__class__.__name__.endswith("DecoderLayer")]
1232+
_lt = list(getattr(getattr(model.config, "text_config", model.config),
1233+
"layer_types", ["?"] * len(_dec)))
1234+
_first_full = next((i for i, t in enumerate(_lt) if "full" in str(t)), 3)
1235+
_pb = {"ev": [], "done": False, "h": []}
1236+
1237+
def _pstat(t):
1238+
if isinstance(t, (tuple, list)) and t and torch.is_tensor(t[0]):
1239+
t = t[0]
1240+
if not torch.is_tensor(t):
1241+
return (None, None, float("nan"))
1242+
tf = t.float()
1243+
fin = tf[torch.isfinite(tf)]
1244+
return (int(torch.isnan(tf).sum()), int(torch.isinf(tf).sum()),
1245+
float(fin.abs().max()) if fin.numel() else float("nan"))
1246+
1247+
def _mk(label):
1248+
def _h(mod, inp, out):
1249+
if _pb["done"]:
1250+
return
1251+
it = inp[0] if isinstance(inp, (tuple, list)) and inp else inp
1252+
inn = int(torch.isnan(it.float()).sum()) if torch.is_tensor(it) else -1
1253+
nan, inf, amax = _pstat(out)
1254+
_pb["ev"].append((label, inn, nan, inf, amax))
1255+
return _h
1256+
1257+
for i, m in enumerate(_dec):
1258+
_pb["h"].append(m.register_forward_hook(
1259+
_mk(f"LAYER[{i:02d}] ({_lt[i] if i < len(_lt) else '?'})")))
1260+
if i == _first_full:
1261+
for sn, sm in m.named_modules():
1262+
if sn:
1263+
_pb["h"].append(sm.register_forward_hook(
1264+
_mk(f" L{i}.{sn} <{sm.__class__.__name__}>")))
1265+
1266+
def _dump(mod, inp, out):
1267+
if _pb["done"]:
1268+
return
1269+
_pb["done"] = True
1270+
print("\n=== [NAN-PROBE] first forward, completion order ===")
1271+
fb = None
1272+
for label, inn, nan, inf, amax in _pb["ev"]:
1273+
bad = (nan and nan > 0) or (inf and inf > 0)
1274+
mark = " <<< FIRST NaN/Inf" if bad and fb is None else ""
1275+
if bad and fb is None:
1276+
fb = label
1277+
print(f"{label:<50} in_nan={inn} out_nan={nan} out_inf={inf} "
1278+
f"absmax={amax:.4g}{mark}")
1279+
print(f"[NAN-PROBE] first NaN/Inf at: {fb}\n", flush=True)
1280+
for h in _pb["h"]:
1281+
h.remove()
1282+
_dec[-1].register_forward_hook(_dump)
1283+
print(f"[NAN-PROBE] installed: {len(_dec)} decoder layers + submodules of "
1284+
f"layer {_first_full} (first full_attention)")
1285+
11661286
# ─── Calibration corpus + baseline PPL (paged forward proves the swap works) ───
11671287
print(f"[calib] loading {'budget ' + str(args.token_budget) + ' tok' if args.token_budget else str(args.n_samples) + ' samples'} "
11681288
f"seq_len={args.seq_len} corpus={args.corpus}")
Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,164 @@
1+
#!/usr/bin/env python3
2+
"""Pinpoint the determinism-induced NaN in the first full-attention layer of the 0.8B
3+
dense-hybrid Qwen3.5 bed. Honors ML8_DETERMINISTIC=1 (replicates the driver's determinism
4+
block EXACTLY, before torch import). Loads the model resident (0.8B fits), registers:
5+
(a) a forward hook on EVERY decoder layer -> first layer whose output goes NaN
6+
(b) a forward hook on EVERY submodule of the first full_attention layer -> the exact op
7+
Runs ONE forward over a small real-token batch and prints, in execution-completion order,
8+
each module's input-nan? / output-nan? / output-inf? / finite-absmax.
9+
10+
Usage: ML8_DETERMINISTIC=1 /usr/bin/python3 diag_nan_probe.py # hammer ON
11+
/usr/bin/python3 diag_nan_probe.py # hammer OFF (control)
12+
"""
13+
import os
14+
import sys
15+
16+
# ── replicate the driver determinism block (env half MUST precede torch import) ──
17+
_DET = os.environ.get("ML8_DETERMINISTIC") == "1"
18+
if _DET:
19+
os.environ.setdefault("CUBLAS_WORKSPACE_CONFIG", ":4096:8")
20+
os.environ.setdefault("HIPBLASLT_DETERMINISTIC", "1")
21+
22+
import torch # noqa: E402
23+
from transformers import AutoModelForCausalLM, AutoTokenizer # noqa: E402
24+
25+
if _DET:
26+
torch.manual_seed(0)
27+
if torch.cuda.is_available():
28+
torch.cuda.manual_seed_all(0)
29+
torch.use_deterministic_algorithms(True)
30+
torch.backends.cudnn.deterministic = True
31+
torch.backends.cudnn.benchmark = False
32+
for _attr, _val in (("allow_tf32", False),
33+
("allow_fp16_reduced_precision_reduction", False),
34+
("allow_bf16_reduced_precision_reduction", False)):
35+
try: setattr(torch.backends.cuda.matmul, _attr, _val)
36+
except AttributeError: pass
37+
try: torch.backends.cudnn.allow_tf32 = False
38+
except AttributeError: pass
39+
40+
print(f"[probe] ML8_DETERMINISTIC={'1 (HAMMER ON)' if _DET else '0 (control)'}", flush=True)
41+
42+
MODEL = "/home/kmbandy/models/Qwen3.5-0.8B-hf"
43+
DEVICE = "cuda:0"
44+
DTYPE = torch.bfloat16
45+
46+
tok = AutoTokenizer.from_pretrained(MODEL)
47+
model = AutoModelForCausalLM.from_pretrained(MODEL, dtype=DTYPE).to(DEVICE).eval()
48+
49+
# match the driver: apply the RDNA fla fp32-scan shim (env ML8_FLA_SHIM=1)
50+
if os.environ.get("ML8_FLA_SHIM") == "1":
51+
from fla_compat import apply_fla_arch_shim
52+
apply_fla_arch_shim(model, DEVICE)
53+
54+
# ── locate the decoder-layer list (robust to multimodal carrier nesting) ──
55+
def find_layers(m):
56+
for name, mod in m.named_modules():
57+
if mod.__class__.__name__.endswith("DecoderLayer"):
58+
# parent ModuleList: strip trailing ".<idx>"
59+
return name.rsplit(".", 1)[0], mod
60+
raise RuntimeError("no *DecoderLayer found")
61+
62+
first_layer_name, _ = find_layers(model)
63+
layers = dict(model.named_modules())[first_layer_name]
64+
print(f"[probe] decoder layers at '{first_layer_name}' n={len(layers)}", flush=True)
65+
66+
# layer_types from config to label which is the first full_attention
67+
lt = getattr(model.config, "text_config", model.config)
68+
layer_types = getattr(lt, "layer_types", None)
69+
if layer_types is None:
70+
layer_types = ["?"] * len(layers)
71+
first_full = next((i for i, t in enumerate(layer_types) if "full" in t), 3)
72+
print(f"[probe] layer_types[:8]={layer_types[:8]} first_full_attention=layer {first_full}", flush=True)
73+
74+
events = [] # (label, in_nan, out_nan, out_inf, finite_absmax)
75+
76+
def stat(t):
77+
if not torch.is_tensor(t):
78+
# tuple/list output: inspect the first tensor element
79+
if isinstance(t, (tuple, list)) and len(t) and torch.is_tensor(t[0]):
80+
t = t[0]
81+
else:
82+
return (None, None, None, None)
83+
tf = t.float()
84+
n = int(torch.isnan(tf).sum())
85+
inf = int(torch.isinf(tf).sum())
86+
finite = tf[torch.isfinite(tf)]
87+
amax = float(finite.abs().max()) if finite.numel() else float("nan")
88+
return (n, inf, amax, t.numel())
89+
90+
def mk_hook(label):
91+
def h(mod, inp, out):
92+
in_t = inp[0] if isinstance(inp, (tuple, list)) and len(inp) else inp
93+
in_nan = int(torch.isnan(in_t.float()).sum()) if torch.is_tensor(in_t) else -1
94+
n, inf, amax, numel = stat(out)
95+
events.append((label, in_nan, n, inf, amax, numel))
96+
return h
97+
98+
handles = []
99+
# (a) every decoder layer
100+
for i in range(len(layers)):
101+
handles.append(layers[i].register_forward_hook(mk_hook(f"LAYER[{i:02d}] ({layer_types[i]})")))
102+
# (b) every submodule of the first full_attention layer
103+
for sub_name, sub_mod in layers[first_full].named_modules():
104+
if sub_name == "":
105+
continue
106+
handles.append(sub_mod.register_forward_hook(
107+
mk_hook(f" L{first_full}.{sub_name} <{sub_mod.__class__.__name__}>")))
108+
109+
# ── input: either a benign synthetic sentence, or the EXACT driver calib cache ──
110+
samples = []
111+
_calib_pt = os.environ.get("ML8_CALIB_PT")
112+
if _calib_pt:
113+
obj = torch.load(_calib_pt, map_location="cpu", weights_only=False)
114+
raw = obj["samples"] if isinstance(obj, dict) and "samples" in obj else obj
115+
for s in raw:
116+
t = s if torch.is_tensor(s) else torch.as_tensor(s)
117+
if t.dim() == 1:
118+
t = t.unsqueeze(0)
119+
samples.append(t.to(DEVICE))
120+
print(f"[probe] loaded {len(samples)} cached calib samples from {_calib_pt}", flush=True)
121+
else:
122+
text = ("The history of scientific discovery is a long and winding road, "
123+
"full of unexpected turns and surprising connections between fields. ") * 8
124+
samples = [tok(text, return_tensors="pt", truncation=True,
125+
max_length=512).input_ids.to(DEVICE)]
126+
127+
print(f"[probe] forward over {len(samples)} sample(s), shapes={[tuple(s.shape) for s in samples[:3]]}...",
128+
flush=True)
129+
130+
def first_bad_in(ev):
131+
for rec in ev:
132+
label, in_nan, n, inf, amax, numel = rec
133+
if (n and n > 0) or (inf and inf > 0):
134+
return label
135+
return None
136+
137+
bad_sample = None
138+
bad_events = None
139+
with torch.no_grad():
140+
for si, ids in enumerate(samples):
141+
events.clear()
142+
model(input_ids=ids)
143+
fb = first_bad_in(events)
144+
print(f"[probe] sample {si} shape={tuple(ids.shape)} first_NaN={fb}", flush=True)
145+
if fb is not None and bad_sample is None:
146+
bad_sample, bad_events = si, list(events)
147+
break
148+
149+
for h in handles:
150+
h.remove()
151+
152+
# ── full trace of the FIRST sample that went bad (or the last clean one) ──
153+
trace = bad_events if bad_events is not None else events
154+
print(f"\n=== forward trace (sample {bad_sample if bad_sample is not None else 'last'}, completion order) ===")
155+
print(f"{'module':<52}{'in_nan':>8}{'out_nan':>9}{'out_inf':>9}{'absmax':>14}")
156+
first_bad = None
157+
for label, in_nan, n, inf, amax, numel in trace:
158+
bad = (n and n > 0) or (inf and inf > 0)
159+
mark = " <<< FIRST NaN/Inf" if bad and first_bad is None else ""
160+
if bad and first_bad is None:
161+
first_bad = label
162+
print(f"{label:<52}{str(in_nan):>8}{str(n):>9}{str(inf):>9}{amax:>14.4g}{mark}")
163+
164+
print(f"\n[probe] OVERALL first NaN/Inf: sample={bad_sample} module={first_bad}", flush=True)
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
#!/usr/bin/env python3
2+
"""Bit-reproducibility check for two ml8 calibration runs. For every matching blob
3+
in dirA and dirB, compare the quantization-defining tensors (indices, centroids,
4+
scales) for EXACT equality. Prints per-blob match and an overall verdict.
5+
6+
Usage: diff_calib_blobs.py <dirA> <dirB>
7+
"""
8+
import sys
9+
import pathlib
10+
import torch
11+
12+
dirA, dirB = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2])
13+
KEYS = ("indices", "centroids_per_group", "scale_per_group")
14+
15+
blobsA = sorted(p.name for p in dirA.glob("*.pt"))
16+
blobsB = set(p.name for p in dirB.glob("*.pt"))
17+
18+
n_match = n_diff = n_missing = 0
19+
diffs = []
20+
for name in blobsA:
21+
if name not in blobsB:
22+
n_missing += 1
23+
diffs.append(f" MISSING in B: {name}")
24+
continue
25+
a = torch.load(dirA / name, map_location="cpu", weights_only=False)
26+
b = torch.load(dirB / name, map_location="cpu", weights_only=False)
27+
blob_ok = True
28+
for k in KEYS:
29+
if k in a or k in b:
30+
ta, tb = a.get(k), b.get(k)
31+
if ta is None or tb is None or ta.shape != tb.shape or not torch.equal(ta, tb):
32+
blob_ok = False
33+
diffs.append(f" DIFF {name}: tensor '{k}' differs")
34+
if blob_ok:
35+
n_match += 1
36+
else:
37+
n_diff += 1
38+
39+
print(f"blobs compared: {len(blobsA)} identical: {n_match} differing: {n_diff} missing: {n_missing}")
40+
for d in diffs[:20]:
41+
print(d)
42+
if n_diff == 0 and n_missing == 0:
43+
print("\n✅ REPRODUCIBLE — every blob's indices/centroids/scales are bit-identical across runs.")
44+
sys.exit(0)
45+
else:
46+
print("\n❌ NOT reproducible — see diffs above.")
47+
sys.exit(1)

0 commit comments

Comments
 (0)