Skip to content

Support for onnx MLPs - #25

Merged
adilfaisal01 merged 11 commits into
mainfrom
feat/onnx-graph
Sep 18, 2026
Merged

adilfaisal01 merged 11 commits into
mainfrom
feat/onnx-graph

Conversation

@adilfaisal01

Copy link
Copy Markdown
Member
  • removed onnxruntime as a dependency, instead using onnx
  • onnx load graph exposes onnx proto files as a DAG, which can be lowered to zig and .so binary allowing C-ABI contract
  • optimized lower.zig, removing mentions of inline for in the inner loop, allowing LLVM IR optimizations, providing significant speed in build time and reductions in binary sizes
  • prevented overflow by allowing size to be global rather than local
  • comparison harness for ticks
  • currently only MLP with a subset of ops are supported, more will be coming soon

The ONNX→Zig policy path reads the graph with `onnx` at compile time and
deploys a dependency-free `.so`, so the extra no longer needs the onnxruntime
inference engine. `onnx` also joins the dev extra, which fixes the ONNX
adapter tests silently skipping under a stock `.[onnx-rl]` install (they
guarded on both packages, but only onnxruntime was declared).

The checked-in adapter still lazy-imports onnxruntime on its eager path; that
path is replaced in a following commit.
Add shinro.codegen.onnx_import: translate onnx.load(...).graph directly into a
memoryless ComposedGraph, bypassing the tracer entirely (ONNX is already a
dataflow graph, so there is nothing to intercept). Supports Gemm (alpha/beta/
transB), MatMul and Add, Relu/Tanh, and composes Sigmoid from exp/neg/add/div
so no new VM op is needed. The observation encoder (integer index selection,
mean/std normalization, clipping) is folded in as arithmetic on baked
constants, leaving the raw plant state as the only input port.

Every emitted node is evaluated eagerly with the ops registry's numpy handler
and that result's shape becomes the node's declared shape, so interpreter and
lowering semantics cannot drift. Nodes that do not feed the declared output are
ignored; unsupported ops/attributes, multi-input models, and batched outputs
fail loudly with actionable messages.

Verified: 24 unit tests (pytest tests/unit/test_onnx_import.py), ruff clean,
pyrefly 0 errors.
Add the action-space surface to the ONNX importer: continuous applies
scale/bias, discrete emits an argmax one-hot, and stochastic splits
[mean; log_std] and either returns the mean or adds
exp(clip(log_std, -10, 2)) * epsilon — mirroring the old runtime
_postprocess.

A non-deterministic discrete/stochastic policy gains an `epsilon` C-ABI input
port: the host supplies the noise (Gumbel for discrete, standard normal for
stochastic) and the kernel does only the arithmetic, matching MPPI's port and
keeping RNG on the host. Discrete sampling uses the Gumbel-max trick,
argmax(logits + g) + one_hot, so no softmax/max VM op is needed.

Single-sided action clips are rejected: the lowerer writes floats as Zig hex
literals and `inf` is not a Zig identifier, so a bound defaulted to ±inf would
fail the build. Gemm's alpha/beta multipliers and the action scale/bias are
emitted unconditionally (defaults included) for uniform lowering.

Verified: 38 importer unit tests, including a 2000-draw Gumbel-max vs softmax
distribution check; all six action-space variants lower via lower_zig into
isolated paths; ruff + pyrefly clean.
Rewrite the onnx_rl adapter on top of the ONNX→shinro importer and drop
onnxruntime entirely. Two interchangeable backends: `model_path` imports the
ONNX graph and runs it with the pure-numpy interpreter (eager, no build step),
`artifact_dir` dlopens the scenario's compiled kernel under
lib/lib_neural_network.so and drives it through the shinro_step C ABI, reading
the port layout from the graph manifest so the .onnx file is not needed at
deploy time.

Sampling noise (Gumbel for discrete, standard normal for stochastic) is drawn
host-side from a seeded RNG and fed to the epsilon port, keeping the kernel
pure arithmetic; reset() makes a run reproducible.

Add the frozen OnnxRLConfig dataclass (silences the registry's missing-Config
warning for onnx_rl), strict-parse the config, and reject unknown observation
keys so a typo cannot silently drop normalization.

Behavior changes vs the runtime implementation: actions are float64 (the graph
is f64 throughout), single-sided action_clip is rejected (a ±inf bound cannot
be lowered), normalize without stats raises ValueError, and
observation.add_batch_dim is a no-op (batch-1 is implicit in the 1-D ports).

Verified: 81 unit tests across tests/unit/test_onnx_{rl_adapter,import}.py;
full suite 1225 passed / 5 skipped; ruff + pyrefly clean.
Make `make compile` reach a policy-only scenario end to end:

- scenario_gen: a scenario may omit [estimator] when its controller is onnx_rl
  (any other type still requires one). Such a scenario bypasses
  build_composed_graph and lowers the importer's graph directly, with the .onnx
  file's sha256 pinned in the manifest provenance.
- [compile].artifact_name selects the installed kernel stem. build.zig installs
  through an explicit sub-path (Zig's addLibrary would otherwise prefix "lib",
  turning lib_neural_network into liblib_neural_network); oracle.load_so, stamp,
  scenario_build and cli thread the name through. The default "libbase" keeps
  every existing artifact and test byte-identical.
- build.zig's build-time readFile cap was 1 MiB, so any graph past ~50k baked
  constants failed with a misleading "graph_data.zig has no has_solve_qp flag;
  regenerate it" panic. Raised to 256 MiB.

Add a committed toy policy so the ONNX path has a stable subject:
tests/fixtures/models/toy_mlp.onnx (26-param 3->4->2 tanh MLP, generated by
scripts/gen_toy_onnx.py), its controller config, and a policy-only scenario.
The adapter/import tests and the compile e2e use it, a drift guard regenerates
it through the script's CLI and compares the graph, and the scenario template
documents the new [compile] key (the template drift guard forces that).

Add scripts/measure_onnx_policy_scale.py — the cold-build sweep of parameter
count against artifact size, graph source size, VM buffers and compile time.
Its numbers and the comptime scaling wall (the VM is a ~10^5-parameter design;
>=830k params trips @setEvalBranchQuota) are written up in the lab note.

Verified: 100 onnx/compile tests including every zig-gated e2e; make test-zig
77 passed / 2 skipped; make test 1233 passed / 5 skipped; ruff + pyrefly clean.
TestOnnxPolicyOracle imports the committed toy policy (tests/fixtures/models/
toy_mlp.onnx) and compiles its four baked variants — continuous,
discrete-deterministic, discrete-sampling and stochastic-sampling — into
tmp-path kernels, then asserts .so == interpret() on seeded random states
(exact for the continuous/one-hot paths, <=1e-14 for the noise paths) plus the
closed form, numpy argmax, and the Gumbel/stochastic formulas. This is the
first oracle whose subject is a learned policy rather than a hand-written
control law, and it proves the importer's output — baked encoder, transposed
Gemms, composed activations, action post-processing — compiles bit-for-bit.
The shared src/shinro/runtime/graph_data.zig is never touched.

Verified: make test-zig 81 passed / 2 skipped; make test 1241 passed /
5 skipped; make lint clean.
configs/controllers/onnx_rl.toml now explains the eager (model_path) and
compiled (artifact_dir) backends, the policy-only `make compile` command, the
epsilon / host-noise contract, and that add_batch_dim is legacy; the stale
`--controller onnx_rl` demo line is removed (no such base config exists).
docs/components.md records that onnx_rl is the only controller that may omit
[estimator] and the only one with a compiled deployment path. The lab note
carries the step 6-7 write-up and the ONNX shift's scale-sweep results.

Verified: the updated config strict-parses into OnnxRLConfig; ruff + pyrefly
clean.
runtime/lower.zig unrolled both the outer node table and every per-element
loop with `inline for` (38 sites, 0 runtime `for`). The outer unroll is cheap
(~1300 nodes); the inner ones emit one statement per element, so shinro_step
became a single function with ~buf_len statements (100k-340k). Zig's comptime
evaluator and LLVM then processed that giant straight-line function: MPPI
production builds at N=200/K=15 took 411-1306 s and produced 1.9-6.1 MiB .so
files whose .text was ~2x buf_bytes.

Keep the outer `inline for (g.nodes)` (op tag and shapes stay comptime, buffer
offsets stay comptime constants) and make all 34 inner element loops runtime
`for` loops over those comptime-known sizes. ew2/bcast_flat stay inline so
shapes and the op tag constant-fold.

Result (MPPI, N=200/K=15, ReleaseFast, real plants):
  - 5.2-11.5x smaller .so
  - 35-106x faster builds (8-kernel matrix ~90 s total)
  - 1.4-2.1x faster ticks (the unrolled function was thrashing I-cache)
ONNX MLP scale sweep: the >=830k-param comptime branch-quota wall is gone;
all six archs (up to 1.88M params) now compile and oracle-verify.

Verified: make test 1241 passed/5 skipped (unchanged), make test-zig 81/2,
make lint clean, all SMC/MPPI/ONNX and closed-loop oracles pass. Shipped
graph_data.zig untouched.
shinro_step declared its per-node scratch as a stack local,
`var buf: [g.buf_len]f64`. For large graphs that reserves buf_bytes on the
caller's stack — 20.8 MiB at ~1.35M parameters — overflowing the default
16 MiB stack when the kernel is called. The compiler was fine; the call
segfaulted.

Move it to a file-scope `workspace` in .bss, sized at compile time from
g.buf_len and addressed with the same comptime offsets (the helpers keep
their `buf` pointer parameter). The graph writes every slot before it is
read, so no initialisation is needed. Documented as not reentrant /
thread-safe — one caller, one tick at a time — matching the QP path's
statically-allocated solver global.

Result: a 1.35M-param policy (20.8 MiB workspace) builds and oracle-verifies
under the default 16 MiB stack; its .so is ~15 KB of .text plus the .bss
workspace, still with no dynamic allocation. Tick latency unchanged within
measurement noise.

Verified: make test-zig 81 passed / 2 skipped, make test 1241 passed /
5 skipped.
scripts/bench_tick.py dlopens a compiled kernel, drives shinro_step with seeded
inputs and recurrent state feedback, and reports ns/tick min/median/p99. It is
the A/B yardstick for the VM lowering changes measured in the lab notes.
MD060 table-separator spacing; content unchanged.
@adilfaisal01
adilfaisal01 merged commit d1dc7dc into main Sep 18, 2026
0 of 3 checks passed
@adilfaisal01
adilfaisal01 deleted the feat/onnx-graph branch September 20, 2026 20:23
Sign up for free to 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