Skip to content

Commit a241649

Browse files
ms609claude
andcommitted
docs(kernel): resolve AVX2-codegen on EPYC — real 1.3-1.7x/cand, ~1-10% end-to-end
EPYC A/B (job 17908514): the shipped -msse2 build pays a per-block AVX2-call boundary (cpu_has_avx2 true on Linux, but the target(avx2) helper can't inline into the -msse2 scorer). -mavx2 removes it: 1.67x/1.38x/1.26x per candidate on project970/510/2668, scaling with block count. Windows masked this (cpu_has_avx2 false there -> inline SSE2 -> gap looked like 9%). But end-to-end (job 17908602, fixed-seed maxReplicates=4, identical trajectory): project970 1.01x, project510 1.10x. Amdahl: scoring is only ~3% (970) / ~34% (510) of MaximizeParsimony wall; the rest is non-scoring machinery (ratchet/ sectorial/tree-ops). The per-candidate win does not propagate -- same dilution that sank lever-6, and why packing (shrinks total_words pervasively) beat AVX2 (reduce compute only). Banked as a -march=native Hamilton deployment build (dev/profiling/tsmarch_build.sh -> /nobackup/pjjg18/tsmarch/lib): free ~1-10% for EPYC runs, machine-specific, NOT for CRAN (breaks non-AVX2 CPUs; release keeps -msse2 + runtime dispatch). The portable target(avx2) clone is not worth it (small, dataset-dependent end-to-end). Confirms the mission: time-to-optimum is bounded by search machinery/stopping, not the per-move kernel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent e17bb16 commit a241649

2 files changed

Lines changed: 95 additions & 63 deletions

File tree

Lines changed: 66 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,66 @@
1-
# AVX2 reduce-codegen gate: packing reopened a ~9% per-candidate win — 2026-07-16
2-
3-
**Status:** FINDING recorded; **no code landed** (the portable source capture did not work locally
4-
— reverted). Resolution deferred to a Hamilton EPYC `-msse2` vs `-mavx2` A/B (fold into the packbuild
5-
rebuild after the 5432 job `17887591` completes — do NOT rebuild packbuild while it runs).
6-
7-
## The finding
8-
9-
The package builds `-O2 -msse2` (no `-mavx2`; `~/.R/Makevars.win` CXXFLAGS). The AVX2 reduce
10-
(`any_hit_reduce_avx2`, `__attribute__((target("avx2")))`, ts_simd.h) is therefore a **non-inlined
11-
call boundary** on every candidate. Packing (landed today, e8a17e60) cut hot blocks to
12-
`n_states`=2-4, where that fixed call/setup/spill is a big fraction of a tiny block body — so
13-
inlining the reduce now pays where it did not pre-packing.
14-
15-
**Whole-program `-mavx2` A/B (local, back-to-back, cutoff-tight/prefetch-off, min-of-15):**
16-
17-
| dataset | `-msse2` ns/cand | `-mavx2` ns/cand | speedup |
18-
|---|---|---|---|
19-
| project970 (1844c) | 60.57 | 55.25 | **1.088× (8.8%)** |
20-
| project2668 (1227c) | 34.72 | 31.90 | **1.081× (8.1%)** |
21-
22-
Reproducible, not session drift. This is the **largest exact per-candidate lever found** post-packing.
23-
Contrast the pre-packing prior (T-P5f whole-program codegen: −1.2%) — packing *reopened* it, exactly
24-
as the lever-hunt `map:simd-reduce` predicted. (Injected via `src/Makevars.win` `PKG_CPPFLAGS=-mavx2`
25-
+ `touch src/*.cpp` to force a flag-correct recompile; the env `PKG_CPPFLAGS` and a `~/.R/Makevars.win`
26-
CXXFLAGS edit both silently did NOT take — R rebuilds the flag string.)
27-
28-
## Why the portable source capture did NOT work here (reverted)
29-
30-
Attempted the map's "hoist target(avx2) to the scorer": an `always_inline` core shared by a default
31-
wrapper and a `TS_TARGET_AVX2` clone of `fitch_indirect_cached_flat_x4`, dispatched by
32-
`cpu_has_avx2()`. REROOT ns was **flat** (58.9→58.8) even when the clone was force-called.
33-
34-
Diagnosis (by elimination — a standalone `__builtin_cpu_supports` probe won't link on this rtools
35-
box, an unrelated `ld` quirk): **`cpu_has_avx2()` is almost certainly `false` on this MinGW/Windows
36-
machine** (`__builtin_cpu_supports("avx2")` is known-unreliable there — the CPU-feature init may not
37-
run). If it were true, the `-msse2` baseline would already call `any_hit_reduce_avx2` and inlining it
38-
(the clone) would have captured the win. It was flat because the clone's *internal* `any_hit_reduce`
39-
also took the SSE2 path (`cpu_has_avx2()` false), so the `target(avx2)` wrapper ran SSE2 code. The
40-
whole-program `-mavx2` win is therefore SSE2→AVX2 via *unconditional* codegen (which ignores the
41-
runtime check).
42-
43-
**Implication (strong hypothesis, verify on Linux/CI):** the shipped **Windows** binary likely runs
44-
the SSE2 reduce, never its AVX2 helpers. On Linux/EPYC `__builtin_cpu_supports` works, so
45-
`cpu_has_avx2()` is true there and the `-msse2` build already uses the AVX2 helpers (with the call
46-
boundary) — a *different* baseline, where the target-clone (killing the boundary) is the right lever.
47-
48-
## Two portable capture paths (platform-dependent) — resolve on Hamilton
49-
50-
1. **If Windows silently runs SSE2** (`cpu_has_avx2()` false): fix CPU detection on MinGW (manual
51-
CPUID / Windows API instead of `__builtin_cpu_supports`) → Windows uses the AVX2 helpers. Portable,
52-
shippable, no `-mavx2` build needed. (Then the target-clone adds the boundary-elimination on top.)
53-
2. **Where `cpu_has_avx2()` is true** (Linux/EPYC, most non-MinGW): a `target(avx2)` scorer clone
54-
dispatched by `cpu_has_avx2()` kills the per-candidate call boundary. (My local test could not
55-
validate this because the runtime check is false here.)
56-
57-
## Next step (deferred, non-blocking)
58-
59-
Hamilton EPYC A/B once `17887591` (5432) finishes: rebuild packbuild `-msse2` vs `-mavx2`, measure
60-
raw-`tbr_search` wall + full `MaximizeParsimony` wall (min-of-runs) on project970/510/5432, and print
61-
`cpu_has_avx2()` on Linux. That settles (a) the achievable end-to-end win, (b) which portable path
62-
applies, and (c) the Windows-SSE2 hypothesis. Consistent with [[hamilton-gcc-module-noop]] (codegen
63-
wins seen on Hamilton, deployment-gated).
1+
# AVX2 reduce-codegen: real per-candidate win, MODEST end-to-end — RESOLVED on EPYC 2026-07-17
2+
3+
**Verdict:** the shipped `-msse2` build pays a per-block AVX2-call-boundary on EPYC; a `-mavx2`
4+
(or `-march=native`) build removes it for a **1.26–1.67× per-candidate** win — but that dilutes to
5+
**~1–10% end-to-end** `MaximizeParsimony` wall, because the default `auto` search is dominated by
6+
non-scoring machinery (ratchet/sectorial/tree-ops), not the reroot reduce. **Banked as a
7+
`-march=native` Hamilton deployment build** (free, machine-specific, off the CRAN release). Not worth
8+
a portable source change. Confirms the mission: the per-move kernel is not the end-to-end bottleneck.
9+
10+
## Per-candidate A/B on EPYC (job 17908514; `avx2ab/lib_base` vs `lib_avx2`, kern_real min-of-15)
11+
12+
Linux `__builtin_cpu_supports("avx2")` = **true** (unlike the MinGW box).
13+
14+
| dataset | blocks | `-msse2` ns | `-mavx2` ns | speedup |
15+
|---|---|---|---|---|
16+
| project970 (1844c) | ~29 | 142.2 | 85.0 | **1.67×** |
17+
| project510 (2954c) | ~46 | 116.0 | 84.3 | **1.38×** |
18+
| project2668 (1227c)| ~20 | 67.5 | 53.6 | **1.26×** |
19+
| project5432 (189c) | 3 | 22.6 | 21.0 | 1.07× |
20+
21+
Scales with block count. **Mechanism:** `cpu_has_avx2()` is true on EPYC, so the `-msse2` build
22+
calls `any_hit_reduce_avx2` (`__attribute__((target("avx2")))`, ts_simd.h) **across a target
23+
boundary it cannot inline — once per block**, plus a `hor_or256` horizontal reduce each block. On
24+
Zen2 that call/spill is costly and compounds over ~29 blocks × millions of candidates. `-mavx2`
25+
compiles the scorer in an AVX2 context so the helper inlines. **Windows masked this:** there
26+
`cpu_has_avx2()` is *false*, so the base build used inline SSE2 (no boundary) and the gap looked like
27+
only ~9%.
28+
29+
## End-to-end MaximizeParsimony wall (job 17908602; same libs, fixed-seed maxReplicates=4 → identical trajectory)
30+
31+
| dataset | base wall | avx2 wall | end-to-end | reach |
32+
|---|---|---|---|---|
33+
| project970 | 3357.1 s | 3321.0 s | **1.01×** | 11704 = 11704 |
34+
| project510 | 847.8 s | 768.3 s | **1.10×** | 18345 = 18345 |
35+
36+
**The per-candidate win does NOT propagate.** Amdahl backs out the scoring fraction: project970
37+
scoring ≈ **3%** of wall (1.67× on 3% → 1.01×); project510 ≈ **34%** (1.38× on 34% → 1.10×). So
38+
~66–97% of `auto`-search wall is non-scoring machinery (ratchet cycles, sectorial decomposition,
39+
tree management, collapse/pool), dataset-dependent. Same dilution that sank lever-6.
40+
41+
Reconciles why **packing** landed 1.2–1.5× end-to-end but AVX2 only ~1–10%: packing shrinks
42+
`total_words` *pervasively* (scoring + edge-set buffer/cache + precompute across every phase),
43+
whereas AVX2 only speeds the reduce *compute* — a smaller slice.
44+
45+
## Windows note (separate, unresolved)
46+
47+
`cpu_has_avx2()` is almost certainly **false on MinGW/Windows** (`__builtin_cpu_supports` unreliable
48+
there; a standalone probe would not link — `ld` quirk). So the shipped **Windows** binary likely runs
49+
the SSE2 reduce and never its AVX2 helpers. Low priority (Windows isn't the compute platform), but a
50+
robust CPU-detect (manual CPUID / Windows API) would let Windows use the AVX2 helpers.
51+
52+
## Banked
53+
54+
- **Hamilton:** `-march=native` deployment build recipe (`/nobackup/pjjg18/tsmarch/tsmarch_build.sh`)
55+
→ `/nobackup/pjjg18/tsmarch/lib`; point search jobs' `lib.loc` / `R_LIBS_USER` at it for the free
56+
~1–10% (larger on scoring-bound char-rich data). Machine-specific; **must not** go in the CRAN
57+
source (`-march=native` breaks non-AVX2 CPUs — the release keeps `-msse2` + runtime dispatch).
58+
- **Not pursued:** the portable `target(avx2)` scorer clone. It *would* work on Linux (the boundary
59+
is real there), but with scoring at ~3–34% of wall the end-to-end payoff is small and
60+
dataset-dependent — not worth the cross-platform multi-versioning risk. Recorded, not built.
61+
62+
## Takeaway
63+
64+
Real kernel headroom exists on EPYC, but time-to-optimum is bounded by the **search machinery /
65+
stopping**, not the per-move reduce — the next real wall lever is there (Mission A / over-search),
66+
not further per-candidate scoring. Consistent with [[hamilton-gcc-module-noop]].

‎dev/profiling/tsmarch_build.sh‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/bin/bash
2+
#SBATCH -p shared
3+
#SBATCH -n 4
4+
#SBATCH --mem=8G
5+
#SBATCH -t 01:30:00
6+
#SBATCH --job-name=tsmarch
7+
#SBATCH --output=/nobackup/pjjg18/tsmarch/logs/tsmarch_%j.out
8+
#SBATCH --error=/nobackup/pjjg18/tsmarch/logs/tsmarch_%j.err
9+
# REUSABLE deployment build: TreeSearch (cpp-search) with -march=native for THIS
10+
# machine class (EPYC 7702 / Zen2). Removes the per-block AVX2-call boundary the
11+
# shipped -msse2 build pays -> ~1-10% end-to-end (larger on scoring-bound char-rich
12+
# data). Machine-specific: point search jobs' R_LIBS_USER / lib.loc at $DEPLOY.
13+
# NOT for CRAN (-march=native breaks non-AVX2 CPUs; release keeps -msse2 + runtime
14+
# dispatch). Re-run to refresh after cpp-search updates. See kernel-avx2-reduce-gate.md.
15+
set -e
16+
module load r/4.5.1 2>/dev/null || true
17+
export OMP_NUM_THREADS=1 OPENBLAS_NUM_THREADS=1
18+
export R_LIBS_USER=/nobackup/pjjg18/TreeSearch/lib
19+
BASE=/nobackup/pjjg18/tsmarch; DEPLOY=$BASE/lib; SRC=$BASE/src
20+
mkdir -p $BASE/logs
21+
rm -rf $SRC && git clone --depth 1 -b cpp-search https://github.com/ms609/TreeSearch.git $SRC
22+
echo "cloned cpp-search @ $(cd $SRC && git rev-parse --short HEAD)"
23+
echo 'PKG_CPPFLAGS = -march=native' > $SRC/src/Makevars
24+
rm -rf $DEPLOY && mkdir -p $DEPLOY
25+
R CMD INSTALL --preclean --no-docs --no-help --library=$DEPLOY $SRC > $BASE/build.log 2>&1 \
26+
&& echo "BUILD OK -> $DEPLOY/TreeSearch" || { echo "BUILD FAILED"; tail -25 $BASE/build.log; exit 1; }
27+
echo "flag on ts_fitch: $(grep 'ts_fitch.cpp' $BASE/build.log | head -1 | grep -oE '\-march=[a-z0-9]+|\-mavx2' | tr '\n' ' ')"
28+
echo "USE: export R_LIBS_USER=$DEPLOY (or library(TreeSearch, lib.loc='$DEPLOY'))"
29+
echo "### DONE $(date)"

0 commit comments

Comments
 (0)