Skip to content

feat(oracle): PyTorch-as-oracle per-op parity harness -- exchange format + NGC runner (ADR 091, T1.3) - #131

Merged
dndungu merged 4 commits into
mainfrom
feat/pytorch-oracle-harness
Jun 10, 2026
Merged

feat(oracle): PyTorch-as-oracle per-op parity harness -- exchange format + NGC runner (ADR 091, T1.3)#131
dndungu merged 4 commits into
mainfrom
feat/pytorch-oracle-harness

Conversation

@dndungu

Copy link
Copy Markdown
Contributor

Implements T1.3 of the GPU training-stack hardening plan (zerfoo docs/plan-gpu-training-hardening.md, decision rationale zerfoo ADR 091): the PyTorch-as-oracle per-op parity harness. The same op, same inputs, through ztensor and through torch (in nvcr.io/nvidia/pytorch:26.02-py3 on the DGX GB10), diffing forward AND backward within per-op tolerances. Catches numerics-convention divergence (fast-math, reduction ordering, eps placement) that ztensor's CPU and GPU engines could share and gradcheck cannot see. Test infrastructure only — the production stack stays pure Go.

Refs #128. Builds on the merged gradcheck registry (#129) and ADR 006.

Bundle format (format_version 1)

One directory per op case (authoritative spec: testing/oracle/bundle.go):

bundles/
generation.json # written/skipped summary
<Op>/
manifest.json # op, torch_expr, dtype, seed, tolerances, tensor refs
input_0.bin ... # op inputs
param_<name>.bin # trainable parameter values (LayerNorm gamma/beta)
upstream.bin # upstream gradient dL/dy fed to Backward
forward.bin # ztensor forward output
grad_input_0.bin ... # ztensor input gradients
grad_param_<name>.bin # ztensor parameter gradients

Tensor files are raw little-endian IEEE-754, row-major, no header; the manifest records shape and dtype (float32/float64, numpy names). Pass criterion per element, identical in testing/oracle/diff.go and scripts/oracle/run_oracle.py: |ztensor − torch| ≤ atol + rtol·|torch|; any NaN fails. First cut records the f32 CPU engine; the GPU-engine variant regenerates bundles through the same format on the DGX.

To run the registry at f32 without duplicating op definitions, the gradcheck op wrappers are now generic over tensor.Float, and gradcheck.NewRegistryNode[T] is the single source of truth for constructor arguments (slopes, axes, reshape targets, layernorm width/eps). Registry() routes through it; no behavior change at float64. A CI test keeps the mapping table and the registry in lockstep.

Op → torch mapping (25 mapped, 1 skipped)

ztensor optorch expression
Add / Sub / Mul / Divx0 + x1 / x0 - x1 / x0 * x1 / x0 / x1
Powtorch.pow(x0, x1)
Tanh / Sigmoid / ReLUtorch.tanh(x0) / torch.sigmoid(x0) / torch.relu(x0)
LeakyReLUtorch.nn.functional.leaky_relu(x0, 0.1)
Exp / Log / Sqrt / Rsqrt / Sin / Costorch.exp(x0) etc.
AddScalar / MulScalarx0 + 0.7 / x0 * -1.3
MatMulx0 @ x1
Transposex0.transpose(0, 1)
Reshapex0.reshape(3, 2)
Softmaxtorch.softmax(x0, 1)
ReduceSum / ReduceMeanx0.sum(dim=1, keepdim=True) / x0.mean(dim=1, keepdim=True)
ReduceMaxx0.amax(dim=1, keepdim=True) (no-ties sampling is load-bearing)
LayerNormtorch.nn.functional.layer_norm(x0, (4,), weight=gamma.reshape(4), bias=beta.reshape(4), eps=1e-05)

SKIPPED:HadamardTransform — torch has no built-in normalized Walsh-Hadamard transform; hand-building the H matrix in the runner would test our own reimplementation rather than torch.

Red proof (CI-side, no torch dependency)

testing/oracle/redproof_test.go encodes the ztensor#125 fast-math tanh class: a Tanh bundle whose recorded "ztensor output" comes from an unsaturated tanh approximation (grows past |x| > 9 instead of clamping to ±1, like the raw cubic-GELU tanh-arg overflow). The Go reference checker — the same diff logic as the Python runner, with math.Tanh standing in for torch.tanh — must flag it red in both forward and backward (TestRedProofFastMathTanhFails), and the saturating twin must pass green (TestGreenProofTrueTanhPasses). NaN policy pinned by TestDiffNaNFails.

Verified locally (beyond CI)

End-to-end against real torch 2.11 on CPU: oracle-genrun_oracle.py gives 25/25 PASS (max fwd diff 4.1e-6 on Exp, everything else ≤2.4e-7); corrupting one forward element makes the runner report FAIL and exit 1. Round-trip, endianness pin (f32 1.0 = 00 00 80 3F), determinism (two generations byte-identical), and full-suite go test -race ./... green.

DGX run procedure (the lead runs this serially — NOT run in this PR)

SPARK=http://192.168.86.250:8080
RUNID=$(git rev-parse --short=8 HEAD)
go run github.com/zerfoo/ztensor/testing/oracle/cmd/oracle-gen -out /tmp/oracle/$RUNID/bundles
rsync -av /tmp/oracle/$RUNID/bundles scripts/oracle/run_oracle.py ndungu@192.168.86.250:/home/ndungu/oracle/$RUNID/
curl -s $SPARK/api/v1/resources # GB10 jobs must serialize
sed "s/RUNID/$RUNID/g" scripts/oracle/oracle-pod.yaml | curl -sf -X POST $SPARK/api/v1/pods -H 'Content-Type: application/yaml' --data-binary @-
curl -s $SPARK/api/v1/pods/ztensor-oracle-$RUNID/logs # poll until Succeeded/Failed
rsync -av ndungu@192.168.86.250:/home/ndungu/oracle/$RUNID/report.json /tmp/oracle/$RUNID/
curl -s -X DELETE $SPARK/api/v1/pods/ztensor-oracle-$RUNID

Full procedure + GPU-engine next cut: scripts/oracle/README.md. This harness is the gate for the kernel-numerics work (plan T3.1–T3.4).

…oss-precision reuse
Generify the opNode wrappers and constructors over tensor.Float and add
NewRegistryNode[T], the single source of truth for registry constructor
arguments, so the PyTorch-oracle harness (T1.3) can run the exact same op
definitions at float32 without duplicating them. Registry() now routes its
Make closures through the factory. No behavior change at float64.
Refs #128, zerfoo ADR 091.
…fixture (T1.3)
testing/oracle implements the ztensor side of the PyTorch-as-oracle harness:
- bundle.go: format_version 1 case bundles -- manifest.json (op, torch
expression, shapes, dtypes f32/f64, seed, per-op tolerances) plus raw
little-endian row-major tensor files (inputs, params, upstream gradient,
ztensor forward output, input/parameter gradients). Writer + reader.
- torchmap.go: op -> torch expression table covering 25/26 registry ops;
HadamardTransform skipped with reason. Per-op tolerance overrides.
- generate.go + cmd/oracle-gen: runs every gradcheck registry op
forward+backward on the float32 CPU engine with seeded inputs/upstream
and dumps one bundle per op (GPU-engine variant reuses the same format).
- diff.go: the exact pass/fail logic mirrored by scripts/oracle/run_oracle.py
(|got-ref| <= atol + rtol*|ref|, NaN always fails).
- redproof_test.go: CI red proof for the ztensor#125 fast-math tanh class --
a bundle recorded with an unsaturated tanh (growth past |x|>9) MUST fail
the diff vs math.Tanh ground truth, and the saturating twin passes.
- bundle_test.go / generate_test.go: endianness pin, f32/f64 round-trips,
registry<->mapping lockstep, full-generation validation, determinism.
Refs #128, zerfoo ADR 091.
… (T1.3)
scripts/oracle/run_oracle.py (stdlib+numpy+torch only, offline) replays the
case bundles in PyTorch, backprops the recorded upstream, and writes a
report.json with per-op max abs/rel diffs for forward and every gradient,
pass/fail vs the manifest tolerances; exit 1 on any failure.
oracle-pod.yaml runs it in nvcr.io/nvidia/pytorch:26.02-py3 on the GB10 with
nvidia.com/gpu: 1 and a memory limit via the Spark HTTP API; README.md
documents generate -> rsync -> submit -> report.
Verified locally end-to-end against torch 2.11 CPU: 25/25 bundles pass; a
corrupted forward fails with exit 1.
Refs #128, zerfoo ADR 091.
@dndungu
dndungu merged commit 1262a87 into mainJun 10, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@dndungu