In-process LLVM backend: native construction via the C API, opt-in (#7241, engine-plan layer 0) - #7301
Conversation
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: ⛔ Files ignored due to path filters (2)
📒 Files selected for processing (25)
📝 WalkthroughWalkthroughThe PR adds an opt-in LLVM 22 in-process backend. It introduces typed LLVM instructions, native module construction, in-process object emission, native and differential modes, cache-key integration, and validation tooling. ChangesIn-process LLVM compilation
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related issues
Possibly related PRs
Suggested labels: ✨ Finishing Touches 💡 1🛠️ Fix failing CI checks 💡
📝 Generate docstrings
🧪 Generate unit tests (beta)
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 |
|
@coderabbitai review |
|
✅ Action performedReview finished.
|
Standalone (own workspace) inkwell 0.9 + LLVM 22 binary with three modes: --version clang-compatible banner, 'demo' builder-API construction of the brief's can-it-express-X list, and a clang-argv shim that compiles Perry's .ll in-process (parse -> verify -> default<O3> -> TargetMachine emit) so PERRY_LLVM_CLANG=<shim> A/Bs the backend through an unmodified perry. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
Demo proves NaN-box constant bit fidelity (f64::from_bits -> const_float preserves 0x7FFC000000000001 payloads end to end), inline asm, module asm, appending @llvm.used and gc attrs all expressible (LLVMSetGC via llvm-sys). Shim arm produces objects BYTE-IDENTICAL to Homebrew clang 22 on the same IR/flags; full perry compile via PERRY_LLVM_CLANG=<shim> yields output byte-identical to the text path and to pinned Node 26.5.1. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
New perry-codegen 'llvm-inprocess' cargo feature (inkwell 0.9 / LLVM 22, optional deps — the default build links no LLVM and is unchanged). Truthy PERRY_LLVM_INPROCESS routes compile_ll_to_object through the C API: parse from memory, verify, default<ON> pipeline, TargetMachine emission — no .ll on disk, no clang subprocess. Decisions (opt level incl. #4880 fallback, -mcpu, inlinehint) are interpreted from the same build_clang_compile_plan argv so the backends cannot drift independently. Flag joins both cache keys; a build without the feature fails loudly instead of falling back. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
Integrated flag validated one-binary: liveness line, byte-identical program output, node-oracle match, distinct object-cache keys observed. 26-test corpus slice through PERRY_LLVM_INPROCESS=1: 25/26 byte-identical, 26/26 proven live, the 1 diff is the pre-existing perry-ext-http panic family (thread-id-only delta, reproduced arm-vs-itself). Featureless build fails loudly under the flag and is untouched without it. inkwell 0.9 NUL-buffer requirement fixed; fmt applied; perry-codegen 526 + perry 881 tests pass. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
PERRY_LLVM_INPROCESS gains 'native' (function bodies built through the LLVM C API; only the module skeleton stays textual) and 'diff' (both arms built in the same LLVM, normalized prints compared, text arm's object returned). Skeleton = LlModule::skeleton_ir; plan argv comes from the same build_clang_compile_plan decision code via native_plan_args (size input = the render-free estimate). inprocess.rs split into parse_ir_text + optimize_and_emit_module for reuse. dialect.rs is a loud-failing stub so the new modes cannot silently no-op before the reader lands; unported paths (unit-split, emit_ir_only) fall through to in-process transport. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
Bounded reader for perry's IR dialect (census-driven): typed operand resolution with per-function %name maps, placeholder-RAUW for non-phi cross-block forward references (the idispatch tower shape), deferred phi incomings, callsite-derived call types (opaque-ptr semantics: a direct call's fn type is the callsite's, not the declare's — measured on js_native_call_value), pointer icmp, atomic/volatile loads, fneg, SIMD vectors, pre-declaration of module-internal defines. Corpus gate: every function of spike_text.ll + batch_kernel.ll (9k+ instructions) constructs natively and passes the LLVM verifier. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
Diff mode's verdict is now emitted-object bytes (construction-time constant folding makes pre-opt print comparison structurally impossible; bytes are ground truth, pre-opt IR dumps remain the localization artifact). Reader keeps textual block order under forward references (move_after), applies returns_twice callsite groups, derives call types from callsites. Result: spike.ts arms emit BYTE-IDENTICAL objects; batch.ts differs by 336 bytes of tighter native-arm code (register-allocation divergence downstream of pre-folded constants — all pre-opt diff classes are folding, none are construction bugs). Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
Two fixes from the gap-corpus slice: parse the explicit 'external' linkage keyword init_body defines carry, and parse the skeleton together with synthesized declares for every define — skeleton globals reference defined wrapper functions (extern-closure descriptors), and the declares double as the module-scope forward-reference mechanism, replacing the separate pre-declare loop. dynamic-import multi-module test now compiles natively with output identical to the text arm. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
LlBlock stores Vec<LlInst> instead of Vec<String>; Raw carries the pre-rendered line so zero bytes of IR change (gate: the full 528-test suite, which pins exact rendered IR). Consumers move to render_into / text_len / scan_str so typed variants need no per-line String. This is the enabling step for migrating block.rs's ~40 semantic methods off format! one opcode at a time, with =diff's object-byte verdict as the per-step gate. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
…xt gone LlFunction::for_each_final_line is the single finalized-order visitor (blocks, entry splices, streamed return-site rewrites); to_ir is now that visitor plus header/brace/volatile-pass, so byte-identity is by construction (528-test gate). dialect::FnStream consumes the stream line by line; native mode materializes no per-function text except for has_try functions, whose setjmp volatile promotion needs whole-function analysis. Remaining transient text is per-LINE formatting of Raw insts — removed opcode-by-opcode as typed LlInst variants land. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
The C-API builder constant-folds 'select true, undef, undef' to the uniqued constant undef: un-RAUW-able, verifier-clean, and silently substituting undef for every non-phi forward reference. Caught by the FULL gap sweep as a class/prototype family failing with '(number).set is not a function' (sampled slices never hit the family); it also explains the batch.ts 336-byte object delta previously misread as benign regalloc divergence. Placeholder is now a load from a scratch alloca (real instructions, never foldable, erased on resolution), and a use/def type mismatch is a hard error. Both corpora now emit BYTE-IDENTICAL objects from the two construction paths. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
…chains The text path leans on LLParser auto-declaring @llvm.* intrinsics at first use (perry's clamp lowering emits smax/smin with no textual declare); the reader now mirrors that for intrinsics only — anything else undeclared stays a loud error. Native-path error wrappers print the full anyhow chain ({:#}) so root causes are not swallowed by the outermost context. Fixes the one compile failure in the full sweep (test_gap_6339_i32_arith_chain_copy), now byte-identical. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
The dialect corpus tests read spike_text.ll/batch_kernel.ll — gitignored until now, so a fresh clone skipped them silently (a gate that cannot fail). Track both (~670 KB). batch_ab.sh: gtimeout/timeout detection and a portable free-disk probe replace the macOS-only spellings. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
On Mach-O the module name reaches nothing; on ELF it lands in the object's symtab (#7131), so differently-named diff arms would fail byte-identity on the identifier alone. Same name for both arms. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
…pus skip Everything needed to run the Phase 2 sweep on Linux, found by running it. batch_ab.sh could not start on Linux at all: `TIMEOUT=$(command -v "$TIMEOUT")` dies under `set -u` (the intent was coreutils' macOS `gtimeout`), and WT was a hardcoded /Users path. WT now derives from the script's own location, and the free-space probe looks at the filesystem the work dir is actually on rather than "/" — on a typical Linux box /home is a different, much larger volume. Two guards earned by a real interruption: a concurrent cargo build pushed the host into the OOM killer, which killed six compiles mid-link. Those landed in results.txt as CFAIL_BOTH — indistinguishable, after the fact, from six real pre-existing failures, and they cost a full restart of a ~80-minute run. So: re-running now resumes (tests already recorded are skipped), and a compile that dies on a signal is never scored — it waits for memory, retries once, and if it dies again it is recorded as OOM_INFRA and reported on its own summary line, a category no backend conclusion may be drawn from. The corpus gate's missing-file branch printed "skipping" and returned green. The corpora have been tracked in-tree since 4f001d3, so absence now means a broken checkout, not a branch without artifacts — it is a hard error. That branch was exactly the vacuous green the Linux run had to rule out by hand. Also documents the Fedora LLVM 22 route (no distro package at any version): the upstream release tarball plus a shim prefix, because the tarball's baked-in `llvm-config --system-libs` names its Ubuntu build host's libzstd.a by absolute path and llvm-sys panics on an entry that is neither -lfoo nor an existing file.
Every emit(format!) in the semantic surface becomes a typed push:
Bin/FNeg/FCmp/ICmp/Alloca/Load{5 flavors}/Store/Cast/Select/Call/
CallIndirect/AsmBarrier/Br/CondBr/Ret/RetVoid/Unreachable/Gep/Phi.
Renderers are byte-identical to the replaced format! strings (gate: the
full 528-test suite, which pins exact rendered IR). push_inst preserves
the terminator discipline and try-region store tracking (typed
note_store_ptr twin); contains_gc_unsafe_call answers structurally for
typed calls. text_len renders into a thread-local scratch so the
codegen-unit balancer's sums stay exact. emit/emit_raw remain the Raw
escape hatch (89 bespoke call sites).
Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
FnStream::item consumes FinalItem::Inst directly: every typed variant builds through the C API with no line formatting and no parsing; only labels, entry splices, ret-rewrite lines and Raw payloads remain text. for_each_final_line is now a rendering adapter over for_each_final_item so the two consumers cannot drift. Parser and typed paths both honor !invariant.load (the parser silently dropped it before). finish() returns (typed, raw) counts — the ratchet. Gates: 528 unit tests; =diff object bytes identical on both corpora through the typed path. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
PERRY_SAVE_LL (which --trace llvm sets, #7154) and PERRY_LLVM_KEEP_IR now work under =native/=diff: they print the CONSTRUCTED module — what LLVM actually verifies and optimizes, construction folds included — instead of silently no-opping. Filenames mirror the text path's with a .native infix. This closes the 'debug knobs dead under native' gap: the in-process no-text pipeline and on-demand .ll dumps are now both flags, independently switchable. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
LlModule::codegen_unit_parts extracts the #5391 partition (greedy size-balanced buckets + per-unit skeleton) with render_codegen_units as a thin text renderer over it (existing unit tests pin the bytes). compile_module_units_native builds each unit as its own context+module — same peak-RSS bound as the per-unit clang model — with internal/private definitions promoted exactly like render_fn_external, then partial-links via the extracted linker::merge_unit_objects. =diff gains a unit-split harness (merged-object byte verdict). PERRY_CODEGEN_UNITS=3 on the batch kernel: byte-identical merged objects. The only remaining text fallthrough is emit_ir_only, which by definition wants text. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
rs4gc_schedules_in_process: RewriteStatepointsForGC runs as a module pass through the in-process pipeline at the pinned LLVM (no opt subprocess, no toolchain skew — the exact blocker #7108 measured), a statepoint lands at the may-GC call, and the live addrspace(1) pointer is gc.relocate'd across it. This is the mechanism engine-plan layer 2 (#7174) was waiting on; the test keeps it true. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo
Second OS, second architecture, second object format, same conclusions. Full 466-test gap A/B under =native on Fedora 43 / x86_64 / glibc / LLVM 22.1.8: 459 SAME, 7 DIFF, 0 compile failures on either arm, 466/466 compiles carrying the liveness line. The 7 residuals are the three macOS noise families (perry-ext-http SIGABRT, tokio listener panic, console.time jitter) and each was proven by re-running a single arm's binary twice — all 7 differ against themselves, so none is backend-attributable. =diff reports byte-identical objects on both corpora, so the two construction paths agree on ELF as they do on Mach-O. -fno-math-errno is closed rather than deferred: it only governs whether LLVM may treat a call to a NAMED libm function as errno-free, and Perry never emits one — math lowers to llvm.* intrinsics (errno-free by definition) or to js_math_* runtime helpers, whose libm behavior lives in libperry_runtime.a and is not produced by this compile. Measured on a 431 KB real module and on a probe with 10 intrinsic call sites, 6 frems and zero named libm callees: byte-identical objects with and without the flag, under clang 22 and clang 19. The in-process backend needs no equivalent knob on glibc.
The Linux bring-up needed one artifact that lived only on the box that did it: a wrapper llvm-config. Upstream LLVM release tarballs are built on Ubuntu and report the BUILD HOST's static system libs by absolute path (/usr/lib/x86_64-linux-gnu/libzstd.a), and llvm-sys panics on any --system-libs entry that is neither `-lfoo` nor an existing file -- so on a distro without an LLVM 22 package the build dies before compiling anything. The write-up described the fix in prose, which left the next person to re-derive it. mk_llvm_sys_shim.sh generates the prefix instead. It generalizes past the one symptom: any dangling absolute path in --system-libs becomes `-lname`, while paths that DO exist are left alone, since llvm-sys handles a real absolute path and silently converting a deliberate static link to a dynamic one is not a change to make on the user's behalf. Verified against the hand-written shim the Linux sweep actually built with: identical output for --system-libs/--version/--libdir/--includedir/--prefix/ --libnames, and a full relink of the perry-codegen test binary through the generated prefix resolves dylib=zstd and passes the corpus gates.
7ea8e02 to
2c6ff43
Compare
|
@coderabbitai review |
✅ Action performedReview finished.
|
✅ Action performedReview finished.
|
…7304) * ci: exercise the in-process LLVM backend (kill-policy arm for #7301) Every shipped mode needs a CI arm or deletion; this is the arm for PERRY_LLVM_INPROCESS. macOS runner, LLVM 22 pinned with a loud drift check, feature build, the 528-test suite with the corpus/RS4GC gates asserted to have RUN (not skipped), and a native-mode smoke that asserts liveness, behavior parity, and both object-byte diff verdicts (single module + forced 3-unit split). Non-required until first green, per the new-gate corollary; concurrency never cancels main runs (gate-trap 3). Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo * ci: changelog fragment for #7304 Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo * ci: job-level relevance filter — promotion-safe required check Trigger-level paths on a required check never create a check run for out-of-scope PRs, wedging the required context at 'waiting' forever (CodeRabbit on #7304 — the valid half). Filtering moves to a cheap ubuntu job querying the PR files API; a skipped native-backend job still reports a check run, which branch protection accepts. batch.ts joins the relevant set (the smoke consumes it). Stays non-required until first green per the new-gate corollary — the sequencing half of the suggestion is declined, with this comment as the reason. Claude-Session: https://claude.ai/code/session_01GsQbePfACqFLd4LaGfQzLo --------- Co-authored-by: Ralph Küpper <ralph@skelpo.com>
Resolution: keep the typed-instruction infrastructure; the invoke + inline continuation label ride the Raw escape hatch inside handler scopes (plain calls stay typed). Try-region volatile tracking and has_try die on both sides; native construction bails to the textual path for personality-carrying modules until the line reader learns invoke (follow-up on #7301); synth_define_header carries the personality clause in lockstep with to_ir.
In-process LLVM backend: native construction, opt-in (#7241, engine-plan layer 0)
With the
llvm-inprocesscargo feature built in andPERRY_LLVM_INPROCESS=native, Perry constructs LLVM modules through the C API against pinned LLVM 22 — no module-scale IR text, no.llon disk, noclangsubprocess, no dependence on the user's toolchain.PERRY_SAVE_LL/--trace llvm/PERRY_LLVM_KEEP_IRprint the constructed module on demand as the debug view. The default build is byte-for-byte unchanged and links no LLVM (proof below). Do not merge without maintainer sequencing — this is layer 0 ofdocs/engine-plan.md; layer 2 (statepoints, #7174) is blocked on it.Full experiment record:
docs/llvm-inprocess-experiment.md+ issue #7241 (Phase 0 answers, corpus censuses, the two audits, every bug found and fixed along the way).What's in the PR
inst.rs): all 68LlBlocksemantic methods emit typedLlInstvariants; renderers are byte-identical to the replacedformat!strings (pinned by the 528-test suite).Rawremains for the 89 bespokeemit_rawsites + entry-splice strings; per-module(typed, raw)counts are the migration ratchet.LlFunction::for_each_final_item): one implementation of finalized order + return-site rewrites, shared byto_ir(text) and the native builder — the two consumers cannot drift.dialect.rs): typed instructions build directly (no text);Rawlines go through a bounded, loud-failing parser for exactly perry's dialect. Placeholder-RAUW for non-phi forward references; callsite-typed calls (opaque-ptr semantics);llvm.*intrinsics auto-declared at use (mirroring LLParser);!invariant.loadhonored on both paths.compile_module_units_native): per-unit context+module (same peak-RSS bound as the per-unit clang model), internal definitions promoted exactly likerender_fn_external,ld -rmerge shared with the text path. The only text fallthrough left isemit_ir_only, which by definition wants text.=diffharness: builds both backends in the same LLVM and byte-compares emitted objects (pre-opt prints can't compare — the C-API builder constant-folds at construction);PERRY_LLVM_DIFF_DIRdumps both arms. There is no "benign divergence" class: byte mismatch = bug.rs4gc_schedules_in_processprovesrewrite-statepoints-for-gcruns as a module pass in-process at the pinned LLVM, emits the statepoint, and relocates the liveaddrspace(1)pointer. gc: RewriteStatepointsForGC over managed-pointer SSA — the measured-only road to native-root file-size parity #7174 can proceed on this pipeline.PERRY_LLVM_INPROCESSparticipates in build- and object-cache keys; a featureless build fails loudly under the flag; first in-process compile prints a liveness line.Evidence
Object-level parity:
=diffbyte-identical onspike.ts(21,193 B) and thebatch.tskernel (60,089 B single-module; 56,296 B as a forced 3-unit split).Full gap suite under
=native(macOS/arm64, one binary, flag-only A/B, per-compile liveness asserted): 459/466 byte-identical outputs (stdout+stderr+exit), 466/466 compiles proven live, 0 compile failures (cold caches, resumable-harness accounting: 0 resumed, 0 infra-excluded). The 7 residual DIFFs are pre-existing runtime noise, each verified to reproduce between two runs of the same arm: 5× perry-ext-httpserver.rs:911SIGABRT + 1× tokio listener panic (stderr embeds the OS thread id), 1×console.timewall-clock jitter.Default unchanged, by object comparison: featureless branch-head vs merge-base (
9cb31f1b0) compilers (separate worktrees, separate target dirs, identical package sets, binaries hash-verified different) over a 12-file corpus incl. a forced 3-unit split and a multi-module compile: 10/12 emitted.llbyte-identical outright; the 2 others differ only injs_register_function_namepair order — a pre-existing run-to-run coin (flips between two runs of a single binary at merge-base; cross-arm runs match when the coin agrees). Side-finding relevant to the codegen: object emission is nondeterministic on Linux — the LLVM temp module name embeds pid + nanotime #7131 determinism work, filed in the issue.528 perry-codegen unit tests (incl. two full-corpus construction gates and the RS4GC pin) + 881 perry unit tests green;
cargo fmt --all -- --checkclean; the four lint-gate scripts report identically at the merge-base (no delta from this branch).Linux verified on-branch (
d3b3c4607): Fedora 43 / x86_64 / glibc / LLVM 22.1.8 — full gap A/B 459/466 SAME, 0 compile failures, 466/466 live, same 7 noise families each re-proven against a single arm;=diffbyte-identical objects on ELF as on Mach-O.-fno-math-errnoclosed: Perry never emits named libm callees (only errno-freellvm.*intrinsics andjs_math_*runtime helpers), and objects measure byte-identical with/without the flag under clang 22 and clang 19.Not verified here (explicitly)
emit_ir_only(bitcode-link mode) intentionally remains text.Costs
LLVM 22 dev libs required only when building the feature (
LLVM_SYS_221_PREFIX); ~+171 MB on a static-linked perry-dev binary when enabled; zero cost by default.cl::optpassthrough state is process-global (documented; one env-derived value per process today).Summary by CodeRabbit
New Features
Documentation
Bug Fixes