#3329

Decide a round's islands after its declarations, before any function body

Draftcramforcewants to merge intofrom

Draft. This change is correct as far as it is proved below, but after the review fix it does not shorten the build it was written for (see "What it measures"). It should wait for #3162.

Stacked on #2881, #3117, #3131 and #3307, which is merged into feat/node-island-decision-wp1, the branch this PR is opened against. The diff is two commits: 4ebfa30be (the early decision: executable/prepare.ts, frontend/lowering/lowerer.ts, one test) and 70bffac22 (which pass may be asked, found by the review bot here).

What

A build finds its Node islands in rounds: lower the whole program, island the packages whose lowering refused, load again, lower again. On a large program the round that only finds the islands costs as much as the round that produces the result.

Most of what names an island is reported long before the first function body is lowered: a class shape that cannot compile, a use of an export a package does not provide, a fence in a module's top-level code. So a round's first lowering pass asks its retry decision once more, early: when every declaration is collected and every module's top-level code is lowered (LowerOptions.stopAfterDeclarations, asked in Lowerer.emitReachablePass before the first function unit). executable/prepare.ts answers with the same rules it applies to a whole pass, on what the pass has reported so far. When that already names islands, the pass ends there, and the function bodies are lowered once, by the round that runs with those islands.

Which pass is asked. A round does not always report from its first pass. A stream pass, the protocol pass and the whole-slot pass each lower the program again in a mode that clears refusals of the first, and the round decides from the pass that stands (#2881). So the first pass is asked only while it has given none of them a reason to run (Lowerer.replacingPass): no stream flag, no async-iteration protocol spelling, no non-canonical degraded type. A round that is not asked is decided from its whole lowering, exactly as before.

Why the build is the same

  • What a pass has reported after its declarations is a subset of what it reports at its end, and every rule asked only adds islands for what it is shown. So the early decision names nothing that pass would not.
  • The pass that is asked is the pass the round would keep, as far as the protocol and whole-slot passes go: those modes change a lowering only where the first pass raises the flag replacingPass reads (the mapper's protocol interfaces, the async * return annotation and a non-generator reaching a generator slot; the one commit point of a degraded type). A prefix without the flag is the prefix those passes would lower.
  • The round that runs with the early islands keeps every rule on and asks them of its whole lowering, so what only a function body shows still gets its island, as before.
  • A fenced package's island is final when the build fails with or without it native, and tentative when the build compiles. The prefix cannot know that the build compiles, so the fence rule joins the early decision only when the program's own files already report an error there. A program that may still compile and has a fenced package in its prefix declines the question and is decided by its whole pass.
  • A package that one order names by a rule and the other never looks at, because its importer was islanded first, is in the set either way (containment, #3307).

Three places where the early and the late decision can still differ. None is found in any program the project has:

  • the stream representation: the stream modes are requested at flow sites, not where a stream type is mapped. A refusal in the prefix that only the stream representation clears, in a program whose first stream flag is raised in a function body, is read from the first pass. The round would decide from the stream pass;
  • a refusal reached only through code the early decision islands: package Q is imported natively and by package A, and Q's only refusal is in a function only A calls. Deciding late, the first round lowers that function through A and islands Q with A; deciding early, A is islanded first and Q stays native. Both are fixpoints of the same rules;
  • a flag that only code inside a package the early decision islands would raise.

SCRIPTC_ISLAND_DECIDE=after-lowering decides every round from its whole lowering, which is how the two are compared below.

The review finding

A class implementing AsyncIterable in the program's top-level code, a package with a fence in its top-level code, and a replaced process.stdout.write. With full rounds the protocol pass compiles the program, the fenced package's island is tentative, the build with the island fails on the stdout replacement (SC8006), and the native build is kept with SC8023. At 9ea6ee321 the first pass's declarations held the protocol refusal, which made the island final, and the build failed with SC8006. tests/corpus/8745-node-island-fence-kept-native is that program: the package stays native with SC8023 and the binary prints what Node prints, on the base, on #3307 and here.

What it measures

The large program this was written for (pi's first build; the build stops at the end of lowering with its remaining errors, so these are frontend times):

  • With the fix, its first round is not asked: the protocol flag is up at the end of its declarations (round 1 declarations not asked: the round's async-protocol pass may replace this one). It takes #3307's two full lowering rounds and prints byte-identical diagnostics (55 errors). On a slower machine than the one below (Intel(R) Xeon(R) Processor @ 2.50GHz, 32 vCPU, builds alone, one build each) it took 1,001 s here and 1,158 s on #3307, the same two rounds with the same passes; one build each says nothing about a difference.
  • Before the fix this PR measured 414 s, 433 s and 446 s against 621 to 675 s for #3307's base (one machine, Intel(R) Xeon(R) Processor @ 2.90GHz, 32 vCPU, builds alone), with the same diagnostics and the same island set. That number came from asking a pass the round then replaces, so it does not stand.
  • After #3162 (which deletes the protocol pass and its flag) the question is which other reasons that program's prefix has. Measured at the end of its first pass's declarations: no stream flag, the protocol flag, and five non-canonical degraded types. So after #3162 its first round is still not asked, now for the whole-slot pass. This PR shortens that build only once neither pass can replace the first.

In the corpus and the npm suite the fix changes nothing that was measured before: the 11 programs that ended their first round after declarations still do.

Not built

Admitting deferred imports of uncompiled packages before any lowering. The large program's one case is a package loaded only by one of the program's island modules, which containment names at the first decision. The corpus has one program with a native deferred import of an uncompiled package (npm dynamic-import); it keeps its second lowering.

Proof

On 70bffac22:

The same build as full rounds. Every corpus program and npm-suite case (5,426) built to LLVM twice, once deciding from declarations and once with SCRIPTC_ISLAND_DECIDE=after-lowering: none differs in exit status, in anything it prints, or in its LLVM text. 80 have an island; 30 take more than one round, the same 30 either way; 11 end their first round after its declarations (6819-node-island-fenced, 6829-node-island-declared-types, 6853-node-island-shared-registry, 8739-node-island-contained-any-rule, 8740-node-island-unbound-export, 8809-node-island-asserted-receivers, 8810-node-island-sdk-request-values, 8830-process-report-island, npm 2584-cjs-conditional-exports, bytes-exit, node24-file-url-conversion); 56 are not asked (30 for the protocol pass, 13 for a stream pass, 14 for the whole-slot pass; one program has two rounds that are not asked). The large program prints the diagnostics #3307 prints, byte for byte.

Zero-cost gate with --gate-corpus=all, against #3307's e4c1a50a8: PASS. 5,207 programs are byte-identical in IR, LLVM and executable, 99 fail to build on both sides, none differ, none is runtime-only. The compiler builds its own source. Compile time: benchmarks -0.39% [95% CI -2.15%, +1.05%], the compiler building itself +0.77% [-0.93%, +1.65%], fixtures -1.29% [-2.48%, -0.10%]. (On the earlier heads of this change the benchmark row measured -0.24%, -0.71%, +2.09% and +0.52%; the gate called the last two inconclusive.)

Both test lanes in Sandboxes, with the libnode pack: plain 21,720 passed, 0 failed; sanitized 18,456 passed, 0 failed; the plain native bootstrap is among them. The sanitized native bootstrap passed on this branch at 6c3158892, before the two review fixes.

Tests. packages/compiler/test/island-round-declarations.test.ts (6 tests), each comparing the early decision with the switch on rounds, islands, diagnostics, warnings and IR: a refusal found while collecting declarations ends the round before any function body; a program that fails in its own declarations takes the fenced packages with it, as the whole pass does; a program that fails only in a function body still decides its declared islands early; a prefix that reports nothing decides nothing; a prefix that a later pass of the round may replace is not asked (the review finding); a package the prefix names is decided by the whole round when the pass may be replaced. The decline rule is held by #2881's fold tests. The new corpus program passes the differential test plain and sanitized; pnpm test:ts7 --baselines-only, scripts/checker-identity.mjs for it, pnpm lint and pnpm format:check are clean.

Order with #3162 and #3225

No order has the regression. #3162 deletes the protocol relower and requiresDynAsyncProtocol; whichever of the two lands second drops the one line of replacingPass that reads it. #3225 and its replay fix are independent of this: a replay names the islands before round 1, so the first pass has nothing new to decide.

🤖 Generated with Claude Code

Activity

4

This pull request is a draft

Merging is unavailable until it is marked ready for review.