feat(deps): drop the five platform runtimes onnxruntime-node ships to everyone - #299
Conversation
… 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.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
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. Comment |
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.
Runner confirmation: the 507 prediction was exactThe 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 507 predicted, 507 measured. The gate's own log line shows the prune doing the work in-place — The The one red was G-ACQUIRE, and it is not this change
Component diff. Mine was This gate's own spread, across 8 runs on 5 branches (4 of them on the base branch itself):
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 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 explainEvery CI job reports Falsified locally, all four giving all six platforms / 212 MiB:
So it is not npm version, not 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 |
onnxruntime-node@1.21.0is the largest package in the production tree — 211.5 MiB of a 681 MiB install — and 207.8 MiB of that isbin/napi-v3: six prebuilt runtimes, of which exactly one can ever be loaded.The mechanism, established before anything was designed
overrides/ range-dedup remove it?dependenciesentry of BOTH@huggingface/transformersandfastembed, each pinning the exact string"1.21.0".os/cpu?os: ["win32","darwin","linux"], nocpu. npm's filtering is per-package, not per-directory — a package declaring all three installs in full on all three.os/cpu-gated arrangement?sharpshows it working — 24 platform-scoped optional deps, of which this host installs 2 — but that is onnxruntime-node's packaging decision, not ours.dist/binding.js:require(`../bin/napi-v3/${process.platform}/${process.arch}/onnxruntime_binding.node`). The only reference tobin/napi-v3in the package'sdist/. 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=devinto an empty dir, postinstall on,du -sk:Per-package diff is exactly one line. Nothing else moved, nothing added, nothing removed. On the gate's own
--ignore-scriptsprotocol: 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 atnew Database()). Both production paths run a real ORT session, with output validated semantically:Bit-identical.
And the proof is not self-satisfying. Hiding
darwin/arm64reds both production paths —fastembedand@huggingface/transformers, each with no explicit ort import — withCannot 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_modulescopyingIt breaks — and it already did. The tree was host-locked in two production deps before this PR:
better-sqlite3/build/Release/better_sqlite3.node→Mach-O 64-bit bundle arm64(singleprebuild-installbinary chosen at install time)@img/sharp-darwin-arm64→os:['darwin'] cpu:['arm64'], resolved by npm's own filteringCopying
node_modulesto another platform, or into a container differing from the install host, was already broken for wigolo. This moves the failure frombetter_sqlite3.nodetoonnxruntime_binding.node; it does not create a new class of it. The supported fix is unchanged: install on the target.WIGOLO_SKIP_ORT_PRUNE=1opts out for anyone deliberately building a multi-arch tree.Fail-open throughout
--ignore-scriptsinstallers simply keep the bytes.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:
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_DROPPEDcomposes 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-webis now the largest package at 92 MiB — a@huggingface/transformersdependency carrying WASM builds for browsers, in a Node-only server.