Optimizing Symmetric Quadratic Form

2022-12-19

This post is motivated by this discourse thread, providing the following code:

using LinearAlgebra, MKL, BenchmarkTools, LoopVectorization
product_1(x, M) = dot(x, M, x)

function product_2(x, M) 
    Mx = M * x
    return dot(x, Mx)
end
function product_3(x, M)
    n = length(x)
    result = zero(eltype(x))

    @inbounds for i = 1:n
        result += x[i]^2 * M[i, i]

        @simd for j = 1 : i - 1
            result += 2* x[i] * x[j] * M[j, i]
        end 
    end

    return result
end

The author expressed surprise that making matrix M symmetric wasn't always faster, and also that product_3, explicitly taking advantage of that symmetry, was slowest.

Setting a baseline:

BLAS.set_num_threads(1) # single threaded `product_2`
x = rand(200);
A = rand(length(x), 2+length(x)) |> y -> y*y';
B = Symmetric(A, :U);
@btime product_1($x, $A)
@btime product_2($x, $A)
@btime product_3($x, $A)
@btime product_1($x, $B)
@btime product_2($x, $B)
@btime product_3($x, $B)

product_1 and product_3 are implemented in "pure Julia", while product_2 calls out to the BLAS library for both the matrix-vector multiply and the vector-vector dot product. I get

julia> @btime product_1($x, $A)
  3.662 μs (0 allocations: 0 bytes)
508214.49429146934

julia> @btime product_2($x, $A)
  2.366 μs (1 allocation: 1.77 KiB)
508214.49429146945

julia> @btime product_3($x, $A)
  5.368 μs (0 allocations: 0 bytes)
508214.49429146847

julia> @btime product_1($x, $B)
  7.090 μs (0 allocations: 0 bytes)
508214.49429146847

julia> @btime product_2($x, $B)
  1.734 μs (1 allocation: 1.77 KiB)
508214.49429146945

julia> @btime product_3($x, $B)
  8.916 μs (0 allocations: 0 bytes)
508214.49429146847

julia> versioninfo()
Julia Version 1.10.0-DEV.175
Commit 0d469bd0f8* (2022-12-18 20:11 UTC)
Platform Info:
  OS: Linux (x86_64-redhat-linux)
  CPU: 36 × Intel(R) Core(TM) i9-10980XE CPU @ 3.00GHz
  WORD_SIZE: 64
  LIBM: libopenlibm
  LLVM: libLLVM-14.0.6 (ORCJIT, cascadelake)
  Threads: 36 on 36 virtual cores

My CPU is cascadelake, an Intel Skylake-X clone feature AVX512 and 1MiB L2 cache/core. MKL is winning, especially for symmetric matrices, despite the fact we've limited it to only a single thread, and that it needs to allocate and write memory. using MKL is encouraged for best performance when possible, the default library OpenBLAS is likely to be slower.

Coming up second best will not do, especially when there's such an obvious deficit in product_2 – the need to allocate and write memory.

Asymptotically, the performance is limited by the rate at which we can loading memory from the matrix. Thus we should certainly see Symmetric winning, at least so long as we take advantage of the structure to avoid loading any elements more than once.

Lets take a quick look using LIKWID to try and get a better idea of the performance. For convenience, I define

using LIKWID, DataFrames, PrettyTables
foreachf(f::F, N::Int, arg1::A, args::Vararg{Any,K}) where {F,A,K} = 
    foreach(_ -> Base.donotdelete(f(arg1, args...)), 1:N)
	
function filtered_print(metrics)
    subsetdict = Dict( 
	    "FLOPS_DP" => ["Runtime (RDTSC) [s]", "Runtime unhalted [s]", "CPI", "DP [MFLOP/s]", "AVX512 DP [MFLOP/s]", "Packed [MUOPS/s]", "Scalar [MUOPS/s]", "Vectorization ratio"],
	    "CYCLE_STALLS" => ["Total execution stalls", "Stalls caused by L1D misses [%]", "Stalls caused by L2 misses [%]", "Execution stall rate [%]", "Stalls caused by L1D misses rate [%]", "Stalls caused by L2 misses rate [%]", "Stalls caused by memory loads rate [%]"],
	    "CACHES" => ["L2 to L1 load bandwidth [MBytes/s]", "L2 to L1 load data volume [GBytes]", "L1 to L2 evict bandwidth [MBytes/s]", "L1 to L2 evict data volume [GBytes]", "L1 to/from L2 bandwidth [MBytes/s]", "L1 to/from L2 data volume [GBytes]"],
        "BRANCH" => ["Branch misprediction rate", "Instructions per branch"]
	)
	ms = String[];
	t1vs = Float64[];
	for (k,vs) = metrics
	    v = first(vs) # extract thread 1 only; FIXME in case of multithreading!
		submetrics = subsetdict[k]
		append!(ms, submetrics)
		for m = submetrics
		    push!(t1vs, v[m])
		end
	end
	pretty_table(DataFrame(Metric = ms, var"Thread 1" = t1vs))
end

foreachf is so that I can run code many times. Here, I will keep N = 1_000_000 across profiles to keep results consistent. filtered_print is to give a more concise output. Let's compare product_1 with product_3 using the dense matrix; I manually filtered the results:

julia> met, evt = @perfmon ("FLOPS_DP", "CYCLE_STALLS", "CACHES", "BRANCH") foreachf(product_1, 1_000_000, x, A);

julia filtered_print(met);
┌────────────────────────────────────────┬────────────┐
│                                 Metric │   Thread 1 │
│                                 StringFloat64 │
├────────────────────────────────────────┼────────────┤
│                    Runtime (RDTSC) [s] │    4.31409 │
│                   Runtime unhalted [s] │    4.09009 │
│                                    CPI │    0.50087 │
│                           DP [MFLOP/s] │    14603.8 │
│                    AVX512 DP [MFLOP/s] │    13769.3 │
│                       Packed [MUOPS/s] │     1760.9 │
│                       Scalar [MUOPS/s] │    754.986 │
│                    Vectorization ratio │    69.9913 │
│                 Total execution stalls │  2.76645e9 │
│        Stalls caused by L1D misses [%] │    83.0887 │
│         Stalls caused by L2 misses [%] │   0.625892 │
│               Execution stall rate [%] │    22.5896 │
│   Stalls caused by L1D misses rate [%] │    18.7694 │
│    Stalls caused by L2 misses rate [%] │   0.141387 │
│ Stalls caused by memory loads rate [%] │    27.0086 │
│     L2 to L1 load bandwidth [MBytes/s] │    64062.1 │
│     L2 to L1 load data volume [GBytes] │    275.821 │
│    L1 to L2 evict bandwidth [MBytes/s] │    77.0877 │
│    L1 to L2 evict data volume [GBytes] │   0.331903 │
│     L1 to/from L2 bandwidth [MBytes/s] │    64139.2 │
│     L1 to/from L2 data volume [GBytes] │    276.153 │
│              Branch misprediction rate │ 6.67933e-5 │
│                Instructions per branch │    7.20618 │
└────────────────────────────────────────┴────────────┘

julia> m, e = @perfmon ("FLOPS_DP", "CYCLE_STALLS", "CACHES", "BRANCH") foreachf(product_3, 1_000_000, x, A);

julia filtered_print(m);
┌────────────────────────────────────────┬────────────┐
│                                 Metric │   Thread 1 │
│                                 StringFloat64 │
├────────────────────────────────────────┼────────────┤
│                    Runtime (RDTSC) [s] │    5.50317 │
│                   Runtime unhalted [s] │    5.21599 │
│                                    CPI │   0.607209 │
│                           DP [MFLOP/s] │    8882.12 │
│                    AVX512 DP [MFLOP/s] │    7275.45 │
│                       Packed [MUOPS/s] │    935.599 │
│                       Scalar [MUOPS/s] │    1554.33 │
│                    Vectorization ratio │    37.5753 │
│                 Total execution stalls │  3.94969e9 │
│        Stalls caused by L1D misses [%] │    41.0844 │
│         Stalls caused by L2 misses [%] │   0.956054 │
│               Execution stall rate [%] │    25.1506 │
│   Stalls caused by L1D misses rate [%] │     10.333 │
│    Stalls caused by L2 misses rate [%] │   0.240453 │
│ Stalls caused by memory loads rate [%] │    26.4968 │
│     L2 to L1 load bandwidth [MBytes/s] │    30344.9 │
│     L2 to L1 load data volume [GBytes] │    167.261 │
│    L1 to L2 evict bandwidth [MBytes/s] │    146.758 │
│    L1 to L2 evict data volume [GBytes] │   0.808929 │
│     L1 to/from L2 bandwidth [MBytes/s] │    30491.7 │
│     L1 to/from L2 data volume [GBytes] │     168.07 │
│              Branch misprediction rate │ 0.00199066 │
│                Instructions per branch │    6.42476 │
└────────────────────────────────────────┴────────────┘

That that product_3 was executing over 50% more scalar MUOPS/s than packed. Each scalar MUOP doesn't contribute nearly as many FLOPS as packed one, so the total DP [MFLOP/s] still wasn't much higher than the AVX512-only value. product_1 did much better in this respect. It also had more instructions per branch. However, it did require much higher L2 to L1 load data, and thus also higher bandwidth, as we expected because it didn't take advantage of symmetry.

CPI is cycles per instruction, the higher the value, the more clock cycles were required per instruction. We see that the execution stall rate was higher for product_3.

For comparison, this is the symmetric product_2:

┌────────────────────────────────────────┬─────────────┐
│                                 Metric │    Thread 1 │
│                                 StringFloat64 │
├────────────────────────────────────────┼─────────────┤
│                    Runtime (RDTSC) [s] │     2.10946 │
│                   Runtime unhalted [s] │     1.95101 │
│                                    CPI │     0.41146 │
│                           DP [MFLOP/s] │     30664.5 │
│                    AVX512 DP [MFLOP/s] │     30663.7 │
│                       Packed [MUOPS/s] │     3833.36 │
│                       Scalar [MUOPS/s] │         0.0 │
│                    Vectorization ratio │       100.0 │
│                 Total execution stalls │   7.93344e8 │
│        Stalls caused by L1D misses [%] │     73.9433 │
│         Stalls caused by L2 misses [%] │     13.8556 │
│               Execution stall rate [%] │     13.5926 │
│   Stalls caused by L1D misses rate [%] │     10.0508 │
│    Stalls caused by L2 misses rate [%] │     1.88334 │
│ Stalls caused by memory loads rate [%] │     14.0704 │
│     L2 to L1 load bandwidth [MBytes/s] │     74542.3 │
│     L2 to L1 load data volume [GBytes] │     156.499 │
│    L1 to L2 evict bandwidth [MBytes/s] │     1823.74 │
│    L1 to L2 evict data volume [GBytes] │     3.82889 │
│     L1 to/from L2 bandwidth [MBytes/s] │     76366.0 │
│     L1 to/from L2 data volume [GBytes] │     160.328 │
│              Branch misprediction rate │ 0.000151162 │
│                Instructions per branch │     19.2526 │
└────────────────────────────────────────┴─────────────┘

Note the vectorization ratio of 100.0; only slightly more 512b instructions executed, and zero scalar. The CPI was also low. product_2 hit over 30 GFLOPS (30664.5 MFLOPS/s). We see a much better use of execution resources (i.e. vectorization), and far fewer branches. The execution stall rate and CPI were lower.

While LoopModels will be able to handle symmetry and arbitrary affine loop nests, LoopVectorization.jl is unfortunately limited to only rectangular loop nests, thus it cannot handle the symmetric case. I also plan on adding cache tiling to LoopModels, but this is also something LoopVectorization.jl does not support, so performance likely will not scale to larger sizes, nor do we consider things like alignment. But a 200x200 matrix fits neatly in cache, and has column sizes that are an integer multiple of 64 bytes, so none of this matters here. As a result, we had

function dotturbo(x, A)
    s = zero(promote_type(eltype(x), eltype(A)))
    @turbo for n ∈ axes(A, 2)
        t = zero(s)
        for m ∈ axes(A, 1)
            t += x[m] * A[m, n]
        end
        s += t * x[n]
    end
    s
end

Note that we manually implemented an optimization also used in LinearAlgebra.dot: we hoisted the multiplication by x[n] out of the loop. It's also my intention to automate this, but LoopVectorization.jl's transforms are fairly limited, and my performance development time has of course moved elsewhere.

I get

julia> @btime dotturbo($x, $A)
  1.875 μs (0 allocations: 0 bytes)
508214.49429146945

At least we're now winning the dense case. Thanks to the occasional GC activity from memory allocations, we're not too far behind the Symmetric case in average performance:

julia> @benchmark dotturbo($x, $A)
BenchmarkTools.Trial: 10000 samples with 10 evaluations.
 Range (min … max):  1.869 μs …  6.652 μs  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     1.887 μs              ┊ GC (median):    0.00%
 Time  (mean ± σ):   1.892 μs ± 56.790 ns  ┊ GC (mean ± σ):  0.00% ± 0.00%

      ▄█▇▅▃
  ▂▂▃▆██████▅▃▃▂▂▂▂▂▂▂▂▂▂▂▂▁▂▁▂▂▁▂▂▁▁▁▁▂▁▁▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂ ▃
  1.87 μs        Histogram: frequency by time        2.03 μs <

 Memory estimate: 0 bytes, allocs estimate: 0.

julia> @benchmark product_2($x, $B)
BenchmarkTools.Trial: 10000 samples with 10 evaluations.
 Range (min … max):  1.733 μs … 107.171 μs  ┊ GC (min … max): 0.00% … 93.21%
 Time  (median):     1.768 μs               ┊ GC (median):    0.00%
 Time  (mean ± σ):   1.843 μs ±   1.972 μs  ┊ GC (mean ± σ):  2.04% ±  1.88%

    ▁█▇▁
  ▁▃████▅▃▃▂▂▂▂▂▂▃▃▃▃▃▃▂▂▂▂▂▂▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ ▂
  1.73 μs         Histogram: frequency by time        2.12 μs <

 Memory estimate: 1.77 KiB, allocs estimate: 1.

1.892 vs 1.843 microseconds.

The LIKWID results of dotturbo:

┌────────────────────────────────────────┬────────────┐
│                                 Metric │   Thread 1 │
│                                 StringFloat64 │
├────────────────────────────────────────┼────────────┤
│                    Runtime (RDTSC) [s] │    2.03067 │
│                   Runtime unhalted [s] │    1.92501 │
│                                    CPI │   0.620848 │
│                           DP [MFLOP/s] │    29055.0 │
│                    AVX512 DP [MFLOP/s] │    28545.1 │
│                       Packed [MUOPS/s] │    3769.91 │
│                       Scalar [MUOPS/s] │   0.844273 │
│                    Vectorization ratio │    99.9776 │
│                 Total execution stalls │  9.37384e8 │
│        Stalls caused by L1D misses [%] │    116.368 │
│         Stalls caused by L2 misses [%] │    1.49769 │
│               Execution stall rate [%] │    16.2874 │
│   Stalls caused by L1D misses rate [%] │    18.9533 │
│    Stalls caused by L2 misses rate [%] │   0.243935 │
│ Stalls caused by memory loads rate [%] │    19.4328 │
│     L2 to L1 load bandwidth [MBytes/s] │  1.37391e5 │
│     L2 to L1 load data volume [GBytes] │    277.845 │
│    L1 to L2 evict bandwidth [MBytes/s] │    406.024 │
│    L1 to L2 evict data volume [GBytes] │     0.8211 │
│     L1 to/from L2 bandwidth [MBytes/s] │  1.37797e5 │
│     L1 to/from L2 data volume [GBytes] │    278.666 │
│              Branch misprediction rate │ 9.64224e-5 │
│                Instructions per branch │    12.6511 │
└────────────────────────────────────────┴────────────┘

Timing reported here is again similar, but CPI is much higher; seems despite being dense we nonetheless needed fewer instructions, which isn't surprising as LoopVectorization.jl's objective function tries to minimize MUOP count. Being dense, the bandwidth needs were higher.

Attaining comparable performance here smells like blood in the water - lets take advantage of symmetry to win this benchmark.

Lets start with product_3, and apply one transform at a time to discuss it's problems. First, we can hoist a lot of arithmetic.

function product_4(x, _M)
    n = length(x)
    result_diag = zero(eltype(x))
    result_supd = zero(eltype(x))
    M = _M isa Symmetric ? parent(_M) : _M
    Base.require_one_based_indexing(x)
    Base.require_one_based_indexing(M)
    @inbounds for i = 1:n
        result_diag += x[i]^2 * M[i, i]
        result_innr = zero(eltype(x))
        @simd for j = 1 : i - 1
            result_innr += x[j] * M[j, i]
        end
        result_supd += x[i]*result_innr
    end
    return 2result_supd + result_diag
end

yielding

julia> @btime product_4($x, $B)
  3.977 μs (0 allocations: 0 bytes)
508214.49429146934

Better, now we're more in line with product_1. If our math is slow, maybe we just need to ask the compiler to make it fast?

@fastmath function product_5(x, _M) # only change is `@fastmath`
    n = length(x)
    result_diag = zero(eltype(x))
    result_supd = zero(eltype(x))
    M = _M isa Symmetric ? parent(_M) : _M
    Base.require_one_based_indexing(x)
    Base.require_one_based_indexing(M)
    @inbounds for i = 1:n
        result_diag += x[i]^2 * M[i, i]
        result_innr = zero(eltype(x))
        @simd for j = 1 : i - 1
            result_innr += x[j] * M[j, i]
        end
        result_supd += x[i]*result_innr
    end
    return 2result_supd + result_diag
end

so we're now beating product_1 with dense arrays:

julia> @btime product_5($x, $B)
  3.503 μs (0 allocations: 0 bytes)
508214.49429146934

But we're still a far cry from @turbo. Unfortunately, because LoopVectorization.jl only supports rectangular loops, we're limited to applying it to the inner-most loop, hamstringing it:

@fastmath function product_6(x, _M) # only change is `@fastmath`
    n = length(x)
    n == 0 && return zero(eltype(x))
    M = _M isa Symmetric ? parent(_M) : _M
    Base.require_one_based_indexing(x)
    Base.require_one_based_indexing(M)
    result_diag = x[1]^2 * M[1, 1]
    result_supd = zero(eltype(x))
    # `@turbo` doesn't support length-0 iteration
    # so we need to make sure length(1:i-1) > 0
    @inbounds for i = 2:n 
        result_diag += x[i]^2 * M[i, i]
        result_innr = zero(eltype(x))
        @turbo for j = 1 : i - 1
            result_innr += x[j] * M[j, i]
        end
        result_supd += x[i]*result_innr
    end
    return 2result_supd + result_diag
end

yet

julia> @btime product_6($x, $B)
  1.795 μs (0 allocations: 0 bytes)
508214.4942914694

julia> @benchmark product_6($x, $B)
BenchmarkTools.Trial: 10000 samples with 10 evaluations.
 Range (min … max):  1.663 μs …  5.431 μs  ┊ GC (min … max): 0.00% … 0.00%
 Time  (median):     1.680 μs              ┊ GC (median):    0.00%
 Time  (mean ± σ):   1.717 μs ± 73.691 ns  ┊ GC (mean ± σ):  0.00% ± 0.00%

   ▂▇█▅▁                               ▁
  ▃█████▅▅▃▂▂▂▂▂▂▁▁▁▂▁▁▂▁▁▂▁▂▂▂▁▁▁▁▂▂▂▄█▇▆▅▃▃▂▂▂▂▂▂▂▂▂▂▂▂▂▂▂ ▃
  1.66 μs        Histogram: frequency by time        1.88 μs <

 Memory estimate: 0 bytes, allocs estimate: 0.

I'm not sure why the minimum time from @btime was so much higher than the mean time from @benchmark.

But we're now at least in clear contention with MKL's symv!+dot. So, if we could only apply @turbo to the inner loop, why did it help so much? LLVM is really bad at producing code for architectures with big vectors, and AVX512 in particular. On a Zen3 CPU with AVX2, I got

julia> @btime product_5($x, $B)
  2.537 μs (0 allocations: 0 bytes)
502848.6107780409

julia> @btime product_6($x, $B)
  2.063 μs (0 allocations: 0 bytes)
502848.61077804095

while on an Apple M1, I got

julia> @btime product_5($x, $B)
  3.604 μs (0 allocations: 0 bytes)
580770.6876433745

julia> @btime product_6($x, $B)
  3.521 μs (0 allocations: 0 bytes)
580770.6876433745

so the performance advantage decreases as the vector width decreases. This is mostly because LLVM doesn't handle remainders efficiently. The larger the vectors, the larger the remainders on average, the worse LLVM's performance.

So, what's left, what's missing? What would @turbo on an outerloop do differently? For one thing, reducing vectors to scalars is expensive, so @turbo will only do so once at the end, while our code is doing so on every iteration of the outer loop. How can we avoid doing so?

We can tell @turbo we want to accumulate into a SIMD vector instead of a scalar by defining our accumulator to be a VectorizationBase.Vec instead of a scalar. Then, we just have to accumulate the vector into a scalar ourselves before returning the value:

using VectorizationBase
@fastmath function product_7(x, _M) # only change is `@fastmath`
    n = length(x)
    T = eltype(x)
    n == 0 && return zero(T)
    M = _M isa Symmetric ? parent(_M) : _M
    Base.require_one_based_indexing(x)
    Base.require_one_based_indexing(M)
    result_diag = Vec(zero(T))
    @turbo for i = 1:n
        result_diag += x[i]^2 * M[i, i]
    end
    result_supd = Vec(zero(T))
    # `@turbo` doesn't support length-0 iteration
    # so we need to make sure length(1:i-1) > 0
    @inbounds for i = 2:n 
        result_innr = Vec(zero(T))
        @turbo for j = 1 : i - 1
            result_innr += x[j] * M[j, i]
        end
        result_supd += x[i]*result_innr
    end
    return VectorizationBase.vsum(2result_supd + result_diag)
end

Now I get:

julia> @btime product_7($x, $B)
  1.354 μs (0 allocations: 0 bytes)
508214.4942914695

Well ahead of any other implementation. The CPI of LIKWID results look quite good, especially CPI:

┌────────────────────────────────────────┬─────────────┐
│                                 Metric │    Thread 1 │
│                                 StringFloat64 │
├────────────────────────────────────────┼─────────────┤
│                    Runtime (RDTSC) [s] │     1.59119 │
│                   Runtime unhalted [s] │     1.50887 │
│                                    CPI │    0.321119 │
│                           DP [MFLOP/s] │     22122.9 │
│                    AVX512 DP [MFLOP/s] │     22121.3 │
│                       Packed [MUOPS/s] │      2765.7 │
│                       Scalar [MUOPS/s] │    0.538865 │
│                    Vectorization ratio │     99.9805 │
│                 Total execution stalls │   4.70885e8 │
│        Stalls caused by L1D misses [%] │     98.6231 │
│         Stalls caused by L2 misses [%] │     3.33533 │
│               Execution stall rate [%] │     10.3786 │
│   Stalls caused by L1D misses rate [%] │     10.2357 │
│    Stalls caused by L2 misses rate [%] │     0.34616 │
│ Stalls caused by memory loads rate [%] │     11.8953 │
│     L2 to L1 load bandwidth [MBytes/s] │     99520.2 │
│     L2 to L1 load data volume [GBytes] │     158.482 │
│    L1 to L2 evict bandwidth [MBytes/s] │     512.454 │
│    L1 to L2 evict data volume [GBytes] │    0.816064 │
│     L1 to/from L2 bandwidth [MBytes/s] │   1.00033e5 │
│     L1 to/from L2 data volume [GBytes] │     159.298 │
│              Branch misprediction rate │ 0.000174263 │
│                Instructions per branch │     9.08776 │
└────────────────────────────────────────┴─────────────┘

With a CPI of <13<\frac{1}{3}, we averaged over 3 instructions per clock cycle! Our vectorization ratio was also excellent, of course.

Now that we have the fastest time, are we done?

No, as hinted above there's another important optimization @turbo applies to the dense case. @turbo cuts down on the x[j] reloads in the inner j loop by unrolling the outer i loop. That is, if we unroll M[j,i] by 8, we can reuse an x[j] load 8 times before discarding it and moving on to the next inner loop iteration. In this way, we need 18\frac{1}{8} as many loads from xx in the inner loop.

function product_8(x, _M)
    N = length(x)
    T = eltype(x)
    N == 0 && return zero(T)
    M = _M isa Symmetric ? parent(_M) : _M
    Base.require_one_based_indexing(x)
    Base.require_one_based_indexing(M)
    # we unroll the outer loop by 8
    remainder = N & 7
    n = remainder
    if n != N # guard
        # 8 accumulators (for the outer loop)
        Base.Cartesian.@nexprs 4 i -> acc_i = Vec(zero(T))
        while true
            Base.Cartesian.@nexprs 8 i -> a_i = Vec(zero(T))
            # iterations i = n+1:n+8
            if n != 0
                # we'll loop normally up through n
                @turbo for j = 1:n
                    Base.Cartesian.@nexprs 8 i -> begin
                        a_i += x[j] * M[j,i+n]
                    end
                end
            end
            # 8x8 diagonal block
            @turbo for j = 1:8
                Base.Cartesian.@nexprs 8 i -> begin
                    mask_i = (j<i) + 0.5*(j==i)
                    a_i += x[j+n] * (M[j+n,i+n] * mask_i)
                end
            end
            Base.Cartesian.@nexprs 4 i -> acc_i += x[i+n]*a_i
            Base.Cartesian.@nexprs 4 i -> acc_i += x[4+i+n]*a_{4+i}
            n += 8
            n == N && break
        end # while true
        acc_1 += acc_3
        acc_2 += acc_4
        acc_1 += acc_2
        ret = 2*VectorizationBase.vsum(acc_1)
        remainder == 0 && return ret
    else
        ret = zero(T)
    end
    # we know remainder != 0, because N == 0 returned early
    r = Base.oneto(remainder)
    # product_5 seemed fast for small inputs
    return ret + @views product_5(x[r], M[r,r])
end

Now performance is starting to look pretty good:

julia> @btime product_8($x, $B)
  987.308 ns (0 allocations: 0 bytes)
508214.49429146945

We're now under 1 microsecond, leaving all the competition well behind, so I think I'll call it quits for today.

LIKWID results:

┌────────────────────────────────────────┬─────────────┐
│                                 Metric │    Thread 1 │
│                                 StringFloat64 │
├────────────────────────────────────────┼─────────────┤
│                    Runtime (RDTSC) [s] │     1.16956 │
│                   Runtime unhalted [s] │     1.10838 │
│                                    CPI │    0.437218 │
│                           DP [MFLOP/s] │     28353.2 │
│                    AVX512 DP [MFLOP/s] │     28350.3 │
│                       Packed [MUOPS/s] │     3544.51 │
│                       Scalar [MUOPS/s] │     1.46549 │
│                    Vectorization ratio │     99.9587 │
│                 Total execution stalls │    3.9451e8 │
│        Stalls caused by L1D misses [%] │     111.613 │
│         Stalls caused by L2 misses [%] │     6.21932 │
│               Execution stall rate [%] │     11.7638 │
│   Stalls caused by L1D misses rate [%] │     13.1299 │
│    Stalls caused by L2 misses rate [%] │    0.731625 │
│ Stalls caused by memory loads rate [%] │     13.9601 │
│     L2 to L1 load bandwidth [MBytes/s] │    125761.0 │
│     L2 to L1 load data volume [GBytes] │     147.678 │
│    L1 to L2 evict bandwidth [MBytes/s] │     633.472 │
│    L1 to L2 evict data volume [GBytes] │    0.743871 │
│     L1 to/from L2 bandwidth [MBytes/s] │   1.26395e5 │
│     L1 to/from L2 data volume [GBytes] │     148.422 │
│              Branch misprediction rate │ 0.000247131 │
│                Instructions per branch │     11.7534 │
└────────────────────────────────────────┴─────────────┘

While we lost our impressive CPI (although still over 2 IPC), this implementation required little more than half as many CPU instructions as any of the other implementations. Without unrolling, each inner loop iteration required a load from x, a load from A, and an fma instruction. On a CPU such as this one, capable of 2 aligned loads and 2 fma (fused multiply add) instructions per clock cycle, removing most of the x[j] reloads from the inner loop thus lifts a bottleneck from the inner most loop. Because that bottleneck existed in the form of a huge number of unnecessary instructions, we don't see removing these instructions show up in improved CPI, but simply in a far lower instruction count. The instruction counts per call were about 76057605, 1409714097, and 1422514225 for product_8, product_7, and product_2, respectively.

I used small arrays that fit into this CPU's large 1MiB L2 cache (which could fit a 360x360 matrix and vector), so that we weren't limited on memory bandwidth and could focus on interesting optimizations. As the arrays grow larger, it turns into a problem of streaming the matrix through memory, where there isn't really anything that can be done, aside from multithreading (to first allow splitting up the problem among many core's L2 caches, and then ultimately to take advantage of the higher memory bandwidth multiple cores have access to vs a single core).

Another important note is that this implementation optimistically assumes that stride(A,2)%8==0.

If we want to try and address alignment, we could consider taking product_7, and then initially pealing off enough initial iterations of the inner loop to align it. Not crossing 64 byte boundaries with loads from A will make them cheaper, but at the cost of reloading x more often. Given aligned loads and a vector width of 8, we could estimate the cost of this as roughly (N8)(N8)+N(N+1)28+N=N264+(N(N+1))16+N\left(\frac{N}{8}\right)\left(\frac{N}{8}\right) + \frac{N(N+1)}{2\cdot 8} + N = \frac{N^2}{64} + \frac{(N(N+1))}{16} + N. The (N8)(N8)\left(\frac{N}{8}\right)\left(\frac{N}{8}\right) reflects the N8\frac{N}{8} outer loop iterations times the NW=N8\frac{N}{W}=\frac{N}{8} loads per inner loop iteration. The N(N+1)2\frac{N(N+1)}{2} are the number of elements we're loading from A, then the 18\frac{1}{8} is again dividing by WW. The final +N+ N are the loads x[i], insignificant thanks to the smaller exponent.

The base pointer of a large array, e.g. A, will normally be 64 byte aligned.

julia> Int(pointer(A))%64
0

Assuming this, the worst case scenario for alignment would be that isodd(stride(A,2)%8). Then, with AVX512, only 1 out of 8 columns will be aligned.

With that, the cost of loading from A becomes

(N(N+1)16)(278)+(N(N+1)16)(18)=15N(N+1)128\begin{aligned} \left(\frac{N(N+1)}{16}\right) \left(\frac{2\cdot 7}{8}\right) + \left(\frac{N(N+1)}{16}\right) \left(\frac{1}{8}\right) = \frac{15N(N+1)}{128} \end{aligned}

That is, loading from AA becomes 158\frac{15}{8} times more expensive. We have an increase in cost of 7N(N+1)128\frac{7N(N+1)}{128}.

If we try to address this by pealing loop iterations at the start of each column to align the loads from AA, this causes one of two complications. Either

  1. We can no longer unroll the outer loop, increasing the cost of reloading xx in the inner loop from N264\frac{N^2}{64} to N28\frac{N^2}{8}, an increase in cost of 16N21282N2128=14128\frac{16N^2}{128} - \frac{2N^2}{128} = \frac{14}{128}, meaning this is potentially a smaller increase in cost than what the unrolled product_8 suffers.

  2. Still unroll, and use shuffle/permute instructions to try and line up the loads of x correctly with the loads from A that may now all be offset to align them. This approach would require generating different specializations for the different offset patterns, where you'd have fixed shuffle sequences in each one. As we're dominated by loads, the shuffles may be "free"/able to execute concurrently, making this likely the fastest, but most complicated, approach. This is especially the case for Zen4, which has fairly fast shuffles but slow loads, or for consumer AVX512 Intel CPUs, which can use port 5 for shuffle instructions but not for floating point arithmetic, again making shuffles more or less "free" to execute concurrently with loads and floating point.

P.S. A goal of LoopModels will be that @turbo will generate code as fast or faster than the above for code as simple as

@turbo function dotturbo(x, A)
    s = zero(Base.promote_eltype(x, A))
    @assert eachindex(x) == axes(A,1) == axes(A,2)
    for i = eachindex(x), j = eachindex(x)
        s += x[i] * A[i,j] * x[j]
    end
    s
end

when A isa Symmetric, and again of course doing the right thing when A isa Adjoint, or simple A isa Matrix. Our code should express our intent, simple and generic. The compiler should find out how to do the right thing for the types.