Repository navigation
AVX2 complex kernels: fenced K step, fmaddsub 4x6 default, vector pack/store - #16
Merged
Merged
Conversation
lkdvos
force-pushed
the
perf/avx2-complex-v3
branch
from
October 7, 2026 18:17
e727e5b to
7f40eed
Compare
Welcome to Codecov 🎉Once you merge this PR into your default branch, you're all set! Codecov will compare coverage reports and display results in all future pull requests. ℹ️ You can also turn on project coverage checks and project coverage reporting on Pull Request comment Thanks for integrating Codecov - We've got you covered ☂️ |
At 16 ymm registers LLVM's haswell/znver2 scheduler hoists every B broadcast of a planar or fmaddsub K step to the top of the loop body, holds them all live and spills them. `memory_fence()`, an empty `asm sideeffect` with a memory clobber (no instruction), after each column group pins the loads next to their FMAs; the FMAs stay free to move. On AVX2, fmaddsub also runs OpenBLAS's Zen zgemm order: the swap(a)*bi pass, a fence, then the a*br pass on A loaded again, so a and swap(a) are never live together. Only 256-bit kernels (`W * sizeof(R) == 32`) take the fenced order; every other shape gets the same statements in the same order as before. code_llvm/code_native of `add_tile` are identical to ad94b73 for all 57 non-256-bit complex and mixed-domain menu shapes (B packed and B in place) under -C icelake-server, znver4, znver2 and haswell. Hot-loop stack references per K step, Julia 1.12.7, -C znver2 (haswell identical), ad94b73 -> this commit, B packed / B read in place (the #22 unpacked-B codegen does not change the picture): planar 4x5/W4, 8x5/W8 4 / 4 -> 0 / 0 fmaddsub 4x6/W4, 8x6/W8 24 / 24 -> 0 / 0 fmaddsub 4x5/W4, 8x5/W8 0 / 0 -> 0 / 0 planar 4x6/W4, 8x6/W8 14 / 12 -> 0 / 13 (not in any menu) The mixed-domain kernels run the real SIMDKernel step and do not spill. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`complex_fastpath_isa_eligible` was AVX-512 only, so on AVX2 every complex sliver packed through the scalar loop and every complex tile (planar, fmaddsub and the mixed-domain kernels, which share those stores) stored element by element. The AVX2 codegen is the AVX-512 path at half width. NEON and unknown targets keep the scalar loops. Measured on the pre-refactor branch (Cascade Lake, QS_FAKE_ISA=avx2 -C znver2, gate off -> on, GF/s): kernel-only planar 8x6/W8 85.4 -> 108.5; engine ComplexF32 2000^3 94.2 -> 105.1, shallowK 47.0 -> 85.5, smallN 47.8 -> 70.4. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… vector store On AVX2, `select_shape` hands a complex contraction to FMAddSub 4x6/W4 (ComplexF64) or 8x6/W8 (ComplexF32) when C's unit run along M fills whole `MR`-row slivers (`run == m_length || run % MR == 0`); otherwise it keeps planar 4x5/8x5. FMAddSub's single accumulator plane holds 12 accumulators + 2 A + 1 broadcast, 15 of 16 registers, and stays spill-free with B packed or in place. Planar NR = 6 fills all 16 and spills 13 per K step with B in place, so it is not a candidate. Where C's rows defeat the vector store, fmaddsub's lane-pair scalar store costs about 1.3x planar's per element, and with a short K the store dominates (ccsd_t_2/3/4, ao2mo_2/3), hence the run condition. `plan_contract` now computes the run for complex `T` too. The FMAddSub NR = 5 entries (4,5,4)/(8,5,8) leave the menus: nothing selects them, and the fenced NR = 6 tiles are spill-free. One `shape_override` row now gives both lane-pair kernels (1m, FMAddSub) their AVX2 shape; for OneMKernel ComplexF32 that is (8,6,8) where the fit used to hand it the AVX-512 menu head (24,8,16). Plans over the 490 suite :contract cases with uniform legs, same-eltype and both mixed-domain orientations, icelake-server/znver4/znver2 profiles, ad94b73 vs this branch: identical everywhere except 419 same-eltype plans on znver2, which change kernel (planar -> fmaddsub) and the blocking derived from it; the execution path is unchanged. Rome, same-node A/B of the same change on 4a77066, before the cleanup (#22) (jobs 7143376/7143377, two rounds each; GF/s of the default kernel, main planar 4x5 -> fmaddsub 4x6, OpenBLAS in brackets): ComplexF64 64^3 35.9 -> 40.6 [42.3], 256^3 42.1 -> 44.6 [49.3], 512^3 41.2 -> 48.2 [50.3], 2048^3 40.5 -> 45.1 [51.2], smallMN 29.3 -> 37.0 [30.2]; time geomean 0.87, no case slower ComplexF32 64^3 61.1 -> 76.7 [72.5], 256^3 80.7 -> 90.4 [95.2], 2048^3 81.5 -> 99.3 [102.8], smallN 44.0 -> 73.0 [53.1]; time geomean 0.79, no case slower Suite (complex :contract and :network): QuasiStrided time geomean 0.78 (ComplexF32) / 0.83 (ComplexF64) of main. Consistently slower: the dim32/96_1_3_1 contract_scrambled cases (1.23-1.47), and in ComplexF64 intensli_7_dim24 and dim8-16 0_2_3 cases (1.06-1.14). A diagnostic run traced the scrambled cases to the kernel method (fmaddsub), not the fence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
lkdvos
force-pushed
the
perf/avx2-complex-v3
branch
from
October 8, 2026 14:41
7f40eed to
27439f6
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.
AVX2 complex kernels: fenced K step, fmaddsub 4x6 default, vector pack/store
Summary
On AVX2 machines (Zen 2 / Rome, and Intel without AVX-512), complex contractions ran at only 50–60% of OpenBLAS. There were three causes:
This PR fixes all three and changes nothing on AVX-512 or NEON. On a Rome node against current
main, the complex suite runs in about 0.6× the time and the GEMM kernels now reach 0.9–1.0× OpenBLAS.Background: why AVX2 is different
AVX2 has 16 vector registers of 256 bits, against AVX-512's 32 registers of 512 bits. A complex microkernel holds a tile of C accumulators in registers. Each K step also needs a few registers for the A sliver and for broadcasts of B. With only 16 registers there is almost no slack, so two things that do not matter on AVX-512 decide performance here:
All of the engine's tuning had been done on AVX-512 machines, so the AVX2 path had never been looked at closely.
What changes, and why
1. A scheduling fence in the AVX2 complex K step (
memory_fence,steps.jl)Problem. For a planar or fmaddsub K step, LLVM's haswell/znver2 scheduler hoists every B broadcast of the step to the top of the loop body to get the loads started early. With 16 registers those broadcasts cannot all stay live next to the accumulators, so they are spilled to the stack and reloaded right away. Main's AVX2 default (planar 4x5) has 4 stack accesses per K step. The register-optimal fmaddsub 4x6 has 24 (table below).
Fix.
memory_fence()is an empty inlineasm sideeffectwith amemoryclobber. It emits no instruction, but loads cannot move across it. Placed after each column group of the K step, it keeps each broadcast next to the FMAs that use it, so a single broadcast register is live at a time. The FMAs themselves can still be reordered freely.For fmaddsub on AVX2 the step also follows OpenBLAS's Zen zgemm ordering: the
swap(a) * b_imagpass, a fence, then thea * b_realpass on a freshly loadeda. That wayaandswap(a)are never live together.Scope. Only 256-bit kernels (
W * sizeof(real(T)) == 32) take the fenced order. The planar and fmaddsub generators each build the same terms once and emit them either in the fenced order or inmain's order, so every other shape generates exactlymain's code. The mixed-domain kernels (ComplexRealKernel,RealComplexKernel) run the realSIMDKernelstep, which does not spill, so they are untouched.2. The complex pack and store fast paths open to AVX2 (
complex_fastpath_isa_eligible)Problem. The vectorised complex pack (de-interleaving or copying slivers) and the vectorised C-tile stores were gated to AVX-512. On AVX2 every complex sliver was therefore packed element by element, and every tile was stored element by element. The mixed-domain kernels reuse these stores, so they were affected too. At small K, or with a thin M or N, this overhead dominates.
Fix. The gate now admits any target with at least 256-bit vectors. The AVX2 code is the AVX-512 path at half width; no new code is needed. NEON and unknown targets keep the scalar loops.
3. AVX2 complex default: fmaddsub 4x6 / 8x6 when C's rows allow its vector store (
select_shape)Problem. Main's AVX2 complex default is planar 4x5 (ComplexF64) / 8x5 (ComplexF32). That is 10 complex accumulators, below what the register file can carry, so too few FMAs are in flight to hide their latency.
Choice. The new default is fmaddsub 4x6/W4 (ComplexF64) and 8x6/W8 (ComplexF32). Its single interleaved accumulator plane holds 12 accumulators + 2 A + 1 broadcast = 15 of 16 registers. With the fence it is spill-free whether B is packed or read in place.
Alternative considered. The natural alternative, planar with NR = 6, needs all 16 registers. It is spill-free with packed B, but when B is read in place (M ≤ 256) it needs one more live address, and it then spills 13 values per K step. On Rome it collapsed to about 0.6× of planar 4x5 on exactly those shapes, so it is not offered.
Why the condition. fmaddsub stores C as lane pairs. When C's unit-stride run along M does not cover whole
MR-row slivers (for example C permuted so that M is strided), every tile falls back to a scalar store. That scalar store costs about 1.3× planar's per element, and at short K the store dominates the tile's time. The TCCGccsd_t_2/3/4andao2mocases ran 1.1–1.3× slower with fmaddsub for this reason. So fmaddsub is chosen only whenrun == m_length || run % MR == 0; otherwise the planner keeps main's planar 4x5/8x5, which is now fenced. To evaluate this,plan_contractnow computes C's run along M for complexTas well.Menu clean-up. The fmaddsub NR = 5 AVX2 tiles (4x5/W4, 8x5/W8) existed only because NR = 6 used to spill; nothing selects them now, so they are dropped from the menu. The AVX2 shape override covers
OneMKernelandFMAddSubKernelalike, which also gives an explicitly requestedOneMKernelComplexF32 an AVX2 shape (8x6/W8) instead of an AVX-512 one.How it was checked
Codegen and plan checks are deterministic and were run on the development machine; performance was measured on Slurm nodes only.
Nothing changes off AVX2.
code_llvm/code_nativeofadd_tilewere compared under-C icelake-server,znver4,znver2andhaswell. The set is every planar, fmaddsub, 1m and mixed-domain menu shape, with B packed and with B in place. All 57 non-256-bit variants are identical tomain.select_shapeand full plans matchmainon icelake-server, cascadelake, znver4 and znver5 profiles. The plans cover the 490 suite contract cases, same-eltype and both mixed orientations: kernel, blocking, line packing and execution path.No spills. Hot-loop stack accesses per K step,
-C znver2(haswellidentical), B packed / B in place:mainTests.
Pkg.test()passes. The suite also passes withQS_FAKE_ISA=avx2andjulia_args = ["-C", "znver2"], apart from the host-detection test, which cannot pass under a forced ISA. New tests pin:Compile latency and allocations.
plan + execute!takes 1.79 s onmainand 1.88 s here natively, and 1.62 vs 1.78 s under a forced AVX2 profile. About 40 ms of that is the shared planar generator, which now carries the fenced order. On AVX2 the first call also compiles fmaddsub instead of planar.execute!allocates 0 B everywhere.Performance
Rome (EPYC 7742, Zen 2), one core,
benchmark/submit_ab.shalternatingmain(ad94b73) and this branch for two rounds on the same node. Best of the two rounds, with no correctness mismatches against StridedBLAS.main(geomean)bench_kernels.jl, default kernel (10 shapes)bench_to_suite.jlcontract (≈155 cases)bench_to_suite.jlnetwork: mps, ctmrg, trg (20)Every kernel shape is faster. Large GEMMs go from 0.51–0.57× to 0.88–0.99× of OpenBLAS (ComplexF64), and from 0.52–0.56× to 0.92–1.04× (ComplexF32). No suite case is consistently slower.
These ratios are larger than when the same changes were measured on 4a77066, before the cleanup in #22. There they gave 0.87 / 0.79 on kernels and 0.83 / 0.78 on the suite. The difference is that
mainitself got slower on AVX2 complex in #22, while this branch did not:main4a77066 → ad94b73dim63_2_2_2_gemm_readymps_2site_D256These come from different nodes, so only the size of the gap is meaningful. The pre-cleanup run also showed a few consistent losses (
dim32/96_1_3_1_contract_scrambled, ComplexF64intensli_7_dim24); they are gone in this run. Whether #22 also slowed real eltypes on AVX2 is not covered here.🤖 Generated with Claude Code