You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[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.
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:
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.cs never compares against real NumPy output)
exp f32 port — port simd_exp_f32 (FMA polynomial) from src/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 blanket unary ~ULP ≤2 MisalignedRegistry branch to exclude exp/Single (house pattern: scoped branch + tightness test) and add corpus cases asserting exp/f32 bit-exact. Stretch: same for log f32 → byte-identical printed losses
GEMM spike + implement — S-A vs S-B behind one ParityMatmul seam; new matmul_parity corpus 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 and sum(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.random MT19937 (replacing System.Random + local Box-Muller, which has no NumPy analog); np.power(grad, 2) → grad*grad; Adam bias correction Math.Pow(β, t) → cached running products β_pow *= β (removes CRT-pow cross-platform risk); real-MNIST normalize mirrored as x * (1f/255f)
Scalar-expression mirroring audit — document every scalar rounding point. Example of the discipline: C# 1 - Beta1 computed in f32 is 0x3DCCCCCE, while the literal 0.1f is 0x3DCCCCCD — the twin must write np.float32(1) - np.float32(0.9), never the simplified constant
mnist_mlp.py twin — at examples/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 .npy at gate points; comparison = file byte-equality; test/oracle/verify_nn_parity.py manual gate (needs Python + SDK, like verify_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 .npy inputs; 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.src routing read from the in-repo NumPy 2.4.2 clone (src/numpy/): noblas loop *(typ*)op += val1 * val2 ascending-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 ~ULP excuse (563 hits) and reduction summation/two-pass precision excuse (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 (matches legacy_gauss incl. the x·d cached / y·d returned convention) — the structural reason P1 probes pass.
NumSharp .npy writer is byte-for-byte np.save output (the NpyOracle gate, 286 cases) — which is what makes "weights parity" checkable as plain file equality.
Scope / Non-goals
Scope: the float32 MnistMlp path only (FullyConnectedFused, SoftmaxCrossEntropy, Adam, MlpTrainer). Deterministic batch order (already no shuffle).
Host pin: parity target is numpy==2.4.2 wheels on FMA-capable x86-64, single-threaded (OPENBLAS_NUM_THREADS=1 on 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).
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)
[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.npybyte-equality (NumSharp's writer is already byte-identical tonp.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.2win-amd64 wheel, AVX2+FMA host; NumSharp replayed NumPy-saved.npyinputs and bit-compared):seed(1337)→normal(loc,scale,size)→astype(f32)— all 5 init/data tensorsnp.dotf32, K=784 (x@W1)np.dotf32, K=128 (x.T@g, transposed view)np.expf32 (softmax)sum(axis=1)contig len-128,sum(axis=0)strided 128 rows, keepdimsEvery 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),astypef64→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.logaffects 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-Cmatmul_inner_noblasloop (acc += a*b, ascending k) is unreachable in the stock wheel for f32 mat@mat (only zero-dim or >BLAS_MAXSIZEreach it). Probes confirm:np.dot≡np.matmulbit-equal (0/16,384 diffs).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)libscipy_openblas64_-*.dllshipped inside the pinned numpy wheel with the same cblas flags NumPy passespip install numpy==2.4.2; fast on both sides-Dblas=none(then everything routes tomatmul_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)NumSharp's existing GEBP FMA GEMM is untouched — the parity path is opt-in for the harness/gates.
Task checklist
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)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 lossesParityMatmulseam; newmatmul_paritycorpus tier with K ∈ {255, 256, 257, 512, 784} (the KC=256 block boundary the current K≤4 tier never crosses) × transposed-view operandssum(axis=1)contiguous andsum(axis=0)strided f32 bit-exact at reduction lengths ≤128 (already passing; pin so it can't regress)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)1 - Beta1computed in f32 is0x3DCCCCCE, while the literal0.1fis0x3DCCCCCD— the twin must writenp.float32(1) - np.float32(0.9), never the simplified constantmnist_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--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 nightlyStage ladder (each gate isolates one pillar; fail-fast localization)
W1, b1, W2, b2after inittrainX, trainY, testX, testYGrads[w], Grads[b]both layersm, vafter 1 stepEvidence
.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.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..npywriter is byte-for-bytenp.saveoutput (theNpyOraclegate, 286 cases) — which is what makes "weights parity" checkable as plain file equality.Scope / Non-goals
MnistMlppath only (FullyConnectedFused, SoftmaxCrossEntropy, Adam, MlpTrainer). Deterministic batch order (already no shuffle).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.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
np.expresults shift ≤2 ULP (toward NumPy exactness); fuzz excuse narrowsSystem.Randomgenerator producedpowdependency)Performance
Parity paths are opt-in; default fast paths untouched. The exp port replaces the
MathF.Expscalar call inside the IL kernel — NumPy's polynomial is Vector256/FMA-vectorizable, so expect ≥ current throughput; gate with thebenchmark/harness before/after. S-A GEMM parity runs at OpenBLAS speed on both sides; S-B makes only the python twin slow.Related issues
unary ~ULP, reduction summation); example CLAUDE.md perf-drift (fusion probe now 0.73×, training 12.8 s — stale doc, separate follow-up)