Skip to content

kernels(cpu)(matmul): F32 outer-product tiled GEMM kernel for prefill - #315

Open
jamesburton wants to merge 2 commits into
kkokosa:mainfrom
jamesburton:issue/312-cpu-outer-product-tiled-matmul
Open

kernels(cpu)(matmul): F32 outer-product tiled GEMM kernel for prefill#315
jamesburton wants to merge 2 commits into
kkokosa:mainfrom
jamesburton:issue/312-cpu-outer-product-tiled-matmul

Conversation

@jamesburton

Copy link
Copy Markdown

Closes #312.

ROADMAP reference

This PR addresses the F32 portion of ROADMAP Phase 3 Step 26 — Outer-product tiled matmul kernels (currently unticked).

Step 26: Replace inner-product GEMM with outer-product formulation: unroll M×N output tile, share one activation load across all weight-row dot products. AVX2: 4×3 tile (12 ymm accumulators)… Blocked on RyuJIT register pressure — 4×3 tile needs 23 YMM registers (only 16 available), causing spills that negate the weight-reuse benefit.

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× VecDot4RowsR4 on AVX2 because RyuJIT spilled 7+ YMM registers (23 needed: 12 acc + 6 token + 1 ones + 4 data).

That register pressure is quantization-specific. The F32 path has no ones mask, no Q8_0 scale extraction, and no Half→float conversion. 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.OuterProductGemmF32 in a new file src/DotLLM.Cpu/Kernels/OuterProductGemm.cs:

  • 4 weight rows × 3 input tokens register tile, applying dotLLM's C[N,M] = B[N,K] × A[M,K]^T convention.
  • Vectorises along K (the contraction axis, 8 floats/lane via 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.
  • At each K-step: 3 token vectors are held in registers and reused across 4 FMAs per row; the weight vector is reloaded for each row and reused 3× across the token FMAs.
  • Horizontal-reduces 12 accumulators into the C tile at the end of the K loop.
  • Scalar K-tail (k % 8), row tail (m % 4), and token tail (n % 3) handle non-tile-aligned shapes.
  • AVX2/FMA detection at the public entry point; falls back to a scalar reference (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.

  • Scalar path: bit-exact equality with 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-contract mul + add to FMA, so true bit equality is required and achieved.
  • Vector path: tolerance ≤ 4e-6 · √K vs 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).
  • Coverage: tile-aligned shapes (M=4, N=3, K∈{8,64}; M=8, N=6; M=12, N=9; up to M=128, N=32, K=1024), all-tail-combination shapes (row tail only; token tail only; both; both plus K tail), and edge cases (M=1, N=1, K=1, pure-inner-product M=1 K=4096).
  • All 53 outer-product tests pass (existing 28 Q8_0 + new 25 F32). No existing tests modified.

Benchmark

benchmarks/DotLLM.Benchmarks/OuterProductGemmF32Benchmark.csOuterProductGemmF32 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, BenchmarkDotNet warmupCount=5, iterationCount=15:

M Baseline GemmF32 OuterProductGemmF32 Speedup
128 2.507 ms ± 0.189 1.238 ms ± 0.107 2.02×
512 10.216 ms ± 1.889 5.588 ms ± 0.580 1.83×
2048 47.070 ms ± 11.420 32.714 ms ± 2.369 1.44×

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:

  • New OuterProductGemm kernel (scalar + AVX2).
  • Parity tests + benchmark.

Out of scope (deliberate follow-ups):

  • Wiring 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.
  • Q8_0 / Q5_0 / K-quant outer-product re-enablement. Those kernels exist in 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.
  • Benchmark --filter "*OuterProductGemmF32Benchmark*" produces the table above.

Inspiration: llamafile / tinyBLAS.

…#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>
Copilot AI review requested due to automatic review settings June 8, 2026 21:45

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.GemmF32 on 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.

Comment on lines +352 to +362
[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();
}
Comment on lines +425 to +427
// 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).
Comment on lines +536 to +540
// 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);
Comment on lines +487 to +491
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>
@jamesburton

Copy link
Copy Markdown
Author

Thanks — went through all four review comments. Pushed 7c34e46 addressing them.

1. HorizontalSum uses Sse3.HorizontalAdd under an Avx2/Fma-only guard — fixed (with a caveat on the premise).

The premise is not quite right: .NET's x86 ISA classes form a hierarchy (Avx2 : Avx : Sse42 : Sse41 : Ssse3 : Sse3 : …), and the JIT sets support flags hierarchically, so Avx2.IsSupported == true implies Sse3.IsSupported == true. There was no reachable runtime throw. (Note the same would have applied to the Sse.Add on the line above, which the comment didn't flag.)

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 Vector128<T>/Vector256<T> over platform-specific intrinsics unless the platform-specific form is measurably faster. Since this changes the summation order of the final 4-wide reduction, I re-ran the full parity suite against both the scalar reference and MatMul.GemmF32 — all shapes still within tolerance.

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 4e-6f * MathF.Sqrt(k). The code is the intended behaviour — √K is the right growth rate for the error of an unbiased K-term float32 sum — so I corrected the comment rather than the tolerance, and made it explicit that the scalar path is compared bit-exactly while only the vector path uses the tolerance.

4. Assertion-free return on non-AVX2 hardware reports as passed — valid, fixed.

Switched to the Xunit.SkippableFact mechanism already used elsewhere in this test project (e.g. the CUDA tests' Skip.IfNot(CudaDevice.IsAvailable(), …)), so a non-AVX2 agent now reports skipped:

[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 RunF32ParityCase helper so it can never silently no-op. The two mixed theories (_HandlesAllTails, _EdgeCases) exercise both paths and their scalar half always asserts, so those stay plain [Theory] with only the vector half gated — skipping them entirely on a non-AVX2 agent would lose real scalar coverage.

Verification: dotnet test --filter "FullyQualifiedName~OuterProductGemm" → 53 passed, 0 failed, 0 skipped (this machine has AVX2/FMA).

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.

kernels(cpu)(matmul): F32 outer-product tiled GEMM kernel for prefill (ROADMAP Phase 3 Step 26)

2 participants