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
Draft
Conversation
8b67cdb to
d70c7b1
Compare
d70c7b1 to
bd71560
Compare
f5ec08b to
4e8ae06
Compare
7701208 to
48e77a0
Compare
get_cpu_features was the top main-thread hot spot (~5.6s for one Fortnite link), 97% of it inside a single ATOMIC_LOAD(g_cpu_features). On MSVC, blake3_dispatch.c defines ATOMIC_LOAD as _InterlockedOr(&x,0) -- a lock'd RMW (full barrier) run on every BLAKE3 compress dispatch. The value is written once and read-only after, so the barrier is pointless. Enable BLAKE3's plain-load path (C11 _Atomic, a plain mov on x86) via build flags only, leaving the vendored third_party/blake3 source untouched: /std:c11 /experimental:c11atomics -DBLAKE3_ATOMICS=1 Scoped to the radlink target. MSVC C11 atomics need both /std:c11 and /experimental:c11atomics. get_cpu_features: 5591ms -> 4ms (main thread). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> (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>
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)
d42fa79 to
c11c30a
Compare
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>
c11c30a to
2a711f1
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
radlink perf + feature series — rebased onto current
dev(2026-07-13)What this is
The remaining radlink perf + feature contribution, rebased onto current
dev(base933bc7c0): 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 ondev, 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 fromGetProcessTimesCPU, 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)/OPT:ICF+/OPT:ICFSTATICimplementation 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.82907eccmade the stored CRC live, so the "dead read" premise is gone./FORCEhandling,.llvm_addrsig, DBI SC range mapping: taken from your side during the rebase.Re-expressed on top of your
/OPT:ICF(last 3 commits)1b0b8dac— canonicalize COMDAT reloc targets to their selected leader.lnk_resolve_reloc_target_symbolonly 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) — keepingtarget_value, so interior-offset differences still split — folded an additional −82.56 MiB of.textat editor scale in our fork's measurement of the same keying fix (623 → 536 MiB, below link.exe for the same inputs).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.57838915— accept/OPT:ICFSTATICas an alias for/OPT:ICF. UnrealBuildTool passes it; your implementation always considers internal-linkage COMDATs, so alias instead ofunknown option.What the 68 commits contain (unchanged story, now on your base)
Headline results (measured on the pre-rebase branch at editor scale)
ifc_redirectbitset filter −54.5 s, leaf-hash XXH3 −24 s, dedup-table sizing −25 s kernel, lib-search skip cache −10.4 sfc7ad342); C++ header-units (LF_IFC_RECORD0x1522) 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
82cc6db8+ parallel applyd0962d3e, bitset filtereed24509): MSVC header-unit objs carryLF_IFC_RECORD(0x1522) placeholder leaves that silently corrupt the TPI and crash VS on inspect; resolve them from the.ifc's.msvc.trait.debug-recordsCV 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+ perfe85a7ea5,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.[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
??_7Base@@6B@+8-style vftable naming for data refs).Cherry-pick whatever you want; nothing depends on being taken wholesale.