IC: critical fix so that edits do not cause full recompiles for the backend

This commit is contained in:
Araq
2026-06-23 23:30:37 +02:00
parent 39e63e9fcc
commit 48568e466c
2 changed files with 51 additions and 28 deletions

View File

@@ -970,9 +970,12 @@ proc generateBackendBuildFile(c: DepContext; forwardedArgs: seq[string]): string
b.addStrLit a
b.addTree "args"
b.endTree()
b.addTree "input"
b.addIntLit 0
b.endTree()
# The project file is a fixed command ARGUMENT, not a tracked input: backend
# stages read NIFs (resolved by suffix), never the `.nim` source, so its
# content cannot change any artifact. Passing it as `(input 0)` made its mtime
# an input to every rule, so editing the main module's source re-fired the
# whole backend.
b.addStrLit mainNif
b.endTree()
template inputStr(s: string) =
@@ -987,37 +990,42 @@ proc generateBackendBuildFile(c: DepContext; forwardedArgs: seq[string]): string
# lower: one rule per module. Transforms (eventually) the routines the module
# OWNS once, in the owner's id space, into `<module>.t.nif`, so the `cg` stage
# reads them instead of re-deriving (which makes a closure `:env`'s identity
# diverge across the parallel `cg` processes). Runs per module in parallel on
# the shallow backend dep-graph. Inputs mirror `cg` (project + every semmed
# NIF) so the rule is ordered after the frontend.
# diverge across the parallel `cg` processes). Runs per module in parallel.
#
# Input is this module's OWN semmed NIF and nothing else. A module does NOT
# depend on its importers, so listing every semmed NIF (or even the import
# closure) was wrong: it made e.g. `strutils`'s rule depend on the `finish`
# that imports it. nifmake handles the indirect dependency for free — the
# frontend writes `.s.nif`s content-stably, so an interface change to a
# dependency re-sems (and re-emits the `.s.nif` of) every transitive importer;
# a module whose own `.s.nif` is unchanged genuinely needs no re-lowering.
for i, node in c.nodes:
b.addTree "do"
b.addIdent "nim_nifc"
b.withTree "args":
b.addStrLit "--icBackendStage:lower"
b.addStrLit "--icBackendModule:" & node.files[0].modname
inputStr mainNif
for n2 in c.nodes:
inputStr c.semmedFile(n2.files[0])
inputStr c.semmedFile(node.files[0])
outputStr tFiles[i]
b.endTree()
# cg: one rule per module. Inputs are the project (slot 0), every semmed
# NIF (so the whole program loads and the rule is ordered after the frontend)
# and this module's `.t.nif` (its lowered bodies); the main module additionally
# depends on every other `.c.nif` (init metas).
# cg: one rule per module. Input is this module's OWN `.t.nif`. cg DOES read
# its dependencies' `.t.nif`s at runtime (loadDepClosure), but ordering is
# guaranteed by nifmake's depth-barriered scheduler: every `lower` is depth 1
# (its `.s.nif` is a leaf) and every `cg` is depth 2, so all lowering finishes
# before any cg starts — no need to list the closure for ordering. For
# invalidation, a dependency's change reaches this module through its own
# `.t.nif` (own `.s.nif` re-sem -> own `lower`); a foreign body this module
# emit-everywhere'd but does not own is dropped by `emit` regardless, so a
# stale copy here is harmless. The main module additionally depends on every
# other `.c.nif` (it reads their init/datInit metas to wire up NimMain).
for i, node in c.nodes:
b.addTree "do"
b.addIdent "nim_nifc"
b.withTree "args":
b.addStrLit "--icBackendStage:cg"
b.addStrLit "--icBackendModule:" & node.files[0].modname
inputStr mainNif
for n2 in c.nodes:
inputStr c.semmedFile(n2.files[0])
# cg loads dependencies FROM their `.t.nif` (toNifFilename), so every module's
# lowered NIF must precede this cg rule.
for j in 0 ..< c.nodes.len: inputStr tFiles[j]
inputStr tFiles[i]
if node.id == 0:
for j in 0 ..< c.nodes.len:
if c.nodes[j].id != 0:
@@ -1030,7 +1038,6 @@ proc generateBackendBuildFile(c: DepContext; forwardedArgs: seq[string]): string
b.addIdent "nim_nifc"
b.withTree "args":
b.addStrLit "--icBackendStage:merge"
inputStr mainNif
for cn in cnifFiles: inputStr cn
outputStr mergeFile
b.endTree()
@@ -1042,12 +1049,13 @@ proc generateBackendBuildFile(c: DepContext; forwardedArgs: seq[string]): string
b.withTree "args":
b.addStrLit "--icBackendStage:emit"
b.addStrLit "--icBackendModule:" & node.files[0].modname
inputStr mainNif
# Inputs: this module's OWN `.c.nif` and the global merge decision. emit also
# loads `.t.nif`s at runtime (getCFile/type resolution), but those are depth 1
# and emit is past the merge barrier, so they always exist — no need to list
# them. (emit still re-fires for every module whenever `merge` rewrites the
# decision file; making that incremental is a separate concern.)
inputStr cnifFiles[i]
inputStr mergeFile
# emit loads modules like cg (for getCFile/type resolution); those come from
# the `.t.nif`s, so they must precede this rule.
for j in 0 ..< c.nodes.len: inputStr tFiles[j]
outputStr cFiles[i]
b.endTree()
@@ -1056,7 +1064,6 @@ proc generateBackendBuildFile(c: DepContext; forwardedArgs: seq[string]): string
b.addIdent "nim_nifc"
b.withTree "args":
b.addStrLit "--icBackendStage:link"
inputStr mainNif
for cf in cFiles: inputStr cf
outputStr exeFile
b.endTree()

View File

@@ -620,7 +620,17 @@ proc generateEmitStage(g: ModuleGraph; mainFileIdx: FileIndex) =
let artifact = cfile & ".nif"
var dropped = 0
let code = renderCFromArtifact(artifact, decision, extractFilename(artifact), dropped)
writeFile(cfile, code)
# Write the `.c` content-stably. `merge` re-runs on any edit and bumps the
# decision file's mtime, so nifmake re-fires every `emit` (the filter is cheap);
# but the FILTERED output is usually byte-identical for modules unaffected by
# the edit. Rewriting it unconditionally would bump every `.c`'s mtime and make
# `callCCompiler` recompile every `.o`. Writing only on a real change preserves
# the mtime, so the C compiler recompiles exactly the modules whose `.c` changed
# — the same DCE model as Nimony's. Safe here (unlike a content-stable merge
# decision): a `.c` is a per-module LEAF consumed only by the C compiler's own
# up-to-date check, not a shared prerequisite in nifmake's mtime ordering.
if not fileExists(cfile) or readFile(cfile) != code:
writeFile(cfile, code)
if isDefined(g.config, "icDceCheck"):
stderr.writeLine "[icEmit] " & extractFilename(cfile) & " dropped " &
$dropped & " bodies (" & $code.len & " bytes)"
@@ -658,7 +668,13 @@ proc generateLinkStage(g: ModuleGraph; mainFileIdx: FileIndex) =
var cf = Cfile(nimname: m.module.name.s, cname: cfile,
obj: completeCfilePath(g.config, toObjFile(g.config, cfile)),
flags: {})
addFileToCompile(g.config, cf)
# `addExternalFileToCompile` (not `addFileToCompile`) gates each `.c` on its
# SHA1 footprint: an unchanged `.c` keeps its `.o` and is flagged Cached, so
# `callCCompiler` skips its compile but still links the existing object. This
# is what makes a localized edit recompile only the handful of `.c`s the
# `emit` stage actually rewrote, instead of every object every time — the
# final piece of per-module backend incrementality after the merge barrier.
addExternalFileToCompile(g.config, cf)
# deps.nim's static scanner can keep a CONDITIONALLY-imported module as a build
# node (e.g. `net`'s `when defineSsl: import openssl`, or a `when defined(os)`
# import) that the NIF-`deps` walk above never reaches because the condition is
@@ -684,7 +700,7 @@ proc generateLinkStage(g: ModuleGraph; mainFileIdx: FileIndex) =
var cf = Cfile(nimname: cbase, cname: cfile,
obj: completeCfilePath(g.config, toObjFile(g.config, cfile)),
flags: {})
addFileToCompile(g.config, cf)
addExternalFileToCompile(g.config, cf)
if g.config.cmd != cmdTcc:
extccomp.callCCompiler(g.config)