radlink: perf + feature series — ICF/ICFSTATIC, GCTYPES, header-units, link-time + memory (rebased on dev, taken commits dropped)#842
Draft
honkstar1 wants to merge 83 commits into
Draft
Conversation
8b67cdb to
d70c7b1
Compare
get_cpu_features was the top main-thread hot spot (~5.6s for one Fortnite link), 97% of it inside a single ATOMIC_LOAD(g_cpu_features). On MSVC, blake3_dispatch.c defines ATOMIC_LOAD as _InterlockedOr(&x,0) -- a lock'd RMW (full barrier) run on every BLAKE3 compress dispatch. The value is written once and read-only after, so the barrier is pointless. Enable BLAKE3's plain-load path (C11 _Atomic, a plain mov on x86) via build flags only, leaving the vendored third_party/blake3 source untouched: /std:c11 /experimental:c11atomics -DBLAKE3_ATOMICS=1 Scoped to the radlink target. MSVC C11 atomics need both /std:c11 and /experimental:c11atomics. get_cpu_features: 5591ms -> 4ms (main thread). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
coff_read_symbol_name scans a cstr in the memory-mapped string table -- the
dominant, page-fault-bound cost of bulk symbol parsing. Many hot callers parse
a full symbol but only read scalar fields (value/section/storage_class/aux) to
interpret the symbol value; the name is never used.
Add name-skipping parse variants and route the interp-only paths through them:
coff_parse_symbol{16,32}_no_name (coff_parse.c) -- and the full variants now
call these + add the name, so the scalar logic lives in one place
lnk_parsed_symbol_from_coff_symbol_idx_no_name (lnk_obj.c)
lnk_interp_from_symbol / lnk_can_replace_symbol / lnk_on_symbol_replace
(lnk_symbol_table.c) and the lnk_search_lib_task loop (lnk.c)
Where the name is still needed (lnk_search_lib) it uses the already-cached
LNK_Symbol.name instead of re-parsing. lnk_can_replace_symbol previously parsed
dst/src twice (full parse + a second parse for interp); collapsed to one
no-name parse each.
coff_parse_symbol32 on the main thread: 3922ms -> ~810ms (name-needed callers).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lnk_fixup_cv_type_indices did two open-addressing probes per type-index reference: lnk_leaf_hash_table_search (leaf_ref -> canonical bucket), then lnk_assigned_type_ht_search (canonical bucket -> assigned type index, via a second hash table keyed by leaf-ref content). Both are cache-miss-bound and this ran across every type-index reference in every obj. Store the assigned type index directly on the leaf hash table: add a ti_arr parallel to bucket_arr. lnk_assign_type_indices_task writes ti = min+i into the leaf's bucket slot (each unique leaf owns a distinct slot, so worker writes never collide), and the new lnk_leaf_hash_table_search_ti recovers it in one probe. Removes the entire assigned_type_hts table and its build pass; deletes the now-dead lnk_leaf_hash_table_search and lnk_assigned_type_ht_search. Correctness: deduplicated leaves share the same ghash (debug_h value), so the fixup query and the assign-time canonical bucket hash to the same slot. lnk_fixup_cv_type_indices (main thread): 1445ms -> 390ms inclusive. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Input obj/lib files are mapped copy-on-write (PAGE_WRITECOPY/FILE_MAP_COPY) so the linker can patch them in place. Pages touched during linking become private-dirty; at process exit the kernel reclaims them in single-threaded address-space rundown -- ~3s of lingering process time after the last thread exits for a large (Fortnite-scale) link. After all outputs are written and inputs are no longer read (post image-write join), unmap the whole-file CoW views in parallel on the thread pool. The same reclaim work then runs multi-threaded, off the serial post-exit path: measured ~34s of aggregate UnmapViewOfFile CPU collapsing to ~0.55s wall, and the post-exit process tail dropping from ~3s to ~0.5s. Only the is_thin whole-file views are swept (lib-member substrings and linkgen arena data are skipped), and only in the copy-on-write (read-only) mapping mode -- read-write-shared mapping would flush dirty pages back to the input files on unmap. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two micro-optimizations on hot parse helpers (profiled as the largest aggregate-CPU functions in a Fortnite link): - lnk_obj_section_from_sect_idx: split out a _no_name variant that skips the section-name string-table lookup (coff_name_from_section_header). The full variant now reuses it + adds the name. lnk_raw_directives_from_obj iterated every section of every obj building the full section struct just to test a flag, computing the name on ~all sections though only .drectve needs it -- now uses the no-name variant and resolves the name only inside the LnkInfo branch. - lnk_parsed_symbol_from_coff_symbol_idx (+_no_name): return the coff_parse_* result directly instead of zero-initializing a local and assigning to it, removing a redundant ~48-byte COFF_ParsedSymbol copy + zero-init per call (RVO). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lnk_leaf_hash_table_search_ti (~EpicGames#2 radlink hotspot, ~48% of its self-time) spent its time in lnk_match_leaf_ref, which is just a_hash==b_hash but fetches the bucket's hash via lnk_hash_from_leaf_ref -> input->debug_h_arr[obj].v[leaf], a scattered cache miss per probe step. Add LNK_LeafHashTable.hash_arr (parallel to bucket_arr), populated at bucket claim/update (both lnk_populate_leaf_ht and lnk_leaf_dedup_task) with the leaf's debug_h hash. search_ti now matches via hash_arr[idx] == hash -- no deref. Exact equivalent (match is pure hash compare); same value across same-hash updates. Gated 65/65 linker torture (ghash_basic/match_debug_t, determ_test, p2r_determinism). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The image buffer was push'd on the shared link arena and only reclaimed in the single-threaded process rundown at exit -- a multi-second kernel page-reclaim tail (observed: one thread 100% in-kernel, zero user frames). Allocate it as a standalone reserve_memory/commit_memory region and release_memory() it the instant the background image-write thread joins (image is on disk, no later reader). VirtualFree(MEM_RELEASE) returns fast; the kernel zeroes the ~1GB on its background thread, overlapping the parallel input-view release + exit instead of blocking rundown. Discard early so the kernel cleans up while the app still runs -- don't defer to exit. Gated 65/65 linker torture (determ_test + p2r_determinism: image correct). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…hotspot) Parse each COFF symbol once in lnk_obj_initer into LNK_Obj.parsed_symbols; lnk_parsed_symbol_from_coff_symbol_idx[_no_name] becomes an array index instead of re-decoding the mmapped symbol table on every access (it was the EpicGames#1 hotspot, lnk_parsed_symbol_from_coff_symbol_idx). All symbol-value patch sites (weak-replace, COMDAT-leader, regular/common fixups) write obj->parsed_symbols[idx], decoupling symbol values from the input mapping. Extracted from the entangled WIP commit 8dc5fa6 (parsed-symbol memo + .rgd staging); this is the memo half only -- no .rgd. Gated separately. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…im ~3GB peak) The CV type-index fixup (05af760) had stored the assigned type index in arrays parallel to the dedup hash table (leaf_ht), whose cap is the TOTAL pre-dedup leaf count summed over all objs. On large links that ti_arr (+ the hash_arr added for deref-free probing) added ~3GB to peak working set vs the prior unique-sized assigned_type_ht. Split the two concerns: - LNK_LeafHashTable: just {cap, bucket_arr} for dedup (total-sized, as before / unavoidable). - LNK_AssignedTiHash {cap, ti_arr, hash_arr}: hash -> assigned ti, sized to the UNIQUE (post-dedup) leaf count. Built in lnk_assign_type_indices_task by hashing each unique leaf into its own slot (atomic claim; unique leaves have distinct hashes since dedup is by hash). search_ti probes it in one deref-free pass (occupant hash stored on the slot), exactly as before -- just on a table sized by unique instead of total. Keeps the single-probe fixup speed of 05af760; removes its peak-memory regression. ti==0 marks empty (assigned ti is always >= CV_MinComplexTypeIndex). Builds clean, 95 linker torture PASS, 0 fail. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
d70c7b1 to
bd71560
Compare
1c15e41 to
4bfa113
Compare
Drop the decoded name from the per-obj parsed-symbol memo (rebuilt on demand from immutable obj data) and pack the remaining fields to a U32 string-table offset + U32 value. Peak committed memory 50.5 -> 48.4 GB (-2.1 GB) at editor scale; 95-link torture run clean; output byte-identical. Squashed from 2 series commits; their original messages follow. * radlink: slim the parsed-symbol memo (drop name, decode on demand) LNK_Obj.parsed_symbols memoized the full COFF_ParsedSymbol per symbol -- including the 16B String8 name -- sized by total symbol count, held to exit. Store a slim LNK_ParsedSymbolLite (every field except name, ~24B vs ~40B) and re-decode the name from the read-only symbol record in the named accessor only. The hot _no_name path (can_replace/GC/resolution) and all symbol-value patching never touch the name, so they stay fully memoized; only the named/push path pays a re-decode (cold relative to total). FN no-rrt full link: peak commit 50.5GB -> 49.1GB (-1.4GB), wall flat (~8-9s no-debug), valid 5.68GB PDB, 95/0 linker torture. * radlink: pack LNK_ParsedSymbolLite to 16B (offset + U32 value) Store the COFF symbol record as a U32 byte-offset into obj->data instead of an 8B pointer, and value as U32 (COFF symbol value is U32). Struct goes 24B -> 16B (no padding): raw_symbol_off(4) value(4) section_number(4) type(2) storage_class(1) aux(1). The named/no_name accessors reconstruct the pointer as obj->data.str + off (obj->data == input->data, so the offset is stable). Sized by total symbol count -> 8B/sym off peak. FN no-rrt full link: peak commit 49.1GB -> 48.4GB (-0.67GB), wall flat, valid 5.68GB PDB, 95/0 linker torture. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
f5ec08b to
4e8ae06
Compare
…before PDB emit (opt-in, default off)
After type merging, prune any merged TPI/IPI record not transitively reachable
from a surviving symbol. Roots are the type indices referenced by the symbols
that survive /OPT:REF (plus inlinee call-site types); the type graph is then
closed over and everything unreached is dropped before the streams are written.
Runs only under /OPT:REF -- it is the debug-info analogue of dead-section
stripping, and is otherwise transparent (no visible type is removed).
Implementation notes:
- parallel transitive closure (bulk-synchronous rounds, atomic mark/expand)
- fwdref<->definition pairing via a per-unique-name ring so a live forward
reference keeps its definition (and vice-versa)
- compaction is in place with the remap kept in scratch, so peak memory is
unchanged
Numbers (UnrealEditorFortnite-Engine.dll, /OPT:REF /OPT:ICF, hashing NONE):
PDB 5315 -> 5081 MB (type-GC alone: -234 MB)
Default OFF: shrinks the PDB but a pruned type can't be cast-to in the debugger watch window.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…arallelized
Fold byte-identical COMDAT sections whose relocations point at equivalent
targets, iterated to a fixpoint, then redirect each group's followers at their
shared symbol-table node so every reference resolves to one leader and /OPT:REF
collects the now-unreferenced follower sections (and their associated
.pdata/.xdata/.debug$S). Mirrors link.exe /OPT:ICF.
- equivalence: round-0 key from content + reloc structure + non-candidate
target identity; refine by candidate targets' colors until the partition is
stable; a final byte-compare + per-reloc target-color check guards folds
(no hash-collision can produce a bad fold)
- folds code AND read-only data (vtables, const tables, string literals);
folding identical read-only data lets the functions that reference it fold
too (cascade)
- fully parallel: candidate collection (count -> exact alloc -> fill),
content hashing + reloc-target resolution, refinement, and final grouping
via a parallel LSD radix sort (8-bit digits). A flat open-addressing map
with an avalanche-scrambled key avoids O(n^2) probing on UE-scale inputs.
Only externally-defined COMDATs are folded (the follower redirects through its
symbol). Static/internal-linkage folding is intentionally out of scope here.
Numbers (UnrealEditorFortnite-Engine.dll, vs /OPT:NOICF, hashing NONE):
.text 727 -> 643 MiB (-84)
.rdata 218 -> 194 MiB (-24)
PDB 5562 -> 5081 MB (-481)
DLL 999 -> 882 MB (-117)
link 33 -> 20 s (-13; less to relocate and emit downstream)
This commit also adds the shared parallel radix-sort helper
(lnk_radix_sort_u64_pairs) used here and by the PDB GSI/PSI sort.
This commit also carries the associated-unwind over-fold fix (owner-reported),
squashed in so the series never presents the broken behavior: /OPT:ICF must
not fold COMDATs whose associated unwind/handler data differs.
ICF keyed/verified only the candidate section's own flags+size+bytes+relocs;
the associative .pdata/.xdata (and funclet) children were never consulted. Two
byte-identical functions where only one registers an exception handler
(UNW_FLAG_EHANDLER, handler RVA, scope table) therefore folded, and the
follower's handler died silently via associative dead-stripping. link.exe folds
only when code AND unwind/handler data are equivalent.
Fix (MSVC-equivalent: keep legal identical-unwind folds, refuse differing ones):
- lnk_icf_gather_assoc: collect a candidate's non-debug associative children,
recursively, cycle-guarded, in deterministic per-obj list order; debug/
discardable/info children are skipped so .debug differences never block folds
- lnk_icf_fill_task: reloc_count now also reserves the children's reloc slots
- lnk_icf_hash_task: hash child flags+fsize+bytes into key0 and append child
relocs (shared lnk_icf_key_reloc canonicalization) into the candidate's rt
slice, so the refine loop and reverse index cover them: a handler-RVA reloc
difference splits classes, a scope-table byte difference splits key0.
Intra-group targets that are not candidates (.pdata -> its own .xdata under
plain /OPT:ICF) key by group ORDINAL + offset, not per-obj section identity,
which would have blocked every legal fold of functions with unwind data
- lnk_icf_fold_verify_task: pairwise-verify child count, flags, fsize, bytes,
and per-child reloc counts (keeps the appended class-compare slice aligned);
child reloc TARGETS class-compare via the existing loop since reloc_count now
spans the appended slice
Covers both external-COMDAT folds and /OPT:ICFSTATIC static folds (shared code).
Repro (funcs.s f1/f2 byte-identical, f2 has .seh_handler): before exit 105
(wrongly folded, handler dropped), after exit 5 + unwind dump identical to
link.exe (3 RUNTIME_FUNCTIONs, f2 EHANDLER/UHANDLER kept). Positive control
(identical handler/unwind pairs) still folds, matching link.exe.
UnrealEditorFortnite-Engine.dll /OPT:ICFSTATIC /BREPRO: deterministic (17-byte
GUID/age diff on relink, same as base), .text +318,544 bytes (+0.058%) from
legitimately refused folds, .pdata +26,412 bytes of kept RUNTIME_FUNCTIONs,
total CPU within noise of base (~697s vs ~694s), no new warnings.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ize GSI/PSI sort
DBI section contributions:
- after sorting, merge contiguous contributions that share (section, module,
flags), absorbing the alignment-padding gaps between them. On UE-scale
input this collapses ~12.5M contribution records to ~2.0M and shrinks the
DBI stream 367 -> 72 MB, with no change to the address map.
GSI/PSI publics sort:
- the comparators got element-stable tiebreakers (sort by record offset /
dereferenced symbol identity, not by slot pointer) so the median-of-9
quicksort cannot degrade to O(n^2) on the large runs of equal-address /
equal-name records that ICF now produces.
- gsi_record_sort_by_sc returns a radix-sorted permutation index (via the
shared lnk_radix_sort_u64_pairs) and the PSI address map is built from it,
replacing a comparator sort that stalled multiple seconds on ICF-heavy links.
Numbers (UnrealEditorFortnite-Engine.dll):
DBI stream 367 -> 72 MB
removes a multi-second GSI/PSI sort stall on ICF-folded inputs
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The closure re-scanned every merged type each round (O(rounds * total types)) to find marked-but-unexpanded leaves. In a full-link trace of UnrealEditorFortnite that round-rescan dominated the type-GC: lnk_gc_expand_task ~13.3 s of CPU. Replace it with a frontier worklist: the atomic mark now gates a single append per leaf, and each round expands only the slice newly marked by the previous round, so total work is O(reachable types) instead of O(rounds * total types). Drops the per-round `expanded` bitmap and full-array sweeps. Output is unchanged -- same reachable set, PDB byte-identical (5081 MB on the same input), and debugger-fidelity checks (addr->symbol 100%, core types resolve) match the pre-change build. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-parse lnk_opt_icf re-parsed every candidate section serially (lnk_coff_relocs_from_ section_header per candidate) just to size the flattened reloc-target arrays. Move the per-candidate reloc count into lnk_icf_fill_task -- which is already parallel and has the section in hand -- so lnk_opt_icf only does a cheap serial prefix sum for reloc_first. No output change (PDB/.text/.pdata identical). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The frontier mark did an interlocked op on every reference edge. Add a plain non-atomic check first (the mark bit only ever goes 0->1, so a stale "already set" read is safe), so the interlocked op runs once per leaf at its 0->1 transition instead of once per edge. Output identical (PDB 5081 MB). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lnk_sort_contribs_task ran one serial radsort per chunk. A section's contribs live in a single chunk sized to the whole section, so the merged .text chunk (millions of entries) was sorted serially on one worker while every other thread idled -- the straggler that stretched the "Sort Section Contribs" phase. Sort chunks >= 64K entries with the parallel radix sort (key = Compose64Bit(obj_idx, obj_sect_idx), which is unique per contrib so the order matches the comparator) using all threads, before the per-chunk task pass handles the small remainder. Output is unchanged: section sizes identical, byte diff within the pre-existing relink noise. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ICF refinement re-densified all ~N candidates every round, radix-sorting the full set each iteration to a fixpoint. But a candidate alone in its equivalence class can never split or merge again -- its color is final. Track an active set of only the candidates still sharing a class with another, and re-densify just that set each round (ids drawn from an ever-increasing base so they never collide with the colors already finalized for singletons). The per-round sort shrinks from all candidates to those that still have a content+reloc twin, and converged classes drop out as they fragment into singletons. Output is unchanged -- relinking UnrealEditorFortnite-Engine.dll is byte-identical to the prior ICF (same folds, same size) and reproducible across runs. On that link the refinement loop drops from ~888ms to ~765ms (first round prunes ~2.1M of 3.98M candidates to singletons immediately). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ved symbols lnk_search_lib_task scans the entire search-chunk symbol set once per library (2733 dispatches on the UE editor link). A symbol that started Undefined/Weak stays in search_chunks even after a definition resolves it, so every later library pass re-parsed it -- lnk_ref_from_symbol + lnk_parsed_symbol_from_coff _symbol_idx -- just to recompute its interp and skip it. That parse faults the COFF symbol record out of the mmap'd obj, and the profile showed those two lines at ~65% of the task and a matching wall of page-fault kernel time. The interp is already computed once in lnk_symbol_table_push_; cache it on LNK_Symbol and read it in the hot loop. Only genuinely Weak symbols still parse (for the weak-extension characteristics). The hash-trie node always points at the current leader symbol, so the cached interp reflects the resolved state. Output unchanged (byte-identical DLL+PDB, reproducible). Lib-search dispatch wall drops ~18% (~1.5s -> ~1.2s on the UE editor link) with a larger drop in aggregate CPU and page-fault traffic. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lnk_link_inputs resolves libraries to a fixpoint: an outer pass loops over every library, and each library is re-searched (a full tp_for_parallel over all workers, scanning every undefined/weak symbol in search_chunks) once per drained input batch until nothing new resolves. On the UE editor link that is ~2733 dispatches, each waking and joining ~60 workers -- and the phase is barrier-bound, so that wake/join is the cost, not the scan. Most re-searches are redundant: search_chunks only grows during the loop (symbols are never removed until the end) and member-queue dedup is idempotent, so a re-search can only queue new members if the undefined/weak symbol set grew or anti-dep searching was just enabled since this library was last searched. Stamp each LNK_Lib with the search_chunks symbol count + anti-dep mode at its last search and skip the dispatch when neither changed. ~24% fewer dispatches (2733 -> ~2089) and ~0.2s of wake/join wall-time removed. Output is byte-identical and reproducible (which dispatch coalesces is timing dependent, but a skipped one provably queued nothing, so the result is unchanged -- verified relink-twice byte-identical across many runs). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
External ICF folds a follower by redirecting its defining symbol to the leader (Fnode->symbol = Lnode->symbol); /OPT:REF then finds the follower unreferenced and dead-strips it along with its associated .pdata/.xdata. Static COMDATs have no external symbol -- they're reached only by section-relative relocs to a static symbol that resolves to themselves -- so that path can't fold them, leaving a large .text gap vs MSVC. Add an opt-in static fold. lnk_icf_section_kind now returns candidates for static COMDATs too; they join the same content+reloc-equivalence classes. Leader selection prefers a non-static member so an external is never folded into a static leader. A static follower records a per-section icf_fold map (LNK_Obj.icf_fold: follower section -> leader obj/section) instead of a symbol redirect. The /OPT:REF mark-live walk consults that map: when a reference (or an associative-section walk) reaches a folded static follower, it marks the LEADER section live cross-obj and enqueues the leader's relocs/associated sections instead -- so the follower dead-strips, taking its .pdata/.xdata with it. Crucially the redirect is applied in the associated-section walk too: folded static .text are often associative COMDATs (EH funclets/thunks) pulled in via associated_sections[], and keeping those followers live was the prior attempt's bug (~250K malformed .pdata + nondeterminism). lnk_set_icf_static_leader_contribs_task then redirects folded followers' sect_map entries to the leader's contrib so any residual reloc resolves to the identical leader. Gated behind /OPT:ICFSTATIC (default off): runtime-unvalidated, like /OPT:GCTYPES. Default ICF output is byte-identical to before. On UnrealEditorFortnite-Engine.dll with /OPT:ICFSTATIC: DLL 925 -> 844 MB (.text 643.1 -> 594.3 MiB, .rdata 194.9 -> 169.2 MiB), output reproducible (relink byte-identical), .pdata clean (1,740,509 records, 0 malformed, 0 out-of-order), link exits 0. No runtime validation performed. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…cap 8 -> 4 Within a worklist round every dirty class is independent (re-keys read the frozen pre-round colors), so the per-class re-key/sort/split-staging now runs as a member-weighted parallel task with a serial commit that assigns slot/color ids in exactly the serial emission order. Early handoff is then cheap, so the region cap drops 8 -> 4: the last region rounds re-sorted the full ~10.8M-active set (~95 ms/round) to resolve churn the worklist handles in single-digit ms. Region+tail 879.8 -> 518.7 ms (cap probe: 8 = 879.8, 6 = 691.5, 4 = 518.7). Hang-guard retuned (structural termination argument + hard round cap). Partition identical at any cap; output byte-identical. Squashed from 2 series commits; their original messages follow. * linker: parallelize the ICF worklist dirty-loop (frozen-colors Jacobi) Within a worklist round every dirty class is independent: re-keys read the frozen pre-round colors[], slot arrays are only mutated at commit, and a class's mem[] rewrite stays inside its own disjoint range. Run the per-class work (re-key -> small_sort -> sub-run detection/staging) as a tp_for over member-weighted contiguous dirty[] ranges (lnk_icf_wl_rekey_task); keep the commit serial. Determinism (numbering EXACTLY as serial): each worker emits its staged sub-run records at a precomputed exclusive member-prefix slab base, so the concatenation in worker order equals the old serial emission order; the serial commit walks that order assigning fresh slot ids / fresh color ids from the same monotone counters and building split_mem in the same member order. kk/vv re-key scratch is carved from shared slabs by the same member prefix (no per-worker allocation). This is the prerequisite for lowering the region cap: at an earlier region->worklist handoff the early worklist rounds carry big dirty sets (millions of members) that the serial loop would grind through. Gate (FN editor Engine.dll, /OPT:ICFSTATIC /BREPRO /RAD_WORKERS:64): selfcheck link exit 0 / no PARTITION MISMATCH, per-round worklist stats byte-for-byte identical to the serial loop; base/head/base control 18B GUID-band, base-vs-head 18B GUID-only, OTHER=0; icf_unwind repro 5/2. * linker: lower the ICF region cap 8 -> 4 (worklist tail absorbs the churn) With the worklist dirty-loop parallel, the last region rounds are pure overhead: each re-sorts the entire ~10.8M-active set (~95ms/round wall, radix-dominated) to resolve churn the worklist now handles in single-digit ms. Hand off after 4 rounds instead of 8. Probe results (FN editor Engine.dll, /RAD_LOG:TIMERS phase lines, region total + worklist tail total): cap=8: region 754.5 ms + tail 125.3 ms (10 rounds) = 879.8 ms cap=6: region 565.8 ms + tail 125.7 ms (12 rounds) = 691.5 ms cap=4: region 382.5 ms + tail 136.2 ms (14 rounds) = 518.7 ms <- chosen Consistent with per-round timers (rounds 5..8 cost ~94-98ms each). Worklist round 1 at cap=4: 4714 dirty classes / 28348 members, 2.9 ms (parallel). Rev-index at the earlier handoff: 57528 KiB rev_adj (vs 56682 at cap=8) -- no blow-up. Hang-guard retune: dropped the 'dirty_n must shrink' stall counter -- at an earlier handoff the dirty set legitimately grows for several early rounds while splits propagate outward. Termination is structural (every round either splits a class, strictly growing the class count, or breaks at the fixpoint test); keep only a generous hard round cap (128) whose trip still AssertAlways's as a true re-dirty bug. The region->worklist handoff is round-count-agnostic (member-level exclusion argument), so partition + output are unchanged at any cap. Gate (FN editor Engine.dll, /OPT:ICFSTATIC /BREPRO /RAD_WORKERS:64): selfcheck link exit 0 / no PARTITION MISMATCH at cap=6 and cap=4; base/head/base control 18B GUID-band, base-vs-head 18B GUID-only, OTHER=0; icf_unwind repro 5/2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ent) An INERT candidate has no iscand relocs: its refine key is a pure function of its own color + reloc constants. Colors are renumbered consistently per class, so two inert members of one class produce equal keys every round -- an equal-key run whose members are ALL inert can never split internally, and (having no iscand out-edges) can never make anything else split. Measured inert fraction at editor scale: 3,284,541 of ~10.8M steady-state actives (~30%) -- above the ~25% go threshold. - lnk_icf_hash_task: per-cand inert byte (computed on the just-filled, cache-hot rt slice). - refine region: new veto phase between radix and scan-mark -- gather the per-sorted-position inert byte, then a run-owner walk (a run is owned by the chunk containing its first position; owner may read past its range end, veto writes stay disjoint) ANDs inert over each run. scan-mark then emits keep=0 for surviving all-inert runs: members leave the active set but KEEP their just-assigned colors, so the class remains a fold group. Downstream is partition-only, so output is byte-identical. - worklist handoff unaffected: frozen classes can never appear in split_mem, and inert referrers contribute no rev-index edges. Measured (FN editor Engine.dll, cap=4): round 1 freezes all 3,284,541 inert members; active at handoff 10.81M -> 7.52M; region 382.5 -> 327.3 ms, worklist rev_adj 57.5 -> 40.7 MiB, tail 136.2 -> 127.4 ms. Total ICF refine 518.7 -> 454.7 ms (879.8 ms before this series). Gate (FN editor Engine.dll, /OPT:ICFSTATIC /BREPRO /RAD_WORKERS:64): selfcheck link exit 0 / no PARTITION MISMATCH; base/head/base control 18B GUID-band, base-vs-head 18B GUID-only, OTHER=0; icf_unwind 5/2. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ntity perf change) The ICF round-0 content key (lnk_icf_hash_task / lnk_icf_key_reloc) hashed each candidate's flags+fsize+section bytes+assoc-child bytes with blake3, plus 2-3 tiny hasher updates per reloc across ~111.7M relocs. The 128-bit result was already truncated to 64 bits via lnk_icf_mix, so cryptographic strength bought nothing: the design already accepts 64-bit birthday collisions, key0 only seeds the initial partition (refinement only splits), and lnk_icf_fold_verify_task byte-compares candidate+children before any fold. Replace blake3 with XXH3-128 (already in tree via base_strings.c, XXH_INLINE_ALL) over the same input fields in the same order, and pack each reloc's shape into one fixed 16B LNK_ICFRelocKeyEntry (type, iscand flag, apply_off, canonical target; no padding, every byte written) -- one update call per reloc instead of three, and the fixed-size entry makes the reloc encoding injective where the old variable-length 6B/14B concatenation was only injective by luck. This is a PROBABILISTIC-IDENTITY change: key0 values change freely by design; what must hold is collision-freedom in practice (identical grouping iff no colliding pair differs). Verified at editor scale (UnrealEditorFortnite-Engine.dll, /OPT:ICFSTATIC /BREPRO /RAD_WORKERS:64): base/head/base A/B was BYTE-IDENTICAL outside the /BREPRO debug-dir timestamp/GUID band (control 18B, base-vs-head 23-26B, OTHER=0 in all 5 pairings), head determinism head-vs-head 18B control pattern, and ICF reached the identical partition in every run (active=10785430 at worklist handoff). ICF unwind repro: 5/2 under both /OPT:ICF and /OPT:ICFSTATIC (refuses illegal fold, positive control still folds). User CPU (2v2 interleaved, GetProcessTimes): base 270.4/277.8s vs head 264.8/267.5s, ~-8s (-2.9%); both head runs below both base runs. Type-merge blake3 (lnk_hash_cv_leaf) and image-GUID blake3 (lnk_blake3_hash_parallel) are intentionally untouched. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Leaf hashes are opaque 8-byte equality keys for type dedup (lnk_match_leaf_ref); they never reach image/PDB bytes -- output depends only on the dedup partition, which is identical iff the hash collision sets are identical. XXH3-128 truncated to 64 bits has the same birthday math as the previous blake3->64 truncation, so this is a probabilistic- identity-preserving swap: values change uniformly, probe/compare logic in lnk_leaf_dedup_task is untouched. Algorithm selection is per link (LNK_CodeViewInput.type_hash_xxh3, decided at the end of lnk_make_code_view_input): XXH3 by default; forced back to blake3 only when precomputed blake3 hashes are already in play, i.e. .debug$H sections actually accepted under /DEBUG:GHASH (MSVC never emits .debug$H, so UE-style links that pass /DEBUG:GHASH still get XXH3) or RRT type servers. RRT format bumped to v3 with an explicit hash_alg tag after the version field; consumer errors on tag/selection mismatch. UDT name hashing and image-GUID blake3 are unchanged. Gates (UnrealEditorFortnite-Engine.dll, /BREPRO, x64): - DLL byte identity: base-vs-head 18B = checksum+GUID band, OTHER=0; head-vs-head determinism 18B; same-binary control 18B. - PDB: 8247 streams, per-stream sizes identical; per-stream SHA256 identical for 8244/8247 incl TPI/IPI/DBI/globals/all modules; the 3 differing streams (PDB info GUID, Public Symbol Hash, Symbol Records) differ identically in the base-vs-base control (pre-existing). - /DEBUG:GHASH regression: obj with real .debug$T linked +/- GHASH, base-vs-head exe byte-identical, PDB differs only in 16B GUID, no warnings. - ICF unwind repro: 5/2 (+ msvc parity), icf and icfstatic. - User CPU (GetProcessTimes, 3v3 interleaved): base 285.9s avg vs head 261.9s avg (-24s / -8.4%); all 3 adjacent pairs favor head (-7.4/-32.7/-32.0s) under rising background load. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pre-dedup unique-leaf estimator swept every debug_h hash into a presence bitmap (~6.6s of worker thread-time on editor-scale links). Sample every 8th leaf POSITION per obj instead: position-based sampling is a pure function of the input (schedule-independent), the presence bitmap shrinks 8x (cheaper cache footprint per update), and the sampled distinct count is scaled back up by a calibrated factor before the existing 1.9x safety + Min(fallback-cap) clamp. SCALE=5.0 satisfies SCALE*1.9 >= K=8, covering the worst-case sampled-to-true ratio for every duplication pattern, so the deterministic overflow-retry stays off; an undershoot would still be caught by that retry (exercised live during calibration at SCALE=1). Measured on the FN editor-scale link: estimate block 182.6 -> ~38 ms wall, caps byte-for-byte identical to the unsampled estimator (64M TPI / 16M IPI, load factors 0.351 / 0.387), output byte-identical. Also logs the estimate/caps/load factors under /RAD_LOG:TIMERS (output-neutral). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lnk_build_pdb_distribute_obj_indices round-robined objs across lanes by index, ignoring per-obj debug$S size, so a lane drawing several giant objs held the barrier pass at its final barrier while other lanes idled. Distribute by greedy LPT instead: objs taken in weight-descending order (obj_idx tie-break), each assigned to the least-loaded lane. Weights are O(1) per obj -- symbols-subsection total_size for the GSI pass, total debug$S size for the module-write pass. The partition is output-neutral: per-obj results land in per-obj module streams or in GSI bucket chains that are content-sorted at serialization (gsi_symbol_is_before), so any deterministic assignment produces byte-identical PDB bytes. Measured on the FN editor-scale link (6 quiet interleaved samples): Write Modules wall 174-191 -> 151-168 ms (~-12%, samples fully separated); Move Global Symbols wall unchanged (~352 ms) -- its heavy sub-phases are partitioned by symbol_input_ranges/symtab chunks, not obj_indices. Also logs both phase walls under /RAD_LOG:TIMERS (output-neutral). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-bucket GSI sort tie-breaks same-name globals on CV_Symbol.offset, which was the compacted deduper slot index -- CAS-arrival order in cv_symbol_deduper_insert_or_update. Same-name different-content records (duplicate S_UDTs with distinct type indices) could swap symrec positions whenever the lane->worker schedule changed (fair-share cohorts under /RAD_SHARED_THREAD_POOL) or probe chains contended. Key on the content hash of the full raw record instead: order becomes a pure function of record bytes (hash -> kind -> data-bytes fallback in gsi_symbol_is_before; byte-identical records are folded by the deduper before the sort). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…paths MemFind_avx512 NUL-terminator scans of long COFF symbol names (60-300B mangled C++ names in the string table) cost ~38s incl across workers at editor scale because lnk_parsed_symbol_from_coff_symbol_idx re-scans the same symbol's name on every call across passes (obj symbol input, symlink assignment, per-reloc resolution/ICF keying/base-reloc gathering, symtab patching). Two complementary changes: 1. Memoize the name LENGTH in LNK_ParsedSymbolLite (U32 name_size, 16->20B): lnk_symbol_name_from_coff_symbol_idx decodes+memoizes on first fetch, later fetches rebuild str8(ptr, size) without scanning. The fill write is idempotent (derived from immutable obj->data), so the cross-worker race is benign; 0 stays 'unknown' (empty names just rescan, which is trivial). 2. Convert call sites that only need scalar fields (interp/section/value) to the _no_name accessor, decoding the name branch-locally only where actually used: obj symbol-input sweep (Regular-static/debug symbols now never decode a name at all), COMDAT symlinks sweep, secdef props, /OPT:REF reloc walk, ICF reloc keying (name only for the rare non-Regular fallback hash), lnk_resolve_symbol (Regular branch resolves via symlink), symtab patch sweeps, reloc apply, and base-reloc page gathering. Output is byte-identical: name bytes and all name-derived hashes/keys are unchanged, only the number of times the terminator scan runs changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…c tree HashMaps, skip name decodes - per-worker lossy open-addressing cache (obj input idx, symbol idx) -> final resolved ref; the resolve chain (interp parse + trie search per hop) repeated per referencing reloc - cycle detection + per-walk visited-section set: flat arrays with linear scan instead of arena-backed tree HashMap nodes (same first-revisit semantics) - lnk_resolve_symbol + walk unpack sites use lnk_parsed_symbol_from_coff_symbol_idx_no_name; name (string-table decode + strlen) only where a by-name symbol-table search happens - test-and-test-and-set on is_live flags to keep already-live cachelines in shared state Live-section set and warning behavior are byte-for-byte unchanged by construction. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The remove pass was a serial O(all sections) walk on task 0 (self-labeled TODO: thread). Section flags are per-obj so writes are disjoint; stride the obj list across tasks via objs_by_idx. Stats accumulate per task and reduce on task 0, keeping the /OPT:REF debug-log totals identical regardless of cohort width or schedule. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The per-cand refine key is a serial lnk_icf_mix chain (xor/mul/xor-shift, ~7 cycle latency per reloc) while the reloc loads (rt_iscand, rt_target, colors gather) are chain-independent, so the phase was latency-bound on the mix chain, not DRAM. Advance FOUR independent cands' chains in lockstep over min(reloc_count), then finish each chain with the exact scalar epilogue (lnk_icf_refine_fold); per-cand fold order is untouched, so every newkey[ci] is bit-identical to the scalar loop by construction. Measured (UnrealEditorFortnite-Engine.dll, 64 workers, interleaved A/B, RAD_LOG:TIMERS refine-phase wall, sum of 4 region rounds): base 88.1 ms mean (89.0/88.1/87.3) 4-way 74.2 ms mean (76.9/72.0/73.6) -15.8% (2-way variant measured -12.9%: 76.4 ms mean over 4 pairs) Region total 333.9 -> 324.5 ms. Whole-link CPU time flat within noise (workers spin at the phase barriers either way). Gates: base/head/base same /OUT byte-identical (0 differing bytes, incl base-vs-base control), head x2 deterministic, 5/5 repro links identical, ICF_WORKLIST_SELFCHECK editor link passes (exit 0, 0 mismatches). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Archive symbol-dir names (lib->symbol_names.v[]) point into the mapped archive's string table, so every bsearch probe's MemCompare chases .str into scattered archive bytes; sorted order != memory order, so probes have no locality. Build a parallel U64 disc[] at lib parse time packing each name's first 8 bytes big-endian (zero-padded): integer compare of discriminators decides str8_compar_case_sensitive order exactly -- including the shorter-prefix-precedes size tie-break -- whenever they differ, and equal discriminators fall through to the full compare. Probe sequence and result are identical to str8_array_bsearch; probes now read the contiguous disc[] array and touch archive string-table bytes only on discriminator ties. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ount The barrier-elision check calls lnk_symbol_table_search_symbol_count once per lib per lib-search round, and it walked every chunk of every worker's search_chunks list each time. Maintain a running symbol_count on LNK_SymbolHashTrieChunkList, bumped at the chunk push site (and decremented on the insert-race rollback, mirroring chunk->count), and sum the per-worker totals instead. Same value, O(workers) per query. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…arse time
cv_debug_t_from_data previously walked every leaf header twice (count pass,
then offset-store pass); with multi-GB total .debug$T input the second sweep
re-faulted pages evicted after the first. Fuse the two sweeps: over-allocate
the offsets array to the worst-case leaf count (data.size / min leaf stride),
fill offsets + per-source counts in a single walk, and arena-pop the unused
tail. The sweep also records whether the stream contains any LF_IFC_RECORD
(0x1522) placeholder leaf.
On top of that, hash eligible objs' leaves directly in lnk_parse_debug_t_task
("touch once"): plain /Z7 objs -- no .debug$P section, first leaf neither
LF_PRECOMP nor a type-server ref, no LF_IFC_RECORD leaf, no RRT input in the
link -- have fully self-contained leaf hashes (lnk_leaf_ref_from_ti resolves
within the same obj), and nothing between the parse phase and the
lnk_merge_types hash phase mutates their leaf bytes or leaf indexing. Hashing
them right after the header sweep touches the .debug$T pages while they are
still resident instead of re-faulting the entire input later.
Enabled only when the internal hash algorithm is decidable up front (no RRT
input; no .debug$H section on any obj under /DEBUG:GHASH), which pins the
XXH3 selection; the debug_h_arr/obj_to_ts allocations are hoisted above the
parse phases for this. The alg-selection scan at the end of
lnk_make_code_view_input skips its .debug$H probe under leaf_prehash (the
gate proved no section exists; prehashed counts would misread as blake3).
Hash values, per-leaf input order and per-obj leaf order are identical to the
lnk_hash_debug_t_task path, so the dedup partition and all output bytes are
unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-barrier arrival stamps on the lnk_move_global_symbols_to_gsi barrier pass (FN editor-scale link, cohort 64) showed two real stalls in the Global Symbols section: - the deduper-insert phase had a ~2x arrival skew (47ms spread on a ~90ms phase): the collect partition is weighted by raw symbol bytes, but insert cost follows global-symbol COUNT, whose density varies per lane. Flatten the per-worker collect lists (in lane order) into one array and re-divide the inserts evenly. The deduper is CAS-based and content-keyed, so any insert partition produces the same deduped content set; downstream order is already schedule-independent (per-chain radsort keyed on the content hash written into n->data.offset). - "Compact Buckets" ran serially on task 0 (~38ms sweep over the ~1.3x global-symbol-count slot array) while the other 63 workers idled at the next barrier. Compact in parallel instead: each worker counts occupied slots in its contiguous slot range, then copies them to its prefix-sum offset. Concatenated ranges preserve ascending slot order, so symbol_arr is identical to the serial compaction. Output is byte-identical (DLL 0-byte diff, PDB 0 differing streams, determinism re-link clean). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The serial fold-apply pass after lnk_icf_fold_verify_task paid a 4-5 dependent cache-miss chain per verified follower (sci -> cand -> obj -> symlinks[sn] -> node), measured 778ms of all-workers-idle main-thread time on the FN editor Engine.dll link. Stage the follower/leader node pointers (and the static-COMDAT icf_fold slot, tagged in bit 0) inside the already-parallel verify task -- pure reads of state the apply pass never mutates -- and keep only the order-sensitive part serial: reading Lnode->symbol and committing the writes in exact group order (an earlier group's follower node can be a later group's leader node, so value reads must observe earlier commits). Same groups, same order, same values -> byte-identical output. Measured fold-apply 778.5ms -> 79.3ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The scratch decommit pass between type merge and PDB build was a near-dead ~0.9 s main-thread window (kernel-serialized MEM_DECOMMIT, whole pool parked at the barrier). ~84% of the decommitted bytes (9.4 of 11.3 GiB on the FN editor link) live in arena FREE-LIST blocks that hold no live data, so each worker now detaches its free chains (same-thread pointer ops) onto a global list and a background thread releases them while the PDB build runs; only the active-chain pages above the live pos are still decommitted synchronously. Window: 866 -> ~45 ms. Outputs unaffected (memory ops only); the reaper is joined next to the existing arena reaper before exit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lnk_radix_sort_dbi_sc_array ran 4 serial histogram/scatter passes on the main thread (~157 ms on the FN editor link) inside the serial pdb_build tail while every worker idled. Replace with a stable parallel LSD sort: five 8-bit-or-smaller digit passes (sec_off bytes 0..3, then section index), per-worker histograms over contiguous input ranges, a small serial exclusive prefix in (digit, worker) order, and a parallel scatter with disjoint per-worker cursors. Output permutation is identical to the serial sort (stable by (sec, sec_off), ties in input order) regardless of worker count; no atomics. Serial path kept for tiny inputs and tp-less callers. Sort: 157 -> ~28 ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The RAD_ switch namespace is owned by radlink, so an unknown /RAD_ switch on the command line means the build system expects a feature this binary does not have. Previously this fell into LNK_Warning_UnknownSwitch, which the release-default /RAD_IGNORE mutes -- the switch was silently dropped with no trace in the build log. Newer build scripts must keep working against older radlink binaries (forward compatibility), so this must not fail the link either: warn once per unknown switch via LNK_Warning_Cmdl (not muted by the release default), ignore the switch, and continue. Unknown non-RAD switches and obj-directive switches keep the old warning. Also register RAD_TYPEHASHALG as an alias of RAD_TPYE_HASH_ALG [sic]: UnrealBuildTool passes /RAD_TypeHashAlg:BLAKE3, which never matched the misspelled table entry and was silently ignored. Honoring it is a no-op (BLAKE3 is already the internal default pushed via /RAD_TPYE_HASH_ALG:BLAKE3), verified by byte-identical Engine.dll A/B. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
arena_alloc_ wrote the arena header through an unchecked reserve_memory/ commit_memory result, and arena_push advanced current->cmt past a failed commit, so any out-of-memory condition surfaced as an 0xc0000005 fatal exception inside arena code (seen live as arena_alloc_ +204 / base_arena.c:81 under /RAD_BUNDLE). All three sites now report a clean "fatal: out of memory" line on stderr (non-graphical builds) and abort_self(1); graphical builds keep the existing message box. NOTE FOR REVIEW: base_arena.c is shared infra; the change is intentionally minimal (result checks + one early-out), no allocation behavior changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two changes to the per-lib search over search_chunks: 1) search_skip: per-slot byte cache (allocated only for search_chunks lists) marking slots whose symbol was observed resolved (interp not Undefined/Weak). Resolution is monotonic -- lnk_can_replace_symbol never lets an Undefined/Weak src displace a resolved leader -- so a marked slot can never queue a lib member again, and every later scan skips it on a sequential 1-byte read instead of a random LNK_Symbol dereference (the dominant cache-miss of lib search). Slots self-heal at scan time; no back-pointer from replace needed. 2) reset_cursor is now raised only when anti-dep search turns ON (0 -> 1). The OFF flip needed no rescan: a mode-1 scan is a strict superset of a mode-0 scan over the same slots. And the ON re-pass over the pre-cursor region now processes only Weak slots: Undefined slots were already searched vs this lib mode-independently (search is a pure fn of (lib,name) and member-queue dedup is idempotent), so re-searching them can queue nothing new. Queued-member set is provably unchanged -> byte-identical output (gated base/head/base + determinism x2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Per-link line behind /RAD_LOG:Summary: wall/user/kern, peak ws, peak commit charge (cm=), CoW-promoted pages (cowp=), page faults, io, mem= available-physical samples (t0/pdb/t1), per-phase wall/user/kernel/faults quadruples with dbgg[]/pdbg[] sub-buckets and other= residuals (local cross-check: sum + residual == phase within 0.2%), pool grant_avg/park and procs=now/peak via a named semaphore (UBA virtualizes named sections per-process; semaphores pass through). The shared-pool cross-process counter detach runs unconditionally on every exit path -- it is the only decrement site (the linker leaves through _exit). This line is what root-caused every production fault-storm from build logs alone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…- serial ~0.9 s stall to parallel burst Production (504-link convoy, 126 concurrent, 64 cores): dbg phase burns 682s kernel vs 303s user; mcvi alone traps 42M page faults (~10us each = the kernel time), merge another 12M. Every process first-touches its mapped .debug$S/$T input one 4K fault at a time and the machine goes unresponsive. Batch-populate those ranges with PrefetchVirtualMemory (chunked + coalesced WIN32_MEMORY_RANGE_ENTRY arrays) right before the parse/hash walks: lnk_make_code_view_input prefetches every obj's .debug$S/$T/$P(/$H) section data ahead of the parse loops; lnk_merge_types prefetches the scheduled objs' .debug$T leaf data ahead of the hash tasks. Pure paging hint: output bytes unaffected, resolved via GetProcAddress with silent fallthrough on pre-Win8, already-resident pages cost a no-op. The mcvi prefetch issued ~630 serial PrefetchVirtualMemory calls covering 14 GiB (~0.9 s) plus ~0.5 s in merge, a measurable single-link wall cost (the kernel's per-page population work dominates the syscall overhead). Chunk the coalesced entry array into 256-entry batches and run them as pool tasks; serial fallback when there is no pool or only one batch. Advisory syscalls with no output -- any batch interleaving is fine. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…obe-and-lock removal commit_memory ran RIORegisterBuffer+RIODeregisterBuffer over every committed range as a batched prefault trick. Registration probe-and-locks EVERY page, so every arena commit eagerly demand-zero faulted its full range: pages never subsequently touched (tail of 512MiB MSF page-data nodes, oversized tables) were still faulted, zeroed, and made resident. pdbg ini= (lnk_build_pdb task init, prod EpicGames#3 bucket: 229s wall / 195s kern / 44.5M faults over 681 links) was exactly this -- the first 512MiB MSF page-data node commit, 131,330 faults in one push (measured, editor link). Plain MEM_COMMIT commits without touching; pages fault lazily on first touch, so fault count tracks actual use and faults land spread across parallel workers instead of serially at commit sites. FN editor link, measured: - single link: pf 16.4M -> 14.4M (-12%), ws peak 51.4G -> 45.4G (-5.9G), ini= 132K faults -> 0, wall flat (kern +29s on an idle box: batched probe vs individual faults; inverts hard under load, below) - 4-concurrent one pool (storm proxy): wall 59.3s -> 25.9s (-56%), kern 490.7s -> 177.6s (-64%), ws 51.1G -> 45.6G per link, pf -1.8M per link Gates: base/head DLL+PDB byte-identical (4-way cross), determinism x2, 4-concurrent overlap smoke green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…age, not per view Input files were mapped FILE_MAP_COPY, which charges pagefile commit for the ENTIRE view at map time even for pages never written: 22.4 GiB of a 49.7 GiB peak commit on a large editor DLL link, and N concurrent links multiply it (4-overlap: 205 GB aggregate commit demand), feeding build-farm memory admission for pages that are 99.9% never dirtied. Views now map FILE_MAP_READ from the same PAGE_WRITECOPY section (zero commit at map time). The few writers that still patch input bytes in place go through two mechanisms: - lnk_cow_promote_range: one VirtualProtect(PAGE_WRITECOPY) over a known hot range (used by the obj section-header patch tasks; ~20 pages per obj, ~240K pages per big link -- per-page faults here cost ~25s kernel) - lnk_cow_page_promote_veh: vectored-exception fallback that promotes single faulting pages (IFC 0x1522 pokes, LF_ENDPRECOMP removal, debug$S TI fixups; ~4K pages per big link), so any in-place writer stays correct without per-site plumbing Also stop dirtying archive pages in lnk_lib_from_data: first-linker-member big-endian offsets are now converted on a private copy instead of in place. Write semantics are unchanged from FILE_MAP_COPY: first write makes the page private, input files are never modified. READ_WRITE and no-map modes are unaffected (their pages never write-fault and their sections refuse PAGE_WRITECOPY). Same classic Win32 APIs as before, so UBA detours see nothing new. Big editor DLL link: peak commit 50.9 -> 28.8 GB (-22.1 GB), wall/user/kernel parity, outputs byte-identical. 4-concurrent: aggregate peak commit 205 -> 116 GB. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…cal duplicates -- an ICF-folded external's node points at the leader symbol while node->name keeps the folded name; emitting symbol->name printed the leader once per follower (~1.4M duplicate S_PUB32 / 213MB on a UE editor DLL) and dropped every folded name (name breakpoints could not bind). Node-name emission turns each duplicate into a distinct alias public at the leader address, like link.exe Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…at the leader RVA; full record tree for different-source folds with locals) Record external folds in the per-section fold map (is_extern, excluded from /OPT:REF redirects), redirect dead followers' sect_map to the leader contrib, and keep each folded function's associated .debug$S: by default Lines-only (reloc-patched to the leader RVA -- source breakpoints on folded bodies bind; symbol records stay dropped, the bulk of link.exe's module-stream cost for the same feature), escalated to the FULL record tree when the fold joins a different source location (identity = FILECHKSMS content hash + first line of the Lines fragment, compared follower-vs-leader at mark time, O(1) per fold) and the tree has locals -- the watch window then labels folded frames with the right source's variable names, like link.exe. Measured on the FN editor DLL: ~6.5% of folds differ in source, mostly empty virtuals; gate DLL cost +2.6% PDB (mod_sym +48.8MB), lines ~1.5%. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ffb61b5 to
218a023
Compare
…e lookup ran reloc apply_off against an obj-wide lines accel built from UNRELOCATED .debug$S (every per-function COMDAT fragment reads sec_off 0, so all fragments overlap at 0 and the match is arbitrary: wrong files, :0 lines, multiple bogus rows per reloc). Map through the function's OWN associated .debug$S with a preceding-row lookup (the cv accel is next-row biased and excludes the final row's span), collapse duplicate locations, and fall back to section+offset instead of printing marker rows Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
de1644b to
8e09255
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
The remaining radlink perf + feature contribution as a curated, reviewable series: 83 commits on top of
dev(basec817d1f1), everything you've already taken dropped. History has been cleaned for review — iterative churn squashed (101 → 83; every "add X, then rework X" pair collapsed so each feature lands in its final shape), every commit message states what/why, the measured numbers, and how it was gated. Cherry-pick whatever you want; nothing here depends on being taken wholesale.Everything was measured on UnrealEditorFortnite-Engine.dll (~751 MB DLL / 4.6 GB PDB, 20M+ type leaves, 64 workers,
/OPT:ICFSTATIC /BREPRO). Perf numbers come fromGetProcessTimesCPU, per-phase timers, and Superluminal captures — never wall-clock alone. Every change was gated on DLL byte-identity (back-to-back control links diff only in the ~18-byte/BREPROtimestamp/GUID band, all other bytes 0), per-stream PDB equality, and run-to-run determinism — except where a commit's whole point is to change bytes, and those say so in their messages.Headline results (cumulative over the series, editor-scale link)
/OPT:ICF+/OPT:ICFSTATIC.text727→643 MiB, DLL −117 MB, PDB −481 MB; with the COMDAT-leader keying fix.text623→536 MiB (−82.56 MiB, below link.exe)ifc_redirectbitset filter −54.5 s, leaf-hash XXH3 −24 s, dedup-table sizing −25 s kernel, lib-search skip cache −10.4 sKiPageFaultinclusive 238 s → 155 s across the series; commit-prefault removal −2.0 M faults, −5.9 GB resident per linkLF_IFC_RECORD0x1522) merge correctly instead of silently corrupting TPICorrectness fixes
These are the commits I'd prioritize.
1.
/OPT:ICFover-fold drops exception handlers — insidee9fff7f7(your report)The fold decision ignored COMDAT-associative
.pdata/.xdata: two functions with byte-identical bodies but different exception handlers folded, and the follower's handler was silently dropped via associative dead-stripping. Fix: a candidate's non-debug associative children are gathered (recursively, cycle-guarded, deterministic per-obj order) and hashed into the ICF content key, their relocs join the candidate's reloc slice so refinement class-compares them, and fold-verify checks them pairwise before any fold. Identical code with identical handler/unwind data still folds, matching link.exe. Squashed into the/OPT:ICFcommit so the series never presents the broken behavior.funcs.s): MSVC keeps 3RUNTIME_FUNCTIONs; the broken fold produced 2. Before: fold accepted (exit 105); after: refused (exit 5); positive control (identical handlers) still folds (exit 2), matching link.exe..text+318,544 bytes (+0.058%) from legitimately refused folds; CPU within noise. Still below MSVC.text.2.
/RAD_SHARED_THREAD_POOLsilently loses PDB symbols under overlap —d988d84dlnk_move_global_symbols_to_gsiindexed two fixed-width partitions (cv->symbol_input_ranges,symtab->chunks) directly bytask_id. When the fair-share governor grants a cohort C < worker_count — i.e. whenever parallel links overlap in one shared pool — lanes[C, worker_count)were never visited: globals, publics, and proc-refs silently vanished from the PDB (observed: PDB 22.66 MB deficient — SYMREC −20.86 MB, PSI −1.40 MB, GSI −0.38 MB; e.g. missing public??_C@_0O@CEGDGJCA@WidthOverride@). Fix: stride the fixed partitions by the cohort so every lane is processed exactly once for any C; at full cohort this degenerates to the old indexing (verified output-neutral: DLL 0 diff bytes, all 8247 PDB streams identical). Overlapped runs at observed C=30/C=32 now produce DLL+PDB per-stream identical to an isolated full-width link. All other barrier passes audited — they size partitions inside the bracket already.3. Same-name global symbol order is schedule-dependent —
1f5c781fPre-existing and independent of the shared pool: the per-bucket GSI sort tie-broke same-name symbols on their dedup-table slot index, which is CAS-arrival order — same-name records with different content (duplicate
S_UDTs with distinct TIs) could swap positions run to run. Fix: tie-break on a content hash of the raw record, so symrec order is a pure function of record bytes. PDB re-baseline #1: this reorders same-name groups (SYMREC/GSI/PSI streams only, sizes unchanged, DLL untouched); re-gated with isolated back-to-back relinks and 4/4 overlapped shared-pool links all per-stream identical.Related robustness (small, standalone):
8e092551unresolved-symbol reports printed garbage file/line references -- reloc apply_off was matched against an obj-wide lines accel built from unrelocated.debug$S, where every per-function COMDAT fragment reads sec_off 0, so all fragments overlap and the match is arbitrary (wrong files,:0lines, several bogus rows per reloc); now mapped through the function's own associated.debug$Swith a preceding-row lookup and duplicate collapse -- one reference, the right line; refs without line info (vftables/RTTI) name the section’s COMDAT symbol (??_7Foo@@6B@+8) instead of a raw section number.b509dabaunknown/RAD_*switches now warn visibly and are ignored — previously the warning was muted by the release-default/RAD_IGNORE, so a stale binary silently dropped a requested mode with no trace in the build log; the warning is deliberately not a hard error so newer build scripts keep working against older radlink binaries (forward compatibility); also aliasesRAD_TYPEHASHALGto the misspelledRAD_TPYE_HASH_ALGtable entry — the spelling UnrealBuildTool actually passes.29080fddarena reserve/commit failure is a cleanfatal: out of memory+ exit instead of an AV — flagged for review:base_arena.cis shared infra (intentionally minimal: result checks + one early-out).Features
/OPT:ICF+/OPT:ICFSTATIC(3 commits:e9fff7f7,4aa92ad8,956e059e)Identical-COMDAT folding (code + read-only data), fully parallel, fold-verified by byte-compare + per-reloc target-color check so no hash collision can produce a bad fold.
.text727→643 MiB,.rdata218→194 MiB, PDB 5562→5081 MB, DLL 999→882 MB, link time −13 s./OPT:ICFSTATICextends folding to internal-linkage COMDATs: DLL 925→844 MB.C++ header-units / IFC (
f5435d9c+ perf follow-ups)MSVC header-unit objs carry
LF_IFC_RECORD(0x1522) placeholder leaves radlink couldn't merge — the TPI was silently corrupted and VS crashed on inspect (WrapSymbol AV). The fix resolves debug records from the.ifc's.msvc.trait.debug-recordsCV blob through the existing merge pipeline: BAD leaf count 921→0, 44/44 sampled types name-match MSVC output, validated by live editor boot + breakpoint/step. The same commit keys ICF non-candidate Regular COMDAT targets by their resolved leader (not per-obj identity), which is the.text−82.56 MiB result above — equivalent sections previously failed to fold whenever their targets resolved to different objs' copies of the same COMDAT./OPT:GCTYPES(3 commits:024f1ed1,1978c60a,ef3bd0a7)Garbage-collects CodeView types unreferenced by any surviving symbol before PDB emit: PDB 5315→5081 MB (−234 MB). Shipped opt-in, default OFF — a GC'd type is unavailable to debugger watch-window casts of the form
(SomeNeverReferencedType*)ptr, which is a real (if rare) debugging pattern; the switch exists for size-sensitive links./RAD_SHARED_THREAD_POOL(507244e4, absorbs #847)Cross-process shared worker budget: a governor grants slots fairly across concurrent radlink processes (fair-share cohorts for barrier passes), capping total running workers near core count during farm convoys instead of workers × links oversubscription. Dual-path: without the switch the upstream barrier implementation runs verbatim. Soaks: 3216-link and 12K-link runs, zero hangs. This series absorbs the #847 commits — if you take this PR, #847 is redundant.
Switch / env summary
/OPT:ICF,/OPT:ICFSTATIC/OPT:GCTYPES/RAD_SHARED_THREAD_POOL:<name>/RAD_*RADLINK_PHASE_LOG(env)Performance (single link)
Each commit message carries its own measurement and gate note; the groups below are reading order, with the headline number per group.
ICF refine/fold (12 commits)
b66873a6skip converged classes →7cd878ecparallel round-loop in one persistent worker region (ICF ~35 s → 22–23 s) →63379963dirty-class worklist tail (lands default-off; first reverse index was10 GB) →334 ms) →63379963re-enabled via 9.3× smaller reverse index (refine exclusive 68.2→28.1 s) →ce9ab472seed from final-round splits + parallel reverse-index build (tail 2507.9→160.7 ms) →8d48a4b6parallel dirty-loop + region cap 8→4 (region+tail 879.8→518.7 ms) →1b50b080freeze all-inert classes (30.4% of actives leave refinement; →454.7 ms) →1eaf52494-way refine interleave (region →7374cebakey0 blake3→XXH3 (−8 s CPU; probabilistic-identity, partition proven identical at editor scale) →1d298826fold-apply staged into parallel verify (main-thread 778.5→79.3 ms) → plusc0d90db0,0737f1f0. Net: refine ~880 → ~334 ms, roughly −40 s CPU across the worklist/refine work.Type merge + PDB build (16 commits)
21ce8686size dedup tables from a deterministic distinct-count estimate — TPI table 536M→67M slots, −866 K faults, −25 s kernel →b44e5e05sample the estimator (182.6→38 ms) →0d23caa2leaf hashing blake3→XXH3 (−24 s CPU; PDB re-baseline #2: RRT format v2→3 with explicithash_algtag, caches rebuild once; forced back to blake3 automatically when.debug$H/RRT blake3 hashes are in play) →5d4673acstatic TI-offset descriptor tables (−5.3 s) →d988d84dGSI/PSI inserts sharded by bucket range (order-preserving, kills the task-0 serial funnel) →344b9472GSI insert balance + parallel compaction →723bdf2egreedy-LPT obj distribution (Write Modules −12%) →19ae680bparallel DBI section-contrib radix sort (157→28 ms, permutation-identical) →cb3c6053coalesce DBI contribs 12.5M→2.0M (stream 367→72 MB) → plus7d32fb1d,8f8f2ef3,a892609c,3c184f7b,5f9614bb,4e020dd7,75021a47.Symbol table / lib search / COFF parse (14 commits)
95a49a57symbol-name length memo +_no_nameaccessors — names were being NUL-scan decoded per reloc across REF/ICF/resolve/patch: −63.8 s user CPU (−14.8%), the biggest single win of the campaign →3e05dd25+3eedb35eparsed-symbol memo, slimmed and packed to 16 B (−2.1 GB peak) →2edfd9f3lib-search resolved-slot skip cache (−10.4 s) →18cc2baaper-lib frontier cursor (lib-search phase11.6→6 s) →cfbf5711,b67fc5f8interp caching + redundant re-search elision →8ebdd8bapacked-discriminator bsearch pre-filter → plus7c6a858a,fbca33ce,9c64504f,b23be588,a257375e,05138390./OPT:REF(2 commits)4fcabfbememoized reloc-symbol resolution, flat visited sets, TTASis_live;98c23d07parallel unreachable-section removal. REF phase −46%.IFC apply (3 commits)
4dd80468,729aeebe,972aaa47: parallel discovery/read/parse/resolution with order-preserving replay (apply 4.3 s→~0.9 s) + exact per-obj bitset filter onifc_redirect_hm(−54.5 s user CPU — the redirect map was probed per TI across all workers).Memory peak + CoW-input hygiene (14 commits)
Input objs are
FILE_MAP_COPYviews; anything that writes them privatizes pages — soft faults during the link, a dirty-page rundown at teardown (46.7 s thread-time before this work), and lost cross-process page sharing on the farm.b62272a7/ff480397/abbc2d55/5e09a4adkeep input pages clean (reloc patching, TI fixup,/PDBSTRIPPED, exit unmap);8dc958c5background-releases scratch free-list blocks (decommit barrier 866→45 ms);4783d22fbackground arena reaper;f251e96d2 MB commit quantum (−90%MEM_COMMITcalls); plusc594e917,e7a0e5ef,e8d041be,db71a5b7,b9836b13,0211c919.KiPageFaultinclusive 238→155 s across the series. One negative result recorded honestly in4783d22f: parallel chunkedMEM_DECOMMITis slower than serial (kernel address-space lock) — the reaper keeps it serial, just off the main thread.72ce8a48completes the arc: input views now mapFILE_MAP_READfrom thePAGE_WRITECOPYsection instead ofFILE_MAP_COPY, so an untouched view carries zero pagefile commit (FILE_MAP_COPYcharges the entire view at map time — 22.4 GiB of a 49.7 GiB peak on the editor link, ×N for concurrent links). The few remaining in-place input writers are handled by a one-callVirtualProtectbulk promote on the known hot range (obj section-header patching, ~240 K pages — per-page faulting there cost ~25 s kernel) plus a vectored-exception fallback that promotes single faulting pages (~3.9 K pages/link; also the safety net for any future in-place writer). First-linker-member big-endian offsets convert on a private copy instead of dirtying archive pages. Measured: peak commit 50.9 → 28.8 GB single link, 205 → 116 GB aggregate over 4 concurrent (whose walls also dropped ~30% on a 192 GB box — the 4×51 GB commit demand was itself the pressure), wall/user/kernel parity single-link, outputs byte-identical,READ_WRITE/no-map modes untouched, same classic Win32 APIs under UBA detours.Image write / misc (4 commits)
ab5e29d0parallel align-byte first-touch fill,2095cbefreloc apply sorted by offset,154c1e59non-temporal stores,ba64cb61false-sharing pads.Farm fault-storm mitigations (3 commits)
Production telemetry (from the summary line below) showed 100+ concurrent links convoying on kernel page-fault work: dbg phase 682 s kernel vs 303 s user,
mcvialone trapping 42M faults; in a 96-concurrent storm the pdb phase paid 125 µs/fault vs 2.3 µs post-storm (54×) for the same fault count — physical-memory exhaustion pushing commits onto the page-repurpose path.89ed96e8+89ed96e8: batchPrefetchVirtualMemoryof mapped.debug$S/$Tbefore the first-touch walks, fanned over the pool. 4-concurrent convoy proxy: sum kernel 1330→535 s (−60%), max wall 57.5→36.5 s (−37%); pure paging hint, byte-neutral, silent fallthrough pre-Win8.34c040e3: stop prefaulting every committed page.commit_memoryranRIORegisterBuffer+RIODeregisterBufferover each committed range as a batched prefault trick — registration probe-and-locks every page, so every arena commit eagerly demand-zero faulted its full range, including pages never subsequently touched (the tail of each 512 MiB MSF page-data node, oversized table slots). Thepdbg ini=bucket (prodSetThreadDescriptiondoesn't appear to be respected #3 build-wide: 229 s wall / 195 s kernel / 44.5M faults over 681 links) was exactly this — the first MSF page-data node commit, 131,330 faults in a single push. PlainMEM_COMMIT+ lazy first-touch faulting: single editor link pf 16.4M→14.4M (−12%), ws peak 51.4→45.4 GB (−5.9 GB),ini=132 K faults→0; 4-concurrent convoy proxy max wall 62.1→28.5 s (−54%), sum kernel 1963→710 s (−64%), ws −5.5 GB per link. Byte-identical output (4-way base/head cross), determinism ×2. Flagged for review like the otherwin32_base.cchange: shared infra, 3-line mechanical removal.Why this deserves attention: the RIO trick is base-layer code shared with the debugger, and there it makes sense — an interactive tool that fully uses its allocations wants ranges populated up front in one syscall rather than faulting on hot paths (and it predates
PrefetchVirtualMemoryavailability). The trade inverts for the linker specifically: huge worst-case commits (512 MiB MSF nodes, estimate-sized tables) whose tails are never touched, times N linkers running concurrently on a build machine — eager population turns unused address space into resident memory and serialized fault storms. If the debugger wants the old behavior, this could become a per-tool policy flag instead of a removal; for link workloads the lazy path is strictly better (measured above). A NOTE at the site explains the history so it doesn't get reinstated unknowingly.After deploying the prefetch round on the production FN build: the 95-link stall window collapsed from an 83.5 s span to 8.9 s, kernel sum 1819→406 s.
Telemetry (3 commits)
449fe37copt-in one-line[radlink summary](v=3), behind/RAD_LOG:Summary: wall/user/kern/ws/pf,cm=peak pagefile commit (what farm memory admission gates on),cowp=CoW-promoted page count (a regression canary — a new in-place input writer shows up as a page-count spike before it costs kernel time),t0=/t1=overlap timeline, per-phase wall/user/kernel/faults quadruples withdbgg[]/pdbg[]sub-buckets andother=residuals (local cross-check: sum + residual == phase within 0.2%),mem=available-physical samples,io=, poolgrant_avg/park,procs=n/peakvia a named semaphore (UBA virtualizes named sections per-process; semaphores pass through). The shared-pool cross-process counter detach runs unconditionally on every exit path — it is the only decrement site. This line is what found every storm above from build logs alone.7f275912ICF timers,08770c43env-gated phase log. All stdout-only, byte-neutral.Debug fidelity under /OPT:ICF (2 commits)
A per-stream diff against a link.exe PDB of the same editor DLL showed the linkers agree on content except for ICF-folded functions: link.exe keeps every folded body's full record tree + line table (~29% of its module bytes here), radlink dropped everything — so a source or name breakpoint on any folded-away function (1.14M names on the editor DLL measured) silently failed to bind.
bbcbd014publics: publish under the trie node's name. A folded external's node points at the leader's symbol whilenode->namekeeps the folded name; emittingsymbol->nameproduced the leader's name once per follower — ~1.4M byte-identical duplicate S_PUB32 (213 MB) on the editor DLL — and no publics at all for folded names. With the node name, each former duplicate becomes a distinct alias public at the leader address (name breakpoints bind), and an adjacent-duplicate guard drops any residual exact copies. Public count is byte-for-byte conserved: 3,044,475 before and after on the gate DLL — every duplicate replaced 1:1 by an alias.218a0236lines + watch-window labels: external folds are recorded in the per-section fold map (taggedis_extern, excluded from /OPT:REF's redirect paths), dead followers'sect_mapentries redirect to the leader contrib, and each folded function's associated.debug$Sis kept Lines-only: reloc-patched (the SECREL/SECTION pair resolves to the leader RVA through the aliased symbol) and merged into its module's C13 — symbol records stay dropped, which is the bulk of link.exe's cost for the same feature. Source breakpoints (F9) on folded bodies bind and hit at the shared address, same behavior as link.exe. Folds that join a different source location (source identity = FILECHKSMS content hash + first line of the function's Lines fragment, compared follower-vs-leader in the mark pass, O(1) per fold) and have locals escalate to the full record tree — so a folded frame's Locals panel shows that source's own variable names, like link.exe. Measured ~6.5% of folds differ in source and most of those are empty virtuals, so the escalation set is small.Cost on the 408 MB gate DLL, all three commits together: PDB 2896.7→3023.1 MB (+4.4%: +25.6 MB folded Lines, +23 MB alias publics, +48.8 MB escalated record trees + their global refs — the 213 MB duplicate-publics removal offsets most of it; link.exe pays ~688 MB of module bytes for the same behaviors on the editor DLL). DLL byte-identical with and without the change; PDB deterministic across runs; minimal-repro validated (two identical functions in two objs, different files: both names public at the shared RVA, both source files' lines present, follower's own proc/locals tree in its module).
Validation discipline
/BREPROchecksum/GUID band,OTHER=0. Gate scripts delete/OUT+/PDBbetween runs (a crash leaving a stale output once produced a false byte-identical)./OUTname, so comparisons always use identical output names.1f5c781f; leaf-hash/RRT0d23caa2) and each was re-gated after baselining.Known issues / notes
/PDBSTRIPPEDat editor scale AVs at baseline (pre-existing, reproducible before this series; not addressed here).75021a47(parse-time leaf prehash) is CPU-neutral on this workload (PCH consumers dominate) — kept as a hedge for/Z7-heavy inputs; the message says so plainly.