Skip to content

AVX2 complex kernels: fenced K step, fmaddsub 4x6 default, vector pack/store - #16

Merged
lkdvos merged 3 commits into
mainfrom
perf/avx2-complex-v3
Oct 8, 2026
Merged

lkdvos merged 3 commits into
mainfrom
perf/avx2-complex-v3

Conversation

@lkdvos

@lkdvos lkdvos commented Oct 1, 2026 •

Copy link
Copy Markdown
Member

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:

  • every complex microkernel spilled registers in its inner loop;
  • the default tile was smaller than the register file allows;
  • packing and storing complex data fell back to scalar loops.

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:

  • how many live values LLVM's scheduler chooses to keep;
  • which tile shape fits in the register file.

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 inline asm sideeffect with a memory clobber. 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_imag pass, a fence, then the a * b_real pass on a freshly loaded a. That way a and swap(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 in main's order, so every other shape generates exactly main's code. The mixed-domain kernels (ComplexRealKernel, RealComplexKernel) run the real SIMDKernel step, 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 TCCG ccsd_t_2/3/4 and ao2mo cases ran 1.1–1.3× slower with fmaddsub for this reason. So fmaddsub is chosen only when run == m_length || run % MR == 0; otherwise the planner keeps main's planar 4x5/8x5, which is now fenced. To evaluate this, plan_contract now computes C's run along M for complex T as 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 OneMKernel and FMAddSubKernel alike, which also gives an explicitly requested OneMKernel ComplexF32 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_native of add_tile were compared under -C icelake-server, znver4, znver2 and haswell. 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 to main.
    • select_shape and full plans match main on 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.
    • On a znver2 profile, 419 same-eltype plans change, and only in their kernel and the blocking derived from it.
  • No spills. Hot-loop stack accesses per K step, -C znver2 (haswell identical), B packed / B in place:

    kernel main this PR
    planar 4x5/W4, 8x5/W8 4 / 4 0 / 0
    fmaddsub 4x6/W4, 8x6/W8 (new default) 24 / 24 0 / 0
    planar 4x6/W4, 8x6/W8 (not offered) 14 / 12 0 / 13
    mixed-domain 256-bit shapes 0 0
  • Tests. Pkg.test() passes. The suite also passes with QS_FAKE_ISA=avx2 and julia_args = ["-C", "znver2"], apart from the host-detection test, which cannot pass under a forced ISA. New tests pin:

    • the AVX2 shape selection, with and without a vector-store run;
    • the fast-path gate;
    • zero spills and the FMA count of the fenced AVX2 tiles.
  • Compile latency and allocations.

    • The first ComplexF64 plan + execute! takes 1.79 s on main and 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.
    • Specialisation counts are unchanged natively.
    • A reused plan's execute! allocates 0 B everywhere.

Performance

Rome (EPYC 7742, Zen 2), one core, benchmark/submit_ab.sh alternating main (ad94b73) and this branch for two rounds on the same node. Best of the two rounds, with no correctness mismatches against StridedBLAS.

time, this PR / main (geomean) ComplexF64 ComplexF32
bench_kernels.jl, default kernel (10 shapes) 0.59 0.54
bench_to_suite.jl contract (≈155 cases) 0.63 0.60
bench_to_suite.jl network: mps, ctmrg, trg (20) 0.57 0.52

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 main itself got slower on AVX2 complex in #22, while this branch did not:

main 4a77066 → ad94b73 this PR, before → after #22
default ComplexF64 kernel 36–42 → 25–27 GF/s unchanged
dim63_2_2_2_gemm_ready 11.9 → 18.6 s 10.0 → 9.9 s
mps_2site_D256 261 → 399 ms 217 → 214 ms

These 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, ComplexF64 intensli_7_dim24); they are gone in this run. Whether #22 also slowed real eltypes on AVX2 is not covered here.

🤖 Generated with Claude Code

@lkdvos
lkdvos force-pushed the perf/avx2-complex-v3 branch from e727e5b to 7f40eed Compare October 7, 2026 18:17
@codecov

codecov Bot commented Oct 7, 2026

Copy link
Copy Markdown

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 ☂️

lkdvos and others added 3 commits October 8, 2026 09:21
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
lkdvos force-pushed the perf/avx2-complex-v3 branch from 7f40eed to 27439f6 Compare October 8, 2026 14:41
@lkdvos
lkdvos merged commit 22c2ab4 into main Oct 8, 2026
1 of 5 checks passed
@lkdvos
lkdvos deleted the perf/avx2-complex-v3 branch October 8, 2026 16:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant