From 3c53629164007656b5c14046dc4f3060bb2011b1 Mon Sep 17 00:00:00 2001 From: araq Date: Fri, 28 Aug 2026 20:03:42 +0200 Subject: [PATCH] IC: don't build interface tables for dep-of-a-dep module loads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 --- compiler/ast2nif.nim | 22 +++++++++++++++++++++- compiler/modulegraphs.nim | 8 +++++++- 2 files changed, 28 insertions(+), 2 deletions(-) diff --git a/compiler/ast2nif.nim b/compiler/ast2nif.nim index 5e1e482390..a6842ff530 100644 --- a/compiler/ast2nif.nim +++ b/compiler/ast2nif.nim @@ -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 = diff --git a/compiler/modulegraphs.nim b/compiler/modulegraphs.nim index a0d8250938..55a5293674 100644 --- a/compiler/modulegraphs.nim +++ b/compiler/modulegraphs.nim @@ -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)