Skip to content

radlink: perf + feature series — ICF/ICFSTATIC, GCTYPES, header-units, link-time + memory (rebased on dev, taken commits dropped) - #842

Draft
honkstar1 wants to merge 87 commits into
EpicGames:devfrom
honkstar1:radlink-pr-series
Draft

radlink: perf + feature series — ICF/ICFSTATIC, GCTYPES, header-units, link-time + memory (rebased on dev, taken commits dropped)#842
honkstar1 wants to merge 87 commits into
EpicGames:devfrom
honkstar1:radlink-pr-series

Conversation

@honkstar1

@honkstar1 honkstar1 commented Jun 22, 2026

Copy link
Copy Markdown

radlink perf + feature series — rebased onto current dev (2026-07-20)

What this is

The remaining radlink perf + feature contribution, rebased onto current dev (base 81bc3459): 46 commits. Everything you have since implemented yourself or that your new code supersedes has been dropped — nothing here duplicates work already on dev, and cherry-picking any prefix should never present superseded behavior.

Measured on UnrealEditorFortnite-Engine.dll (~742 MB DLL / ~5 GB PDB, 20M+ type leaves, 64 workers, /OPT:ICF /BREPRO). This branch is fully gated at that scale: back-to-back links byte-identical (DLL and PDB, 0 differing bytes), and — the headline — the output DLL is byte-identical to what your current dev head produces on the same inputs. Everything image-side in this series is now output-neutral perf/features; the only output difference left is the PDB: 4.74 GB vs 5.40 GB (−12.2%) from the folded-function debug-info slimming, plus schedule-independent symrec ordering.

Dropped this round as taken / superseded by your recent work

Your last ~16 commits absorbed most of the perf catalogue this PR used to carry, so the following are gone from the series: parsed-symbol memoization + name-length memo + _no_name parse trims (your LNK_SymbolNameCache + light parse), the single-probe assigned-TI fixup (your ti_arr fold), leaf-hash XXH3 (your /RAD_DEBUG_TYPE_HASH), parallel DBI SC radix + coalescing (your db256940), PSI address-map radix (6bf29b25), lib-search frontier cursors / interp cache / skip cache (your f9cb3479, 624a931b, 81bc3459), read-only-data ICF folding (your a8f78cbc — our fold policy and carve-outs match it exactly, hence the byte-identical DLL), the async PDB write + early view release (your background file writer + a06b6b07), and assorted micro-optimizations on code you rewrote.

What the 46 commits contain

Features

  • C++ header-units / IFC: MSVC header-unit objs carry LF_IFC_RECORD (0x1522) placeholder leaves that silently corrupt the TPI and crash VS on inspect; resolve them from the .ifc's .msvc.trait.debug-records CV blob through the merge pipeline (+ parallel apply + exact-key bitset filter). Validated by live editor boot + breakpoint/step.
  • GDK import members: COFF_ImportBy_Undecorate / COFF_ImportBy_NameNoPrefix were NotImplemented — release builds crashed with a null-reloc-symbol AV in coff_obj_writer_serialize on Xbox FortniteClient links (GDK import libraries use both). Implemented per spec; lld-link parity.
  • /OPT:GCTYPES (opt-in, default off): CodeView type GC before PDB emit, with frontier-worklist closure and a fast already-reachable path.
  • /RAD_SHARED_THREAD_POOL: cross-process fair-share governor so N concurrent links on one box run ~cores total workers instead of N×cores — plus a fair-share floor so a long barrier phase (ICF refinement) opened at a busy instant is not pinned at cohort 1-2 for its whole duration. Validated with a 12K-link soak; 4-concurrent convoy walls 54-57s → 37s off-peak.
  • Folded-function debug info (the −12% PDB): a REF-live folded function's associative .debug$S used to survive in full at the leader RVA. Record folds at fold-apply, merge back only the follower's C13 Lines re-bound to the leader RVA — all a source breakpoint needs; name breakpoints bind through publics. Different-source folds with locals keep their full tree so watch labels stay right.
  • Opt-in [radlink summary]: one-line wall/user/kernel/ws/faults/phase-buckets for production triage.
  • Unresolved-symbol reports: print the real referencing source line / vftable symbol instead of garbage (the lines accel ran reloc offsets against unrelocated .debug$S, so every per-function COMDAT fragment overlapped at 0).

Perf / memory (all output-neutral — DLL byte-identical to your head)

  • Memory behavior: read-only input file views (commit charge per dirtied page, not per view; peak commit 50.9 → 28.8 GB per link), commit-prefault removal (−2.0M faults, −5.9 GB resident), debug-reloc patch-on-copy (CoW input pages stay clean), 2MB arena commit quantum, background scratch/bucket-arena release, batch-prefetch of mapped debug input (fault-storm fix: production 95-link stall window 83.5 → 8.9 s).
  • Type merge: deterministic unique-count estimator (distinct-hash bitmaps + linear counting) sizing the dedup tables (TPI 536M → 67M slots, −866K faults, −25 s kernel) with sampled positions and a deterministic overflow retry; static TI-offset descriptor tables replacing per-TI linked lists; parallelized lnk_make_code_view_input setup.
  • GSI/PSI: bucket-range-sharded inserts (single owner per bucket, global walk order), insert balancing + parallel compaction, size-weighted (LPT) obj distribution for the PDB barrier passes.
  • /OPT:REF: memoized reloc-symbol resolution, parallel unreachable-section removal.
  • /OPT:ICF perf: dirty-skip refinement round keys (a color changed last round iff its value lies in that round's split-allocation window — unchanged contributions reuse their cached hash; identical fixpoint), opt-in /RAD_ICF_HASH_ALG:XXH3 round keys (BLAKE3 default; byte-identical outputs on our tests), FILECHKSMS memo in the folded-lines pass.
  • Misc: symbol-trie inline-hash fast-reject, archive symbol-dir bsearch pre-filter, non-temporal image fill, reloc sort for monotone patch writes, image-buffer early release, clean OOM exit, visible unknown-/RAD_* warnings, C11 atomics for BLAKE3.

Note on RAD_TYPEHASHALG

UnrealBuildTool passes /RAD_TypeHashAlg:BLAKE3; the series keeps that spelling as an alias of your /RAD_DEBUG_TYPE_HASH so existing build configs keep working.

Heads-up on your ghash_check_hash_alg test

It fails on current dev with your own binary: the Warning(086): ... mismatched .debug$H hash algorithm line goes to stdout, but the test scans stderr (g_errors). Not touched by this series.

Validation state

  • Full editor-scale gates at this head: back-to-back links byte-identical (DLL and PDB, /BREPRO), DLL byte-identical to dev@81bc3459 output on the same inputs, PDB −12.2% (4.74 vs 5.40 GB), torture suite green except your pre-existing ghash_check_hash_alg failure above.
  • Every commit builds standalone.

Cherry-pick whatever you want; nothing depends on being taken wholesale.

@honkstar1
honkstar1 force-pushed the radlink-pr-series branch from 8b67cdb to d70c7b1 Compare June 22, 2026 05:20
@honkstar1
honkstar1 force-pushed the radlink-pr-series branch from d70c7b1 to bd71560 Compare June 25, 2026 06:01
@honkstar1 honkstar1 changed the title radlink: link-time perf, peak-memory, /OPT:ICF[STATIC], /OPT:GCTYPES, C++ header-units radlink: perf + feature series — ICF/ICFSTATIC, GCTYPES, header-units, link-time + memory (rebased on dev, taken commits dropped) Jun 25, 2026
@honkstar1
honkstar1 force-pushed the radlink-pr-series branch 4 times, most recently from f5ec08b to 4e8ae06 Compare July 4, 2026 02:32
@honkstar1
honkstar1 force-pushed the radlink-pr-series branch 3 times, most recently from 7701208 to 48e77a0 Compare July 12, 2026 15:37
Add identical COMDAT folding using a color-refinement over foldable
read-only sections. Hash section contents, relocation targets, and target
colors until partitions stabilize, then redirect folded COMDAT symlinks
and discard duplicate sections.

Honor .llvm_addrsig via /LLVM_ADDRSIG so address-significant sections
are not folded, keep separate color space for code, unwind info, and
MSVC vftables, and update COMDAT symbol/section mapping after folding.

Add linker tests covering function folding, relocation-sensitive folds,
alignment, color-space separation, symlink chains, .llvm_addrsig,
pdata/xdata, and C++ ICF cases.
Log and skip section contributions that fail to map into the linked image.
Keep a file-range-to-section-number map so compact file ranges produce the
correct DBI section numbers.
Previously, COMDAT symlinks were assigned as each object batch was loaded.
Weak symbol replacement could later change the selected leader, leaving
objects from earlier passes with stale COMDAT leader refs. Library search
does not need symlinks to be valid, so solve this by doing a single sweep
assignment of COMDAT leaders after a fixed point is found.
Decode UTF-16LE response files with a BOM and strip UTF-8 BOMs before
parsing arguments. Add coverage for UTF-16 response-file input.
Parse /DEF, /GUARD, and /SECTION switches, including DEF
handling for NAME/LIBRARY, BASE, VERSION, HEAPSIZE, STACKSIZE, EXPORTS,
and SECTIONS.

Store section directives in linker config and apply them consistently
when gathering object sections when pushing/searching image sections.

Pull load-config symbols for /GUARD without setting GUARD_CF until guard
tables are emitted.

Add coverage for DEF file parity, section directives, and guard
load-config handling.
Fold section definition and contributions collection into a worker-count
task with barriers around the serial merge. Share section filtering
between both phases and keep zero-size COMDATs so offset-zero symbols
still map to a section.
Collapse COMDAT leader symbol patching to a single raw-symbol write path
and make the same-object versus cross-object symlink handling explicit.
Preserve public symbol offsets for ICF folds while avoiding cross-object
section number rewrites.
honkstar1 and others added 25 commits July 20, 2026 22:35
…mate

On dup-heavy input (PCH/type-server fan-out) the unique leaf count is a
small fraction of the total, and sizing the dedup probe tables from the
total leaf count oversizes them 5-10x, costing tens of seconds of
demand-zero page faults on 64B-apart random probes.

Estimate the distinct-hash count per CV_TypeIndexSource from the already
produced debug_h hashes (presence bitmap + linear counting) and size the
tables from that, clamped to the old total-based caps. The bitmaps are
filled with commutative atomic ORs over deterministic input hashes, so
the estimate -- and therefore the caps -- are identical run to run.

Winner selection per hash class (min lnk_leaf_ref_compare) and the sorted
extraction are cap-independent: only probe sequences change, not probe
logic, so output bytes are unchanged.

Overflow safety: the table-full case (probe wraps without a slot, which
previously only fired a debug Assert) now sets a shared flag; the passes
bail out early and the whole dedup is redone once with the always
sufficient total-based caps. Whether that happens is a pure function of
the input, so the retry path is deterministic too.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit ffe2aa1)
lnk_move_global_symbols_to_gsi hashed globals, proc refs, and publics in
parallel but then funneled every gsi_push_ through a task 0 serial loop
while the other workers parked on the barrier.

Shard the inserts instead: worker i owns buckets [i*B/W, (i+1)*B/W) and
walks the full symbol sequence in global order, inserting only symbols
whose hash % bucket_count lands in its range. Each bucket has exactly one
owner and receives its inserts in global sequence order, so per-chain
order -- which is serialized into the PDB -- is byte-identical to the
serial loop, for any worker count. No locks or atomics; gsi->symbol_count
is bumped once by task 0 behind a barrier.

The publics walk previously consumed the per-worker lists in place
(clearing node->next during iteration), which would race with concurrent
shard walkers; flatten the lists into a global-order node/hash array
first, then shard-insert from that.

Barrier passes walk the fixed lane partition strided by the pinned cohort so no lane is dropped under /RAD_SHARED_THREAD_POOL fair-share.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit c820b64)
…escriptor tables

cv_get_leaf/symbol_type_index_offsets built an arena-pushed CV_TypeIndexInfo
node per type index per record, and hot loops (leaf hashing, TI fixup, type
GC) pointer-chased those lists; the two functions showed ~13s combined
exclusive CPU across workers on editor-scale links.

New CV_TiOffsets view returns, with zero allocation for the common kinds:
- fixed-shape leaves/symbols: pointer into a static per-kind {source,offset}
  table (POINTER picks between two static variants off attribs)
- count-stride kinds (ARGLIST, SUBSTR_LIST, BUILDINFO, VFTPATH,
  CALLERS/CALLEES/INLINEES): inline {run_base, run_count} descriptor,
  offset(i) = run_base + i*4
- member-walk kinds (FIELDLIST/METHODLIST/inlinee lines) keep their walk and
  materialize a flat CV_TiOff array (doubling growth, footprint comparable
  to the old per-node list)

Emission order is preserved exactly (incl. FUNC_ID IPI-before-TPI and
UDT_SRC_LINE TPI-then-IPI asymmetries); the blake3 leaf-hash stream and all
consumers see identical offset sequences, so output bytes are unchanged.

All hot consumers (lnk_hash_cv_leaf(_deep), lnk_fixup_cv_type_indices,
lnk_gc_visit_offsets/expand, ifc closure, pdb_builder TI patch) now iterate
the flat view; legacy list API kept as a thin shim over the new one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit b36f595)
The align-byte fill is the first touch of the freshly committed ~751MB image
buffer (every page a demand-zero fault) and ran serially on the main thread
while all workers parked.

Range-split the fill into a flat (dst, byte, size) task list and dispatch it
via tp_for_parallel: sections are cut into ~4MB chunks with PAGE-ALIGNED split
points inside the page-aligned image reservation, so no two workers ever touch
the same 4K page. Each task keeps the NT-store lnk_stream_set fill and sfences
its own stores before signalling completion, so everything is globally visible
after the join (replacing the single main-thread sfence).

Measured on a FN editor DLL link (751MB image, 64 workers), via a throwaway
timer around the fill block: serial 24.5-29.1ms -> parallel 4.7-5.0ms, i.e.
about -20ms wall. Smaller than the profile-estimated 0.3-0.8s: Windows fault
clustering + NT-store bandwidth make the serial first-touch much cheaper here
than the estimate assumed.

Writes are value-identical to the serial loop and byte-disjoint -> output is
byte-identical by construction (verified: base-vs-head diff is the PE checksum
byte + debug-directory GUID/timestamp band only, matching the same-binary
control).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 51af745)
Bump the commit quantum from the 64KB default to 2MB for the three arena
families that take essentially all of a 50GB editor link's commit churn:

  - thread-pool per-worker arenas (tp_arena_alloc)
  - per-thread scratch arenas (tctx_alloc)
  - the linker's huge debug-info arena (lnk_get_huge_arena)

via their arena_alloc params only -- the global 64KB default is untouched, so
small arenas do not bloat. arena_decommit_unused is page-granular and tracks
cmt exactly, so it stays correct with the larger quantum.

Measured on a FN editor DLL link (751MB image, ~50.7GB committed, 64 workers),
via a throwaway atomic counter in commit_memory:

  VirtualAlloc(MEM_COMMIT) calls:  80.5-81.1K -> 8.3K   (-90%)
  committed bytes:                 50.7GB     -> 51.3GB (+0.6GB quantum slack)
  peak working set:                56.4GB     -> 56.8GB (+0.3-0.5GB)
  kernel time (GetProcessTimes):   no detectable movement (210-282s run noise
                                   swamps it; faults, not commit syscalls,
                                   dominate kernel time on this link)

So this is a syscall/address-space-lock relief change, not a measurable
wall/kernel win on this workload; kept because the slack cost is bounded and
small relative to the 56GB peak.

Commit sizes only affect when pages are committed, never what is written ->
output is byte-identical (verified: base-vs-head diff is the PE checksum byte
+ debug-directory GUID/timestamp band only, same as the same-binary control).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
(cherry picked from commit 11ac6b1)
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>
(cherry picked from commit 857a941)
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>
(cherry picked from commit 5690f99)
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>
(cherry picked from commit 59862fa)
…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>
(cherry picked from commit c990a45)
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>
(cherry picked from commit 2f86507)
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>
(cherry picked from commit 8225285)
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>
(cherry picked from commit 72563e5)
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>
(cherry picked from commit 4bf14ad)
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>
(cherry picked from commit 2b45cc0)
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>
(cherry picked from commit 661c894)
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>
(cherry picked from commit 2223534)
…- 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>
(cherry picked from commit 45824bd)
…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>
(cherry picked from commit 2977ff7)
…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>
(cherry picked from commit 4b247bb)
…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>
(cherry picked from commit 48e77a0)
…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>
…ASH_ALG:XXH3, FILECHKSMS memo

Three independent reductions measured on a UE editor DLL link (Superluminal,
lnk_opt_icf_task = 167s inclusive of 576s total link CPU):

1. Refinement dirty-skip: the per-round key hash is a pure function of
   static_hash + target colors, so recompute a contribution's hash only when
   one of its target colors changed in the previous round's update. Change
   detection is one read per target: next_color is monotone and a split's
   retained subgroup keeps its exact old color value, so a color changed last
   round IFF its value lies in that round's split-allocation window
   (last_split_rng). Unchanged inputs reproduce the cached hash bit-for-bit --
   the fixpoint, the round count, and every key are identical. Refinement
   converges after a few rounds, so this removes most of the ~49s of
   per-round blake3.

2. /RAD_ICF_HASH_ALG:{BLAKE3|XXH3}: optional XXH3-128 for the round keys
   (default stays blake3). Group equality still compares the full 128-bit hash
   plus old_color, and content identity stays anchored by the blake3
   static_hash mixed into every key. Validated: same obj linked with both algs
   produces byte-identical exe and PDB.

3. lnk_icf_mark_folded_lines: FILECHKSMS lookup now memoizes the slice per obj
   (leaders are shared across many follower objs; the single-entry cache
   thrashed, 6.4s) and scans subsection headers directly with an early out
   instead of cv_debug_s_from_data's full parse (a list node per subsection
   over megabytes of obj-wide .debug$S). lnk_icf_color_space_from_section
   fetches the symlink name through the memoized decode instead of a full
   symbol parse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
lyra.rsp trains with /OPT:NOREF /OPT:NOICF, so the profile had zero coverage
of the /OPT:REF walk and the ICF refinement -- the largest CPU consumers of a
real editor link -- and -fprofile-use laid them out as cold. Run a second
training link with REF+ICF appended (later switches win), store per-process
profraws, and merge both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pe_make_import_dll_obj_static/_delayed left COFF_ImportBy_Undecorate and
COFF_ImportBy_NameNoPrefix as NotImplemented, which is a no-op in release
builds: iat_symbol stayed null, the jump-thunk emit pushed a reloc with a
null symbol, and coff_obj_writer_serialize crashed dereferencing it
(coff_obj_writer.c:207). Hit by GDK import libraries (Xbox FortniteClient),
which use both name types; lld-link and link.exe accept the same inputs.

Derive the import lookup name per the PE/COFF spec -- NO_PREFIX skips one
leading '?', '@', or '_'; UNDECORATE additionally truncates at the first
'@' -- and share the ImportBy_Name path. The __imp_ symbol and jump thunk
keep the decorated name.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tp_barrier_begin sized the cohort by a non-blocking sweep of free budget
slots and pinned it for the whole bracket. A long barrier phase (ICF
refinement: 60-130s CPU) that opened its bracket while sibling links held
the machine ran the entire phase at width 1-2 and never grew -- even after
the siblings exited. 4-concurrent convoy: one link's icf phase 2.5s -> 76s
wall, grant_avg 2.5, park 6588s.

If the sweep lands below this process's fair share (machine budget /
attached processes, from the nproc counter), keep taking with bounded
waits (200ms deadline) until the floor is reached. Slots flow back
continuously as sibling path-A workers drain, so the floor normally fills
within milliseconds; if every sibling is pinned in its own bracket the
deadline bounds the wait -- never a deadlock, cohort >= 1 always.

Convoy A/B (4x UnrealEditorFortnite-Engine.dll): worst-case unchanged when
all four ICF brackets collide, but off-peak links drop 54-57s -> 37s. The
full fix -- re-opening the bracket between refinement rounds so width
tracks availability -- needs the ICF driver split into per-round passes;
kept separate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@honkstar1
honkstar1 force-pushed the radlink-pr-series branch from e4a2a9c to 3c25d94 Compare July 21, 2026 05:48
honkstar1 and others added 3 commits July 20, 2026 23:33
… clean

The image symbol-patch passes (lnk_patch_comdat_leaders_task,
lnk_patch_common_block_leaders_task, lnk_patch_regular_symbols_task,
lnk_patch_common_symbols_task) store final section numbers / values into
every regular symbol record in place. The symbol tables live in the
copy-on-write input mapping, so this copy-on-writes one page per touched
symbol-table page: ~1.16M pages / ~4.5GB of CoW commit and ~85s of kernel
fault time on a large editor link.

Give each obj a private copy of its symbol table before the patch phase
(one sequential memcpy per obj -- the same bytes the CoW faults would have
copied, without the per-page fault + zero + charge machinery).
lnk_coff_symbol_table_from_obj prefers the copy, and every patcher writes
through symbol.raw_symbol pointers derived from it, so the input mapping
stays clean and later readers (debug info, GSI, publics) see the patched
values as before.

Measured (UnrealEditorFortnite-Engine.dll, quiet machine): CoW-promoted
pages 1,161,034 -> 68, pagefile commit 29.2 -> 20.7 GB, kernel CPU
300 -> 130 s. DLL and PDB byte-identical; determinism pair 0 bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… into private memory

The leaf type-index fixup (lnk_cv_patcher_leaves_task) patched final TIs
in place into the mapped .debug sections, copy-on-writing one page per
touched page. On /Z7-style input where most objs carry their own type
records (e.g. clang /DEBUG:GHASH builds) that is ~690K pages / ~2.7GB of
CoW commit per editor link; PCH/type-server-heavy input mostly hides it
because the leaves concentrate in few objs.

Fuse the fixup into the unbucket pass: copy each unique raw leaf (in the
existing sorted order) into one contiguous private buffer, apply the
fixup to the copy, and point result.v at the copy. Identical bytes,
identical order, clean input pages; the buffer lives exactly as long as
the merged-types result it backs.

Measured (UnrealEditorFortnite-Engine.dll, /DEBUG:GHASH inputs):
CoW-promoted pages 694,335 -> 4,159, kernel CPU -50s. DLL and PDB
byte-identical; determinism pair 0 bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…d statics too

lnk_icf_debug_s_has_locals gated the full-record-tree escalation for
different-source ICF folds on S_LOCAL/S_REGREL32 only. A fold whose
follower tree carries function-scoped static records (S_LDATA32 and
friends) but no stack locals got Lines-only treatment, dropping the
record that names the static -- the (folded) data itself survives in the
image, but the debugger could no longer evaluate the follower's static
by name. Reaching this requires the static's section to fold too (COMDAT
const data), so the window is narrow, but the check should match the
intent: escalate whenever the tree names anything the leader's tree
cannot describe.

Add S_LDATA32/S_GDATA32/S_LTHREAD32/S_GTHREAD32/S_FILESTATIC/S_CONSTANT
to the escalation set. Cost at editor scale: +8KB PDB total; DLL and PDB
otherwise byte-identical, determinism pair 0 bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

3 participants