IC: route a cg definition by who owns it, not by who asked

`findPendingModule` decides which translation unit a demanded definition
is emitted into. Under `--icBackendStage:cg` it was `return m` with a
`# TODO fixme` over it: the destination was whichever TU did the asking,
because the stage was built around a single `--icBackendModule` and
there was never a second answer to give.

That hardcoding is what makes the backend inflexible to batching. It is
not that codegen cannot place a definition with its owner — the branch
four lines down already does exactly that, creating the `BModule` on
demand — it is that the cg stage could not express "this process writes
several modules' TUs", so the question never got asked.

So ask it. `BModuleList.icEmitted` is the set of module positions this
process writes a TU for, and routing consults it: an owner in the set
gets its own definition (the ordinary whole-program routing, now
reachable from cg), an owner outside it means the definition has nowhere
else to go and is emitted here as well — today's emit-everywhere, which
`merge` still deduplicates. Duplication therefore falls smoothly as the
set grows rather than switching over at some threshold.

The stage puts one module in the set, so this run is a no-op by
construction: the owner is `m` and the branch returns what `return m`
returned. Verified as one — a 67-module `--ic:on` build produces `.c`
AND `.c.nif` byte-identical to the previous commit's, `testament cat ic`
is 40/40, and `koch boot -d:release` reaches "executables are equal".

A change that provably alters nothing needs a live-probe check, or it is
indistinguishable from dead code. Temporarily adding `system`'s position
to the set — a module these processes do NOT write — moved 12 generic
instantiations (`addQuoted_i*`, `clamp_i*`) out of the TUs that demanded
them and into a TU nobody wrote, failing the link with exactly those 12
undefined symbols and shrinking generated `.c.nif` volume by 4%. The
routing is live; what it still lacks is the plumbing to write a TU per
batch member, which is the next step.
This commit is contained in:
araq
2026-08-31 10:18:09 +02:00
parent 98b1e0ab50
commit bad9d3b2bc
3 changed files with 54 additions and 16 deletions

View File

@@ -67,28 +67,51 @@ proc addForwardedProc(m: BModule, prc: PSym) =
proc newModule*(g: BModuleList; module: PSym; conf: ConfigRef; idgen: IdGenerator): BModule
proc getCFile*(m: BModule): AbsoluteFile
proc ownerModule(m: BModule; s: PSym): BModule =
## The BModule of `s`'s own module, created on demand. A NIF backend loads
## modules lazily, so the owner may have no BModule yet even though the symbol
## resolved.
var ms = getModule(s)
registerModule m.g.graph, ms
if ms.position >= m.g.mods.len:
result = newModule(m.g, ms, m.config, idGeneratorForBackend(ms))
else:
result = m.g.mods[ms.position]
if result == nil:
result = newModule(m.g, ms, m.config, idGeneratorForBackend(ms))
proc findPendingModule(m: BModule, s: PSym): BModule =
# TODO fixme
if m.config.cmd == cmdNifC and m.config.icBackendStage == "cg":
# Per-module backend codegen: only module M (`m`) is emitted in this
# process, so every demanded definition — whether a normal proc owned by
# another (here unwritten) module or a minted instance/hook — is emitted
# into M's TU. Definitions owned elsewhere are emitted again by their own
# module's cg process; the merge stage keeps one per C name and turns the
# rest into prototypes (which already live in the unmarked protos section).
# Per-module backend codegen. `m.g.icEmitted` is the set of modules THIS
# process writes a TU for, so it — not the identity of whichever TU happened
# to demand `s` — decides where the definition goes:
#
# * owner in `icEmitted`: this process is writing that module's TU, so the
# definition belongs in it and nowhere else. That is the ordinary
# whole-program routing below, and honouring it is what lets one process
# emit SEVERAL modules without their definitions collapsing into the first
# TU to ask for them. With the set at its current size of one, the owner
# IS `m` and this returns exactly what the old unconditional `return m`
# did — the point of the branch is that it stops being a special case.
#
# * owner elsewhere: the module is not written in this process, so the
# definition has nowhere else to go and is emitted here as well
# (emit-everywhere). The process that owns it emits it too; `merge` keeps
# one per C name and turns the rest into prototypes, which already live in
# the unmarked protos section.
#
# `getModule` walks the owner chain and yields nil if it never reaches a
# module (backend-minted symbols can be parented outside one), which is a
# definition with no owning TU: emit it here.
let ms = getModule(s)
if ms != nil and ms.kind == skModule and m.g.icEmitted.contains(ms.position):
return ownerModule(m, s)
return m
if m.config.symbolFiles == v2Sf or optCompress in m.config.globalOptions:
let ms = s.itemId.module #getModule(s)
result = m.g.mods[ms]
elif m.config.cmd in {cmdNifC, cmdM}:
var ms = getModule(s)
registerModule m.g.graph, ms
if ms.position >= m.g.mods.len:
result = newModule(m.g, ms, m.config, idGeneratorForBackend(ms))
else:
result = m.g.mods[ms.position]
if result == nil:
result = newModule(m.g, ms, m.config, idGeneratorForBackend(ms))
result = ownerModule(m, s)
else:
var ms = getModule(s)
result = m.g.mods[ms.position]

View File

@@ -142,6 +142,13 @@ type
# not a list of IDs nor can it be made to be one.
mangledPrcs*: HashSet[string]
icEmitted*: IntSet
## Under `--icBackendStage:cg`: the positions of the modules THIS process
## writes a translation unit for. `cgen.findPendingModule` consults it to
## decide where a demanded definition goes — see the comment there. Empty
## outside that stage, which is why every other backend keeps the ordinary
## whole-program routing.
TCGen = object of PPassContext # represents a C source file
s*: TCFileSections # sections of the C file
flags*: set[CodegenFlag]
@@ -238,7 +245,8 @@ proc newProc*(prc: PSym, module: BModule): BProc =
proc newModuleList*(g: ModuleGraph): BModuleList =
BModuleList(typeInfoMarker: initTable[SigHash, tuple[str: Rope, owner: int32]](),
config: g.config, graph: g, nimtvDeclared: initIntSet())
config: g.config, graph: g, nimtvDeclared: initIntSet(),
icEmitted: initIntSet())
iterator cgenModules*(g: BModuleList): BModule =
for m in g.modulesClosed:

View File

@@ -553,6 +553,13 @@ proc generateCgStage(g: ModuleGraph; mainFileIdx: FileIndex) =
"per-module codegen: module not found for suffix: " & g.config.icBackendModule)
return
# Declare which modules this process writes a TU for. `findPendingModule`
# reads it to route a demanded definition to its owner when the owner is one
# of them, and into the current TU otherwise. One member today — the set is
# what a batched `cg` grows, and what keeps its members' definitions in their
# own TUs instead of in whichever one demanded them first.
BModuleList(g.backend).icEmitted.incl target.module.position
# The `lower` stage already wrote each module's transformed bodies + lifted
# hooks into its `.t.nif`, which the loaders above read directly (toNifFilename
# resolves the `.t.nif`); transformed bodies arrive via loadSymFromCursor and