Repository navigation
ssvfilter: two running maxima in the x86 SSV band kernels - #36
kevinmoran1988 wants to merge 4 commits into
Conversation
|
The change is correct, but we couldn't reproduce the speedup. On 80,000 SSV calls, with models of 1 to 3,983 positions and both random and model-emitted sequences, the status and score matched
Change in time per call; negative means the PR is faster. Rows are model length, columns are sequence length. Intel, AVX2:
Intel, SSE:
AMD, AVX2:
AMD, SSE:
On the AMD machine, mid-size models run up to 18% faster. Neither machine comes close to the 35–47% in your Which CPU and compiler did you use, and how did you time the kernel (build flags, how the benchmark called |
Each step of a band folded every vector straight into xEv, so each max waited for the one before it, through the step and on into the next. A step now folds its vectors into a temporary and joins that to xEv once, so only that one max waits on the step before. Scores are unchanged. This is in the three x86 kernels: impl_avx/ssvfilter_avx.c, impl_avx/ssvfilter_sse.c and impl_sse/ssvfilter.c. A band of two vectors keeps its chain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
With -ftree-coalesce-vars, which is the default, gcc copies every band vector to a scratch register and back at each step. The widest bands then run out of registers and keep their vectors on the stack. With it off for these three files the copies go, and so does nearly all of the stack traffic. The pragma is for gcc only; other compilers skip it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
3bc5a22 to
03e7359
Compare
|
You're right, and thank you for testing this so carefully. The gain in my tables is specific to my CPU, and the slowdown you measured for long models is real. I've reworked the kernel so that it no longer needs the extra register behind the slowdown. Details below. Your questions
Why you saw no gain The change shortens a chain of
What the CPUs can do:
A step takes the larger of what the units need for its 26 operations and what the chain needs. In cycles:
The chain columns count what a step must wait for from the step before. The fold still runs 12 maxes in a row inside a step, but the next step does not wait for them. These counts are my arithmetic from the first table, and they are floors for the vector work alone; the loop has other costs. So the tables in the description are Zen 5 numbers. I should have named the machine, and tried a second compiler, before posting them. Sources: the uops.info pages for Why long models got slower The widest kernel holds 14 band vectors, What I've changed I've pushed two commits in place of the old one.
In short, for models of 100 positions and up, as change in time per call against
The measured figures cover both compilers and all three kernels, 144 cells per CPU. A cell counts as slower from 1.5%; smaller differences are within noise. On my machine, with gcc 13.4. Each cell is the pragma alone on Zen 5, AVX2:
Zen 5, SSE-only build:
I also ran all of this on a Zen 2 machine, a Threadripper 3990X, with gcc 13.4 and gcc 15.2: the same design as your benchmark, one pinned core, change against Zen 2 with gcc 13.4. Each cell is the pragma alone on Zen 2, AVX2:
Zen 2, SSE-only build:
So on Zen 2 the pragma does most of the work, and the fold by itself does little. The two together are best in the AVX2 kernel and on long sequences. On short sequences in the SSE-only build the pragma alone is as good or slightly better. Across both compilers and all three kernels in this run, the slowest cell with both changes is +6% (model 200 at L = 30, in the SSE kernel of the AVX2 build). The pragma alone has a cell at +9%, and the fold alone has one at +13%. One caution: single cells on that machine are not stable from run to run. One cell of the fold with the pragma read +88% in one run and +1% in the next, with rounds inside each run agreeing to 1%; I could not find the cause, so a stray slow cell is worth a second run. What I checked
If you have time to run your benchmark on the new head, I'd like to know whether the slowdown is gone on your machines. I'll update the title and description once you've had a look. 🤖 Generated with Claude Code |
|
Problem. The SSV filter spends nearly all its time in band kernels. Each step of a band does one saturating subtract and one byte max per vector, 26 operations for a band of 13, and in Why it depends on the CPU. Two things limit a step: the chain, and how many execution units the core has for these operations. Intel runs both operations on the same two units, so 26 operations take 13 cycles, exactly as long as the chain. It was never waiting on the chain, and shortening it gains nothing. AMD's cores have more units than the chain let them use. Zen 3, 4 and 5 run byte max on four units and saturating subtract on two, enough for about 7 cycles a step, but the chain held them to 13. Zen 5 was hit hardest, because a chained vector operation costs it 2 cycles where every other core pays 1: a 26-cycle chain for work its units could do in 7. Solution. Two changes replace the original. The fold combines a step's vectors into a short-lived temporary and joins the running maximum once. The maxes inside a step still follow one another, but only that one join waits on the step before, so steps overlap, and no extra long-lived register is needed.
Separately, a gcc pragma turns off variable coalescing in the three kernel files, because gcc otherwise copies every band vector through a scratch register at each step (28 wasted copies out of 65 instructions in the widest band) and loses a register doing it; that saving does not depend on units. Together they measure 20–72% faster on Zen 5 and up to 40% faster on Zen 2, where the pragma does most of the work and the worst cell is +6%. Intel should see no change from the fold and is unmeasured for the pragma, and Zen 3 and Zen 4 are predicted from their unit counts, not timed. Scores match |
clang already reorders a step's maxes by itself. With the fold, clang 21 was faster for narrow bands but slower for the widest ones, by up to 22% in the SSE kernels. Until that is understood, other compilers get the straight chain, as before the fold. The code gcc produces is unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
I've added a third commit: the fold now applies with gcc only. I timed clang, which I had not done before (clang 21, on the Zen 5 machine). Two things came out of it. clang did not need the fold. It reorders a step's maxes by itself and has no scratch copies (what the gcc pragma fixes), so
The fold made clang slower in places. Against clang-built So both changes are now fixes for how gcc compiles this kernel. With clang 21 the three files compile to the same machine code as I have only timed clang on Zen 5, and only that one version. Update: older gcc. Many clusters run older versions of gcc, so I tested more of them: 8.5, 11.5, 12.5 and 14.3, on top of 13.4 and 15.2. gcc 9 and 10 are still untested.
So I've added a fourth commit: the pragma now applies from gcc 11 on, and older gcc gets the fold alone. For gcc 11 to 15 the machine code is unchanged. Scores match 🤖 Generated with Claude Code |
With gcc 8.5, on Zen 4, turning off variable coalescing made the kernels slower: by up to 23% by itself, and by up to 8% on top of the fold. gcc 11 to 15 gain from it. gcc 9 and 10 are untested, so they get the fold alone as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The SSV filter is one of the largest costs in a search, and nearly all of its time is in the
calc_band_Nkernels. This PR makes those kernels faster on x86. Scores don't change.What was slow
Each step of a band subtracts a score from every vector in the band and folds the result into one running maximum:
Every
maxhas to wait for the one before it. A band can be up to 14 vectors wide, which makes a chain of 14 operations that can only run one after another, although the subtractions feeding them are independent.What changes
Bands of three or more vectors keep two running maxima, one for the even vectors and one for the odd ones, and join them when the band ends. Each chain is half as long, and the two run side by side. The maximum of the two is the same value the single one held, so the result is identical.
The NEON kernel already does this with six accumulators. This brings the same idea to
impl_avx/ssvfilter_avx.c,impl_avx/ssvfilter_sse.candimpl_sse/ssvfilter.c. NEON is not touched.Bands of one or two vectors keep a single maximum. There is no chain to split there, and a second one only added register setup. Those kernels compile to the same instructions as before.
Kernel timing
p7_SSVFilter()alone on one core, best of 5, change in time per call. Rows are model length, columns are sequence length.AVX2:
SSE:
Models short enough to fit in two vectors (up to 64 positions with AVX2, 32 with SSE) are within ±2%, which is the run-to-run noise. Sequences of length 1 move by −8% to +6%, a few nanoseconds either way; the default minimum ORF length of 20 means a search doesn't make such calls.
bathsearchAgainst a 352 Mb genome with 16 threads:
The 50-model AVX2 run uses 17% less CPU but takes no less time. That run is limited by the single thread that reads the target, not by the search, so the search threads just wait longer. TravisWheelerLab/easel#6 speeds up the reader; with both changes that search takes 20.9 s (24.4 s with the reader change alone).
What I checked
bathsearchrun above, in 50 single-model searches of the genome, and in 523 small single-thread searches.make checkpasses on an AVX2 build and on an SSE-only build.I have no ARM machine, which is one reason NEON is left as it is. I also couldn't test on a CPU without AVX2 other than by configuring the SSE-only build.
If you'd like this arranged differently, or would rather keep a single maximum in some of the kernels, I'm happy to change it.
🤖 Generated with Claude Code