Repository navigation
exp: in-process LLVM backend — stop shipping IR as text (Phase 0: feasible, byte-identical objects) #7241
Copy link
Copy link
Closed
Description
Branch: exp/llvm-inprocess · Write-up: docs/llvm-inprocess-experiment.md on that branch.
Perry ships LLVM IR as text to a user-supplied clang -c — a runtime, unpinned LLVM dependency (1.4 GB transient IR on the Claude Code bundle, #4880 whole-module -Os demotion, Apple clang 21 vs LLVM 22 parse skew, errors as line numbers in gigabyte files). This experiment builds the module pipeline in-process via the LLVM C API, exactly like the exp/stackmap-viability pattern: isolated branch, opt-in, default path untouched.
Phase 0 answer (feasibility: CONFIRMED)
- Bindings:
inkwell 0.9.0+llvm-sys 221for the few unwrapped calls (LLVMSetGC,LLVMParseCommandLineOptions,LLVMGetVersion). Nothing Perry's IR needs was inexpressible: NaN-box constants keep exact payload bits throughf64::from_bits → const_float(verified end-to-end by running the compiled artifact), inline asm with exact constraint strings, module-level asm, appending@llvm.used, andgc "statepoint-example"all construct, verify, and emit. - LLVM version: 22 (
llvm22-1/ Homebrewllvm22.1.4). Picking 22 removes the version-skew problem — no second toolchain parses our output anymore. - Discovery:
LLVM_SYS_221_PREFIX(orllvm-configon PATH) at build time. - Contributor cost: none by default. The
llvm-inprocesscargo feature is off; the default build links no LLVM and is byte-for-byte unchanged. Enabling it needs LLVM 22 dev libs and adds ~171 MB to a static-linked perry-dev binary.
Parity evidence
- On the same IR + flags, the in-process pipeline emits an object byte-identical to
clang -c(Homebrew clang 22). - 77-test gap-corpus A/B through an unmodified perry (shim as
PERRY_LLVM_CLANG; both caches already key on it): 76/77 outputs byte-identical; the 1 diff is a pre-existingperry-ext-httpSIGABRT whose stderr embeds the OS thread id (differs run-to-run on the same arm). - 26-test slice through the integrated
PERRY_LLVM_INPROCESS=1backend (one binary, flag-only A/B, liveness asserted per compile): 25/26 identical, same pre-existing panic family for the remainder. .ll → parse → printround-trip diff classes are all mechanical (alignment made explicit, attr-group renumbering, float formatting, headers) — the normalization catalogue for a future construction-parity harness.
What's on the branch
experiments/llvm-inprocess-spike/— standalone spike: builder-APIdemo, clang-argv shim, corpus A/B script.crates/perry-codegen/src/inprocess.rsbehind thellvm-inprocessfeature:compile_ll_to_objectgets an in-process implementation (memory-buffer parse → verify →default<O_n>→ object-to-memory; zero disk transit). It interprets the samebuild_clang_compile_planargv as the clang path, so backend decisions can't drift independently; unknown flags are a hard error.PERRY_LLVM_INPROCESSparticipates in build-cache and object-cache keys; a featureless build fails loudly under the flag; first use prints a liveness witness line.
Honest negatives / open work
- Compile-time win not demonstrated at small scale (245 KB–421 KB modules, loaded host): subprocess spawn isn't the bottleneck;
-O3is. The claimed wins live at bundle scale (parse + disk transit), in version control, and in what the seam unlocks (per-function opt vs Compile-time blowup on wide object literals (2100 keys ≈ 2 min, 3000 keys > 7 min) #4880, DWARF viaDIBuilder, in-process RS4GC for gc: RewriteStatepointsForGC over managed-pointer SSA — the measured-only road to native-root file-size parity #7174). Those need a quiet box, ≥25 GB disk, and the gap suite green under the flag before any number is quoted. - The brief's instruction-level seam ("lowering ↔ IR construction") is not where this codebase's seam is: the emitter is ~340k lines of string-oriented code, and
compile_ll_to_objectis the boundary the code itself documents as "the seam". Recommendation in the write-up: land the transport seam opt-in, profile string-build cost through it at scale, and only then decide the construction rewrite. -fno-math-errnohas no C-API equivalent; measured byte-identical on macOS (Darwin defaults errno-free) — must be re-measured on Linux.cl::optstate is process-global; Windows/COFF and cross-targets untested; llvm-sys/inkwell version pins are ours to maintain.
Next steps
- Gap suite (full) under
PERRY_LLVM_INPROCESS=1, macOS + Linux. - Claude Code bundle compile through the flag on a quiet box: fill the measurement table (wall/user, peak RSS, transient disk, binary size/sections, runtime + RSS vs pinned Node).
- Coordinate with gc: RewriteStatepointsForGC over managed-pointer SSA — the measured-only road to native-root file-size parity #7174 before scheduling
RewriteStatepointsForGCin-process — that branch owns the statepoint work. - Decide per-function opt levels +
DIBuilderscope from the measured profile.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
exp: link tracking issue #7241 in the write-up
proggeramlug commentedon Aug 2, 2026
Phase 2 landed (opt-in): native IR construction — module-scale text is gone under
PERRY_LLVM_INPROCESS=nativeOwner direction after Phase 0: the transport isn't the lever — making the generated
.lloptional is. That now exists on the branch, defaults untouched.What
=nativedoes: every function body is built through the LLVM C API. The only text that still exists per module is the few-KB skeleton (globals, declares incl. synthesized ones for defines, attribute groups) plus transient per-function scaffolding that is dropped immediately after construction. The 1.4 GB-scale module concatenation, the disk transit, and the full-grammar LLVM parse no longer exist on this path.--trace llvm/PERRY_LLVM_KEEP_IRremain the debug view.Architecture (differs from the original brief's builder-trait seam, deliberately): two audits showed 96.4% of instruction emission (6,097 call sites) already funnels through ~40 semantic methods on
LlBlock, butLlFunction::to_ir()performs load-bearing post-render passes (entry-alloca hoists, boundary splices, per-retshadow-frame rewrites, setjmp volatile promotion). So the port point is the finalized function: a bounded dialect reader (perry-codegen/src/dialect.rs, census-driven, fails loudly on unknown forms) consumes each finalized function and constructs it natively — those transforms keep exactly one implementation. Next step to kill the per-line scaffolding too: typedLlInstvariants insideLlBlock, migrated method-by-method with the diff harness as gate; the per-moduleinstructions=counter is the ratchet.Corpus-measured traps encoded in the reader (each cost a real failure first):
idispatch.towershape) → placeholder-RAUW, LLParser-style. 73% of phi incomings are forward references.js_native_call_valuedeclared(double,i64,i64), called with(double,ptr,i64)) → call types derive from callsites.zext i1 false,select i1 false, …, constant GEPs), so pre-opt print comparison is structurally impossible →=diff's verdict is emitted object bytes (pre-opt IR dumps viaPERRY_LLVM_DIFF_DIRremain the localization artifact).externalis an explicit linkage keyword oninit_bodydefines; atomic load ordering trails the pointer operand.Results:
.llcorpora (9,110+ instructions) constructs natively and passes the verifier.spike.ts: text-parse arm and native-construction arm emit byte-identical objects; the native binary matches the pinned Node oracle.batch.ts: object bytes differ by 336 B of tighter native code — verified at disassembly level to be regalloc/layout divergence downstream of pre-folded constants, same symbols; all pre-opt diff lines are the folding class, none are construction bugs.=native(one binary, flag-only A/B, per-compile liveness asserted): 26/26 byte-identical outputs (one initially failed compile exposed the linkage + skeleton-declare bugs, fixed; one harness false-negative was a warm object cache suppressing the liveness line — re-verified cold).cargo test -p perry-codegen --features llvm-inprocess: 528 passed. Default (featureless) build unaffected.Still open before any default flip: full 466-test gap suite under
=native(macOS + Linux), bundle-scale compile with RSS measurement (the folding-divergence class should also be re-checked at that scale), the typed-LlInstmigration to remove per-function transient text, per-function opt levels / DWARF / RS4GC (#7174 coordination) that the in-memory module now makes reachable.proggeramlug commentedon Aug 2, 2026
Full gap-suite gate under
PERRY_LLVM_INPROCESS=native: closed (macOS)Full 466-test A/B (text arm vs native-construction arm, same binary, flag-only difference, per-compile liveness asserted):
server.rs:911SIGABRT and 1× tokio listener panic (stderr embeds the OS thread id), 1×console.time()wall-clock jitter (0.032 vs 0.033 ms).The full sweep earned its keep twice over the sampled slices:
(number).set is not a function): the forward-reference placeholderselect true, undef, undefwas constant-folded by the C-API builder to the uniqued constantundef— un-RAUW-able and verifier-clean, silently substitutingundeffor every non-phi forward reference. Also explains the earlierbatch.ts336-byte object delta previously misread as benign regalloc divergence: it was this bug deleting never-executed code. Fixed (placeholder is now aloadfrom a scratch alloca — unfoldable, erased on resolution; use/def type mismatch is a hard error). Post-fix, both corpora emit byte-identical objects across construction paths, and the harness rule is strict: byte mismatch = bug, there is no benign class. Localization recipe that found it:=diffdumps +opt -passes=instcombineover BOTH arms + diff (representation folds vanish, semantics survive).@llvm.smax.i32/smin.i32calls with no textual declare — LLVM's.llparser auto-declares intrinsics at first use, so the text path never noticed. The reader now mirrors that (forllvm.*names only; other undeclared callees remain loud errors).Also landed since the last update: typed
LlInststorage behind the emit choke point (Raw variant, byte-identical by the 528-test gate) andLlFunction::for_each_final_line— a single finalized-order visitor shared byto_irand the native builder, which now streams lines directly into the C API with no per-function text materialized (exception:has_tryfunctions, whose setjmp volatile pass needs whole-function analysis). Remaining transient text is per-lineRawformatting, removed opcode-by-opcode as typed variants land; per-module instruction counts are the ratchet.Still open before any default consideration: Linux run of this same sweep, bundle-scale compile with RSS measurement on a quiet box, and the typed-variant migration of the ~40
LlBlocksemantic methods.proggeramlug commentedon Aug 3, 2026
Current state + Linux bring-up (handoff)
Branch
exp/llvm-inprocess@2c2c5c0f0is fully pushed. Summary of where things stand (details in the two comments above):Works, validated (macOS/arm64):
PERRY_LLVM_INPROCESS=nativebuilds all function bodies through the LLVM C API for standard single-module compiles — full 466-test gap A/B: 459 byte-identical, 0 compile failures, 0 backend divergences (7 residuals are pre-existing panic/thread-id + timer noise). Both benchmark corpora emit byte-identical objects across construction paths.=diffis the harness (object-byte verdict;PERRY_LLVM_DIFF_DIRdumps both arms).Known gaps (honest list):
format!-ed once and parsed by the dialect reader. The typed-LlInstmigration (~40LlBlocksemantic methods; storage + streaming visitor already landed) removes it.has_tryfunctions additionally materialize function text (setjmp volatile pass needs whole-function analysis).=native:PERRY_LLVM_KEEP_IR,PERRY_SAVE_LL,--trace llvmcurrently no-op silently there (they sit on the text path). Small fix: print from the constructed module. Until fixed, use=diff+PERRY_LLVM_DIFF_DIRfor inspection.Linux bring-up (fresh clone is all you need — nothing lives only on the Mac):
Linux test plan, in order:
cargo test --profile perry-dev -p perry-codegen --features llvm-inprocess --lib— 528 tests incl. the corpus gates (the corpus.llfiles are now tracked; if these print "skipping", that is a bug, not a pass).experiments/llvm-inprocess-spike/spike.tswith and withoutPERRY_LLVM_INPROCESS=native(setPERRY_RUNTIME_DIR=$PWD/target/perry-dev,PERRY_NO_AUTO_OPTIMIZE=1); outputs must match and the native run must printperry: in-process LLVM backend active (LLVM 22.x).=diffbyte-verdict on spike +benchmarks/app-patterns/kernels/batch.ts— expect[ir-diff] OK. (Two ELF traps pre-fixed on the branch: diff arms share one module identifier since ELF records it in STT_FILE; the A/B harness now usestimeoutand a portable disk probe.)experiments/llvm-inprocess-spike/batch_ab.sh /tmp/gap-linux flag 1 0withINPROCESS_VALUE=native. Compare against the macOS triage above; anything outside the known noise families is new signal..ll(PERRY_LLVM_KEEP_IR=1on a text-path compile), thenclang-22 -c -O3 -mcpu=native x.ll -o a.ovs the same plus-fno-math-errno, andcmp a.o b.o. On macOS they are byte-identical (Darwin defaults errno-free); if they differ on glibc, the in-process pipeline needs an equivalent knob before Linux parity can be claimed.Merge-to-main (opt-in) checklist beyond Linux: CI arm building
--features llvm-inprocess+ running the suite + one liveness-asserted=nativesmoke (land non-required, then promote); changelog.d fragment; reviewer callout that theLlInst/visitor refactor touches the default path'sto_ir(byte-identity pinned by the 528-test suite); security-audit green with inkwell 0.9/llvm-sys 221 as optional deps.proggeramlug commentedon Aug 3, 2026
This is now layer 0 of the consolidated GC plan — see
docs/src/internals/rfc-rooting-by-construction.md("Where this sits in the one GC correctness plan", landed in #7292).Its importance is larger than "stop shipping IR as text". #7108 measured statepoint/stack-map GC as viable but blocked, concluding that "the text-IR-plus-stock-clang architecture is what rules the cheapest design out". This experiment removes that constraint, and its own Phase 0 independently verified that
gc "statepoint-example"constructs, verifies and emits through the C API.⇒ Statepoints (#7108/#7174) depend on this landing first. That makes this the enabler for the layer that would eliminate an entire class of GC bugs by construction — #7280, #7271, #7252 and #7243 all become unrepresentable once the emitted code's liveness is tracked by LLVM rather than by a hand-maintained shadow stack.
Worth stating so it is weighed: the payoff is not only the IR-as-text problems (1.4 GB transient IR, #4880's whole-module
-Osdemotion, errors as line numbers in gigabyte files) but control of the pass pipeline and stackmap emission, which is what makes the 4.5–16.6 MB metadata cost negotiable rather than fixed.proggeramlug commentedon Aug 3, 2026
Linux bring-up: closed. 459/466 A/B,
-fno-math-errnoanswered, harness made portable + resumableMeasured at
89b7dd191on Fedora 43 / x86_64 / glibc / LLVM 22.1.8 / ELF — i.e. before the five commits the Mac landed while this ran (typedLlInst, unit-split native, RS4GC). Scope note at the bottom.Test plan results, in the handoff's order
perry-codegen --features llvm-inprocess --libspike.tstext vs=native=diffbyte verdict[ir-diff] OKonspike.ts(31,929 B) andbatch.ts(84,345 B)=native-fno-math-errnoon glibcThe two construction paths are byte-identical on ELF/x86-64, not just Mach-O/arm64. That is the result I'd most want carried forward: the placeholder-RAUW, callsite-derived call types, and skeleton-declare machinery are not accidentally Mach-O-shaped.
The 7 residuals are not backend-attributable — proven, not asserted
Same three families as macOS, and I applied the branch's own standard rather than pattern-matching them: re-ran one arm's binary twice and diffed it against itself. All 7 differ against themselves.
perry-ext-httpserver.rs:911SIGABRT (exit 134 on both arms) — stderr embeds the OS thread idlistener.rs:304panic,test_gap_net_connect_bound_value(exit 124 both arms)console.time()wall-clock jitter —test_gap_console_methods, 0.008 vs 0.009 ms; the same binary re-run gives 0.002 vs 0.001 ms-fno-math-errno: closed, and for a structural reasonThe open question assumed the risk was "glibc defaults the other way". The real answer is that the flag has nothing to act on: it only governs whether LLVM may treat a call to a named libm function as errno-free, and Perry never emits one. Math lowers either to
llvm.*intrinsics (errno-free by definition) or tojs_math_*runtime helpers, whose libm behavior lives insidelibperry_runtime.aand is not produced by this compile at all.Measured, not just argued — on a 431 KB real module and on a probe exercising every
Math.*entry point plusfrem(10 intrinsic call sites, 6frems, zero named libm callees): objects byte-identical with and without the flag, under both clang 22.1.8 and clang 19. The in-process backend needs no equivalent knob on glibc.The load-bearing property is "no named libm callee in emitted IR". If the emitter ever gains one, this needs re-measuring — worth a cheap assertion if this graduates.
Fedora needed a route the handoff's recipe doesn't cover
Fedora 43 has no LLVM 22 package at any version (repos stop at
llvm20-devel; system libs are 21), soapt.llvm.orghas no analogue. Used the upstream release tarball (LLVM-22.1.8-Linux-X64.tar.xz, ~12 GB unpacked, ships headers +llvm-config+ staticlibLLVM*.a+ clang 22).One trap worth knowing before someone else loses an hour: that tarball is built on Ubuntu, so its baked-in
llvm-config --system-libsnames the build host's static zstd by absolute path (/usr/lib/x86_64-linux-gnu/libzstd.a). llvm-sys panics on any--system-libsentry that is neither-lfoonor an existing file, so on any non-Debian distro the build dies before it starts. Fix is a one-file shim prefix whosebin/llvm-configrewrites that to-lzstd; documented in the write-up.Feature build cost here:
perry199 MB (vs 53 MB default), 2m49s atperry-dev.Harness fixes (
89b7dd191)batch_ab.shcould not start on Linux at all:TIMEOUT=$(command -v "$TIMEOUT")dies underset -u(the intent was coreutils' macOSgtimeout), andWTwas a hardcoded/Userspath. Both fixed; the free-space probe now looks at the filesystem the work dir is actually on rather than/.Two guards earned the hard way: a concurrent cargo build pushed this box into the OOM killer, which killed six compiles mid-link. They landed in
results.txtasCFAIL_BOTH— after the fact, indistinguishable from six real pre-existing failures — and cost a full restart of a ~72-minute run. So the harness now resumes (tests already recorded are skipped) and never scores a compile that died on a signal: it waits for memory, retries once, and if it dies again recordsOOM_INFRAon its own summary line, a category no backend conclusion may be drawn from. Both counters read 0 on the clean run.Also removed a vacuous green: the corpus gate's missing-file branch printed "skipping" and returned pass. The corpora have been tracked since
4f001d39e, so absence means a broken checkout — it is a hard error now. That branch was exactly what the handoff told me to rule out by hand.Peak RSS per compile (gap-test scale)
244 MB text arm vs 273 MB native arm — the in-process context costs ~29 MB here. Says nothing about the bundle-scale RSS question, which is still open.
Scope limits
PERRY_NO_AUTO_OPTIMIZE=1on both arms.89b7dd191unit-split modules still fell through to transport, so the sweep did not exercise native construction on them.e370f5554has since ported that path.Suggested next on Linux
LlInstmigration and the unit-split native path are both unvalidated on ELF, and the unit-split one is the bundle-scale shape.PERRY_CODEGEN_UNITS=3=diffon Linux, mirroringe370f5554's macOS check.exp: link tracking issue #7241 in the write-up
In-process LLVM backend: native construction via the C API, opt-in (#…
proggeramlug commentedon Aug 5, 2026
Shipped. The experiment landed opt-in in #7301/#7304 and became the default in #7353 —
perry-codegen/Cargo.tomlnow carriesdefault = ["llvm-inprocess"]withinkwell/llvm-sysas real dependencies. Closing as delivered.