Repository navigation
Bound compile time of static matrix products with a budget-tiled kernel - #1360
Conversation
|
This looks like something generally reasonable but it would be nice if we could get rid of some of the worst runtime regressions. I've used Claude to extended the benchmark with non-square matrices and here are the results: New benchmark script: using StaticArrays, LinearAlgebra, ForwardDiff, BenchmarkTools
using ForwardDiff: Dual
struct T1 end; struct T2 end
D(N) = Dual{T1,Float64,N}
DD(N) = Dual{T2,Dual{T1,Float64,N},N}
rnd(::Type{T}) where {T<:Union{Float32,Float64,ComplexF64}} = rand(T)
rnd(::Type{Dual{T,V,N}}) where {T,V,N} = Dual{T}(rnd(V), ntuple(_ -> rnd(V), Val(N))...)
rmat(T, M, K) = SMatrix{M,K,T}(ntuple(_ -> rnd(T), Val(M*K)))
label(T) = replace(string(T), "ForwardDiff." => "", "Main." => "", r"Dual\{T\d, " => "Dual{")
@noinline f_mul(A, B) = A * B
@noinline f_mul!(C, A, B) = mul!(C, A, B)
@noinline f_mul5!(C, A, B, α, β) = mul!(C, A, B, α, β)
types = [Float32, Float64, ComplexF64, D(1), D(3), D(4), D(8), DD(2), DD(4)]
for T in types # warm up scalar arithmetic and the generic machinery
f_mul(rmat(T, 1, 1), rmat(T, 1, 1))
end
# (M, K, N) for A::M×K, B::K×N, C::M×N
shapes = [
[(n, n, n) for n in (2, 3, 4, 5, 6, 8)];
[(2, 3, 4), (3, 2, 3), (4, 8, 2), (8, 2, 4), # small, various aspect ratios
(1, 8, 8), (8, 8, 1), (8, 1, 8), # row × matrix, matrix × column, outer product
(8, 8, 7), (7, 8, 8), (6, 10, 6), # partial edge blocks, long inner dimension
(10, 3, 10), (12, 4, 3), (3, 4, 12), (16, 4, 2)] # more than 8 rows / wide output
]
open(ARGS[1], "w") do io
println(io, "op\ttype\tM\tK\tN\tcompile_ms\trun_ns")
for T in types, (M, K, N) in shapes
A = rmat(T, M, K); B = rmat(T, K, N); C = MMatrix(rmat(T, M, N))
α, β = rnd(T), rnd(T)
for (op, call, bench) in (
("*", () -> f_mul(A, B), () -> @belapsed(f_mul($A, $B), seconds = 0.2)),
("mul!", () -> f_mul!(C, A, B), () -> @belapsed(f_mul!($C, $A, $B), seconds = 0.2)),
("mul!5", () -> f_mul5!(C, A, B, α, β), () -> @belapsed(f_mul5!($C, $A, $B, $α, $β), seconds = 0.2)))
tc = @elapsed call()
tr = bench()
println(io, op, '\t', label(T), '\t', M, '\t', K, '\t', N, '\t', round(1000tc, digits = 1), '\t', round(1e9tr, digits = 1))
flush(io)
end
end
endversioninfo: I can try getting Claude to improve that on my machine. |
Products of unwrapped static matrices (`SMatrix`, `MMatrix`, `SizedMatrix`) with `isbitstype` number elements are computed with a new kernel for `*`, `mul!` and 5-arg `mul!`. Only a block of `mr`×`nr` accumulators with `mr * nr * sizeof(eltype) <= MUL_TILE_BUDGET` (384 bytes, i.e. the 8×6 `Float64` block of `mul_loop`) is unrolled; the loops over blocks and over the inner dimension are not. The amount of generated code is therefore bounded independently of the size of the matrices and of their elements, which reduces compile times for e.g. nested `ForwardDiff.Dual`s by up to two orders of magnitude (JuliaArrays#513). - If the product consists of a single block, `*` returns the accumulators directly. - `mul!` writes to `MArray` destinations through a pointer within a single `GC.@preserve` block, since the per-element preserves of `setindex!` prevent LLVM from vectorizing the blocks. - For elements of at least 32 bytes, rows are distributed evenly among the blocks. - Wrapped matrices (`Transpose`, `Symmetric`, triangular, ...), transposed destinations and large `BlasFloat` products (BLAS) keep the existing code paths. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Test a loop over row blocks with a block at the bottom edge, the even distribution of rows for elements of at least 32 bytes, and products of `Bool` and `Int8` matrices (including accumulators promoted to `Int`). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`mul_tiled!` accessed `MArray` destinations with `unsafe_load` and `unsafe_store!`, which assume an alignment of 1 (see JuliaArrays#1361). The aligned loads and stores of `getindex` and `setindex!` are moved to the internal helpers `_unsafe_load_aligned` and `_unsafe_store_aligned!`, which are used by `mul_tiled!` as well. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
d4113f2 to
77fa588
Compare
|
Thanks for the benchmarks! Most of the worst cases don't show up on my machine: eg I tried to fully unroll small products, i.e., all loops if Could you run your script with that branch on your machine? If it helps on x86, we could include it, possibly with a different threshold ( |
|
I've let Claude try to figure it out on another machine (desktop x86) and after a lot of tests it looks like a small improvement can be achieved with a limited tweak: The machine this time is Can you test what effect that patch has on your machine? |
For elements of at least 32 bytes, which are not vectorized across rows, `mul_tile` now uses approximately square blocks, which minimize the loads of elements of the factors per update of an accumulator. Products that fit into the budget are computed as a single block, also if they have more than 8 rows, so that e.g. `*` and `mul!` of 16×2 `Float64` products no longer go through several blocks (and a temporary `MMatrix` for `*`). Co-authored-by: Mateusz Baran <mateuszbaran89@gmail.com> Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks for the patch! I benchmarked it on the M2 Pro with both scripts (the one from the PR description, 180 cases, and your extended one, 540 cases). I split it into its two parts and also tried two variations. Every variant ran twice in fresh processes, and a case counts as changed only if it changed by more than 10% in both runs. (An A/A run of this PR against itself showed no such runtime changes. Total compile time, however, varied by up to 16% between runs.)
What I saw:
So I pushed your square blocks, plus the single-block rule within the budget, in Could you run your script on this version on the Ryzen? It would be good to know whether the 4× rule helps on x86 enough to be worth it for |
|
I've run a long job with benchmarks and below is the summary. I think this PR is already good enough for smaller element sizes, and here Claude proposed a slightly different rule to tackle larger sizes. If that also works for you then I think we could go with that or some slight variation that works better for M2. We definitely can't have a 100% win without any regressions here without much more complex kernel selection/generation logic, and I'm not sure if that would be worth the necessary effort. --- Claude output here Block sizes of
|
| case | tile with / without | * |
mul! |
mul!5 |
|---|---|---|---|---|
| Float32 16×4×2 | (16,2) / (8,2) | 2.7 → 5.2 (1.93) | 2.4 → 4.9 (2.04) | 3.5 → 8.6 (2.46) |
| Float64 16×4×2 | (16,2) / (8,2) | 5.0 → 9.8 (1.96) | 3.0 → 9.3 (3.10) | 3.8 → 11.4 (3.00) |
| Float32 12×4×3 | (12,3) / (8,3) | 5.9 → 5.8 (0.98) | 4.3 → 3.6 (0.84) | 5.2 → 4.6 (0.88) |
| Float64 12×4×3 | (12,3) / (8,3) | 7.1 → 6.6 (0.93) | 4.7 → 4.0 (0.85) | 5.5 → 6.0 (1.09) |
| Dual{Float64,3} 4×8×2 | (4,2) / (2,2) | 54.1 → 47.2 (0.87) | 54.2 → 46.8 (0.86) | 65.5 → 50.0 (0.76) |
| Dual{Float64,3} 8×8×1 | (8,1) / (3,1) | 59.0 → 56.5 (0.96) | 58.7 → 56.2 (0.96) | 33.4 → 39.8 (1.19) |
| Dual{Float64,4} 4×8×2 | (4,2) / (2,2) | 53.3 → 64.9 (1.22) | 53.0 → 62.6 (1.18) | 47.1 → 45.7 (0.97) |
| Dual{Float64,4} 8×8×1 | (8,1) / (3,1) | 46.6 → 83.0 (1.78) | 45.7 → 78.0 (1.71) | 64.0 → 46.9 (0.73) |
Over these 24 cases, removing the rule makes them 1.25× slower (geometric mean).
Caveat: the small Float32/Float64 cases are not reliable at the level of single cases. Some cases with identical generated code differed by up to 2× between the two package versions (e.g. * Float32 8×1×8: 7.8 ns vs 4.0 ns), and the difference repeated in every run. That points to code placement, not tiling. The Dual timings were stable to within 1%.
2. Exhaustive tile sweep for wide elements (in-sample)
I patched a copy of the package so that mul_tile returns a forced tile (MUL_TILE_OVERRIDE). Then I timed every tile with mr ≤ min(M, 8), nr ≤ N, mr * nr ≤ 12 and mr * nr * s ≤ 2048. Each tile used a fresh Dual tag type, so it got freshly generated code, and every result was checked against Matrix(A) * Matrix(B).
- Types:
Dual{Float64,N}for N = 3, 4, 6, 8 (32, 40, 56 and 72 bytes),Dual{Dual{Float64,2},2}(72 bytes) andDual{Dual{Float64,4},4}(200 bytes). - Shapes (M, K, N): 2×2×2, 3×3×3, 4×4×4, 5×5×5, 2×3×4, 3×2×3, 4×8×2, 2×8×4, 8×2×4, 8×8×1, 1×8×8, 8×1×8, 6×4×2, 3×8×3, 4×4×3, 8×8×8.
- Ops:
*,mul!,mul!5. - In total, 288 (type, shape, op) cases.
Each candidate rule is scored as the geometric mean, over the 288 cases, of the time of its tile divided by the time of the fastest tile for that case.
rule for s >= 32 |
slowdown vs. best tile |
|---|---|
| current (single block if within budget, else near-square) | 1.297 |
| current without the single block | 1.300 |
| near-square, budget 64 … 768 | 1.27 … 1.35 |
column blocks (min(M, 8, b ÷ s), 1), b = 32 … 1024 |
≥ 1.30 |
fill a row first, then extend over rows (nr = min(N, b ÷ s), mr = (b ÷ s) ÷ nr) |
≥ 1.235 |
always (1, 1) |
1.35 |
row blocks (1, min(N, b ÷ s)), b = 384 |
1.216 |
row blocks (1, min(N, b ÷ s)), b = 512 |
1.194 |
row blocks, b = 512, plus single block if M * N * s ≤ 128 … 384 |
1.195 … 1.219 |
best fixed tile for all cases, (1, 8) clamped to the shape |
1.201 |
Single-row blocks win across all element types except Dual{Dual{Float64,2},2}, where they were slightly worse (1.272 vs 1.245).
The tile landscape is very irregular. The best tile often differs between *, mul! and mul!5 for the same shape, and there are large outliers that probably come from LLVM's loop vectorizer. For example, * with Dual{Float64,3} 8×8×1 and a 1×1 tile takes 15.8 ns, while every other tile takes 50 ns or more. Optimizing for single cases is therefore pointless, so I only compared simple rules with few parameters.
3. Out-of-sample validation
Benchmark: bench_dmw.jl with types = [D(3), D(4), D(6), D(8), DD(2), DD(4)] and 7 shapes added that the sweep did not tune on: (4, 2, 8), (2, 4, 8), (7, 3, 5), (5, 7, 3), (1, 4, 6), (6, 4, 1), (4, 6, 4). That gives 27 shapes × 6 types × 3 ops = 486 cases.
Variants (77040a8 as the baseline):
row384:s >= 32 && return 1, min(N, max(1, budget ÷ s))row512: same with4 * budget ÷ 3hybrid(proposed):row512, but only forN > 1
Speedup = t(77040a8) / t(variant):
| row384 | row512 | hybrid | |
|---|---|---|---|
| geometric mean, 486 cases | 1.064 | 1.090 | 1.105 |
| > 10% faster / > 10% slower | 175 / 74 | 238 / 78 | 235 / 65 |
| compile time of first call (geometric-mean ratio) | 1.37 | 1.22 | 1.15 |
Why N == 1 is excluded. I split the row512 results by whether 77040a8 uses a single block and whether N == 1. N == 1 was the only consistent loss: geometric means of 0.87, 0.82 and 0.77 for *, mul! and mul!5 when 77040a8 used a single block. Single-row blocks degenerate to 1×1 blocks there, so the hybrid falls back to the existing rule for N == 1.
Hybrid speedup by type and op:
| group | geometric mean | min | max |
|---|---|---|---|
| Dual{Float64,3} | 1.122 | 0.55 | 2.74 |
| Dual{Float64,4} | 1.164 | 0.56 | 2.64 |
| Dual{Float64,6} | 1.123 | 0.71 | 2.55 |
| Dual{Float64,8} | 1.098 | 0.55 | 1.60 |
| Dual{Dual{Float64,2},2} | 1.036 | 0.68 | 1.71 |
| Dual{Dual{Float64,4},4} | 1.093 | 0.85 | 1.34 |
* |
1.120 | 0.55 | 2.74 |
mul! |
1.138 | 0.67 | 2.64 |
mul!5 |
1.060 | 0.58 | 1.79 |
Largest regressions of the hybrid (ns):
| case | 77040a8 | hybrid |
|---|---|---|
* Dual{Float64,8} 3×2×3 |
32.5 | 59.4 |
* Dual{Float64,3} 3×2×3 |
14.4 | 26.0 |
* Dual{Float64,4} 3×2×3 |
17.4 | 31.1 |
mul!5 Dual{Float64,4} 3×3×3 |
25.9 | 44.5 |
* Dual{Float64,8} 8×1×8 |
128.9 | 218.8 |
mul!5 Dual{Float64,4} 16×4×2 |
98.5 | 157.6 |
* Dual{Float64,8} 4×2×8 |
107.5 | 166.2 |
* Dual{Float64,4} 3×3×3 |
23.3 | 36.0 |
Most of these are small * products with a small inner dimension, where the single block of 77040a8 (or its square block) happens to vectorize well. Restoring the single block for such products did not help in the sweep, so the patch has no special case for them.
Caveats
- Effective budget. For elements of 32 bytes or more, the blocks may hold up to
4/3 * MUL_TILE_BUDGETbytes of accumulators, so the invariantmr * nr * s <= budgetno longer holds for them. With the plain budget (row384), the speedup drops from 1.090 to 1.064, mostly becauseDual{Dual{Float64,4},4}(200 bytes) falls back to 1×1 blocks. - Mechanism unclear. I don't have a confirmed explanation of why single rows are faster. The argument for square blocks (fewer loads per update) does not seem to matter for
Duals, whose arithmetic dominates the loads. - One machine. The results come from a single Zen 5 machine (AVX-512) and one Julia version. They should be checked on other CPUs, e.g. AVX2-only ones.
- Test coverage of edges. With the patch, the
Complex{Int128}(32 bytes) exact-arithmetic tests no longer produce blocks at the bottom edge (mr == 1), and produce right-edge blocks only forN > 16. The edge code is still covered by theIntcases.
Patch
The patch is against 77040a8 and is also in benchmark/mul_tile_rows.patch. All 1988 tests in test/matrix_multiply.jl pass with it.
mul_tile_rows.patch
diff --git a/src/matrix_multiply.jl b/src/matrix_multiply.jl
index a35dcce..618df56 100644
--- a/src/matrix_multiply.jl
+++ b/src/matrix_multiply.jl
@@ -260,23 +260,21 @@ const MUL_TILE_BUDGET = 384
Size `(mr, nr)` of the block of accumulators of `mul_tiled!` for a product of size `M`×`N`
with elements of `s >= 1` bytes.
-The block satisfies `mr * nr * s <= budget`, unless a single element exceeds the budget
-(then `mr = nr = 1`). If the whole product fits into the budget, it is a single block.
-Otherwise the block has at most 8 rows.
-
Elements of at least 32 bytes (the size of AVX2 registers) are not vectorized across rows.
-For them the block is approximately square, which minimizes the number of loads of elements
-of the factors per update of an accumulator (`mr + nr` loads for `mr * nr` updates), and
-the rows are distributed evenly among the blocks: Otherwise the block at the bottom edge
-might consist of only a few rows, i.e., few independent accumulators.
+For them, unless `N == 1`, the block is a single row with at most `4 * budget ÷ (3 * s)`
+columns. In benchmarks with `ForwardDiff.Dual` numbers, such blocks were faster than
+approximately square blocks (which minimize the number of loads of elements of the factors
+per update of an accumulator) and than a single block for products within the budget.
+
+Otherwise the block satisfies `mr * nr * s <= budget`, unless a single element exceeds the
+budget (then `mr = nr = 1`). If the whole product fits into the budget, it is a single
+block. Otherwise the block has at most 8 rows.
"""
function mul_tile(M::Int, N::Int, s::Int, budget::Int)
+ s >= 32 && N > 1 && return 1, min(N, max(1, 4 * budget ÷ (3 * s)))
M * N * s <= budget && return M, N
- mr = min(M, 8, max(1, s >= 32 ? isqrt(budget ÷ s) : budget ÷ s))
+ mr = min(M, 8, max(1, budget ÷ s))
nr = min(N, max(1, budget ÷ (s * mr)))
- if s >= 32
- mr = cld(M, cld(M, mr))
- end
return mr, nr
end
diff --git a/test/matrix_multiply.jl b/test/matrix_multiply.jl
index 010c623..f1194c6 100644
--- a/test/matrix_multiply.jl
+++ b/test/matrix_multiply.jl
@@ -455,22 +455,24 @@ Base.:*(a::WideInt, b::WideInt) = WideInt(a.x * b.x)
@test StaticArrays.mul_tile(8, 8, 8, budget) == (8, 6)
@test StaticArrays.mul_tile(8, 8, 1000, budget) == (1, 1)
@test StaticArrays.mul_tile(9, 9, 8, budget) == (8, 6)
- @test StaticArrays.mul_tile(6, 6, 72, budget) == (2, 2)
- @test StaticArrays.mul_tile(7, 8, 32, budget) == (3, 4)
+ @test StaticArrays.mul_tile(6, 6, 72, budget) == (1, 6)
+ @test StaticArrays.mul_tile(7, 8, 32, budget) == (1, 8)
+ @test StaticArrays.mul_tile(8, 8, 200, budget) == (1, 2)
+ @test StaticArrays.mul_tile(8, 1, 32, budget) == (8, 1)
+ @test StaticArrays.mul_tile(8, 1, 72, budget) == (5, 1)
@test StaticArrays.mul_tile(16, 3, 8, budget) == (16, 3)
@test StaticArrays.mul_tile(16, 4, 8, budget) == (8, 4)
for M in (1:10..., 16, 17), N in 1:10, s in (1, 4, 8, 16, 32, 72, 200, 1000)
mr, nr = StaticArrays.mul_tile(M, N, s, budget)
- if M * N * s <= budget
+ if s >= 32 && N > 1
+ # Single row
+ @test mr == 1 && nr == min(N, max(1, 4 * budget ÷ (3 * s)))
+ elseif M * N * s <= budget
# Single block
@test (mr, nr) == (M, N)
else
@test 1 <= mr <= min(M, 8) && 1 <= nr <= N
@test mr * nr * s <= budget || mr == nr == 1
- if s >= 32
- # Approximately square blocks, whose rows are distributed evenly
- @test cld(M, mr) == cld(M, min(M, max(1, isqrt(budget ÷ s))))
- end
end
end
endTile sweep script (needs a MUL_TILE_OVERRIDE hook in mul_tile)
Hook added to src/matrix_multiply.jl for the sweep only:
const MUL_TILE_OVERRIDE = Ref{Any}(nothing)
function mul_tile(M::Int, N::Int, s::Int, budget::Int)
o = MUL_TILE_OVERRIDE[]; o !== nothing && return o::Tuple{Int,Int}
# ... unchanged ...Sweep script (MAXACC=12 julia -t1 sweep.jl out.tsv):
using StaticArrays, LinearAlgebra, ForwardDiff, BenchmarkTools
using ForwardDiff: Dual
const OV = StaticArrays.MUL_TILE_OVERRIDE
struct Tg{m,n} end; struct Tg2{m,n} end
# A fresh tag per tile forces freshly generated code for every tile
kinds = [("D3", (m,n)->Dual{Tg{m,n},Float64,3}), ("D4", (m,n)->Dual{Tg{m,n},Float64,4}),
("D6", (m,n)->Dual{Tg{m,n},Float64,6}), ("D8", (m,n)->Dual{Tg{m,n},Float64,8}),
("DD2", (m,n)->Dual{Tg2{m,n},Dual{Tg{m,n},Float64,2},2}), ("DD4", (m,n)->Dual{Tg2{m,n},Dual{Tg{m,n},Float64,4},4})]
rnd(::Type{Float64}) = rand()
rnd(::Type{Dual{T,V,N}}) where {T,V,N} = Dual{T}(rnd(V), ntuple(_ -> rnd(V), Val(N))...)
rmat(T, M, K) = SMatrix{M,K,T}(ntuple(_ -> rnd(T), Val(M*K)))
@noinline f_mul(A, B) = A * B
@noinline f_mul!(C, A, B) = mul!(C, A, B)
@noinline f_mul5!(C, A, B, α, β) = mul!(C, A, B, α, β)
shapes = [(2,2,2),(3,3,3),(4,4,4),(5,5,5),(2,3,4),(3,2,3),(4,8,2),(2,8,4),(8,2,4),(8,8,1),(1,8,8),(8,1,8),(6,4,2),(3,8,3),(4,4,3),(8,8,8)]
maxacc = parse(Int, get(ENV, "MAXACC", "12"))
open(ARGS[1], "w") do io
println(io, "op\ttype\tM\tK\tN\tmr\tnr\trun_ns")
for (kl, kT) in kinds, (M, K, N) in shapes, mr in 1:min(M, 8), nr in 1:N
mr * nr <= maxacc || continue
T = kT(mr, nr); sizeof(T) * mr * nr <= 2048 || continue
A = rmat(T, M, K); B = rmat(T, K, N); C = MMatrix(rmat(T, M, N)); α, β = rnd(T), rnd(T)
OV[] = (mr, nr)
R = f_mul(A, B); f_mul!(C, A, B); C2 = copy(C); f_mul5!(C, A, B, α, β)
OV[] = nothing
ref = Matrix(A) * Matrix(B)
(R ≈ ref && C2 ≈ ref && C ≈ C2 * β + α * ref) || error("wrong result $kl $M $K $N $mr $nr")
for (op, t) in (("*", @belapsed(f_mul($A, $B), seconds = 0.05)),
("mul!", @belapsed(f_mul!($C, $A, $B), seconds = 0.05)),
("mul!5", @belapsed(f_mul5!($C, $A, $B, $α, $β), seconds = 0.05)))
println(io, op, '\t', kl, '\t', M, '\t', K, '\t', N, '\t', mr, '\t', nr, '\t', round(1e9t, digits = 1))
end
flush(io)
end
end|
Thanks for the thorough analysis! I ran your patch (applied to 77040a8) on the M2 Pro with the same protocol as last time: base and patch alternating, two rounds in fresh processes, and a case counts as changed only if it changed by more than 10% in both rounds. I used three scripts: the one from the PR description (180 cases), your extended one (540 cases), and your out-of-sample setup (wide types On the M2, the single-row rule is a net loss for wide elements. The control types (
To see where the differences come from, I split the wide setup by how the patch changes the block:
So on the M2, single rows are roughly neutral for The I also tried to generalize this ("fill the budget"), but neither generalization holds up on the M2:
So the same single-row rule is about 10% faster on Zen 5 and about 3.6% slower on the M2. Combined with the irregular tile landscape you found, I don't think we can get a clear win for wide elements on both machines with a simple rule, apart from the function mul_tile(M::Int, N::Int, s::Int, budget::Int)
M * N * s <= budget && return M, N
s >= 32 && N == 1 && return min(M, 8, max(1, budget ÷ s)), 1
...Your hybrid already uses exactly these blocks for |
|
Thanks for testing. I've checked your suggestion on my machine but it doesn't really help. The current state (commit 77040a8) is still on average an improvement over the master branch so I think it's worth merging but first I'd like to give other people a chance to provide their input, so I'd suggest leaving this open as ready for review for a couple of days before merging. Below is the Claude's summary. Thanks for running this on the M2! I checked the Setup: Ryzen 9 9950X3D, Julia 1.13.1, one thread. I compared three variants, all based on 77040a8:
The variants ran simultaneously, each pinned to its own core. There were 3 rounds in fresh processes, with the core assignment rotated between rounds. Each value is the minimum over rounds, and a case counts as changed only if it changed by more than 10% in all 3 rounds. The median run-to-run variation was 1.3–1.8%. I ran both Overall
The control types (
|
| op | n1 / 77040a8 | faster / slower by >10% | your M2 numbers |
|---|---|---|---|
* |
0.91 | 3 / 1 | 0.79 |
mul! |
0.91 | 2 / 1 | 0.94 |
mul!5 |
1.05 | 1 / 2 | 0.75 |
The gains in * and mul! mostly come from DD(2). Several of your largest mul!5 wins go the other way on Zen 5, and D(6) 8×8×1 flips between ops:
| case | 77040a8 (ns) | n1 (ns) | ratio |
|---|---|---|---|
DD(2) 8×8×1, mul! |
225 | 144 | 0.64 |
DD(2) 8×8×1, * |
232 | 153 | 0.66 |
D(6) 8×8×1, mul!5 |
67 | 50 | 0.75 |
DD(2) 8×8×1, mul!5 |
134 | 148 | 1.10 |
D(8) 8×8×1, mul!5 |
73 | 82 | 1.11 |
D(6) 8×8×1, mul! |
72 | 87 | 1.20 |
D(8) 6×4×1, mul!5 |
34 | 41 | 1.22 |
D(6) 8×8×1, * |
75 | 98 | 1.31 |
Compile time: in these cases the first call takes about 2.1–2.4× longer with the N == 1 rule, for example DD(2) 8×8×1 mul!5 takes 29 → 80 ms. With 2-row blocks, 8 rows are a loop over a single block body. With 5 + 3 or 6 + 2 rows, there are two separately unrolled block bodies. Do you see the same on the M2?
Single rows, split as in your table
For comparison, here are the rows / 77040a8 ratios on Zen 5, with your M2 numbers in the last column:
| change of the block | * |
mul! |
mul!5 |
M2: * / mul! / mul!5 |
|---|---|---|---|---|
| unchanged | 1.00 | 1.00 | 1.00 | 0.99 / 1.00 / 1.00 |
| single block → single rows | 1.01 | 0.85 | 0.99 | 1.33 / 1.04 / 1.15 |
| approximately square → single rows | 0.89 | 0.88 | 0.92 | 1.02 / 0.99 / 1.15 |
N == 1 |
0.91 | 0.91 | 1.05 | 0.79 / 0.94 / 0.75 |
So the two microarchitectures disagree exactly where the M2 loses most: 5-arg mul! and products that fit into the budget.
Suggestion
The N == 1 rule is roughly neutral overall on Zen 5. It has ±30% outliers in both directions and about twice the compile time for the cases it changes. So I'd keep 77040a8 as it is, since it doesn't regress on either machine. The tuning for wide elements can be left for a follow-up with data from more machines, as you suggested (e.g. an AVX2-only x86). If you'd still prefer to include the N == 1 change, I'm fine with that too: it's small and doesn't affect anything else.
The `@_inline_meta` alias was removed together with the support for Julia < 1.10, but the merge of master brought in new uses of it in the tiled matrix multiplication, which broke precompilation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Fixes #513.
Problem
Multiplying static matrices of wide element types takes very long to compile.
Wide elements include
ForwardDiff.Duals, and especially nested Duals.For example,
*of two 8×8SMatrix{Dual{Dual{Float64,4},4}}takes about 3 s to compile, and 5-argmul!about 4.6 s.The reason is the amount of generated code:
mul_loopunrolls every 8×6 block position.muladd_unrolled_all!/muladd_unrolled_chunks!unroll the whole product.The generated code therefore grows with M·N and with the cost of each element operation.
Change
*,mul!and 5-argmul!of unwrapped static matrices (SMatrix,MMatrix,SizedMatrix) withisbitstypeNumberelements now use one kernel:mr×nrblock of accumulators is unrolled, withmr * nr * sizeof(eltype) ≤ MUL_TILE_BUDGET = 384bytes. If the whole product fits into the budget, it is a single block; otherwise blocks have at most 8 rows.Float64block ofmul_loop, soFloat64keeps its block shape.muladdin the same order as before.*returns the accumulators directly, asmul_loopdid. This includes products with more than 8 rows that fit into the budget, e.g. 16×2Float64.mul!into anMArray: the kernel writes through a pointer inside a singleGC.@preserveblock. The per-element preserves ofsetindex!are only removed after LLVM's vectorizer runs, so they kept the blocks scalar. This alone makesmul!withFloat642–5× faster for sizes 4–8.Dual{3}, 2×2 forDual{8}), which minimizes the loads per update. Rows are distributed evenly among the blocks, which avoids a thin block at the bottom edge.Transpose,Adjoint,Symmetric,Hermitian, triangular,Diagonal), transposed destinations, non-isbitstypeelements, matrix–vector products, and the BLAS path for largeBlasFloatproducts all keep the existing code paths.Wrappers could be supported in a follow-up. Copying
Symmetric/Hermitianmatrices to a plain matrix efficiently was the open question: an unrolled copy has compile time growing with the matrix size, while a copy in a loop is slower at runtime.Benchmarks
Apple M2 Pro, Julia 1.13.1. Compile time is the first call of a
@noinlinewrapper, after warming up scalar arithmetic and the other element types. Runtime is from@belapsed, averaged over two runs in fresh processes. Format: master → this PR.*mul!*mul!mul!(5-arg)*Dual{4}*Dual{4}mul!Dual{8}*Dual{Dual{2},2}mul!(5-arg)Dual{Dual{4},4}*Dual{Dual{4},4}*Dual{Dual{4},4}mul!Dual{Dual{4},4}mul!(5-arg)Across all 180 cases of the first script (9 element types, sizes 2–8, 3 operations, plus wrappers):
Across the 540 cases of @mateuszbaran's script with non-square shapes (9 element types, 20 shapes including 1×8×8, 8×1×8, 16×4×2, 6×10×6, 3 operations):
Known regressions (runtime vs master, consistent in both runs):
Dual{Dual{4},4}2×2 and 3×2×3*1.36–1.38×,mul!1.48–1.53×*with ComplexF64 andDuals*goes through a temporaryDual{1}*with 7–16 rows (8×8, 7×8×8, 10×3×10, 16×4×2)Dual{3}/Dual{4}*4×4 and 3×4×12mul!(2×2, 3×3) with Float32, ComplexF64,Dual{1},Dual{3},Dual{4},Dual{Dual{2},2}*1×8×8 with Float32/Float64*Float64 8×8 compile timeBoolproducts are slow to compile and to run, on master as well: 8×8 · 8×48 takes 1.8 s (master) and 2.2 s (this PR) to compile, and about 2 µs to run.true * trueis aBoolbutmuladdofBools is anInt, so the accumulators have mixed types. This is left for a separate fix.Alternatives benchmarked and not adopted (same machine; each compared with this PR):
*)Dual{1}*8×8, 7×8×8 and 8×1×8 (0.65–0.87×), butFloat648×8*1.14×,Dual{3}/Dual{4}16×4×2 1.37× andDual{4}10×3×10 1.33× slower; total compile time +12–23%. Undecided without more x86 numbersmul!Float648×8mul!22 → 35 ns; total compile time +33–43%Dual{3}9×9–12×12: 5×2 instead of 5×1)Dual{3}5×5/6×6 regressions without slowing down 5-argmul!Bool/Int8/Int168×8 · 8×{24,48} 2–9× lower, but runtime 1.1–1.26× slower, and 8×24 products lose the single-block path. TheBoolcase is better fixed by a stable accumulator typedo-block closures for the row and column block loops instead of the duplicated codestoreclosure);@nospecializedoes not helpHelp wanted: all numbers above are from a single Apple Silicon machine. More benchmarks on x86 (AVX2, AVX-512) would be very welcome, in particular to decide on the single block of up to 4× the budget for
*. The scripts are below.Benchmark script (square products and wrappers)
Benchmark script by @mateuszbaran (non-square products)
Behaviour changes
mul!results can differ from previous versions in the last bit. Products are accumulated in the same order withmuladd, butmuladdlets the compiler decide per call site whether to fuse multiply and add.mul!into a destination with a different element type converts each entry assetindex!does, e.g. anInexactErrorfor a non-integer result in anIntdestination.Tests
New tests in
test/matrix_multiply.jl:mul_tileblock shapes, including single blocks with more than 8 rows and the square blocks with evenly distributed rows for elements of at least 32 bytes;IntandComplex{Int128}elements for sizes with a single block (also with more than 8 rows) and with edge blocks (including a loop over row blocks followed by a block at the bottom edge), forSMatrix/MMatrix/SizedMatrixinputs andMMatrix/SizedMatrixdestinations, 3- and 5-arg;mul!(A, A, A));BoolandInt8elements, including accumulators thatmuladdpromotes fromBooltoInt;🤖 Generated with Claude Code