Skip to content

native bootstrapping (Linux, Windows) - #2303

Merged
Araq merged 14 commits into
masterfrom
araq-native-boot
Aug 7, 2026
Merged

native bootstrapping (Linux, Windows)#2303
Araq merged 14 commits into
masterfrom
araq-native-boot

Conversation

@Araq

@Araq Araq commented Aug 7, 2026

Copy link
Copy Markdown
Member

No description provided.

Araq and others added 14 commits August 6, 2026 20:54
As a template it was expanded into the (rightly) .inline Cursor/TokenBuf
=destroy/=dup hooks, dragging the whole cold ORC free path into every
splice site and cascading multiplicatively into every container-hook
instantiation — the single largest source of backend IR blowup. As a
plain proc the hooks splice as the few tokens they read as: a nil test
and a call. Measured on nimsem: IR x8.38 -> x6.52, boot 50.4s -> 42.1s,
runtime unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sm__ label

An importc proc with neither header nor nodecl used its libc identifier
in the generated C, and its extern prototype (types derived from the NIF
signature) conflicted with the real header's prototype whenever both met
in one TU — 'extern NI64 write(int, void*, NU64)' vs <unistd.h>'s
'ssize_t write(int, const void*, size_t)'. Authors dodged this by hand
(see threadpool's reportResidualFailure comment), but inliner splices now
carry bare-importc references into arbitrary modules, so the dodge no
longer composes.

Declare such procs under their mangled C identifier and bind it to the
real symbol with an asm label: 'extern NI64 sysWrite_0_... (...)
__asm__(NIM_ASM_PREFIX "write");'. The identifier can never collide
with a header prototype; NIM_ASM_PREFIX (new in the prelude) supplies
Mach-O's leading underscore via string-literal concatenation. Procs with
header/nodecl and all exportc handling are unchanged, as is the LLVM
backend (IR has no headers to collide with).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The inliner's per-call heuristic rewards literal arguments because they
promise post-splice simplification — but nothing downstream delivered
it. A spliced 'proc use(c: ptr T) = (if c != nil: ...c.f...)' called
with nil kept its dead '(deref (nil))' arm; the C backend swallows such
arms, arkham's typed getType rightly cannot.

Two pieces make the promise real. New rewrite rules fold what literal
substitution makes constant: eq/neq on nil literals, not on bool
literals, and-false / or-true (with a purity guard). The new branchprune
pass (nifcore, self-tested) then deletes what the folding decided:

- (false)-elifs drop, the first (true)-elif becomes the else of any
  undecided branches before it (or replaces the if outright), a fully
  decided if vanishes;
- a literal (nil) condition reads as false — pointer truthiness, in
  condition position only;
- a branch is only droppable when every label it defines has no uses
  outside it: hexer's try/except parks the handler in an
  '(elif (false) (stmts (lab :`exlab.N) ...))' entered by jmp from the
  try body, which must survive, while the splicer's own returnLabel
  (def and jumps both in-branch) pins nothing;
- an unreachable-statement sweep drops label- and decl-free statements
  between an unconditional jmp/ret and the next lab — the value
  splice's dead 'dest = result' self-copy read a never-written var,
  which nifasm's clobber verifier rejects.

Wired into optimizeBody after the rewriter fixpoint, with one refold
when a prune fired. Note the pass only runs under the optimizer;
non-optimized native builds still ship unpruned guards.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The inlining decision no longer follows the .inline annotation (which
keeps its emission meaning): a body of at most 100 tokens always
splices — forwarders, accessors and hooks, annotated or not — .noinline
never does, and bigger bodies go through the per-call-site weighted
score, so a fat body needs genuinely decisive arguments. Both passes
derive the policy from the module file they already parse
(indexProcBodies), ending the pragma transport.

Sound and bounded by construction:

- per-param weights are clamped: the weight walk adds context value per
  occurrence, so uncapped weights grew with body size while the
  threshold did too — the ratio was size-independent and any body whose
  params fed conditions inlined at every site. Multi-GB hexer RSS on a
  nimsem build traced back to exactly this (plus the pass's unlimited
  depth, now capped at 4 like the cross-module one);
- each caller gets a growth budget (it may roughly double, floor 1000
  tokens), charged by every committed splice including those inside
  re-walked spliced content — the hard backstop that keeps program
  growth linear whatever the per-call heuristic thinks;
- importc procs never inline: their '(stmts .)' body is a placeholder
  and splicing it deletes the call (memfiles inlined posix open's empty
  shell and never called open(2));
- varargs procs never inline (a spliced '(var :p (varargs) ...)'
  binding cannot be sized);
- assembler procs never inline (the splice strands {.register.}-pinned
  locals in ordinary code);
- call sites whose arguments contain an aggregate constructor are
  declined: the C backend renders those as block-scope compound
  literals, and the splice's (scope ...) cut their lifetime short when
  the address escaped (static Shape[N] params read dead stack). Lifting
  this needs the splicer to hoist constructor temps out of its scope.

Measured on nimsem: cold native build 9.7s -> 5.8s, IR blowup x6.52 ->
x2.4, binary 11MB -> 5.5MB, native boot 42.1s -> 28.7s with stages
1=2=3 byte-identical; full suite green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The staging-exhaustion class that gated the native boot is fixed on the
nativenif side (transient-register demand held inside the budget per
emit step), and the inliner rework upstream keeps arkham's input sane.
Boot verified: stages 1=2=3 byte-identical, 28.7s total.

tunion.nim.c picks up the two intended codegen changes: the
NIM_ASM_PREFIX prelude macro and the module-init proc now being spliced
into main by the size-driven policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Leng has no noreturn pragma of its own, so the fact was dropped at the
lengcgen boundary. Carry it as the existing `(attr "noreturn")`: the C
backend renders it `__attribute__((noreturn))` (a codegen win by
itself), arkham skips pragmas it does not know, and the optimizer's
condition-elimination pass reads it back to learn what an assert/panic
guard's fall-through proves.

NOT for `.raises` procs. Under goto exceptions a raising "noreturn"
proc — raiseOSError — RETURNS at the Leng level, handing back an error
code for the caller to propagate; `retType` above already rewrites its
signature for exactly that. Telling C it never returns made gcc delete
the callers' error handling and broke the stage-2 boot, and it would
mislead the fall-through learning the same way. Only a proc that
genuinely diverges — exits or aborts — gets the attribute.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An inlined accessor re-materializes the same guard over and over:
`cursorTagId` alone re-tests `c.p != nil and 0 < c.rem` and
`kind == TagLit` several times, each dragging a ~200-token
write-stderr-and-quit block behind it. The first guard's fall-through
already PROVES the condition; every later re-test is dead weight that no
existing pass could see, because the operands are calls and the temps
holding them all have different names.

The new pass keys facts by a canonical VALUE serialization: single-
assignment locals are substituted by their defining expression (so
`x.0h379` and `x.0h386` collapse to one key), xelim's and/or diamond
is recognized and serialized as `(and C E)`, symbols carry a version
bumped on assignment, memory reads carry an epoch bumped by any writing
call, and READONLY callees — derived from the callee's own body, since
hexer's summaries mark every cross-module caller `callsUnknown` — key
like pure expressions. Facts come from diverging guards (a branch whose
tail calls a `noreturn` proc) and from single-value case arms, and
decompose through not/and/or and eq/neq duals. Matching folds a
condition to a literal; `branchprune`, running right after, deletes the
branch. Loops and jumped-to labels reset the fact set; branch-local
facts and definitions do not survive a join.

Two soundness rules the boot fixed point taught us, both with
regression tests:
 - an ADDRESS-TAKEN local is memory, never substituted by its
   definition: `f.rem > 0` folded across `sigmatchLoop(m, &f, …)`,
   which advances `f`;
 - only SINGLE-ASSIGNMENT locals are substituted at all: two mutable
   `SymId` locals both initialized to 0 shared one key, so
   `lenSym == 0` being false in an else-branch deleted an unrelated
   `if dataSym == 0: dataSym = …` lazy-init in expreval.

CONDELIM=off / CONDELIM_SKIP=<suffix,…> disable the pass wholesale or
per module — that bisection is what found both bugs. Measured on the
sem module: 443 guards learned, 24 conditions folded; full suite green
and native boot 1=2=3 byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A proc's `result` slot receives `=wasMoved` followed by `=destroy` before
its first assignment — `some[T]`/`none[T]` in `std/opt` are the shortest
example. Both hooks dispatched on the discriminant of memory that has
never been written.

The pair is self-neutralizing only while the two loads of that garbage
agree: `=wasMoved` clears the payload of whatever branch it read, so the
`=destroy` behind it finds an empty one. Nothing guarantees they agree.
An uninitialized load is `undef` and may be materialized independently
per use; `=wasMoved` reading None while `=destroy` reads Some frees a
`string` that was never constructed.

That is why this stayed latent on master, on Linux, and under older
clang, and why it surfaces now: the inliner splices both hooks into
their caller, handing the uninitialized load to SROA. It was found
chasing tests/nimony/stdlib/topt.nim, which fails on the macOS CI runner
alone — a message-less assert in the joined group, exit 133 (SIGTRAP)
standalone — and is consistent with this defect, though the runner's
clang is five majors newer than anything available to reproduce on.

So write the discriminant instead of reading it: assign the first
branch's value (its low bound for an `of lo..hi`), then let the existing
dispatch clear exactly that branch. Every op a `=wasMoved` emits is a
pure write, so the result is a valid, trivially-destroyable value even
on uninitialized memory.

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

Deleting the shoggoth optimizers (fd9d6b0) took three duties with them
that were correctness obligations of the inliner, not optimizations, and
stage 1 of the native boot died on each in turn. They now live where the
constant is manufactured: after a splice's nested re-walk, `prunedInto`
rewrites the expansion before it reaches the caller's buffer.

- An `(elif …)` arm whose guard the substituted literals decide is
  deleted: `if c != nil: …c.f…` spliced with `c := nil` keeps an
  ill-typed `(deref (nil))` in its false arm, and arkham (rightly)
  asserts on it. The guard is judged only after the nested re-walk,
  because a `!=` forwarder must itself be spliced before the guard is a
  literal comparison.
- An arm that defines a `(lab …)` some outside code jumps to is pinned
  however its guard folds — hexer's try/except lowering parks the
  handler in an `(elif (false) (stmts (lab :`exlab.N) …))` entered only
  via `(jmp …)` — so a pinned arm bails its `if` out to a verbatim copy.
- Statements between an unconditional `(jmp …)` and the next `(lab …)`
  are dropped: the value-splice epilogue's `dest = result` self-copy is
  dead on a callee whose every path returns explicitly, and it reads a
  `result` that was never written — nifasm's clobber check rejects that.

Splice-time is also where this information can still feed the inlining
POLICY (the growth budget sees the true residual size), which a
whole-module pass after the fact never could. Boot is green again:
stages 1/2 and 2/3 byte-identical; tests/nimony 666/666; full tree walk
clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rking lot

The handler used to sit in `(if (elif (false) (stmts (lab :`exlab.N)
<handler>)) (else <finally>))`, entered only via `(jmp `exlab.N)` from the
raise sites. The structure encoded "normal path runs the finally, handler
path skips it" — but every consumer paid for the pretense:

- arkham cannot know the guard is dead, so every native try/except
  materialized the `(false)`, compared it against 0 and emitted a
  never-taken branch;
- every branch-pruning pass needs a label-pinning rule to keep it from
  deleting a "dead" arm that outside code jumps into — the exact trap the
  optimizer removal (fd9d6b0) walked into.

Now the same control flow is spelled directly:

    <try body>            # raises inside became (jmp `exlab.N)
    <finally>             # normal path; raise-site finallys are duplicated
    (jmp `exend.N)
    (stmts (lab :`exlab.N) <handler>)
    (lab :`exend.N)

The except cursors are parked while the finally (which follows them in the
tree) is emitted first; handler and finally are both translated with the
label already popped, so a nested raise propagates past this try as before.
Handlers keep their `(stmts …)` wrapper, so the spliced shape downstream is
unchanged except for the vanished `if`.

The label-pinning bailout in intramodinliner stays as a safety net, but no
lowering emits label-carrying dead arms anymore.

Gates: boot green (stages 1/2 and 2/3 byte-identical), tests/nimony
666/666, full tree walk clean, zero golden churn; a native try/except/
finally smoke test runs both paths correctly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Araq
Araq merged commit d579797 into master Aug 7, 2026
9 checks passed
@Araq
Araq deleted the araq-native-boot branch August 7, 2026 20:54
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