Skip to content

feat(rt): hoist 222 clojure.core primitives to named //lg:native decls - #639

Merged
nnunley merged 8 commits into
nooga:mainfrom
nnunley:hoist-native-primitives
Jul 28, 2026
Merged

feat(rt): hoist 222 clojure.core primitives to named //lg:native decls#639
nnunley merged 8 commits into
nooga:mainfrom
nnunley:hoist-native-primitives

Conversation

@nnunley

@nnunley nnunley commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Stack: #627 (fix) → this → per-package-registrars → provenance-manifest. Bottom of this PR's new work sits on #627; until #627 merges, the diff shows that fix commit too — it drops out once #627 lands.

Lifts the inline anonymous closures registered as clojure.core primitives in lang.go into named, //lg:native-annotated top-level functions, via a new codemod (cmd/hoist-natives). Three wins: real stack-trace frames (rt.CorePlus, not installLangNS.func42); declarative registration; and closures narrow toward typed/ec-threaded signatures.

Commits (this feature)

  • feat(rt): non-terminating diagnostics for generated-primitive registration (audit + LG_REGPRIM_DEBUG + regression-guard test)
  • feat(cmd): the hoist-natives codemod
  • refactor(rt): lift shared native helper closures to package level
  • feat(build): runtime-free primitive registrar generator (lgprimgen) — decouples the generator from the runtime it produces (the p0/p1 bootstrap problem)
  • feat(cmd/hoist-natives): safety analysis (5 skip categories: multi-name aliases, //lg:native dups, .Lookup-by-name, stdlib-.lg redefinitions, surviving-reference captures)
  • feat(vm,rt): guard-deviation diagnostics (LG_GUARD_DEBUG)
  • feat(rt): the 222-primitive hoist + regenerated artifacts

Verification

make check-generated OK (bundle + lowered tree); clojure.core surface unchanged at 807 publics; native-prims-intact? true at boot and after (require 'clojure.string); full go test ./... green.

222 of 292 closures were classified safe; the rest were correctly skipped (see the safety-analysis commit). Reviewed per-commit + a whole-branch pass.

@mparrett

Copy link
Copy Markdown
Collaborator

The hoist holds up under measurement:

lane result on a0778dbf
bundled (go test -short ./...) 0 failures
source bootstrap (-tags bootstrap -short ./... -skip TestClojureTestSuite) 37 failures

Those 37 are byte-identical to the set #627 produces on its own, so this PR adds none of its own. Your 807 publics figure reproduces — (count (ns-publics 'clojure.core)) is 807 both pre- and post-hoist. Bodies come across unchanged, //lg:name carries the Lisp names, and the per-body arity checks survive the []vm.Value...vm.Value signature change.

The audit guard doesn't cover the case its commit message claims. 405af75d describes TestAuditGeneratedPrimitivesCleanAfterInit as the permanent regression guard that "would have caught the alias bug." On this branch it passes while the bootstrap lane is 37 red:

--- PASS: TestAuditGeneratedPrimitivesCleanAfterInit (0.00s)
--- PASS: TestAuditGeneratedPrimitivesFlagsMisregistration (0.00s)

AuditGeneratedPrimitives (native_prims_lifecycle.go:163) walks genPrimBindings and asserts each generated primitive resolves in its canonical namespace. They all do. What breaks is the namespace's source-defined siblings: clojure.string/upper-case is generated and guarded, clojure.string/join comes from string.lg and is gone, and nothing in the audit looks at it. Mechanism is in my #627 review.

Cheapest way to close the gap is to have the guard also assert one known source-only name resolves after a require. clojure.string/join is a good canary because upper-case is the only generated primitive in that namespace, so the two sit side by side.

Every emitted adapter carries a dead arity check. All 224 open with:

if len(vs) < 0 {
    return vm.NIL, fmt.Errorf("wrong number of args (%d), expected at least 0", len(vs))
}

Always false. No correctness impact, since the real checks live in the bodies (if len(vs) != 1 { ... }), but the emitter could skip it when the minimum is zero rather than shipping 224 copies.

Question on cmd/hoist-natives. It's 1060 lines with no tests, and nothing references it — not the Makefile, not scripts/generate.lg, not a workflow. If more hoist batches are coming it earns its place and wants a smoke test on a small fixture. If it did its job once, a branch may suit it better than a permanent entry in cmd/. Either way works for me; I'd like to know which it is so I review it as a tool or as an artifact.

The pkg/vm additions (AllVars, GuardDeviated) read fine: snapshot under RLock, diagnostic-only, fast path untouched.

Nothing here needs to change before #627 is settled, which is where the blocker sits. Tell me which way you're taking hoist-natives and I'll re-run both lanes once the alias fix is in.

@nnunley
nnunley force-pushed the hoist-native-primitives branch from a0778db to 0d6b2b6 Compare July 27, 2026 21:09
@nnunley

nnunley commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto current main — now supersedes #613's direct-call natives

Rebasing revealed that #613 ("direct-call natives for the hot clojure.core surface") landed while this stack was in flight, building the same //lg:native surface — and shipping an arity-lossy subset that was harmless only because it registered into the dead clojure.core namespace (the bug #627 fixes). Once #627 canonicalizes that namespace, the arity loss becomes live and breaks core fns.

Reconciliation (this PR now owns the direct-call surface at full arity):

Verification (reconciled tree): both build tags compile; 1287 tests pass; check-generated OK; ir-stress ratchet 2456/2467 native (11-failure baseline held); boot clean; the previously-broken forms (symbol "a" "b"), (not= 1 2 3), (assoc! (transient {}) :a 1 :b 2), (swap! a f 1 2 3 4) all evaluate correctly.

@nnunley
nnunley force-pushed the hoist-native-primitives branch 2 times, most recently from 147f840 to c823f74 Compare July 27, 2026 21:28

@mparrett mparrett left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Requesting changes for one source-bootstrap blocker.

The ordinary lane passes:

go test -short -count=1 ./...

But the bootstrap lane fails on current head c823f74e. Focused repro:

go test -tags bootstrap -run 'TestLGBParity/ns-require-string' -count=1 ./pkg/bytecode

This fails with Can't resolve s/join in this context; the full bootstrap lane cascades through other source namespaces. It also matches the currently failing GitHub Actions build check.

[P1] Preserve the pending source load for registrar-created namespaces (internal/primgen/prims_emit.go:707).

The emitter now calls LookupOrRegisterNSNoLoad("clojure.string"). During rt init that creates the canonical string placeholder, but it does not set nsNeedsLoad. Later RequireNS("clojure.string") reaches LookupOrRegisterNS, sees an existing namespace with needsLoad == false, and returns it without executing string.lg. Only the generated upper-case binding exists, so source-only siblings such as join, capitalize, and blank? never become resolvable.

Please mark a namespace created solely for generated bindings as still needing its real source/bundle load, without re-marking an already-loaded namespace, and add a bootstrap regression covering a source-only sibling such as clojure.string/join.

make check-generated reached its consistency check successfully. I did not find another correctness blocker in the hoisted bodies or registrar generation.

nnunley added 8 commits July 28, 2026 09:24
RegisterGeneratedPrimitives (the lginterop-generated //lg:native
registrar) Defs primitives via LookupOrRegisterNSNoLoad("clojure.core"),
but that path — unlike the loading LookupOrRegisterNS — never resolved
the ns alias, so it registered into a DISTINCT "clojure.core" namespace
instead of the canonical "core". Hand-registered primitives masked this
by also Def'ing into the canonical ns via installLangNS; a primitive
hoisted to a pure //lg:native decl has ONLY the generated registration,
so it landed in the wrong namespace and was invisible to core.lg's own
bootstrap compile ("Can't resolve +").

Resolve the alias as the first step of LookupOrRegisterNSNoLoad, matching
the loading variant. This unblocks hoisting native closures in lang.go to
named //lg:native primitives for real stack-trace frames.

Verified: make check-generated OK (bundle + lowered tree unchanged, this
is a Go-only registration fix); clojure.core surface = 807 publics,
byte-identical before/after a proof hoist of + - * /.
…ation

Hoisting native closures in lang.go to //lg:native primitives means a prim
can now have ONLY the generated registration — so a misregistration (wrong
namespace, dropped //lg:name) surfaces as a terminating bootstrap panic
("Can't resolve X") that reports one symbol at a time. Add diagnostics so a
whole batch of newly hoisted primitives is root-caused in one run:

- AuditGeneratedPrimitives(): non-terminating audit returning one line per
  generated primitive NOT resolvable in its canonical namespace, including
  where the name actually landed (the exact signature of the alias bug fixed
  in the parent commit: bound in "clojure.core", absent from "core").
- evalInit bootstrap compile: on failure, append the audit to the panic so
  every misregistered primitive is listed at once, not one panic per re-run.
- LG_REGPRIM_DEBUG: per-primitive registration trace (name, requested ns,
  canonical ns, landed-canonical) — grep for landed-canonical=false.
- TestAuditGeneratedPrimitivesCleanAfterInit: permanent regression guard that
  every generated primitive resolves in its canonical ns (would have caught
  the alias bug); the gate to run after each hoist batch.

Go-only; no generated artifacts change (check-generated unaffected).
…ve decls

Codemod that lifts the inline anonymous closures registered as clojure.core
primitives in pkg/rt/lang.go into named, //lg:native-annotated top-level
functions. Three wins: named stack traces (rt.corePlus not installCore.func42),
declarative registration (drop ns.Def, lginterop emits from annotations), and
the nooga#411 Fn-adapter done as a codemod (no runtime adapter).

Only hoists SAFE sites: no genuine enclosing-scope capture (package refs + params
are fine; an installCore local like buildArray/asChunked is not) and a
discoverable lg-name from ns.Def("name", var). First run on lang.go:
213 safe, 62 capture-blocked (real shared local helpers), 17 no-name.

Dry-run by default (report + proposed funcs); -apply rewrites; -only scopes.
Converts trailing []vm.Value params to variadic ...vm.Value (lginterop's shape).

Analyzer + generator validated (output gofmt-clean, return types preserved);
-apply rewrite path implemented but not yet exercised end-to-end.
Lift installCore-local helper closures (buildArray, asChunked, rangeInt,
regexSubmatchValue, regexSubmatchVector) to package-level funcs via
cmd/hoist-natives -helpers. Unblocks ~14 primitives whose closures captured
them, en route to hoisting the native primitives to //lg:native decls.
The registrar in pkg/rt/zz_primitives_generated.go is what lets the runtime
boot; a generator that boots the runtime to produce it depends on the very
artifact it emits (p0 depending on p1). When a primitive is hoisted out of
installLangNS into a //lg:native decl, the runtime cannot boot until this file
carries its registration — so the generator must run in exactly that state.

- Extract the primitives codegen into internal/primgen, a PURE go/ast tool
  with no pkg/compiler / pkg/rt import (prims_scan, prims_emit, Generate,
  ScanNativeNames, KebabCase).
- Add cmd/lgprimgen, a thin binary over primgen; cmd/lginterop delegates its
  -primitives mode to the same package (its compiler import is only for the
  interop-EDN path).
- Makefile: regenerate zz_primitives_generated.go via lgprimgen BEFORE the
  lgbgen bootstrap that consumes it, breaking the boot cycle. generate.lg
  calls lgprimgen too.
- Fix a latent emit bug: variadic-spread adapters (Fn(vs...)) dropped the
  closing paren — never exercised until the first //lg:native ...vm.Value
  primitives. gofmt failures now surface stderr + dump the source.
Harden the codemod so it only lifts closures that are genuinely safe to lift,
leaving the rest untouched. Skip / demote a candidate when:
- its lg-name is registered under MULTIPLE names (hoisting one //lg:name would
  drop the others from clojure.core);
- its lg-name is already owned by a pre-existing //lg:native decl (duplicate
  registration, the generated registrar fails to compile);
- it is fetched BY lg-name via a .Lookup string arg anywhere in the package,
  possibly another file, before the generated registrar runs (would be nil);
- it is redefined by a stdlib .lg source; the eager core reapply skips it, so a
  guarded native there would deviate permanently (native-prims-intact? false);
- its local var is still referenced by surviving code (a 2nd registration, a
  helper, a skipped closure body), removing it would leave an undefined ident.

Also: strip the dead err-guard(s) left when a hoisted Wrap assignment is cut,
emit EXPORTED CoreX names (the gogen_ir lowered tree direct-calls them as
rt.Core-prefixed funcs), and reuse internal/primgen scanner so the codemod and
lgprimgen agree on what a name is.
Hoisting closures into //lg:native decls adds them to the guarded native-root
set, so a primitive shadowed at load (not restored by reapply) trips
native-prims-intact? with no clue which one. Add the diagnostics that pinpoint it:

- vm.Var.GuardDeviated() + Namespace.AllVars(): read whether a guarded root
  deviated from its canonical value, and snapshot every interned var.
- rt.deviatedGuardedVars() + LG_GUARD_DEBUG: on the off-fast-path branch of
  NativePrimsIntact(), name the deviated primitive(s) to stderr instead of a
  bare intact=false. The fast path stays a single atomic load.
Lift the inline anonymous closures registered as clojure.core primitives in
lang.go into named, //lg:native-annotated top-level functions (via
cmd/hoist-natives). Three wins: real stack-trace frames (rt.CorePlus, not
installLangNS.func42); declarative registration (the ns.Def call is dropped, the
generated registrar owns it); and the closures narrow toward typed/ec-threaded
signatures. Regenerates zz_primitives_generated.go, the bundle, and the sums.

222 closures were classified safe by the codemod; the analysis skipped the rest
(multi-name aliases, //lg:native duplicates, .Lookup-by-name, stdlib-.lg
redefinitions, surviving-reference captures). Verified: make check-generated OK
on both artifacts; clojure.core surface unchanged at 807 publics;
native-prims-intact? true at boot and after (require clojure.string); full
go test ./... green.
@nnunley
nnunley force-pushed the hoist-native-primitives branch from c823f74 to 6162d6c Compare July 28, 2026 13:38

@mparrett mparrett left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rechecked the updated head. The pending-load fix addresses the source-bootstrap blocker: the focused clojure.string/join repro passes, both the ordinary and bootstrap full test lanes pass locally, make check-generated passes, and all current GitHub checks are green. No remaining findings.

@nnunley
nnunley merged commit 428ce9e into nooga:main Jul 28, 2026
18 checks passed
mparrett added a commit that referenced this pull request Aug 6, 2026
…dowed duplicate

reduce had two implementations: a NewCtxNativeFn closure in lang.go carrying
ArrayVector and Range fast paths plus a reused two-element argument buffer, and
the //lg:native Reduce/Reduce3 decls delegating to reduceColl, which had none of
them. Both predate #639. The generated registration used to land in a distinct
"clojure.core" namespace rather than canonical "core", so it was invisible and
the closure won; #639 fixed that alias, and because the generated registrar
drains last it now takes the var root. reduce lost the fast paths silently.

The naive body allocates a fresh []vm.Value{acc, elem} per element and an
ArrayChunk per 32 elements via ChunkedFirst — 66.5 MB against 17.5 MB reducing
(range 1000000), and 33 GC cycles against 6. Measured 1.88x slower end to end
(#656).

Port both fast paths and the reused buffer into reduceColl and delete the
closure, so there is one implementation. The buffer is shared by the general
seq path too, which never had it: ec.Invoke does not retain its argument slice,
so one buffer per reduce call beats one per element.

Measured on darwin/arm64, hyperfine 3 warmup / 14 runs, reversed-order
confirmed, reducing (range 1000000):

  pre-#639  34.6 ms   6 GC cycles
  main      64.9 ms  33 GC cycles
  this      41.3 ms   9 GC cycles

Every reduce shape improves, not just the two with fast paths: over a list
37.1 -> 30.1 ms, over a vector 29.8 -> 25.5 ms, over a map 97.1 -> 90.1 ms.
The residual against pre-#639 is not reduce-specific — `some`, `get`, `nth`,
`conj`, `deref` and `str` all sit 1.06-1.49x above pre-#639 on main with no
fast-path story, so it is separate and still open.

Refs #656

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mparrett added a commit that referenced this pull request Aug 7, 2026
…dowed duplicate

reduce had two implementations: a NewCtxNativeFn closure in lang.go carrying
ArrayVector and Range fast paths plus a reused two-element argument buffer, and
the //lg:native Reduce/Reduce3 decls delegating to reduceColl, which had none of
them. Both predate #639. The generated registration used to land in a distinct
"clojure.core" namespace rather than canonical "core", so it was invisible and
the closure won; #639 fixed that alias, and because the generated registrar
drains last it now takes the var root. reduce lost the fast paths silently.

The naive body allocates a fresh []vm.Value{acc, elem} per element and an
ArrayChunk per 32 elements via ChunkedFirst — 66.5 MB against 17.5 MB reducing
(range 1000000), and 33 GC cycles against 6. Measured 1.88x slower end to end
(#656).

Port both fast paths and the reused buffer into reduceColl and delete the
closure, so there is one implementation. The buffer is shared by the general
seq path too, which never had it: ec.Invoke does not retain its argument slice,
so one buffer per reduce call beats one per element.

Measured on darwin/arm64, hyperfine 3 warmup / 14 runs, reversed-order
confirmed, reducing (range 1000000):

  pre-#639  34.6 ms   6 GC cycles
  main      64.9 ms  33 GC cycles
  this      41.3 ms   9 GC cycles

Every reduce shape improves, not just the two with fast paths: over a list
37.1 -> 30.1 ms, over a vector 29.8 -> 25.5 ms, over a map 97.1 -> 90.1 ms.
The residual against pre-#639 is not reduce-specific — `some`, `get`, `nth`,
`conj`, `deref` and `str` all sit 1.06-1.49x above pre-#639 on main with no
fast-path story, so it is separate and still open.

Refs #656

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
mparrett added a commit that referenced this pull request Aug 8, 2026
* perf(rt): restore reduce's ArrayVector/Range fast paths, drop the shadowed duplicate

reduce had two implementations: a NewCtxNativeFn closure in lang.go carrying
ArrayVector and Range fast paths plus a reused two-element argument buffer, and
the //lg:native Reduce/Reduce3 decls delegating to reduceColl, which had none of
them. Both predate #639. The generated registration used to land in a distinct
"clojure.core" namespace rather than canonical "core", so it was invisible and
the closure won; #639 fixed that alias, and because the generated registrar
drains last it now takes the var root. reduce lost the fast paths silently.

The naive body allocates a fresh []vm.Value{acc, elem} per element and an
ArrayChunk per 32 elements via ChunkedFirst — 66.5 MB against 17.5 MB reducing
(range 1000000), and 33 GC cycles against 6. Measured 1.88x slower end to end
(#656).

Port both fast paths and the reused buffer into reduceColl and delete the
closure, so there is one implementation. The buffer is shared by the general
seq path too, which never had it: ec.Invoke does not retain its argument slice,
so one buffer per reduce call beats one per element.

Measured on darwin/arm64, hyperfine 3 warmup / 14 runs, reversed-order
confirmed, reducing (range 1000000):

  pre-#639  34.6 ms   6 GC cycles
  main      64.9 ms  33 GC cycles
  this      41.3 ms   9 GC cycles

Every reduce shape improves, not just the two with fast paths: over a list
37.1 -> 30.1 ms, over a vector 29.8 -> 25.5 ms, over a map 97.1 -> 90.1 ms.
The residual against pre-#639 is not reduce-specific — `some`, `get`, `nth`,
`conj`, `deref` and `str` all sit 1.06-1.49x above pre-#639 on main with no
fast-path story, so it is separate and still open.

Refs #656

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* test(rt): ratchet the set of hand registrations the generated registrar shadows

A name registered both by hand in installLangNS and by the generated
//lg:native registrar is a silent hazard rather than a style problem: the
generated registrar drains last, so the hand-written closure never runs, and
nothing stops the two bodies drifting. reduce drifted for months and the whole
suite stayed green — including a test named TestReduceRangeFastPath, which
asserts results, not the path.

Record the collision where it already gets detected (setPrimitiveRoot's
existing-binding branch) and expose the set, so adding a //lg:native decl for
an already-hand-registered name fails here instead of years later in a
benchmark. Twelve names are in the accepted set today; each is a duplicate
implementation awaiting the same port-then-delete treatment reduce just got.
The two bodies agree for the ones spot-checked (get, some); the rest are
unaudited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(rt): don't record a re-registered generated primitive as a hand shadow

recordShadowedHandRegistration treated any pre-existing var as evidence of a
hand-written registration. Registration state is process-global, so the second
generated registration of the same ns/name finds the first one's root already
interned and records its own predecessor as a collision.

That made the ratchet depend on how many times the suite ran: `go test -count=2
./pkg/rt` failed with phantom collisions for bind.probe.ns/probe-fn and
test.reapplyhybrid/prim, both bound by tests that call the registration path
directly.

The recorder now skips names already present in genPrimBindings — it runs
before the current binding is recorded, so a hit there means an earlier
generated registration owns the var. TestReRegistrationIsNotAHandShadow pins
the distinction; it fails without the check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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.

2 participants