kernels(cpu)(matmul): F32 outer-product tiled GEMM kernel for prefill - #315
kernels(cpu)(matmul): F32 outer-product tiled GEMM kernel for prefill#315jamesburton wants to merge 2 commits into
Conversation
…#312) Add a new F32 outer-product GEMM kernel as a companion to the existing Q8_0/Q5_0/K-quant outer-product kernels landed in PR #61. The Q8_0 outer-product (4×3 AVX2 tile) is currently blocked on RyuJIT register pressure (23 YMM needed, 16 available) due to the Q8_0-specific overhead — `ones` mask vector, scale extraction, `Half→float` conversion. The F32 path has none of these artifacts: a 4×3 tile reaches exactly 12 + 3 + 1 = 16 YMM (12 accumulators + 3 token vectors + 1 reloaded weight vector), which fits the AVX2 register file naturally. Kernel design - 4 weight rows × 3 input tokens register tile. - Vectorises along K (8 floats/lane via Vector256<float>). - At each K-step, 3 token vectors are held in registers and reused across 4 FMAs per row (weight-vector reused 3× across the tokens). - Horizontal-reduces 12 accumulators into the C tile. - Scalar K-tail, row tail, and token tail handle non-tile-aligned shapes. - AVX2/FMA detection at the public entry point; falls back to a scalar reference implementation otherwise. Tests (tests/DotLLM.Tests.Unit/Cpu/Kernels/OuterProductGemmTests.cs) - 25 new test cases extending the existing Q8_0 outer-product test class. - Scalar path: **bit-exact** equality with MatMul.GemmF32Scalar (same accumulation order, no auto-FMA contraction). - Vector path: tolerance ≤ 4e-6·√K vs MatMul.GemmF32 (FMA + horizontal- reduction reorders rounding). - Coverage: tile-aligned shapes, all-tail-combination shapes (row tail, token tail, K tail, all three combined), and edge cases (M=1, N=1, K=1, pure inner product). Benchmark (benchmarks/DotLLM.Benchmarks/OuterProductGemmF32Benchmark.cs) - Compares OuterProductGemmF32 vs production MatMul.GemmF32 at three prefill profiles (M ∈ {128, 512, 2048}, K=4096, N=32). - Intel Core Ultra 7 155H (AVX2, no AVX-512), single-threaded, 15 iterations: | M | Baseline | Outer-product | Speedup | |------|-----------|---------------|----------| | 128 | 2.507 ms | 1.238 ms | 2.02× | | 512 | 10.216 ms | 5.588 ms | 1.83× | | 2048 | 47.070 ms | 32.714 ms | 1.44× | Scope - New, independent kernel — no callers re-wired. Production prefill continues to use MatMul.GemmF32 / GemmF32(..., pool). Caller switching (and threaded outer-product dispatch) is a separate PR after broader benchmark validation. - F32 only — Q8_0/Q5_0/K-quant outer-product re-enablement remains blocked on AVX2 register pressure as documented in the roadmap. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Note
Copilot was unable to run its full agentic suite in this review.
Adds a new F32 outer-product (4×3) tiled GEMM kernel for prefill workloads, along with parity tests and a benchmark to compare against the existing GEMM implementation.
Changes:
- Introduces
OuterProductGemm.OuterProductGemmF32(AVX2/FMA) plus a scalar reference implementation. - Adds unit tests validating scalar bit-exact parity and vector-mode tolerance-based parity vs existing GEMM paths.
- Adds a BenchmarkDotNet benchmark to compare the new kernel against
MatMul.GemmF32on prefill-shaped sizes.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.
| File | Description |
|---|---|
| tests/DotLLM.Tests.Unit/Cpu/Kernels/OuterProductGemmTests.cs | Adds parity, tail-handling, and edge-case tests for the new F32 outer-product GEMM kernels. |
| src/DotLLM.Cpu/Kernels/OuterProductGemm.cs | Implements scalar + AVX2/FMA outer-product GEMM microkernels (4×3 tile) and helpers. |
| benchmarks/DotLLM.Benchmarks/OuterProductGemmF32Benchmark.cs | Adds a benchmark comparing the new kernel vs the existing production GEMM for prefill-shaped workloads. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| [MethodImpl(MethodImplOptions.AggressiveInlining)] | ||
| private static float HorizontalSum(Vector256<float> v) | ||
| { | ||
| // 8 → 4 via cross-lane add, then 4 → 2 via hadd, then 2 → 1 via hadd. | ||
| Vector128<float> lo = v.GetLower(); | ||
| Vector128<float> hi = v.GetUpper(); | ||
| Vector128<float> sum128 = Sse.Add(lo, hi); | ||
| sum128 = Sse3.HorizontalAdd(sum128, sum128); | ||
| sum128 = Sse3.HorizontalAdd(sum128, sum128); | ||
| return sum128.ToScalar(); | ||
| } |
| // FMA vs separate-mul-add reorders the rounding, so we use a relative | ||
| // tolerance that scales with K (the contraction dim accumulates error | ||
| // linearly in K for uniformly-distributed inputs). |
| // summation vs the scalar inner product. Use an absolute | ||
| // tolerance that scales with √K — for unit-magnitude operands | ||
| // and float32 (ULP ≈ 1.2e-7) the standard error of an unbiased | ||
| // K-term sum grows as √K. | ||
| float absTol = 4e-6f * MathF.Sqrt(k); |
| if (useVector && (!Avx2.IsSupported || !Fma.IsSupported)) | ||
| { | ||
| // Vector path falls back to scalar; covered by the scalar case. | ||
| return; | ||
| } |
… GEMM (#312) - HorizontalSum: drop the Sse.Add/Sse3.HorizontalAdd pair for a cross-platform `Vector128.Sum(lo + hi)` reduction. The Avx2/Fma guard at the entrypoint already implies Sse3 via the .NET ISA hierarchy, so this was not a live throw, but the reduction now carries no ISA requirement of its own and matches the "prefer cross-platform Vector128/Vector256" convention. Parity tests confirm the vector path still matches the reference within tolerance. - Tests: the header comment claimed a *relative* tolerance scaling linearly in K while the code uses an *absolute* tolerance scaling with √K. Comment corrected to describe what the code actually does. - Tests: vector-only AVX2 cases now use [SkippableTheory] + Skip.IfNot instead of an assertion-free early `return`, so a run on a non-AVX2 agent reports "skipped" rather than a false "passed". The mixed tail/edge-case theories keep their unconditional scalar assertions and gate only the vector half. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks — went through all four review comments. Pushed 1. The premise is not quite right: .NET's x86 ISA classes form a hierarchy ( That said, the suggestion is still an improvement, so I took it — the reduction is now expressed on the cross-platform surface and depends on no x86 ISA class at all: return Vector128.Sum(v.GetLower() + v.GetUpper());This also matches the repo convention of preferring 2 + 3. Tolerance commentary is internally inconsistent — valid, fixed. Both comments describe the same defect (the header block; the in-body comment at the second location was already correct). The header claimed a relative tolerance growing linearly in K, while the code uses an absolute tolerance 4. Assertion-free Switched to the [SkippableTheory]
public void OuterProductGemmF32_Avx2_MatchesReference(int m, int n, int k)
{
Skip.IfNot(Avx2FmaSupported, "AVX2/FMA not supported on this machine");
…
}The guard moved out of the shared Verification: |
Closes #312.
ROADMAP reference
This PR addresses the F32 portion of ROADMAP Phase 3 Step 26 — Outer-product tiled matmul kernels (currently unticked).
Why F32 (and not Q8_0)
PR #61 landed the Q8_0 / Q5_0 / K-quant outer-product kernels but reverted GEMM dispatch — the 4×3 Q8_0 microkernel was ~14% slower than 3×
VecDot4RowsR4on AVX2 because RyuJIT spilled 7+ YMM registers (23 needed: 12 acc + 6 token + 1ones+ 4 data).That register pressure is quantization-specific. The F32 path has no
onesmask, no Q8_0 scale extraction, and noHalf→floatconversion. A 4×3 F32 tile budgets to exactly 16 YMM (12 accumulators + 3 token vectors + 1 reloaded weight vector), which fits the AVX2 register file naturally.Kernel design
OuterProductGemm.OuterProductGemmF32in a new filesrc/DotLLM.Cpu/Kernels/OuterProductGemm.cs:C[N,M] = B[N,K] × A[M,K]^Tconvention.Vector256<float>). The maintainer's literal ROADMAP description envisions broadcast-style outer-product à la BLIS, which requires per-tile A-packing/transpose. Vectorising along K instead achieves the same register-reuse property (each load is reused across the orthogonal-axis FMAs in the tile) while avoiding a separate pack pass — and still ships within the literal "12 YMM accumulators" budget.k % 8), row tail (m % 4), and token tail (n % 3) handle non-tile-aligned shapes.OuterProductGemmF32Scalar) otherwise.[SkipLocalsInit]+[MethodImpl(AggressiveOptimization)]on the AVX2 microkernel;[AggressiveInlining]on the helpers per project convention.Tests
tests/DotLLM.Tests.Unit/Cpu/Kernels/OuterProductGemmTests.cs— 25 new test cases extending the existing Q8_0 outer-product test class.MatMul.GemmF32Scalar(Assert.Equal(reference, value)— no tolerance). The scalar microkernel accumulates the same terms in the same ascending-index order as the production scalar reference, and .NET does not auto-contractmul + addto FMA, so true bit equality is required and achieved.MatMul.GemmF32. The √K scaling comes from the standard error of an unbiased K-term sum for unit-magnitude operands with float32 ULP ≈ 1.2e-7. Mirrors the tolerance convention used in the existing Q8_0 outer-product tests (1e-2f/1e-3f).Benchmark
benchmarks/DotLLM.Benchmarks/OuterProductGemmF32Benchmark.cs—OuterProductGemmF32vs productionMatMul.GemmF32at three prefill profiles (M ∈ {128, 512, 2048}, K=4096, N=32).Intel Core Ultra 7 155H (AVX2, no AVX-512), single-threaded, BenchmarkDotNet
warmupCount=5, iterationCount=15:GFLOPS at M=512 jumps from ~13 to ~24; at M=2048 from ~11 to ~16. Speedup narrows at large M as memory bandwidth (rather than register-reuse) becomes the dominant cost — consistent with outer-product GEMM theory.
This is a single-threaded kernel-vs-kernel comparison. The pooled
GemmF32(..., ComputeThreadPool)overload — which is what production prefill actually dispatches — is not touched in this PR; caller wiring is the natural follow-up.Scope (explicit)
In scope:
OuterProductGemmkernel (scalar + AVX2).Out of scope (deliberate follow-ups):
TransformerModel(or any caller) to the new kernel. Caller switching belongs in a separate PR after the benchmark is reproduced on a second machine and validated against the threaded path.MatMul.cs(PR Step 26: Outer-product tiled matmul — kernels + investigation (blocked on AVX2) #61) but remain blocked on AVX2 register pressure as documented in the roadmap. AVX-512 (32 ZMM) or a native C microkernel is a separate investigation.Verification
dotnet build src/DotLLM.Cpu/DotLLM.Cpu.csproj -p:EnableSourceControlManagerQueries=false -c Release— green.dotnet build tests/DotLLM.Tests.Unit/DotLLM.Tests.Unit.csproj -p:EnableSourceControlManagerQueries=false -c Release— green.dotnet test tests/DotLLM.Tests.Unit/ -c Release --filter "FullyQualifiedName~OuterProductGemmF32"— 25/25 pass.dotnet test tests/DotLLM.Tests.Unit/ -c Release --filter "FullyQualifiedName~OuterProduct"— 53/53 pass (existing 28 + new 25).dotnet build benchmarks/DotLLM.Benchmarks/DotLLM.Benchmarks.csproj -p:EnableSourceControlManagerQueries=false -c Release— green.--filter "*OuterProductGemmF32Benchmark*"produces the table above.Inspiration: llamafile / tinyBLAS.