Skip to content

feat(deps): drop the five platform runtimes onnxruntime-node ships to everyone - #299

Merged
KnockOutEZ merged 4 commits into
studio-handofffrom
slice-ort-platform-repack
Aug 11, 2026
Merged

feat(deps): drop the five platform runtimes onnxruntime-node ships to everyone#299
KnockOutEZ merged 4 commits into
studio-handofffrom
slice-ort-platform-repack

Conversation

@KnockOutEZ

Copy link
Copy Markdown
Owner

onnxruntime-node@1.21.0 is the largest package in the production tree — 211.5 MiB of a 681 MiB install — and 207.8 MiB of that is bin/napi-v3: six prebuilt runtimes, of which exactly one can ever be loaded.

The mechanism, established before anything was designed

question answer
can overrides / range-dedup remove it? No. Hard dependencies entry of BOTH @huggingface/transformers and fastembed, each pinning the exact string "1.21.0".
does the package declare os / cpu? os: ["win32","darwin","linux"], no cpu. npm's filtering is per-package, not per-directory — a package declaring all three installs in full on all three.
is there an os/cpu-gated arrangement? Upstream would have to adopt one. sharp shows it working — 24 platform-scoped optional deps, of which this host installs 2 — but that is onnxruntime-node's packaging decision, not ours.
how is the binary selected? One line, dist/binding.js: require(`../bin/napi-v3/${process.platform}/${process.arch}/onnxruntime_binding.node`). The only reference to bin/napi-v3 in the package's dist/. Nothing enumerates the directory.

So the only lever is a prune, and it runs as a postinstall.

Measured, on a real clean production install

npm ci --omit=dev into an empty dir, postinstall on, du -sk:

total onnxruntime-node
before 698400 KB / 683 MiB 216536 KB
after 516928 KB / 505 MiB 35064 KB

Per-package diff is exactly one line. Nothing else moved, nothing added, nothing removed. On the gate's own --ignore-scripts protocol: 681 → 503 MiB, and that 681 reproduces S10-e's recorded local figure to the MiB.

Proof that real inference still works

Not a require() that succeeds — this program has been burned by exactly that (better-sqlite3's require succeeds on an ABI-mismatched build and only fails at new Database()). Both production paths run a real ORT session, with output validated semantically:

                          BEFORE                AFTER
embedding dims            384                   384
non-zero activations      384/384               384/384
cos(cat, feline)          0.7503                0.7503
cos(cat, revenue)         0.3669                0.3669
rerank logits             9.6047 / -11.4259     9.6047 / -11.4259
                          15/15 pass            15/15 pass

Bit-identical.

And the proof is not self-satisfying. Hiding darwin/arm64 reds both production paths — fastembed and @huggingface/transformers, each with no explicit ort import — with Cannot find module '../bin/napi-v3/darwin/arm64/onnxruntime_binding.node'. Restoring it greens them. So the kept directory is load-bearing, and inference genuinely routes through it rather than through some other backend.

What breaks for cross-platform node_modules copying

It breaks — and it already did. The tree was host-locked in two production deps before this PR:

  • better-sqlite3/build/Release/better_sqlite3.nodeMach-O 64-bit bundle arm64 (single prebuild-install binary chosen at install time)
  • @img/sharp-darwin-arm64os:['darwin'] cpu:['arm64'], resolved by npm's own filtering

Copying node_modules to another platform, or into a container differing from the install host, was already broken for wigolo. This moves the failure from better_sqlite3.node to onnxruntime_binding.node; it does not create a new class of it. The supported fix is unchanged: install on the target. WIGOLO_SKIP_ORT_PRUNE=1 opts out for anyone deliberately building a multi-arch tree.

Fail-open throughout

  • Planner refuses to remove anything when it cannot find the host pair — including the case where the host platform is present but the host arch is not. A tree we cannot prove has a usable binary is a tree we must not touch.
  • A removal that throws is logged and skipped.
  • The driver never exits non-zero. --ignore-scripts installers simply keep the bytes.
  • Idempotent: npm re-runs postinstalls on installs that did not re-extract.

G-DIET re-derived: 693 → 515

The gate installs with --ignore-scripts, which suppressed our postinstall too — leaving the win ungated. The runner now invokes the prune explicitly (deterministic, offline, ours; it makes the measured tree closer to a real install, not further).

The gate now guards two reversions, and the smaller one binds:

regression lands at
A. prune stops happening (178 MiB) 685
B. browser driver back on the default path (17 MiB) 524

Sizing against the 178 MiB just won is the tempting move and it is how B stops being caught: clean + a tenth of the saving = 525, above the 524 that B lands on. The window is 507..524 and 515 keeps the previous threshold's exact shape — 8 MiB headroom, 9 MiB margin.

507 is a prediction (laptop 503 + the 4 MiB laptop→runner offset S10-e measured), stated as one; the anchor moves to the runner's number the moment CI produces one.

Blast radius caught by the suite

G_TOTAL_DESKTOP_DROPPED composes G-DIET's limit, and its assertion redded on the first run (1315 against a stale 1493). Re-derived: today 1271, post-flip 1025, joint bound 1315, window 44 MiB — unchanged, because it is the sum of each gate's own headroom and this slice kept G-DIET's at 8 deliberately. Worth flagging: the spec's dropped 1000 was "unreachable in either world" at a post-flip 1203; it now stands at 1025, a 25 MiB margin rather than 203.

Next lever

onnxruntime-web is now the largest package at 92 MiB — a @huggingface/transformers dependency carrying WASM builds for browsers, in a Node-only server.

… everyone

onnxruntime-node@1.21.0 is the largest package in the production tree — 211.5
MiB of a 681 MiB install — and 207.8 of that is bin/napi-v3, six prebuilt
runtimes of which exactly one can ever load. On darwin-arm64 the host pair is
30.6 MiB, so 178 MiB is shipped to every user to be read by nobody.

It cannot be fixed in the manifest. The package is a hard dependencies entry of
BOTH @huggingface/transformers and fastembed, each pinning the exact string
"1.21.0", so there is no range to dedup and no overrides target. It declares
os: [win32, darwin, linux] and no cpu, and npm's os/cpu filtering is per-package
rather than per-directory — sharp shows the arrangement that would work, 24
platform-scoped optional deps of which this host installs 2, but that is
upstream's packaging decision to make.

Selection is therefore purely a runtime concern, and it is one line in
dist/binding.js: require(`../bin/napi-v3/${process.platform}/${process.arch}/…`).
That is the only reference to bin/napi-v3 in the package's dist, so a sibling
that is absent is a sibling nothing looks for. Verified by falsification rather
than by reading: hiding darwin/arm64 reds both production paths with
MODULE_NOT_FOUND from binding.js, and restoring it greens them.

Measured on a real clean production install (npm ci --omit=dev, postinstall on):
683 -> 505 MiB, per-package diff exactly one line, onnxruntime-node 216536 ->
35064 KiB, nothing else moved. Real inference before and after is bit-identical
— BGE-small embeddings 384-dim, 384/384 non-zero, cos(paraphrase)=0.7503 vs
cos(unrelated)=0.3669; cross-encoder logits 9.6047 / -11.4259.

The pruned tree is bound to the platform that installed it, which it already was:
better-sqlite3 ships a single prebuild-install binary and sharp resolves through
npm's own os/cpu filtering, both measuring Mach-O arm64 here. Cross-platform
node_modules copying was already broken for wigolo; the failure moves rather than
appears. WIGOLO_SKIP_ORT_PRUNE=1 opts out.

Fail-open throughout: the planner refuses to remove anything when it cannot find
the host pair, a removal that throws is skipped, and the driver never exits
non-zero. A size optimisation that can fail an install is a worse trade than the
bytes it saves.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c36c3ac8-ef4e-4e73-bbb4-3ab402cbd2b4

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

The gate installs with --ignore-scripts, which suppressed OUR postinstall along
with everyone else's. That left the 178 MiB win both unmeasured and ungated: the
gate could not see the prune, so it could not see the prune being removed. The
runner now invokes scripts/prune/run.mjs itself. --ignore-scripts stays, because
it keeps third-party postinstalls and their network access out of a gate that
must not flake; ours is deterministic, offline, and ours, and running it makes
the measured tree closer to what a user installs rather than further from it.

The gate now guards two reversions and the SMALLER one binds. The prune stopping
is worth 178 MiB and lands at 685, which nearly any limit catches. The browser
driver returning to dependencies is worth 17 and lands at 524. Sizing the new
headroom as a share of the 178 just won — the natural move, because the total
moved so far — gives 507 + 17.8 = 525, which sits ABOVE 524, so the gate would
sail past the exact regression it was guarding before this slice. The window is
507..524, no wider than the 685..702 it replaces, and 515 keeps the same shape:
8 MiB of headroom, 9 MiB of margin.

507 is a PREDICTION — laptop 503 plus the 4 MiB laptop-to-runner offset S10-e
measured — and is recorded as one. What licenses carrying the offset is that the
same laptop reproduces S10-e's pre-prune 681 exactly, so it is being applied to a
number known to sit where it did when it was taken. If the runner reads higher,
the anchor is the runner and 515 moves; the 524 ceiling does not.

run.mjs takes an explicit resolution base for this. Without it the gate would
resolve through the CHECKOUT's node_modules and prune the developer's own tree
while reporting on a temporary one.

G_TOTAL_DESKTOP_DROPPED composes G-DIET's limit and its assertion redded on the
first run, 1315 against a stale 1493 — which is what that assertion is for. Today
507 + 764 = 1271, post-flip 507 + 518 = 1025, joint bound 515 + 800 = 1315. The
failure window is 44 MiB in both derivations, because it is the sum of each
gate's own headroom and this slice kept G-DIET's at 8 deliberately, so the
decision to drop the composed gate stands on the argument it already stood on.
Noted for the next slice: the spec's 1000 was dropped as unreachable when
post-flip stood at 1203; it now stands at 1025.
It accumulated across roots, so in a tree where hoisting was defeated the second
copy's log line would report the first copy's bytes as well as its own. Log-only,
but locateOrtRoots exists precisely to handle that tree and the report should not
misdescribe it.
…yout

`Dirent.isDirectory()` describes the LINK, not its target, so a platform
directory materialised as a symlink was invisible to the tree reader. The
consequence is not a broken install — the planner refuses when it cannot see the
host pair, so nothing gets deleted — but the prune then silently does nothing,
and a size optimisation that quietly no-ops is worse than one that fails loudly.

Forced the condition rather than reasoning about it: a fixture whose host
platform dir is a symlink and whose foreign one is real. Before, the driver
reports "host pair darwin/arm64 absent — refusing to prune" and keeps every byte;
after, "kept darwin/arm64, removed linux/x64". `statSync` follows the link and
answers the question actually being asked — can this be descended into.

Found while investigating a separate, still-unexplained observation: every CI job
reports only its own host pair present after `npm ci`, while the budget gate's
own install on the SAME runner sees all six (685 MiB pre-prune). Symlinks do not
explain that — it predicts the opposite pattern, the host being the hidden one —
so this closes a real hole it did not cause.
@KnockOutEZ

Copy link
Copy Markdown
Owner Author

Runner confirmation: the 507 prediction was exact

The threshold was set from a prediction (laptop 503 + the 4 MiB laptop→runner offset S10-e measured) and flagged as one. The runner has now measured it, on the clean-machine-smoke macos-latest / node 22 arm:

base  studio-handoff   PASS G-DIET  685 MiB   (onnxruntime-node 212 MiB — all six platforms)
base  re-run           PASS G-DIET  683 MiB   (the ~2 MiB run-to-run variance the protocol records)
tip   this branch      PASS G-DIET  507 MiB   detail: pre-prune 685 MiB, post-prune 507 MiB

507 predicted, 507 measured. The gate's own log line shows the prune doing the work in-place — kept darwin/arm64, removed darwin/x64, linux/arm64, linux/x64, win32/arm64, win32/x64 (177 MiB) — so the delta is observed on the gating machine, not carried from a laptop. 515 stands as derived; no re-anchoring needed.

The largest packages line is now led by onnxruntime-web at 92 MiB, with onnxruntime-node down to 35 MiB in fifth place.

The one red was G-ACQUIRE, and it is not this change

clean-machine smoke (macos-latest, node 22) failed its first attempt on G-ACQUIRE 803 MiB vs <= 800. G-ACQUIRE measures bytes warmup downloads into ~/.wigolo and the browser cache — a different artifact from node_modules entirely. Classified rather than assumed:

Component diff. Mine was browsers +559, data +244. Base was browsers +545, data +245. The excess is entirely in browsers; data — the only component that could conceivably relate to an ONNX runtime — was lower than base.

This gate's own spread, across 8 runs on 5 branches (4 of them on the base branch itself):

total 765 767 768 775 782 784 790 792
browsers 547 540 550 548 541 556 545 550

27 MiB of observed spread against 8 MiB of headroom under its 800 limit, with a documented baseline of 764 and a clear upward drift (studio-handoff alone went 765 → 775 → 784 → 790 across one day). The browsers component spans 16 MiB on unrelated branches.

Re-run: passed. Base re-run measured 788; this branch's re-run of the same job passed.

I have not touched G-ACQUIRE — loosening a gate to go green is the wrong move and it is not my slice. Flagging it: G-ACQUIRE's threshold now sits inside its own drift band and is going to red somebody's clean build. It needs the same re-derivation treatment G-DIET just got.

One thing I could not explain

Every CI job reports onnxruntime platform prune — only <host pair> present — nothing to do after npm ci on the checkout — macOS→darwin/arm64, ubuntu-x64→linux/x64, ubuntu-arm→linux/arm64, windows→win32/x64. Yet the budget gate's own install on the same runner in the same job sees all six (685 MiB pre-prune).

Falsified locally, all four giving all six platforms / 212 MiB:

  • npm ci --omit=dev (scripts on)
  • npm ci with devDependencies (CI's exact command)
  • npm install --omit=dev --ignore-scripts (the gate's protocol)
  • npx npm@10.9.8 ci --omit=dev — the runner's exact npm version

So it is not npm version, not ci vs install, not --omit=dev, not scripts. The published tarball does ship all six (unpackedSize 217,912,684 / 40 files). Unresolved, and stated as unresolved.

It does not affect anything measured here — the win is taken by the gate on the runner (685→507, base corroborated twice) and reproduced four ways locally — and the prune behaves correctly in that environment: it finds the host pair, refuses to touch anything, and reports a no-op. That is the idempotent path running in production on four operating systems.

The symlink hardening in d06b8c21 came out of investigating this. It is a real hole — Dirent.isDirectory() is false for a symlink to a directory, which would make the prune silently no-op — but it predicts the opposite pattern (host hidden, not foreign hidden), so it is not the explanation. Verified against a forced fixture: before, refusing to prune; after, kept darwin/arm64, removed linux/x64.

@KnockOutEZ
KnockOutEZ merged commit 260bbae into studio-handoff Aug 11, 2026
18 checks passed
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