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 83 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 83 commits into
EpicGames:devfrom
honkstar1:radlink-pr-series

Conversation

@honkstar1

@honkstar1 honkstar1 commented Jun 22, 2026

Copy link
Copy Markdown

What this is

The remaining radlink perf + feature contribution as a curated, reviewable series: 83 commits on top of dev (base c817d1f1), 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 from GetProcessTimes CPU, 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 /BREPRO timestamp/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)

Area Result
/OPT:ICF + /OPT:ICFSTATIC .text 727→643 MiB, DLL −117 MB, PDB −481 MB; with the COMDAT-leader keying fix .text 623→536 MiB (−82.56 MiB, below link.exe)
Single-link wall 20.9 s → ~14.3 s over the campaign (64 cores)
Link CPU ~−35% total; largest single wins: symbol-name memo −63.8 s (−14.8%), ifc_redirect bitset filter −54.5 s, leaf-hash XXH3 −24 s, dedup-table sizing −25 s kernel, lib-search skip cache −10.4 s
Page faults dedup-table sizing alone −866 K faults; KiPageFault inclusive 238 s → 155 s across the series; commit-prefault removal −2.0 M faults, −5.9 GB resident per link
Farm fault storms 4-concurrent convoy proxy: sum kernel 1330→535 s (−60%), max wall −37%; production FN build after deploy: 95-link stall window 83.5 s span → 8.9 s, kernel sum 1819 → 406 s
Commit charge input views now map read-only: peak pagefile commit 50.9 → 28.8 GB (−22.1 GB) per link; 4-concurrent aggregate 205 → 116 GB — headroom for farm memory admission (80/90% gates)
Correctness shared-pool PDB lane-drop fixed; schedule-dependent symrec order fixed; ICF unwind/handler over-fold fixed (your report); C++ header-units (LF_IFC_RECORD 0x1522) merge correctly instead of silently corrupting TPI

Correctness fixes

These are the commits I'd prioritize.

1. /OPT:ICF over-fold drops exception handlers — inside e9fff7f7 (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:ICF commit so the series never presents the broken behavior.

  • Repro included (funcs.s): MSVC keeps 3 RUNTIME_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.
  • Cost at editor scale: .text +318,544 bytes (+0.058%) from legitimately refused folds; CPU within noise. Still below MSVC .text.

2. /RAD_SHARED_THREAD_POOL silently loses PDB symbols under overlap — d988d84d

lnk_move_global_symbols_to_gsi indexed two fixed-width partitions (cv->symbol_input_ranges, symtab->chunks) directly by task_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 — 1f5c781f

Pre-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): 8e092551 unresolved-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, :0 lines, several bogus rows per reloc); now mapped through the function's own associated .debug$S with 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. b509daba unknown /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 aliases RAD_TYPEHASHALG to the misspelled RAD_TPYE_HASH_ALG table entry — the spelling UnrealBuildTool actually passes. 29080fdd arena reserve/commit failure is a clean fatal: out of memory + exit instead of an AV — flagged for review: base_arena.c is 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. .text 727→643 MiB, .rdata 218→194 MiB, PDB 5562→5081 MB, DLL 999→882 MB, link time −13 s. /OPT:ICFSTATIC extends 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-records CV 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

Switch Default Purpose
/OPT:ICF, /OPT:ICFSTATIC off (opt-in) COMDAT folding
/OPT:GCTYPES off CodeView type GC
/RAD_SHARED_THREAD_POOL:<name> off cross-process worker budget
unknown /RAD_* warns, ignored visible warning + link continues (forward compat; was silently-muted warning)
RADLINK_PHASE_LOG (env) off machine-parseable per-phase micros (diagnostic)

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)

b66873a6 skip converged classes → 7cd878ec parallel round-loop in one persistent worker region (ICF ~35 s → 22–23 s) → 63379963 dirty-class worklist tail (lands default-off; first reverse index was 10 GB) → 63379963 re-enabled via 9.3× smaller reverse index (refine exclusive 68.2→28.1 s) → ce9ab472 seed from final-round splits + parallel reverse-index build (tail 2507.9→160.7 ms) → 8d48a4b6 parallel dirty-loop + region cap 8→4 (region+tail 879.8→518.7 ms) → 1b50b080 freeze all-inert classes (30.4% of actives leave refinement; →454.7 ms) → 1eaf5249 4-way refine interleave (region →334 ms) → 7374ceba key0 blake3→XXH3 (−8 s CPU; probabilistic-identity, partition proven identical at editor scale) → 1d298826 fold-apply staged into parallel verify (main-thread 778.5→79.3 ms) → plus c0d90db0, 0737f1f0. Net: refine ~880 → ~334 ms, roughly −40 s CPU across the worklist/refine work.

Type merge + PDB build (16 commits)

21ce8686 size dedup tables from a deterministic distinct-count estimate — TPI table 536M→67M slots, −866 K faults, −25 s kernelb44e5e05 sample the estimator (182.6→38 ms) → 0d23caa2 leaf hashing blake3→XXH3 (−24 s CPU; PDB re-baseline #2: RRT format v2→3 with explicit hash_alg tag, caches rebuild once; forced back to blake3 automatically when .debug$H/RRT blake3 hashes are in play) → 5d4673ac static TI-offset descriptor tables (−5.3 s) → d988d84d GSI/PSI inserts sharded by bucket range (order-preserving, kills the task-0 serial funnel) → 344b9472 GSI insert balance + parallel compaction → 723bdf2e greedy-LPT obj distribution (Write Modules −12%) → 19ae680b parallel DBI section-contrib radix sort (157→28 ms, permutation-identical) → cb3c6053 coalesce DBI contribs 12.5M→2.0M (stream 367→72 MB) → plus 7d32fb1d, 8f8f2ef3, a892609c, 3c184f7b, 5f9614bb, 4e020dd7, 75021a47.

Symbol table / lib search / COFF parse (14 commits)

95a49a57 symbol-name length memo + _no_name accessors — 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 + 3eedb35e parsed-symbol memo, slimmed and packed to 16 B (−2.1 GB peak) → 2edfd9f3 lib-search resolved-slot skip cache (−10.4 s) → 18cc2baa per-lib frontier cursor (lib-search phase 11.6→6 s) → cfbf5711, b67fc5f8 interp caching + redundant re-search elision → 8ebdd8ba packed-discriminator bsearch pre-filter → plus 7c6a858a, fbca33ce, 9c64504f, b23be588, a257375e, 05138390.

/OPT:REF (2 commits)

4fcabfbe memoized reloc-symbol resolution, flat visited sets, TTAS is_live; 98c23d07 parallel 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 on ifc_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_COPY views; 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/5e09a4ad keep input pages clean (reloc patching, TI fixup, /PDBSTRIPPED, exit unmap); 8dc958c5 background-releases scratch free-list blocks (decommit barrier 866→45 ms); 4783d22f background arena reaper; f251e96d 2 MB commit quantum (−90% MEM_COMMIT calls); plus c594e917, e7a0e5ef, e8d041be, db71a5b7, b9836b13, 0211c919. KiPageFault inclusive 238→155 s across the series. One negative result recorded honestly in 4783d22f: parallel chunked MEM_DECOMMIT is slower than serial (kernel address-space lock) — the reaper keeps it serial, just off the main thread.

72ce8a48 completes the arc: input views now map FILE_MAP_READ from the PAGE_WRITECOPY section instead of FILE_MAP_COPY, so an untouched view carries zero pagefile commit (FILE_MAP_COPY charges 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-call VirtualProtect bulk 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)

ab5e29d0 parallel align-byte first-touch fill, 2095cbef reloc apply sorted by offset, 154c1e59 non-temporal stores, ba64cb61 false-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, mcvi alone 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: batch PrefetchVirtualMemory of mapped .debug$S/$T before 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_memory ran RIORegisterBuffer+RIODeregisterBuffer over 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). The pdbg ini= bucket (prod SetThreadDescription doesn'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. Plain MEM_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 other win32_base.c change: 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 PrefetchVirtualMemory availability). 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)

449fe37c opt-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 with dbgg[]/pdbg[] sub-buckets and other= residuals (local cross-check: sum + residual == phase within 0.2%), mem= available-physical samples, io=, pool grant_avg/park, procs=n/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. This line is what found every storm above from build logs alone. 7f275912 ICF timers, 08770c43 env-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.

bbcbd014 publics: publish under the trie node's name. A folded external's node points at the leader's symbol while node->name keeps the folded name; emitting symbol->name produced 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.

218a0236 lines + watch-window labels: external folds are recorded in the per-section fold map (tagged is_extern, excluded from /OPT:REF's redirect paths), dead followers' sect_map entries redirect to the leader contrib, and each folded function's associated .debug$S is 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

  • Byte-identity gate on every commit: base/head/base A/B with a same-binary control; diffs must be confined to the ~18-byte /BREPRO checksum/GUID band, OTHER=0. Gate scripts delete /OUT+/PDB between runs (a crash leaving a stale output once produced a false byte-identical).
  • Per-stream PDB comparison (all ~8247 streams, sizes + SHA256) — the PDB embeds the /OUT name, so comparisons always use identical output names.
  • Determinism: head×2 relinks; the two intentional PDB re-baselines are called out above (symrec ordering 1f5c781f; leaf-hash/RRT 0d23caa2) and each was re-gated after baselining.
  • ICF: refuse/fold repro suite (unwind over-fold, exit 5/2 + link.exe parity) on every ICF commit; a selfcheck link mode asserts partition equality against the reference refinement path.
  • Shared pool: 3216-link and 12K-link soaks, zero hangs; overlap regression (2- and 3-way) with per-stream PDB equality vs isolated full-width links; old/new exe coexistence on one pool name.
  • Production: the summary line ran on real FN farm builds (504-link and 247-link jobs); the storm numbers above are from that telemetry, and the prefetch round's effect was confirmed on the next production build.
  • Series tip is tree-identical to the integration branch validated at editor scale (live editor boot + debug + step).

Known issues / notes

  • /PDBSTRIPPED at editor scale AVs at baseline (pre-existing, reproducible before this series; not addressed here).
  • ICF fold-verify byte-compares candidates, but reloc targets class-compare via 64-bit key0 colors; a 64-bit collision between never-splitting candidates could in principle fold a wrong caller transitively. Pre-existing property of the key truncation, unchanged by this series (XXH3 swap keeps the width) — noted for awareness.
  • 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.

honkstar1 and others added 9 commits June 24, 2026 21:56
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>
@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 2 times, most recently from 1c15e41 to 4bfa113 Compare July 2, 2026 16:06
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>
@honkstar1 honkstar1 force-pushed the radlink-pr-series branch 2 times, most recently from f5ec08b to 4e8ae06 Compare July 4, 2026 02:32
honkstar1 and others added 11 commits July 9, 2026 11:47
…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>
honkstar1 and others added 27 commits July 9, 2026 11:49
…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>
@honkstar1 honkstar1 force-pushed the radlink-pr-series branch from ffb61b5 to 218a023 Compare July 9, 2026 18:54
…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>
@honkstar1 honkstar1 force-pushed the radlink-pr-series branch from de1644b to 8e09255 Compare July 10, 2026 05:52
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.

1 participant