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 68 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 68 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-13)

What this is

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

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. On the pre-rebase branch every change was gated on DLL byte-identity, per-stream PDB equality, and run-to-run determinism at that scale; the numbers below are those measurements. (A full editor-scale re-gate of this rebased branch is pending a fresh uncompressed FN build — the current tree's objs are UBA-compressed and only readable under UBA.)

Dropped as taken / superseded by your /OPT:ICF (d0d8b596)

  • Our entire /OPT:ICF + /OPT:ICFSTATIC implementation and its ~15 implementation-specific perf commits (refine-region parallelization, dirty-class worklist, candidate-map builds, XXH3 key swap, fold-apply staging, ICF timers). Your color-refinement implementation covers the feature; the perf work was specific to our data structures.
  • Folded-function debug aliasing (alias publics + Lines at leader RVA): your publics emission already emits each symbol's own name at the leader address, so the 1.4M-duplicate-publics bug we fixed in our fold path does not exist in yours.
  • Per-section image CRC removal: your 82907ecc made the stored CRC live, so the "dead read" premise is gone.
  • /FORCE handling, .llvm_addrsig, DBI SC range mapping: taken from your side during the rebase.

Re-expressed on top of your /OPT:ICF (last 3 commits)

  1. 1b0b8dac — canonicalize COMDAT reloc targets to their selected leader. lnk_resolve_reloc_target_symbol only walks Undefined/Weak, so a target defined in the referencing obj keys per-obj; byte-identical functions that each reference their own obj's copy of a shared COMDAT (static guards, inline-function locals, selectany globals) never fold. Keying by the leader's (obj, section) — keeping target_value, so interior-offset differences still split — folded an additional −82.56 MiB of .text at editor scale in our fork's measurement of the same keying fix (623 → 536 MiB, below link.exe for the same inputs).
  2. 6f8c1be7 — identical code with different exception handlers must not fold. The fold key hashes only the candidate's own bytes/relocs; handlers are referenced from associative .xdata, not from code, so two functions with identical code but different handlers fold and the follower's handler dies with its associative group (link.exe refuses these). Fix mixes every non-debug associative child's content + relocs (recursively, parse order, cycle-guarded) into the candidate's refinement hash; the Unwind color space is untouched. We hit this exact over-fold class in production before adding the same rule to our fold path.
  3. 57838915 — accept /OPT:ICFSTATIC as an alias for /OPT:ICF. UnrealBuildTool passes it; your implementation always considers internal-linkage COMDATs, so alias instead of unknown option.

What the 68 commits contain (unchanged story, now on your base)

Headline results (measured on the pre-rebase branch at editor scale)

Area Result
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; commit-prefault removal −2.0 M faults, −5.9 GB resident per link
Farm fault storms 4-concurrent convoy proxy: sum kernel −60%, max wall −37%; production FN build: 95-link stall window 83.5 s → 8.9 s
Commit charge read-only input views: peak pagefile commit 50.9 → 28.8 GB per link; 4-concurrent aggregate 205 → 116 GB
Correctness schedule-dependent symrec order fixed (fc7ad342); C++ header-units (LF_IFC_RECORD 0x1522) merge correctly instead of silently corrupting TPI (82cc6db8); unresolved-symbol reports print the real referencing source line / vftable symbol instead of garbage (df189c95); clean OOM exit (7fe30f0b); unknown /RAD_* warn visibly (fd0216f5)

Features

  • C++ header-units / IFC (82cc6db8 + parallel apply d0962d3e, bitset filter eed24509): 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. BAD leaf count 921→0, 44/44 sampled types name-match MSVC, validated by live editor boot + breakpoint/step.
  • /OPT:GCTYPES (4df6eb40 + perf e85a7ea5, 34f03d61): opt-in CodeView type GC before PDB emit.
  • /RAD_SHARED_THREAD_POOL (c4a0fcb3): cross-process fair-share governor so N concurrent links on one box run ~cores total workers instead of N×cores. Validated with a 12K-link soak; two deadlock classes found and fixed during it.
  • Opt-in [radlink summary] (57deea74): one-line wall/user/kernel/ws/faults/phase-buckets for production triage.

Perf commits

Each commit message carries what/why/how-it-was-gated and the measured number. Broad strokes: parsed-symbol memoization (the #1 hotspot), name-decode elimination on scalar-only paths, type-merge/dedup cache-shape work, parallelization of previously-serial phases (DBI SC sort/radix, GSI/PSI, IFC apply, REF removal, image fill, debug-input prefetch), memory-behavior fixes (read-only CoW views, prefault removal, arena quantum, background scratch release), and lib-search improvements (frontier cursors, skip cache, interp memo).

Validation state of this rebased branch

  • Builds clean (clang, release), and the repro cases for the unresolved-symbol reporter pass (exact source line for code refs, ??_7Base@@6B@+8-style vftable naming for data refs).
  • Full editor-scale byte-identity/determinism re-gate pending an uncompressed FN build (the current FN tree's objs are UBA-compressed; standalone linkers can't read them — link.exe reports LNK1136 on the same inputs). The pre-rebase branch carrying the same changes was fully gated at that scale.

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
honkstar1 and others added 18 commits July 13, 2026 14:21
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>
(cherry picked from commit 7c6a858)
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>
(cherry picked from commit fbca33c)
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>
(cherry picked from commit 7d32fb1)
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>
(cherry picked from commit c594e91)
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>
(cherry picked from commit 9c64504)
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>
(cherry picked from commit 8f8f2ef)
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>
(cherry picked from commit e7a0e5e)
…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>
(cherry picked from commit 3e05dd2)
…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>
(cherry picked from commit e8d041b)
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>
(cherry picked from commit 3eedb35)
…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>
(cherry picked from commit 024f1ed)
…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>
(cherry picked from commit 3f1f776)
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>
(cherry picked from commit 58d0c27)
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>
(cherry picked from commit 9872144)
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>
(cherry picked from commit c2c1bce)
…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>
(cherry picked from commit d7aea7f)
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>
(cherry picked from commit 3054138)
Resolve MSVC C++ header-unit IFC debug records (LF_IFC_RECORD 0x1522) by
merging the .ifc .msvc.trait.debug-records CodeView stream and redirecting
each record to its real type -> fixes VS debugger AV stepping into header-
unit code (BAD 921->0, 44/44 MSVC name-match, live debug verified).

(cherry picked from commit 4b7f8bf, ICF leader-keying half dropped: superseded
by upstream d0d8b59's own /OPT:ICF)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
honkstar1 and others added 10 commits July 13, 2026 17:23
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)
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>
(cherry picked from commit 9066027)
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)
…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>
(cherry picked from commit bf6bfe9)
…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)
@honkstar1 honkstar1 force-pushed the radlink-pr-series branch from d42fa79 to c11c30a Compare July 14, 2026 00:25
honkstar1 and others added 18 commits July 13, 2026 17:45
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)
…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>
(cherry picked from commit 01341a2)
…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>
(cherry picked from commit 21857f2)
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)
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>
(cherry picked from commit 893df0a)
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)
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>
(cherry picked from commit b3b869c)
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)
The fork gated internal-linkage COMDAT folding behind /OPT:ICFSTATIC; this
implementation always considers them. Build systems (UnrealBuildTool) still
pass the switch -- treat it as ICF instead of failing the link with
'unknown option'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed leader

lnk_resolve_reloc_target_symbol only walks Undefined/Weak symbols, so a
reloc whose target is DEFINED in the referencing obj keys against that
obj's local copy of the COMDAT. Byte-identical functions that each
reference their own obj's copy of a shared COMDAT (static guards,
inline-function static locals, selectany globals) then get distinct
target ids and never fold, even though all copies collapse to one
canonical definition at link time anyway.

Key such targets by the COMDAT leader's (obj, section) instead -- the
post-selection canonical definition -- while keeping the reloc's own
interior offset (target_value), so label/case-offset differences still
split classes. Strictly more folds, never a semantic change: the leader
IS the section every copy resolves to in the image.

At UE editor scale (fork measurement of the same keying fix): .text
623 -> 536 MiB, -82.56 MiB, below link.exe for the same inputs.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…must not fold

A COMDAT function's unwind/exception data rides along as associative
sections (.pdata/.xdata, funclets). The fold key hashed only the
candidate's own bytes and relocations, so two functions with identical
code but DIFFERENT handlers (handlers are referenced from .xdata, not
from the code) folded on code identity; the follower's .pdata/.xdata
then died with the folded section and its handler was silently dropped.
link.exe refuses these folds.

Mix every non-debug associative child's content and relocations
(recursively -- funclets have children too; breadth-first in parse
order, cycle-guarded) into the candidate's refinement hash. Identical
code with identical handler/unwind data still folds. The Unwind color
space is unchanged: .pdata/.xdata still fold among themselves by
content; this only makes the CODE key see handler differences.

Reloc hashing is shared with the candidate's own loop via
lnk_icf_hash_section_relocs (target color, interp, interior offset).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@honkstar1 honkstar1 force-pushed the radlink-pr-series branch from c11c30a to 2a711f1 Compare July 14, 2026 00:46
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