IC: don't build interface tables for dep-of-a-dep module loads

`loadTransitiveHooks` loads a module only to register its hooks, macro-cache
replay and generic-instance offers. It is a dep-of-a-dep, not an import, so
none of its symbols are visible to the module being semchecked — yet it went
through the full `loadNifModule`, which builds the interface string tables by
calling `loadSymFromIndexEntry` on every index entry, and `processTopLevel`,
whose `export` branch calls `resolveSym` on every exported symbol. Both write
into an `interf`/`interfHidden` pair that is scratch, shared across all
iterations, and never read. Their only other effect is warming the name-keyed
`c.syms` cache, which `resolveSym` refills lazily from the same index on a miss.

New `LoadFlag.SkipInterfaceTables`, set only by `loadTransitiveHooks`, gates
both. Measured on a 219-module program: a one-line edit rebuild goes 5.58s ->
3.21s (-42%), and the compiler's own cold IC build 202s -> 164s (-19%), since
every `nim m` in a cold build pays this too. Profile of the `nim m` before:
direct imports 17 modules/1.40s, transitive hooks 209 modules/2.99s,
re-exports 39/0.23s.

This is NOT byte-inert, unlike the previous commit: skipping the eager stub
creation shifts the per-module load-order counter, so lazily-created stubs get
different serialized item ids and 44 of 238 `.s.bif` change. The artifacts stay
deterministic (238/238 reproducible across two independent cold builds) and
16/16 metamorphic tests pass, including `clean cache == incremental cache` and
`a no-op edit changes no artifact` — so an existing nimcache re-sems those
modules once and then converges. Also green: the 17-file `koch ic` suite, 13/13
differential edit checks against `nim c`, both `bootic` fixed points (debug and
release), and 482 tests across tests/arc, destructor, closure, iter, gc, async
with a failure set identical to the parent commit's.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
araq
2026-08-28 20:03:42 +02:00
parent 1f504865ed
commit 3c53629164
2 changed files with 28 additions and 2 deletions

View File

@@ -2545,6 +2545,18 @@ proc cursorFromIndexEntry(c: var DecodeContext; module: FileIndex; entry: NifInd
type
LoadFlag* = enum
LoadFullAst, AlwaysLoadInterface
SkipInterfaceTables
## Do not eagerly build the module's interface string tables. Set by
## `modulegraphs.loadTransitiveHooks`, which loads a module only to
## register its hooks / macro-cache replay / generic-instance offers and
## throws the tables away — the module is a dep-of-a-dep, not an import, so
## none of its symbols are visible to the module being semchecked.
##
## The eager pass calls `loadSymFromIndexEntry` for EVERY index entry, and
## its only other effect is pre-populating the name-keyed `c.syms` cache —
## which `resolveSym` fills lazily on a miss anyway, straight from the same
## index. So for these loads it is pure work: on a 219-module program a
## one-line edit paid it 209 times over.
proc isGlobalIndexSym(s, dottedSuffix: string): bool =
## Mirror of `nifbuilder.addSymbolDefRetIsGlobal` / `bif.isGlobalSymbol`: a sym
@@ -3834,6 +3846,13 @@ proc processTopLevel(c: var DecodeContext; cur: var Cursor; flags: set[LoadFlag]
elif tagIs(cur, "reppureenum"): loadLogOp(c, result.logOps, cur, PureEnumEntry, attachedTrace, module)
elif tagIs(cur, "repcppmember"): loadLogOp(c, result.logOps, cur, CppMemberEntry, attachedTrace, module)
elif tagIs(cur, "export"):
if SkipInterfaceTables in flags:
# Same reason the interface tables are skipped: `interf` is a scratch
# table this caller throws away, so every `resolveSym` here (one per
# exported symbol, plus `addReexportedEnumFields`) only warms the
# name-keyed `c.syms` cache that `resolveSym` refills lazily on a miss.
skip cur
continue
cur.into:
while cur.hasMore and cur.kind == DotToken: skip cur # flags / type
while cur.hasMore:
@@ -3973,7 +3992,8 @@ proc loadNifModule*(c: var DecodeContext; suffix: ModuleSuffix; interf, interfHi
# Populate interface tables from the NIF index structure
# Symbols are created as stubs (Partial state) and will be loaded lazily via loadSym
# Use exports collected by processTopLevel
populateInterfaceTablesFromIndex(c, module, interf, interfHidden, string(suffix))
if SkipInterfaceTables notin flags:
populateInterfaceTablesFromIndex(c, module, interf, interfHidden, string(suffix))
proc loadNifModule*(c: var DecodeContext; f: FileIndex; interf, interfHidden: var TStrTable;
flags: set[LoadFlag] = {}): PrecompiledModule =

View File

@@ -1035,7 +1035,13 @@ when not defined(nimKochBootstrap):
var isKnownFile = false
let fileIdx = g.config.registerNifSuffix(string suffix, isKnownFile)
if not g.hookClosure.containsOrIncl(fileIdx.int):
let precomp = loadNifModule(ast.program, suffix, interf, interfHidden, {})
# `SkipInterfaceTables`: `interf`/`interfHidden` here are scratch tables
# shared by every iteration and never read — this module is a
# dep-of-a-dep, so none of its symbols are visible to the module being
# semchecked. Building them called `loadSymFromIndexEntry` for every
# index entry of every closure member.
let precomp = loadNifModule(ast.program, suffix, interf, interfHidden,
{SkipInterfaceTables})
registerLoadedHooks(g, precomp.logOps)
# Record this transitively-loaded module so the sem driver applies its
# VM-level load effects (macro-cache replay + `{.compileTime.}` global init)