Skip to content

feat(native): build reusable units in owned compiler sessions - #8980

Open
marsninja wants to merge 67 commits into
jaseci-labs:mainfrom
marsninja:native-unit-model
Open

feat(native): build reusable units in owned compiler sessions#8980
marsninja wants to merge 67 commits into
jaseci-labs:mainfrom
marsninja:native-unit-model

Conversation

@marsninja

@marsninja marsninja commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Native modules now own reusable products in the shared JIR cache, and every native entry point uses the same dependency-aware link plan. A body edit recompiles the affected unit; an unchanged artifact returns before whole-program optimization and object emission. This implements #8943 and removes the per-unit subprocess workaround that made build-kit substantially slower.

Compiler ownership and reuse

  • Build-owned sessions reuse the existing frontend, dependency resolver, scheduler, interface codec, and native registry. Each source is analyzed once. Cyclic interface settlement reruns lowering; optimization and persistence happen after agreement, with source identity checked before publication.
  • Programs own their semantic trees, evaluators, and hydrated catalogs. Immutable catalog bytes remain shared. Nested compiler imports and direct compiler-source builds retain analysis until their active compilation finishes and release their semantic closures together, collection budgets count released units, and source analysis no longer borrows an execution hub or requires force_target_program.
  • Cache verification uses one owned oracle, suppresses recursive verification inside it, and releases its closure in finally. Bootstrap compilations retain their frontend tier through code generation, even if imports finish in between. Native coverage replays from the completed link plan, including cached units.
  • Star imports use canonical, cycle-aware dependency ingestion instead of recursively compiling Python implementation bodies. Imported symbols preserve the existing lazy type provider. C3 inheritance uses canonical class identity; lazy context-manager bindings resolve through the shared evaluator.
  • Removes per-unit child processes, environment controls, metadata transport, and duplicate library-loading infrastructure. Kernel bootstrap remains in the existing kernel resolver.

Cache, linking, and distribution

  • JIR format 28 stores native interfaces, bitcode, optional objects, consumed interface digests, and compile-time dependencies by their full compiler stamp. Target and codegen variants coexist. A shared bootstrap-safe file lock protects the complete read/merge/replace transaction; kernel builds and toolchain installs use the same primitive. Replacing bitcode invalidates an older optional object. Existing caches rebuild automatically.
  • Native binaries, layout sidecars, JIR files, stub catalogs, and downloaded binary caches share one atomic writer. Rebuilds publish a new file identity, preserving open executable images and avoiding stale macOS code-signature state.
  • CLI, packages, kernel/seal builds, wasm, and JIT share the link plan. The final action digest covers unit products, glue, runtime inputs, and configuration; the existing sidecar verifies output integrity before reuse.
  • Replaces handwritten AArch64 outline-atomic assembly with pinned, unmodified LLVM compiler-rt sources and upstream CPU detection. The existing archive linker selects required members. ELF links preserve constructor priorities and startup initialization.
  • JIT binds foreign functions to their declared library through engine-local mappings and retains library handles for the engine lifetime. Missing libraries/symbols fail compilation. This prevents process-global symbol collisions from mixing incompatible SSL implementations.
  • Serve manifests compile in an owned session without JIT-loading native libraries on the build host; the session is released after metadata extraction. Native clocks select clock IDs from the target libc.
  • Darwin sockets use the existing platform-variant mechanism for address layout, errno access, and IPv6 constants. Module globals resolve correctly when a module and its class share a name. Chained OSP filters reuse the ES filter lowering used for inline filters.
  • Automatic test concurrency uses the same shared memory-ceiling budget as jac check, consistent with the test worker retirement setting; explicit counts remain operator overrides. The repository no longer forces eight workers on every machine. The formatter parallelism regression explicitly requests two workers.
  • Keeps module-qualified private symbols, public C ABI exports, relocatable package identities, qualified-entry exception reporting, and lightweight native package imports. Build logs report native analysis/lowering/codegen counts.

Validation

Final CI is green at 9ebbf110d: all 26 executed jobs passed, including Everything Passed.

  • Full native/compiler-tools suite plus lifetime and warm-cache coverage through the final rebuilt sealed binary with strict testing: 1,605 passed, 5 existing platform/tool skips. Final Linux CI native/compiler-tools lane: 1,588 passed, 4 existing skips; equivalence lane: 204 passed.
  • Final Linux CI repository type-check gate: 1,030 files passed, using the existing .jacignore without new exclusions. Runtime CI: 1,924 passed, 8 existing skips, including the worker-budget and formatter regressions.
  • Publication failure recovery, open executable images, link plans, warm CLI builds, alternating configurations, concurrent cache writers, and stale-object invalidation: 31 passed. The open-image regression fails against the previous sealed compiler. A controlled run without transaction locking retained only 1 of 8 concurrent variants; the corrected transaction retained all 8 plus bytecode.
  • Interface caches, imports, native scheduling, and compile-time dependencies: 69 passed. Bootstrap tiers, schedule validation, and warm native-import coverage: 36 passed; the bootstrap regression fails against the previous sealed compiler.
  • Direct compiler-source lifetime and bootstrap schedules: 18 passed; the eviction regression reproduces the missing-CFG failure against the previous compiler. Manifest isolation and codespace analysis: 58 passed. Target clock IDs and primitive/compression equivalence: 102 passed, 1 existing skip. Deployment dependency tests pass in both sealed and development-source modes.
  • Binary caches and Kubernetes binary injection: 49 passed.
  • Package tests after fixing recursive star imports: 199 passed, 1 existing skip in both source-Python and sealed-binary development-source modes. A fresh source checkout, home, and runtime cache passed in 92.49s on macOS; cold worker peaks reached 4.2 GB. The exact Linux CI source and binary passed with one worker in 139.92s, and the corrected automatic memory budget and removal of the repository’s fixed eight-worker override passed with warm caches in 47.05s, without an environment override. Final Linux CI confirms 199 passed, 1 existing skip in 110.58s, plus 21 docs tests passed in 12.39s. The old budget regression fails against the previous sealed compiler; sizing, worker retirement, and formatter regressions pass with the fix (4 tests), and the changed production file passes type checking.
  • Darwin sockets, TLS verification, urllib, and explicit JIT library binding: 8 passed, 1 existing platform skip. OSP equivalence: 30 passed.
  • Full kit build: 12/12 steps passed, linking 76 native units with zero repeated analysis/lowering/codegen during sealing. Bootstrap: 6/6 tests passed. Seed manifest: 80 modules passed. Changed-file formatting gate passed.
  • Earlier focused validation also covers bounded evaluator/oracle lifetime, the existing 4 GB compiler RSS limit, bundled-app ownership, canonical inheritance, ELF constructors, upstream atomics, and sealed parser/early-pass execution.

Final build-kit at 9ebbf110d passed in 20m29s, down from the original branch’s 30m30s (about 33% faster). Payload compilation took 934.3s and sealing 36.5s. The preceding run at 005b6b1d8, with identical binary-producing code, took 18m49s (853.3s compilation, 33.8s sealing). Other cleanup revisions measured 17m48s–21m11s. The historical main build was 15m05s on an earlier revision with fewer payload modules; this is not a claim of parity with that baseline. Full downstream CI passed at 9ebbf110d, including the package lane after removal of the repository’s fixed eight-worker override. The worker-memory ceiling is honored through the existing shared pool budget.

Design: jac/jaclang/cli/docs/internals/native-units.md.

…i-labs#8943)

The native backend no longer builds one fused LLVM module per closure with
its own IR-text cache, kernel-unit concatenation, regex-ordered
initializers and a process-global scope flag. The module is the native
unit: every native-locked module's JIR carries its native interface
(SEC_NIFACE, digest-prefixed like the type interface), its relocatable
object (SEC_NOBJ) and its bitcode (SEC_NBITCODE), each stamped with the
compiler digest, codegen identity and triple; SEC_DEPS records each native
dependency's interface digest, so a body edit rebuilds one object and an
interface edit rebuilds its dependents.

Every external symbol that is not :pub is module-qualified under the unit
prefix; :pub symbols keep bare names. One link plan
(backends/native/link_plan.jac) produces every artifact for jac nacompile,
jac build --as native, the kernel, the seal and the in-process JIT, in
objects (incremental) or bitcode (whole-program) mode over one synthesized
glue object (link_glue.jac). The kernel comes from resolve_kernel()
(kernel_resolve.jac): explicit switch, sealed image record, source tree by
plan digest with derivation on demand, or the store parser. The OSP, fmt
and region kernels are ordinary native units pulled by dependency edge.

Removed: the .ir_cache/.ir_meta dependency cache and no_ir_cache,
DepClibMeta and the fused linker in NativeCompilePass, kernel_units.jac,
shared_emit.jac, the .nademote sidecar (the verdict lives in
SEC_PLACEMENT), the scope-lowering globals (CompileOptions.native_unit),
toolchain_native_by_policy (placement_facts.native_unit / native_only),
_kit_site_present, CompileOptions.kernel_unit, and the diagnostics only
the fused linker emitted. JIR format 25; sealed manifest format 9 records
a package's native units and its one linked artifact, verified at load; a
sealed app with native pins ships that artifact behind a ctypes engine.
@greptile-apps

greptile-apps Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

Too many files changed for review (332 files, 100 file limit).

Bypass the limit by tagging @greptile-apps to review.

…ocess unit compiles, host-only kernel, SEC_NDEPS

The first revision of jaseci-labs#8980 was written without running it. This makes it
run, and records the design in docs/internals/native-units.md.

- The OSP region-death hook is `weak` and recorded in the unit's interface;
  the glue emits its no-op fallback only when no unit supplies it. A
  `linkonce_odr` definition nothing in its unit called was discarded by the
  unit's optimizer, and the weak glue stub then replaced it, so the graph
  runtime kept anchors of nodes whose arena had died: the kernel parsed one
  file and crashed on the next.
- `jac_str_new` is exported only when a unit defines its target.
- MCJIT takes llvmlite's default target machine; the pic/small pair the
  linked artifacts use made its AArch64 stubs branch into the GOT.
- Native dependency digests live in their own stamped section, SEC_NDEPS
  (JIR format 26). In SEC_DEPS, any bytecode compile of a unit rewrote the
  rows without them and the next plan recompiled the closure.
- The plan walks dependencies first and compiles each stale unit in a child
  jac process: an isolated in-process unit compile retained ~450 MB through
  comptime values cached on nodes shared with the selfhost program, and a
  kernel build reached 47 GB. Consumers take a dependency's interface as it
  stands; agreement is the plan's check.
- A type-only import of a native unit is a dependency edge (it contributes
  layouts); a typed import served from the interface catalog gets its full
  AST, and a direct import served that way keeps its edge.
- Loading a compiler-tree module's bytecode never rebuilds an in-process
  engine from its native sections.
- The kernel is host-only, derived in a child process, and accepted by a
  source key (compiler digest plus unit module keys) without a plan. A unit
  compile child never derives one.
- Tests updated to the plan's real behaviour: the region arena joins every
  plan by edge, a stale record is recompiled until the plan agrees, the
  str_new export rule, and the source-key acceptance.

Measured on the chess example: runtime and kernel parse speed at parity
with the fused build; first compile of a module and the cold kernel build
are slower (details in the PR).
…lies

A suppressed lowering code left a function undemoted and its module with
invalid IR: the demotion of a function is decided by the errors its
lowering recorded, and the pass driver already applies the diagnostic
policy when it delivers a pass's alerts to the program. The IR generator
now records every lowering diagnostic and lets delivery suppress it.
…THONPATH

The build boots its interpreter with -I and an explicit sys.path, which a
child does not inherit; importing jaclang registers the .jac importer, so
the package directory is all a unit or kernel child needs.
…ks in the sealed lane

The precompile stage compiled every native module and then built an
in-process engine for it through the link plan, which the seal never
uses. The precompile program now asks for no engine and get_bytecode
honours a program's engine intent. link_plan.jac aliases the imported
layout digest (its method shadowed the function) and types the parsed
unit module.
python -c puts the working directory first on sys.path, and a build runs
from the checkout while sealing a copy of the tree, so a unit or kernel
child imported the checkout's jaclang and recorded toolchain edges into the
wrong tree. Children now run in safe-path mode and insert the spawning
package's parent directory first.
The precompile command is the build's engine, not a sealed kit's command,
and it ends its process itself.
…n hand back their products

A unit's symbol prefix hashed its absolute path, so a sealed unit compiled at
the build's staging path defined symbols a consumer compiled from the
installed kit never referenced; the JIT resolved them to null and every OSP
fixture crashed in static init under the sealed kit. The module key is now the
module's path within its package root (a sealed package, a jac.toml project
or the jaclang package), which agrees across the move.

Detached unit compiles hand their interface, deps, object and bitcode back to
the requesting process through a products blob instead of leaving them in the
shared module cache, so concurrent compile identities in one test worker no
longer thrash each other's entries.

The kernel's handle release uses a raw pointer, since one test sets
Py_DecRef.argtypes process-wide and a py_object argument was then refused
for every later parse in the worker.

Tests that hand-linked one unit's IR with a hand-written entry now start from
the plan's whole-program IR; the move-elision count skips the unit's exported
jac_retain helper; the restore-failure test patches only the loader that
still exists.

Refs jaseci-labs#8943
glibc exports __cxa_atexit and keeps atexit in libc_nonshared.a, so the
launcher built by the plan failed at load with an undefined atexit; the glue
now defines the shim for every ELF artifact that is not static against musl,
as the old entry injector did.

Refs jaseci-labs#8943
The sealed build's checker refused the comprehension over the merged
module's functions; the trace now walks them through a helper.

Refs jaseci-labs#8943
…nit children keep their diagnostics to themselves

Two units exporting one :pub symbol reached LLVM's linker, which reported a
multiply defined global instead of the E5026 diagnostic that names both
modules; the plan now checks its units' :pub exports before anything links.

A detached unit compile's stderr is captured and shown only when the child
fails: a first run that derives the kernel printed every unit's seam
warnings, hundreds of kilobytes that the kit action's warm step piped into
grep -q, which quit on the first match and killed echo with a broken pipe.
The warm steps also read their output through here-strings now.

Refs jaseci-labs#8943
…re for building the compiler

Every unit compile spawned a child, and a test lane with four workers held
thirteen compiler processes at once, 28 GB on a 16 GB runner. A program's
few units now lower in the requesting process; JAC_NATIVE_UNIT_ISOLATE asks
for a child per unit, which the kernel build and the seal set for
themselves, where dozens of units would otherwise pile up in one process.

A root compiled from a string keeps its recorded interface (no file can be
newer than it) and takes its digest as its plan key; the cache-marker test
pins a module placement keeps in Python, since a module placement promotes
legitimately caches its native products.

Refs jaseci-labs#8943
An empty target and the host's own triple compiled the same code but keyed
different identities, so an artifact built with an explicit host target
could not use the units the sealed image carries. Unit children also
receive the app and nogc fields the identity folds in.

Refs jaseci-labs#8943
…deriving one

The source key folds each unit's package identity rather than its path, and
the sidecar records those identities beside the build's paths, so a kernel
built at the kit's staging directory is recognized in a checkout elsewhere.
A rerouted checkout that cannot accept a kernel beside its loader now looks
at the kernel the running kit carries and, when its source key matches,
copies it in place; only a compiler that differs from both derives.

The reroute lanes spent fourteen minutes deriving a kernel the kit they
run on had already built from the same sources.

Refs jaseci-labs#8943
The site's game failed to build three ways once its modules became units:
the RC-free audit read the merged program, where the runtime units define
the reference-counting helpers whatever the application's gc setting, so
it now audits the application's own unit; the host imports a platform
module declares were missed because only the entry module's C declarations
counted, so every unit's count; and the host called __jac_glob_init, frame
and compute by bare name, so the glue defines __jac_glob_init for wasm and
the root unit's module-level functions and globals take their bare names
in the merged module.

Refs jaseci-labs#8943
…hains under the unit model

A demoted module was decided native again on its retry: the pin lookups that
run without a program could not see a verdict that lives in the program's
registry, so every pin check now takes the program. The importer asks the
artifact carrier for a module's engine, and a plan that settled by lowering
the entry again as a unit had evicted the module that held it; the compile
pass now shares the engine and interop tables with the carrier. A module
lowered in this process enters the registry with the digests of the
dependencies it was lowered against, so a root compiled from a string is
never asked to lower again from a file that does not exist. A re-export
module made only of imports and includes forwards its own dependencies to
the importer's plan, so a star or include chain links the units behind it.
nacompile's assert-no-rc audits the program's own units, as the wasm audit
does; the nogc CLI target and the body-scoped clib pin follow.

Refs jaseci-labs#8943
…rted names keep their modules apart

A unit's interface and its dependency-digest rows named dependencies by
absolute path, so a kit's sealed units pointed at the build's staging tree
and any plan that reached them from the installed kit failed. They are
written as package identities now and resolved beside the reading unit.

Two imported modules defining one name (hashlib's private `new`, hmac's
public `new`) collided in the consumer's symbol table: the second was
never declared and the first's demotion marked the bare name, so a
module-qualified call failed to lower. Imported functions also register
under a module-qualified key, a private function of another module is
neither declared nor marked, and a colliding `:pub` name is left for the
plan's collision report. Inherited-method thunks keep external linkage and
are recorded as exports, and an engine carries every unit's interface so
tests resolve inherited and imported symbols. The dev-loop lane takes the
LLVM shim from the kit's runtime site instead of a build-keyed cache, and
the local forced-native helpers in the equivalence suites pin their unit.

Refs jaseci-labs#8943
…ts reach; the sweep still retires the flat native directory

The kernel's plan links the AST's data model and its types beside the
scope: they are jaclang modules compiled as units, not strangers. A cache
root from before the generation layout still holds a native directory,
which the sweep retires as it did.

Refs jaseci-labs#8943
…r the package sentinel

Sealed JIRs carry the module cache's placement summary so a demotion
verdict survives the seal, but the summary named the module by the
staging directory's absolute path, and two seals of one tree packed to
different bytes. The seal rebases every path under the package root to
the sentinel, as it does for the comptime dependency rows.

Refs jaseci-labs#8943
…, and the linker tests read the plan's program

The whole-repo type check refused an ambient import, a loosely typed manifest
record, the `root` keyword as a loop name, an untyped engine handle and a few
untyped JSON reads; each is typed now. A kernel-format walk bound a symbol
under its bare name without recording its owner, and the next imported
module of that name declared it twice; an already-declared symbol is bound
instead. Toolchain units lead the plan order whatever path the walk reached
them by. In objects mode the IR dump holds every unit and the glue, and
nacompile reports its object emission. The PE, wasm, region-partition and
string-kernel tests read the plan's whole program or the unit's symbol
rather than the old whole-module IR.

Refs jaseci-labs#8943
@marsninja marsninja changed the title feat(native): the module is the unit, every artifact is a link (#8943) feat(native): reuse per-module products for incremental native builds Sep 6, 2026
@marsninja marsninja changed the title feat(native): reuse per-module products for incremental native builds feat(native): build reusable units in owned compiler sessions Sep 7, 2026
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