[NumPy Parity] Neural Networks Project to fully train byte-identical weights on training
@Nucs is already working on this.
Since Jul 26, 2026.
- Dominant language
- C#
- Stars
- 1.5k
- Forks
- 205
- Avg merge
- 7d 7h
- Merged PRs (30d)
- 2
Description
[NumPy Parity] Neural Networks Project to fully train byte-identical weights on training
Overview
Embed a call-exact NumPy twin (mnist_mlp.py) next to the C# MNIST MLP demo (examples/NeuralNetwork.NumSharp/MnistMlp/) and make both sides produce byte-identical weights after a full 100-epoch training run — verified by .npy byte-equality (NumSharp's writer is already byte-identical to np.save, so the gate is literal file-hash equality). This extends the differential-fuzz philosophy (NumPy is the oracle, bit-exact or excused-loudly) from single ops to a complete composed workload: init → forward → loss → backward → Adam × 4,600 steps.
Problem
The demo trains and converges, but nothing proves NumSharp's training math is NumPy's. Per-op fuzz tiers pass individually, yet two documented excuse classes sit directly on the weight path, and the GEMM diverges at real sizes despite being fuzz-green at the corpus' K≤4 shapes.
Empirical probe results (this session; numpy==2.4.2 win-amd64 wheel, AVX2+FMA host; NumSharp replayed NumPy-saved .npy inputs and bit-compared):
| Pillar | Op (MLP usage) | Result | Status |
|---|---|---|---|
| P1 RNG | seed(1337) → normal(loc,scale,size) → astype(f32) — all 5 init/data tensors |
BIT-EXACT (784×128, 128×10, 128×784, 128×128 ×2) | ✅ done, needs CI pin |
| P2 GEMM | np.dot f32, K=784 (x@W1) |
15,486/16,384 elements differ, max ~976K ULP | ❌ blocker |
| P2 GEMM | np.dot f32, K=128 (x.T@g, transposed view) |
29,469/100,352 differ, max ~17.8K ULP | ❌ blocker |
| P3 exp | np.exp f32 (softmax) |
6,377/16,384 differ, all ≤2 ULP (the documented blanket unary-ULP excuse, 563 hits) | ❌ blocker |
| P4 sums | sum(axis=1) contig len-128, sum(axis=0) strided 128 rows, keepdims |
BIT-EXACT (all 3) | ✅ done at MLP shapes, needs scoped pin |
Every other weight-affecting op is IEEE-exact by construction and already fuzz-green: add/sub/mul/div (incl. bias broadcast and the NDExpr fused Max(in0+in1,0) / in0*Greater(in1,0) kernels — plain exact ops), maximum, sqrt (correctly-rounded), astype f64→f32, max(axis=1) (order-independent), one-hot integer writes, view slicing. The softmax → CE backward is (softmax − labels) * (1/128) — exact (1/128 is a power of two). np.log affects only the printed loss, not weights — log parity is an optional stretch goal.
Why the GEMM diverges — and why no stride trick can fix it (NumPy 2.4.2 source finding)
numpy/_core/src/umath/matmul.c.src (@TYPE@_matmul, the "copy if not blasable, see gh-12365 & gh-23588" branch): for float32/float64 matrix@matrix NumPy copies any non-blasable operand into a contiguous temp and still calls cblas — the pure-C matmul_inner_noblas loop (acc += a*b, ascending k) is unreachable in the stock wheel for f32 mat@mat (only zero-dim or >BLAS_MAXSIZE reach it). Probes confirm:
np.dot≡np.matmulbit-equal (0/16,384 diffs).- BLAS result ≠ sequential mul+add chain ≠ sequential-FMA chain (per-element references, 8 probes each) → scipy-openblas sgemm uses an arch-specific multi-accumulator register scheme.
- Even BLAS-vs-BLAS differs by transpose variant: forcing a copy via a negative-stride column view changed 5,487/16,384 results (different transA/B kernel).
So byte-matching the stock wheel means matching that OpenBLAS binary's accumulation, not any portable algorithm.
Proposal
GEMM strategy (decide via spike, one seam: ParityMatmul)
| Option | Mechanism | Pros | Cons |
|---|---|---|---|
| S-A (recommended) | Opt-in NumSharp parity backend P/Invokes the exact libscipy_openblas64_-*.dll shipped inside the pinned numpy wheel with the same cblas flags NumPy passes |
Bit-identical by construction on any machine; python twin stays stock pip install numpy==2.4.2; fast on both sides |
Native interop in the parity path (example/test-harness scope, not core runtime); must locate/copy the wheel's DLL |
| S-B (portable fallback) | Python twin runs a numpy built with -Dblas=none (then everything routes to matmul_inner_noblas: acc += a*b, ascending k, compiler-contraction-defined); NumSharp adds a sequential parity kernel (SIMD across output columns is legal — the per-element k-chain is preserved) |
Pure managed C#; portable semantics; trivial kernel | Custom numpy build for the mirror; noblas python side is 10–50× slower; contraction (mul+add vs FMA) must be pinned per toolchain |
| Rejected | Reimplement scipy-openblas sgemm assembly semantics per CPU arch in managed code | — | Brittle, per-arch, breaks on every wheel/CPU change |
NumSharp's existing GEBP FMA GEMM is untouched — the parity path is opt-in for the harness/gates.
Task checklist
- RNG oracle tier — committed corpus pinning
seed → standard_normal/normal doubles → astype(f32)streams in CI (P1 is proven live but only state-round-trip tested today;Randomizer.Tests.csnever compares against real NumPy output) - exp f32 port — port
simd_exp_f32(FMA polynomial) fromsrc/numpy/numpy/_core/src/umath/loops_exponent_log.dispatch.c.src(clone already in-repo); elementwise → lane-independent → identical bits at any vector width on FMA hardware. Narrow the blanketunary ~ULP ≤2MisalignedRegistry branch to exclude exp/Single (house pattern: scoped branch + tightness test) and add corpus cases asserting exp/f32 bit-exact. Stretch: same forlogf32 → byte-identical printed losses - GEMM spike + implement — S-A vs S-B behind one
ParityMatmulseam; newmatmul_paritycorpus tier with K ∈ {255, 256, 257, 512, 784} (the KC=256 block boundary the current K≤4 tier never crosses) × transposed-view operands - Sum floors — corpus cases pinning
sum(axis=1)contiguous andsum(axis=0)strided f32 bit-exact at reduction lengths ≤128 (already passing; pin so it can't regress) - Demo determinism changes (both twins identically) — synthetic data via
np.randomMT19937 (replacingSystem.Random+ local Box-Muller, which has no NumPy analog);np.power(grad, 2)→grad*grad; Adam bias correctionMath.Pow(β, t)→ cached running productsβ_pow *= β(removes CRT-powcross-platform risk); real-MNIST normalize mirrored asx * (1f/255f) - Scalar-expression mirroring audit — document every scalar rounding point. Example of the discipline: C#
1 - Beta1computed in f32 is0x3DCCCCCE, while the literal0.1fis0x3DCCCCCD— the twin must writenp.float32(1) - np.float32(0.9), never the simplified constant -
mnist_mlp.pytwin — atexamples/NeuralNetwork.NumSharp/MnistMlp/parity/, file-by-file mirror (Program / MlpTrainer / FullyConnectedFused / SoftmaxCrossEntropy / Adam / MnistLoader-synthetic) with C# file/line cross-references in headers - Staged byte-gate harness —
--parity-dump <dir>flag on both twins dumping.npyat gate points; comparison = file byte-equality;test/oracle/verify_nn_parity.pymanual gate (needs Python + SDK, likeverify_npy_interop.py); optional nightly - Run the ladder, document — results + host pin into the example's CLAUDE.md; any surviving divergence into the MisalignedRegistry-style ledger (excused loudly, never silent)
Stage ladder (each gate isolates one pillar; fail-fast localization)
| Stage | Byte-compares | Proves |
|---|---|---|
| S0 | W1, b1, W2, b2 after init |
P1 RNG + astype |
| S1 | trainX, trainY, testX, testY |
data pipeline (np.random synthetic or IDX normalize) |
| S2 | logits of batch 0 | GEMM K=784/K=128 + fused bias/ReLU |
| S3 | softmax cache + loss-gradient | exp + axis-1 sum + exact chain |
| S4 | Grads[w], Grads[b] both layers |
axis-0 sum + transposed-view GEMMs |
| S5 | weights + Adam m, v after 1 step |
power/sqrt/scalar mirroring |
| S6 | weights after epoch 1 | 46-step composition |
| S7 | final weights after 100 epochs | the headline claim |
Evidence
- Probe harness: NumPy 2.4.2 generated
.npyinputs; NumSharp loaded them (byte-exact reader) and recomputed; per-element uint32 bit-compare. RNG/sums bit-exact; exp ≤2 ULP on 39% of elements; GEMM as tabled above. matmul.c.srcrouting read from the in-repo NumPy 2.4.2 clone (src/numpy/): noblas loop*(typ*)op += val1 * val2ascending-k (lines ~282–320); the copy-then-BLAS branch (gh-23588) makes it unreachable for stock-wheel f32 mat@mat.- Fuzz ledger (
test/NumSharp.UnitTest/Fuzz/README.md):unary ~ULPexcuse (563 hits) andreduction summation/two-pass precisionexcuse (401 hits) are the two permanent branches this work narrows; the host-pinned-parity precedent already exists there (cast kernels reproduce the MSVC NumPy answer — "a NumSharp regression pin rather than portable NumPy parity"). np.random.csNextGaussian()is NumPy's exact polar method with gauss-cache (matcheslegacy_gaussincl. the x·d cached / y·d returned convention) — the structural reason P1 probes pass.- NumSharp
.npywriter is byte-for-bytenp.saveoutput (theNpyOraclegate, 286 cases) — which is what makes "weights parity" checkable as plain file equality.
Scope / Non-goals
- Scope: the float32
MnistMlppath only (FullyConnectedFused, SoftmaxCrossEntropy, Adam, MlpTrainer). Deterministic batch order (already no shuffle). - Host pin: parity target is
numpy==2.4.2wheels on FMA-capable x86-64, single-threaded (OPENBLAS_NUM_THREADS=1on the twin; NumSharp multithreading stays default-off). Same pin class as the MSVC cast-kernel precedent. Cross-OS byte-parity only under S-B. - Non-goals: the vanilla
NeuralNet/FullyConnected/Sigmoid/BCE scaffolding; Half/Decimal networks; loss-print parity (needs the log port — stretch); shuffling/validation-split features; replacing NumSharp's native GEMM (parity path is opt-in).
Breaking changes
| Change | Impact | Migration |
|---|---|---|
| exp f32 kernel → NumPy polynomial port | all float32 np.exp results shift ≤2 ULP (toward NumPy exactness); fuzz excuse narrows |
none — strictly closer to NumPy |
| Demo synthetic dataset → MT19937 | different synthetic pixels/labels than the System.Random generator produced |
demo-only; convergence characteristics re-baselined |
| Adam bias correction → running β products | bit-level only (removes CRT pow dependency) |
none |
Performance
Parity paths are opt-in; default fast paths untouched. The exp port replaces the MathF.Exp scalar call inside the IL kernel — NumPy's polynomial is Vector256/FMA-vectorizable, so expect ≥ current throughput; gate with the benchmark/ harness before/after. S-A GEMM parity runs at OpenBLAS speed on both sides; S-B makes only the python twin slow.
Related issues
- NumPy: numpy/numpy gh-23588, gh-12365 (the copy-then-BLAS matmul routing that closes the noblas door)
- In-repo: fuzz ledger excuse branches this narrows (
unary ~ULP, reduction summation); example CLAUDE.md perf-drift (fusion probe now 0.73×, training 12.8 s — stale doc, separate follow-up)
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.