summaryrefslogtreecommitdiff
path: root/annotate_pgns/export_nnue_weights.py
diff options
context:
space:
mode:
authorScott Gasch <[email protected]>2026-10-20 09:26:44 -0700
committerScott Gasch <[email protected]>2026-10-20 09:26:44 -0700
commitc8c6bbc2c1a103e5b07b079a0f8bdf66c7724d80 (patch)
treeb6c2137c9ac577fa83b1e948fa0311d53dfe445d /annotate_pgns/export_nnue_weights.py
parent3b5d3677bb14ae14728a1c4b221176bb83667d92 (diff)
Wire NNUE eval into search, build the training pipeline, and add a fast
move/eval-agreement benchmark against Stockfish (tests/lop) Implements the architecture decisions in src/docs/NNUE.md: HalfKAv2_hm feature set, int16/int8 quantization, per-ply accumulator backup for king moves, and a lazy-eval-ready integration into search.c/eval.c. Adds the full annotate_pgns/ training pipeline (extraction, K-fit/ labeling, quiet filtering, tensor building, training, export) plus an end-to-end orchestrator (run_nnue_pipeline.py) and validation tooling (sanity_check_net.py, compare_vs_stockfish_net.py). Adds tests/lop: a 10k-position list-of-positions benchmark comparing engine eval rank and move choice against pre-computed Stockfish/lc0 references, much cheaper than a full self-play match for day-to-day progress checks. First sd10-matched self-play result against classic HCE: LLR +2.28 (H1=+2.94), -72=119+169 over 360 games, confirming the net is "smarter per node" at matched depth. A real-time (--st 4) match is currently weaker (candidate loses ~3 plies of depth to a ~3.3-3.7x nps gap against an unrecalibrated search), consistent with NNUE.md's adoption-criterion section -- pruning/reduction constants still need retuning for the new eval's score scale before a fair real-time comparison is possible. Co-Authored-By: Claude Sonnet 5 <[email protected]>
Diffstat (limited to 'annotate_pgns/export_nnue_weights.py')
-rw-r--r--annotate_pgns/export_nnue_weights.py326
1 files changed, 326 insertions, 0 deletions
diff --git a/annotate_pgns/export_nnue_weights.py b/annotate_pgns/export_nnue_weights.py
new file mode 100644
index 0000000..4c8cb99
--- /dev/null
+++ b/annotate_pgns/export_nnue_weights.py
@@ -0,0 +1,326 @@
+#!/usr/bin/env python3
+"""Exports a trained nnue_model.py checkpoint into a flat binary file
+the C engine (src/nnueeval.c's NnueLoadWeights) can read directly --
+no parsing, just a fixed-order sequence of arrays behind a small
+header.
+
+File format (all little-endian), version "NNU3":
+ magic: 4 bytes, ASCII "NNU3"
+ dims: 4x uint32 -- NNUE_DIMENSIONS, L1, L2, L3 (sanity-checked
+ against the C side's own compile-time constants on
+ load, so a mismatched build can't silently misread the
+ file)
+ ft_scale: 1x float32 -- fixed-point scale for ft.weight/ft_bias
+ (int16). NnueForward divides the int32 accumulator sum
+ by this to get the float "fc1 input" value.
+ act_scale: 3x float32 (AS1, AS2, AS3) -- fixed-point scales used
+ to quantize each stage's post-ReLU activation to uint8
+ before feeding the next layer's int8 dot product:
+ AS1 quantizes fc1's input (the ReLU'd, concatenated
+ accumulator halves)
+ AS2 quantizes fc1's ReLU'd output (fc2's input)
+ AS3 quantizes fc2's ReLU'd output (fc3's input)
+ Derived from real sampled activations (see
+ CalibrateActivationScales) with headroom, NOT from a
+ theoretical max -- see docs/NNUE.md's "Quantization
+ plan" / the int8 float-vs-quantized correctness check.
+ w_scale: 3x float32 (WS1, WS2, WS3) -- fixed-point scales for
+ fc1.weight/fc2.weight/fc3.weight (int8), one per layer,
+ each derived from that layer's own max|.| (exact, not
+ sampled -- weights are fixed, known precisely from the
+ checkpoint).
+ then, in this exact order:
+ ft.weight [NNUE_DIMENSIONS x L1] int16 (quantized @ ft_scale)
+ ft_bias [L1] int16 (quantized @ ft_scale)
+ fc1.weight [L2 x (2*L1)] int8 (quantized @ WS1)
+ fc1.bias [L2] float32 (unquantized --
+ added post-dequant, tiny
+ array, not worth it)
+ fc2.weight [L3 x L2] int8 (quantized @ WS2)
+ fc2.bias [L3] float32
+ fc3.weight [1 x L3] int8 (quantized @ WS3)
+ fc3.bias [1] float32
+
+Both the embedding table (int16) and the FC head (int8) are quantized
+now -- see docs/NNUE.md's "Quantization plan". Measured accuracy cost
+of the FC-head int8 step, 5000 real holdout positions vs the exact
+float reference: mean |delta| 1.55cp, median 0.99cp, max 20.91cp, zero
+clipping -- an order of magnitude below the sd10-vs-sd16 search-depth
+noise (mean 19cp) the project already tolerates in its training
+labels. See the conversation/commit history for the simulation that
+produced those numbers; CalibrateActivationScales below reproduces the
+scale derivation deterministically from a real checkpoint + its
+training tensors.
+
+Usage:
+ python3 export_nnue_weights.py \
+ runs/sd10_twic_2M/nnue_ckpt/ckpt_epoch019.pt \
+ runs/sd10_twic_2M/nnue_ckpt/weights.nnue \
+ --tensor-dir runs/sd10_twic_2M/tensors_train
+"""
+import argparse
+import struct
+import sys
+from pathlib import Path
+
+import numpy as np
+import torch
+
+from nnue_feature_spec import DIMENSIONS, PS_NB
+from nnue_model import L1, L2, L3, NnueNet
+
+# Fixed-point scale for ft.weight/ft_bias. Checked against
+# ckpt_epoch019.pt (ft.weight max|.|=5.01, ft_bias max|.|=0.52): at
+# this scale, 5.01*4096 ~= 20502, comfortably under int16's 32767
+# ceiling (~1.6x headroom for a larger Phase B net before this needs
+# re-deriving). See docs/NNUE.md's "Quantization plan".
+FT_SCALE = 4096.0
+
+# Headroom multiplier applied to the largest value seen in a real
+# sample before deriving each activation scale -- protects against
+# rare tail values in the full corpus that a finite sample might not
+# have hit. 1.3x chosen as a round, conservative margin; revisit if a
+# larger/differently-distributed Phase B net ever reports clipping.
+ACT_HEADROOM = 1.3
+
+
+def CalibrateActivationScales(model, tensor_dir, n_sample=50000, seed=42):
+ """Runs n_sample real positions from tensor_dir (one of
+ build_training_tensors.py's output directories) through the float
+ model, capturing each stage's real post-ReLU activation
+ distribution via forward hooks, and returns (AS1, AS2, AS3) --
+ uint8 fixed-point scales with ACT_HEADROOM margin over the
+ observed max. Must run against the SAME checkpoint being
+ exported -- these scales are not portable across checkpoints."""
+ d = Path(tensor_dir)
+ white_idx = np.load(d / "white_idx.npy"); white_off = np.load(d / "white_off.npy")
+ black_idx = np.load(d / "black_idx.npy"); black_off = np.load(d / "black_off.npy")
+ stm = np.load(d / "stm.npy")
+ n = len(stm)
+ rng = np.random.default_rng(seed)
+ rows = rng.choice(n, size=min(n_sample, n), replace=False)
+ rows.sort()
+
+ captured = {}
+ h1 = model.fc1.register_forward_hook(
+ lambda m, i, o: captured.setdefault("fc1_in", []).append(i[0].detach()))
+ h2 = model.fc2.register_forward_hook(
+ lambda m, i, o: captured.setdefault("fc2_in", []).append(i[0].detach()))
+ h3 = model.fc3.register_forward_hook(
+ lambda m, i, o: captured.setdefault("fc3_in", []).append(i[0].detach()))
+
+ with torch.no_grad():
+ for i in range(0, len(rows), 5000):
+ chunk = rows[i:i + 5000]
+ w_idx_list, w_off_list, b_idx_list, b_off_list = [], [], [], []
+ off_w, off_b = 0, 0
+ for r in chunk:
+ wi = white_idx[white_off[r]:white_off[r + 1]]
+ bi = black_idx[black_off[r]:black_off[r + 1]]
+ w_idx_list.append(wi); w_off_list.append(off_w); off_w += len(wi)
+ b_idx_list.append(bi); b_off_list.append(off_b); off_b += len(bi)
+ w_idx = torch.from_numpy(np.concatenate(w_idx_list).astype(np.int64))
+ w_off = torch.tensor(w_off_list, dtype=torch.int64)
+ b_idx = torch.from_numpy(np.concatenate(b_idx_list).astype(np.int64))
+ b_off = torch.tensor(b_off_list, dtype=torch.int64)
+ stm_t = torch.from_numpy(stm[chunk].astype(np.int64))
+ model(w_idx, w_off, b_idx, b_off, stm_t)
+
+ h1.remove(); h2.remove(); h3.remove()
+
+ scales = []
+ for key in ("fc1_in", "fc2_in", "fc3_in"):
+ t = torch.cat(captured[key]).float()
+ m = t.max().item()
+ scales.append(255.0 / (m * ACT_HEADROOM))
+ return tuple(scales)
+
+
+def QuantizeFt(arr):
+ """Rounds + clamps a float32 array to int16 at FT_SCALE. Returns
+ (int16 ndarray, number of values that clipped)."""
+ scaled = np.round(arr.astype(np.float64) * FT_SCALE)
+ clipped = int(np.count_nonzero((scaled < -32768) | (scaled > 32767)))
+ scaled = np.clip(scaled, -32768, 32767)
+ return scaled.astype(np.int16), clipped
+
+
+def QuantizeWeightS8(arr, scale):
+ """Rounds + clamps a float32 array to int8 at the given scale.
+ Returns (int8 ndarray, number of values that clipped)."""
+ scaled = np.round(arr.astype(np.float64) * scale)
+ clipped = int(np.count_nonzero((scaled < -128) | (scaled > 127)))
+ scaled = np.clip(scaled, -128, 127)
+ return scaled.astype(np.int8), clipped
+
+
+def CoalesceFactorization(sd, tensor_dir, n_check=4096, seed=123):
+ """If the checkpoint has a trained ft_p factorization table (see
+ nnue_model.py's 2026-10-18 note), folds it into ft.weight in
+ place (idx % PS_NB is the P-factor's row for real feature idx)
+ and returns a modified copy of sd with ft_p dropped -- the
+ coalesced ft alone must reproduce the factored forward pass, so
+ ft_p carries zero inference-time cost. No-op (returns sd
+ unchanged) if ft_p isn't present (an older, unfactored
+ checkpoint).
+
+ Required correctness check, per NNUE.md: a bad coalesce silently
+ produces a WORSE net (real rows learn less because the factor
+ absorbed shared signal) with no sign of it in the training loss
+ curve. Verified here by comparing the pre-coalesce (factored,
+ two-table) forward pass against the post-coalesce (plain,
+ ft-only) forward pass on n_check real positions sampled from
+ tensor_dir -- must agree to ~1e-5 or this refuses to export."""
+ if "ft_p.weight" not in sd:
+ return sd
+
+ ft_p_weight = sd["ft_p.weight"]
+ if tuple(ft_p_weight.shape) != (PS_NB, L1):
+ sys.exit(f"ft_p.weight: expected shape {(PS_NB, L1)}, checkpoint "
+ f"has {tuple(ft_p_weight.shape)} -- wrong model version?")
+
+ idx_mod = torch.arange(DIMENSIONS) % PS_NB
+ coalesced_ft_weight = sd["ft.weight"] + ft_p_weight[idx_mod]
+
+ sd_factored = dict(sd)
+ model_factored = NnueNet()
+ model_factored.load_state_dict(sd_factored)
+ model_factored.eval()
+
+ sd_plain = dict(sd)
+ sd_plain["ft.weight"] = coalesced_ft_weight
+ sd_plain["ft_p.weight"] = torch.zeros_like(ft_p_weight)
+ model_plain = NnueNet()
+ model_plain.load_state_dict(sd_plain)
+ model_plain.eval()
+
+ d = Path(tensor_dir)
+ white_idx = np.load(d / "white_idx.npy"); white_off = np.load(d / "white_off.npy")
+ black_idx = np.load(d / "black_idx.npy"); black_off = np.load(d / "black_off.npy")
+ stm = np.load(d / "stm.npy")
+ rng = np.random.default_rng(seed)
+ rows = rng.choice(len(stm), size=min(n_check, len(stm)), replace=False)
+ rows.sort()
+
+ w_idx_list, w_off_list, b_idx_list, b_off_list = [], [], [], []
+ off_w, off_b = 0, 0
+ for r in rows:
+ wi = white_idx[white_off[r]:white_off[r + 1]]
+ bi = black_idx[black_off[r]:black_off[r + 1]]
+ w_idx_list.append(wi); w_off_list.append(off_w); off_w += len(wi)
+ b_idx_list.append(bi); b_off_list.append(off_b); off_b += len(bi)
+ w_idx = torch.from_numpy(np.concatenate(w_idx_list).astype(np.int64))
+ w_off = torch.tensor(w_off_list, dtype=torch.int64)
+ b_idx = torch.from_numpy(np.concatenate(b_idx_list).astype(np.int64))
+ b_off = torch.tensor(b_off_list, dtype=torch.int64)
+ stm_t = torch.from_numpy(stm[rows].astype(np.int64))
+
+ with torch.no_grad():
+ out_factored = model_factored(w_idx, w_off, b_idx, b_off, stm_t)
+ out_plain = model_plain(w_idx, w_off, b_idx, b_off, stm_t)
+ max_delta = (out_factored - out_plain).abs().max().item()
+ if max_delta > 1e-4:
+ sys.exit(f"Coalesce check FAILED: max |factored - coalesced| = "
+ f"{max_delta:.6g} over {n_check} real positions (expected "
+ f"~1e-5) -- refusing to export a bad coalesce. Check "
+ f"ft_p's shape/init and the idx % PS_NB formula.")
+ print(f"Coalesce check OK: max |factored - coalesced| = {max_delta:.3g} "
+ f"over {n_check} real positions")
+
+ sd_out = dict(sd)
+ sd_out["ft.weight"] = coalesced_ft_weight
+ del sd_out["ft_p.weight"]
+ return sd_out
+
+
+def main():
+ ap = argparse.ArgumentParser(description=__doc__)
+ ap.add_argument("checkpoint")
+ ap.add_argument("outfile")
+ ap.add_argument("--tensor-dir", required=True,
+ help="a build_training_tensors.py output dir, used "
+ "to calibrate the FC head's activation scales "
+ "against real positions (e.g. tensors_train)")
+ args = ap.parse_args()
+
+ ckpt = torch.load(args.checkpoint, map_location="cpu", weights_only=True)
+ sd = ckpt["model"]
+ sd = CoalesceFactorization(sd, args.tensor_dir)
+
+ expected_shapes = {
+ "ft.weight": (DIMENSIONS, L1),
+ "ft_bias": (L1,),
+ "fc1.weight": (L2, 2 * L1),
+ "fc1.bias": (L2,),
+ "fc2.weight": (L3, L2),
+ "fc2.bias": (L3,),
+ "fc3.weight": (1, L3),
+ "fc3.bias": (1,),
+ }
+ for name, shape in expected_shapes.items():
+ if tuple(sd[name].shape) != shape:
+ sys.exit(f"{name}: expected shape {shape}, checkpoint has "
+ f"{tuple(sd[name].shape)} -- wrong model version?")
+
+ model = NnueNet()
+ # strict=False: CoalesceFactorization deliberately drops ft_p.weight
+ # (folded into ft.weight above) when the checkpoint had it, but
+ # NnueNet always declares the ft_p parameter regardless -- missing_keys
+ # is expected to be exactly ["ft_p.weight"] in that case, or empty for
+ # an older unfactored checkpoint CoalesceFactorization no-op'd on.
+ missing, unexpected = model.load_state_dict(sd, strict=False)
+ bad_missing = [k for k in missing if k != "ft_p.weight"]
+ if bad_missing or unexpected:
+ sys.exit(f"load_state_dict mismatch beyond the expected ft_p.weight "
+ f"drop -- missing={bad_missing}, unexpected={unexpected}")
+ model.eval()
+
+ AS1, AS2, AS3 = CalibrateActivationScales(model, args.tensor_dir)
+
+ ft_weight_f32 = sd["ft.weight"].detach().contiguous().to(torch.float32).numpy()
+ ft_bias_f32 = sd["ft_bias"].detach().contiguous().to(torch.float32).numpy()
+ fc1_w_f32 = sd["fc1.weight"].detach().contiguous().to(torch.float32).numpy()
+ fc2_w_f32 = sd["fc2.weight"].detach().contiguous().to(torch.float32).numpy()
+ fc3_w_f32 = sd["fc3.weight"].detach().contiguous().to(torch.float32).numpy()
+
+ ft_weight_q, clipped_ftw = QuantizeFt(ft_weight_f32)
+ ft_bias_q, clipped_ftb = QuantizeFt(ft_bias_f32)
+
+ WS1 = 127.0 / np.abs(fc1_w_f32).max()
+ WS2 = 127.0 / np.abs(fc2_w_f32).max()
+ WS3 = 127.0 / np.abs(fc3_w_f32).max()
+
+ fc1_w_q, clipped_w1 = QuantizeWeightS8(fc1_w_f32, WS1)
+ fc2_w_q, clipped_w2 = QuantizeWeightS8(fc2_w_f32, WS2)
+ fc3_w_q, clipped_w3 = QuantizeWeightS8(fc3_w_f32, WS3)
+
+ total_clipped = (clipped_ftw + clipped_ftb + clipped_w1 + clipped_w2 +
+ clipped_w3)
+ if total_clipped:
+ sys.exit(f"Quantize: {total_clipped} value(s) clipped -- "
+ f"re-check headroom/scales before shipping this export "
+ f"(see docs/NNUE.md)")
+
+ with open(args.outfile, "wb") as f:
+ f.write(b"NNU3")
+ f.write(struct.pack("<4I", DIMENSIONS, L1, L2, L3))
+ f.write(struct.pack("<f", FT_SCALE))
+ f.write(struct.pack("<3f", AS1, AS2, AS3))
+ f.write(struct.pack("<3f", WS1, WS2, WS3))
+ f.write(ft_weight_q.tobytes())
+ f.write(ft_bias_q.tobytes())
+ f.write(fc1_w_q.tobytes())
+ f.write(sd["fc1.bias"].detach().contiguous().to(torch.float32).numpy().tobytes())
+ f.write(fc2_w_q.tobytes())
+ f.write(sd["fc2.bias"].detach().contiguous().to(torch.float32).numpy().tobytes())
+ f.write(fc3_w_q.tobytes())
+ f.write(sd["fc3.bias"].detach().contiguous().to(torch.float32).numpy().tobytes())
+
+ print(f"Wrote {args.outfile} from {args.checkpoint}")
+ print(f" ft_scale={FT_SCALE}")
+ print(f" act_scales (AS1,AS2,AS3)=({AS1:.4f},{AS2:.4f},{AS3:.4f})")
+ print(f" weight_scales (WS1,WS2,WS3)=({WS1:.4f},{WS2:.4f},{WS3:.4f})")
+
+
+if __name__ == "__main__":
+ main()