# In-place mul! much slower than \* for real-complex matrix multiplication

**URL:** <https://discourse.julialang.org/t/in-place-mul-much-slower-than-for-real-complex-matrix-multiplication/134938>\
**Category:** Performance\
**Created:** [January 7, 2026, 2:58pm UTC](https://discourse.julialang.org/t/in-place-mul-much-slower-than-for-real-complex-matrix-multiplication/134938 "2026-01-07T14:58:18Z")\
**Posts on this page:** 10\
**Page:** 1

<div class="post-metadata">

**Author:** ![chrisbrahms](https://avatars.discourse-cdn.com/v4/letter/c/c0e974/32.png) [@chrisbrahms](https://discourse.julialang.org/u/chrisbrahms)\
**Post date:** [January 7, 2026, 2:58pm UTC](https://discourse.julialang.org/t/in-place-mul-much-slower-than-for-real-complex-matrix-multiplication/134938/1 "2026-01-07T14:58:18Z")

</div>

In some use cases of [Luna.jl](https://github.com/LupoLab/Luna.jl), the runtime is dominated by large matrix-matrix multiplications inside [Hankel.jl](https://github.com/chrisbrahms/Hankel.jl). I see a dramatic performance drop when using complex-valued matrices, which I’ve traced to the `mul!` operation. Somehow this is _much_ slower than even the (massively allocating) equivalent `*`:

```julia
using BenchmarkTools
import LinearAlgebra: mul!

N1 = 256
N2 = 1024

A = rand(Float64, N1, N1)
B = rand(ComplexF64, N1, N2)
out = similar(B)

@btime A*B;
@btime mul!(out, A, B);

```

Results on my M1 Pro MacBook Pro are

```julia
998.542 μs (12 allocations: 10.00 MiB)
  46.952 ms (0 allocations: 0 bytes)

```

Looking at the profiling, it seems that `mul!` doesn’t call BLAS and instead reverts to `_generic_matmatmul_nonadjtrans!`.

If I make `A` complex, the difference disappears completely:

```julia
A_complex = rand(ComplexF64, N1, N1)
@btime A_complex*B;
@btime mul!(out, A_complex, B);

```

```julia
  954.250 μs (3 allocations: 4.00 MiB)
  944.333 μs (0 allocations: 0 bytes)

```

My understanding is that `*` internally calls `mul!` after allocating an appropriate output array. Where could this difference come from? Am I missing something obvious?

version info below:

```julia
julia> versioninfo()
Julia Version 1.12.3
Commit 966d0af0fdf (2025-12-15 11:20 UTC)
Build Info:
  Official https://julialang.org release
Platform Info:
  OS: macOS (arm64-apple-darwin24.0.0)
  CPU: 10 × Apple M1 Pro
  WORD_SIZE: 64
  LLVM: libLLVM-18.1.7 (ORCJIT, apple-m1)
  GC: Built with stock GC
Threads: 1 default, 0 interactive, 1 GC (on 8 virtual cores)
Environment:
  JULIA_EDITOR = code
  JULIA_VSCODE_REPL = 1
  JULIA_NUM_THREADS = 1

```

---

<div class="post-metadata">

**Author:** ![mstewart](https://avatars.discourse-cdn.com/v4/letter/m/b5a626/32.png) [@mstewart](https://discourse.julialang.org/u/mstewart)\
**Post date:** [January 7, 2026, 4:23pm UTC](https://discourse.julialang.org/t/in-place-mul-much-slower-than-for-real-complex-matrix-multiplication/134938/2 "2026-01-07T16:23:04Z")

</div>

> [@chrisbrahms](#):
>
> Looking at the profiling, it seems that `mul!` doesn’t call BLAS and instead reverts to `_generic_matmatmul_nonadjtrans!`.

I can’t swear that no one has ever added it to a BLAS library, but conventionally there are no BLAS routines for multiplying matrices of mixed numeric types. So the options for `mul!` are to copy `A` into a `Matrix{ComplexF64}` and then call BLAS or to fall back to some generic routine. Presumably the expectation is that `mul!` will not allocate a matrix, so it does the latter.

---

<div class="post-metadata">

**Author:** ![mcabbott](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/mcabbott/32/6603_2.png) [@mcabbott](https://discourse.julialang.org/u/mcabbott)\
**Post date:** [January 7, 2026, 4:56pm UTC](https://discourse.julialang.org/t/in-place-mul-much-slower-than-for-real-complex-matrix-multiplication/134938/3 "2026-01-07T16:56:23Z")

</div>

Some real \* complex operations can be done by BLAS by calling `reinterpret`. I think Julia has a (possibly imperfect) set of fast paths for such cases. The fast case here does something else, simply two real operations (at least on Julia 1.11):

```julia-auto
julia> @btime A*B;
  1.397 ms (12 allocations: 10.00 MiB)

julia> @btime mul!(out, A, B);
  47.326 ms (0 allocations: 0 bytes)

julia> @which A*B
*(A::StridedMatrix{var"#s4937"} where var"#s4937"<:Union{Float32, Float64}, B::StridedMatrix{var"#s4936"} where var"#s4936"<:Union{ComplexF64, ComplexF32})
     @ LinearAlgebra /Applications/Julia-1.11.app/Contents/Resources/julia/share/julia/stdlib/v1.11/LinearAlgebra/src/matmul.jl:150

julia> @less A*B

```

```julia-auto
function (*)(A::StridedMatrix{<:BlasReal}, B::StridedMatrix{<:BlasComplex})
    temp = real(B)
    R = A * temp
    temp .= imag.(B)
    I = A * temp
    Complex.(R, I)
end

```

Edit: if we reverse the matrices, then we hit the reinterpret case:

```julia-auto
julia> N1 = 512; N2 = 512; # now square matrices

julia> A = rand(Float64, N1, N1);

julia> B = rand(ComplexF64, N1, N2);

julia> out = similar(B);

julia> @btime mul!(out, B, A); # reinterpret(B, Float64, ...)
  1.554 ms (0 allocations: 0 bytes)

julia> @btime mul!(out, A, B); # slow case, as above
  93.720 ms (0 allocations: 0 bytes)

julia> @btime A * B; # fast case above
  2.419 ms (12 allocations: 10.00 MiB)

julia> @btime B * A; # also reinterpret
  1.578 ms (3 allocations: 4.00 MiB)

```

---

<div class="post-metadata">

**Author:** ![foobar\_lv2](https://avatars.discourse-cdn.com/v4/letter/f/ee59a6/32.png) [@foobar\_lv2](https://discourse.julialang.org/u/foobar_lv2)\
**Post date:** [January 7, 2026, 4:57pm UTC](https://discourse.julialang.org/t/in-place-mul-much-slower-than-for-real-complex-matrix-multiplication/134938/4 "2026-01-07T16:57:27Z")

</div>

> [@mstewart](#):
>
> Presumably the expectation is that `mul!` will not allocate a matrix, so it does the latter.

But then, the appropriate fallback should be to allocate, use and then free a temporary matrix, once the size is larger than some threshold (but definitely not wait with freeing the backing memory until the garbage collector reclaims it).

---

<div class="post-metadata">

**Author:** ![mstewart](https://avatars.discourse-cdn.com/v4/letter/m/b5a626/32.png) [@mstewart](https://discourse.julialang.org/u/mstewart)\
**Post date:** [January 7, 2026, 6:22pm UTC](https://discourse.julialang.org/t/in-place-mul-much-slower-than-for-real-complex-matrix-multiplication/134938/5 "2026-01-07T18:22:05Z")

</div>

Nice. I have seen this trick before with direct calls to BLAS, but I did not know that `mul!(out, B, A)` did this and have never thought to check whether Julia did it. However, it doesn’t seem to work on Julia 1.12.3. It resolves to the same slow generic multiplication. In Julia 1.11.8 it does work for me, eventually resolving to a call

```julia-auto
BLAS.gemm!(tA, tB, alpha, reinterpret(T, A), B, beta, reinterpret(T, C))

```

where in the case `T` is `Float64` and `A` is the complex matrix. However, I don’t think this works with the order of `A` and `B` reversed, which might explain the difference between `mul!(out, B, A)` and `mul!(out, A, B)`. BLAS assumes that `stride(A,1) == 1` (and likewise for all matrices). `reinterpret` gives real and complex parts in submatrices that have stride 2 in the first index.

I’m not quite sure where this breaks in Julia 1.12.3…

---

<div class="post-metadata">

**Author:** ![chrisbrahms](https://avatars.discourse-cdn.com/v4/letter/c/c0e974/32.png) [@chrisbrahms](https://discourse.julialang.org/u/chrisbrahms)\
**Post date:** [January 7, 2026, 8:29pm UTC](https://discourse.julialang.org/t/in-place-mul-much-slower-than-for-real-complex-matrix-multiplication/134938/6 "2026-01-07T20:29:58Z")

</div>

Thanks @mstewart and @mcabbott, this is exactly my problem! I had missed the specialised method splitting it into real and imaginary (which will hopefully teach me to use `@which` next time, rather than trying to work out the method calls myself).

Since my matrices are not square, I’ve resorted to converting `A` to be complex whenever `B` is complex. Thankfully `A` is constant, so this doesn’t add significant runtime.

---

<div class="post-metadata">

**Author:** ![mstewart](https://avatars.discourse-cdn.com/v4/letter/m/b5a626/32.png) [@mstewart](https://discourse.julialang.org/u/mstewart)\
**Post date:** [January 7, 2026, 8:32pm UTC](https://discourse.julialang.org/t/in-place-mul-much-slower-than-for-real-complex-matrix-multiplication/134938/7 "2026-01-07T20:32:23Z")

</div>

> [@foobar\_lv2](#):
>
> But then, the appropriate fallback should be to allocate, use and then free a temporary matrix, once the size is larger than some threshold (but definitely not wait with freeing the backing memory until the garbage collector reclaims it).

Perhaps. It would be faster. But I do sometimes use `mul!` when I have several large dense matrices and my memory can’t accommodate more. The docstring for `mul!` doesn’t seem to promise anything regarding allocations, but it’s very GEMM-like in what it does, so doing a big allocation is something that would at least surprise me.

---

<div class="post-metadata">

**Author:** ![mcabbott](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/mcabbott/32/6603_2.png) [@mcabbott](https://discourse.julialang.org/u/mcabbott)\
**Post date:** [January 7, 2026, 9:09pm UTC](https://discourse.julialang.org/t/in-place-mul-much-slower-than-for-real-complex-matrix-multiplication/134938/8 "2026-01-07T21:09:59Z")

</div>

> [@mstewart](#):
>
> I’m not quite sure where this breaks in Julia 1.12.3…

There was an issue… [Poor performance for product Matrix{ComplexF64} with Matrix{Float64} · Issue #1519 · JuliaLang/LinearAlgebra.jl · GitHub](https://github.com/JuliaLang/LinearAlgebra.jl/issues/1519) fixed in 1.12.4 although that’s not released yet.

---

<div class="post-metadata">

**Author:** ![mstewart](https://avatars.discourse-cdn.com/v4/letter/m/b5a626/32.png) [@mstewart](https://discourse.julialang.org/u/mstewart)\
**Post date:** [January 7, 2026, 9:20pm UTC](https://discourse.julialang.org/t/in-place-mul-much-slower-than-for-real-complex-matrix-multiplication/134938/9 "2026-01-07T21:20:33Z")

</div>

Thanks. I need to get my eyes checked. I looked and didn’t see that. I was getting ready to file an issue.

---

<div class="post-metadata">

**Author:** ![photor](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/photor/32/14343_2.png) [@photor](https://discourse.julialang.org/u/photor)\
**Post date:** [January 8, 2026, 6:23am UTC](https://discourse.julialang.org/t/in-place-mul-much-slower-than-for-real-complex-matrix-multiplication/134938/10 "2026-01-08T06:23:12Z")

</div>

This issue has been discussed in this topic:

> [@In-place multiplication is too much slower for ComplexF64](https://discourse.julialang.org/t/in-place-multiplication-is-too-much-slower-for-complexf64/75887):
>
> The ordinary version: using BenchmarkTools const p = 60 const dd=randn(p,p) const ψ=randn(p,p\*p)+im\*randn(p,p\*p) function d2x(ψ::Array{ComplexF64,2})::Array{ComplexF64,2} ψr=dd\*ψ return ψr end @btime d2x(ψ); The result is 1.330 ms (4 allocations: 3.35 MiB) The in-place version: function d2x(ψ::Array{ComplexF64,2})::Array{ComplexF64,2} ψr = similar(ψ) mul!(ψr,dd,ψ) return ψr end @btime d2x(ψ); The result is 9.511 ms (8 allocations: 3.30 MiB) Is it a bug or somethin…
