Repository navigation
Conversation
…an prove Every element-wise loop advanced one partial vector at a time: LoadPart and StorePart on every iteration, each working out how many elements remain. On arm64, which has no masked loads, that costs more than the operation itself (about 50 instructions per 4 lanes of AccumRowInt32s), and the bounds checks of slicing each operand at _i stay in the loop on every target. The loop now cuts every slice it touches to its window before it starts: the elements from the first iteration at that slice's offset, as many as the loop runs, with a capacity of as many, indexed from zero. _count is the first window's length, so the compiler knows it is not negative. Under conditions of the form _i <= _count-k*_lanes it then proves every slice the loop takes of a window in bounds and drops all bounds and length checks from the loop. The main loop runs four copies of the body per iteration over consecutive vectors, each copy all the statements in order, so the chunks run in the same order as with one vector per iteration; each window is cut to the iteration's four vectors, so every copy addresses its vector at a constant offset. Whole vectors then run one at a time, and one partial vector takes what is left. Integer reductions get the four accumulators float reductions have: an integer vector add takes two cycles on arm64, where they make SumInt32s three times as fast, and integer folds wrap, so regrouping them gives the same result. Because a partial load now only ever sees fewer elements than a vector holds, the emulated Load*Part of golang/go#82001, which returns len(s), returns the right count, and GODEBUG=simd=0 builds produce the scalar result.
Regenerated with make test-update and make bench-regen. The -d scripts (flag_diff.txt, readme_patterns.txt) also check the size of the generated hunk and one generated line, which change with it.
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.
The change is the first commit; the second only regenerates the goldens and
bench/ops_simd.go.This changes the shape of the generated element-wise loop. Across
bench/it's about 2.75x faster than what master generates on an M4 Max, and 27-43% faster on the amd64 runners. Most of the gain comes from getting the compiler to drop every check inside the loop.Right now each iteration is a
LoadPart/StorePart, and each call works out how many elements are left. arm64 has no masked loads, so that becomes a cascade of compares and register moves per vector, about 50 instructions per 4 lanes inAccumRowInt32s. On top of that, the bounds checks for slicing each operand at_istay in the loop on every target.The new loop:
Every operand is cut to its window up front. Each window's length and capacity are
_count, and_countis alen(), so the compiler knows it isn't negative. Given_i <= _count-k*_lanes, prove can then show that every slice taken inside the loop is in bounds, and the main loop comes down to the loads, the operation, the stores, the increment and the branch. Each of those details matters. I measured the alternatives on arm64, and each costs 30-50%: writing_i+_lanes <= limit, slicing the original operands at_i+off, taking the count from an expression likelen(dst)-1, or using two-index windows.The unrolled body runs four copies one after another, each with all the statements in order. So the chunks happen in the same order as they would one vector at a time, and a read ahead of a stored slice still happens before that copy's stores. Reductions keep their own loop but use the same condition. Integer reductions now also get the four accumulators that float reductions already had: an integer vector add takes two cycles on arm64, and
SumInt32sgets 3x faster. Integer folds wrap, so regrouping them gives exactly the same result.Since partial loads now only ever see less than a full vector, the emulated
Load*Partfrom golang/go#82001 (which returnslen(s)) gives the right count, andGODEBUG=simd=0builds produce the scalar result. I updated BUGS.md for that.Numbers are from
bench/, best of 6 interleaved runs, in ns/op on an M4 Max:The geomean against master is 0.48x at n=64, 0.31x at 4096 and 0.32x at 1M. On the GitHub runners, measured together with #18, it's -32% on an EPYC 9V45 (Zen 5, AVX-512) and -43% on an EPYC 7763 (Zen 3, AVX2). The only kernel slower than master there is
FillUint8s/64(+10%): that's a single 64-lane store, where the setup now costs more than the store does.For context, I also compiled the same loops as C with
clang -O3 -mcpu=apple-m4. Master's code took 4.4x as long as clang's; with this it's 1.58x (1.50x at 4096, 1.16x at 1M). Most of what remains is on the compiler side: Go doesn't pair adjacent 128-bit loads and stores intoldp/stp, and each inlined simd call leavesNOPs in the loop.On tests: the goldens are regenerated. A new
loop_shape.txtruns sizes 0-300, and byte loops past 4×64, through every part of the loop for several body shapes, and both builds must print the same checksums. Thebench/fuzzers ran for 15-30s each and TSVCTestGoldenpasses through toolexec.It needs #16, which keeps the new names (
_count,_w1, ...) from capturing the function's own, and mauri870#2: the windows are cut from the loop's bounds, and mauri870#2 rejects a constant bound or offset that puts an index below zero instead of rewriting it.I started on this because the generated code was slower than scalar for a lot of loops on arm64 when I tried loopvec on goav1.