This function seems to allocate memory only if the argument types are not annotated in the definition

I am defining a custom data type that behaves like a vector (in the linear algebra sense). I defined it as a structure with a 6-element StaticVector that holds the underlying data:

using StaticArrays

struct MyVectorType
    data::SVector{6, Float64}
end

One operation that I need is the cross product between instances of MyVectorType, which is defined by a particular combination of cross products between 3-component sub-vectors of the underlying data:

import LinearAlgebra: ×

function cross_product1(a::MyVectorType, b::MyVectorType)
    #split the vectors in two 3-component parts:
    a1 = a.data[SVector(1,2,3)]
    a2 = a.data[SVector(4,5,6)]
    b1 = b.data[SVector(1,2,3)]
    b2 = b.data[SVector(4,5,6)]

    #compute two combinations of cross products:
    part1 = a1 × b1
    part2 = a1 × b2 + a2 × b1

    #concatenate the two parts to form a new 6-component vector:
    return MyVectorType( (part1..., part2...) )
end

This function has been tested to make sure that it doesn’t allocate any memory:

#two random vectors:
a = MyVectorType(rand(6))
b = MyVectorType(rand(6))

using BenchmarkTools
@btime cross_product1(a,b);
5.357 ns (0 allocations: 0 bytes)

However, I have noted something very strange: if I remove the type annotations from the arguments in the function definition, the funtion now allocates memory!!

function cross_product2(a, b)
    #split the vectors in two 3-component parts:
    a1 = a.data[SVector(1,2,3)]
    a2 = a.data[SVector(4,5,6)]
    b1 = b.data[SVector(1,2,3)]
    b2 = b.data[SVector(4,5,6)]

    #compute two combinations of cross products:
    part1 = a1 × b1
    part2 = a1 × b2 + a2 × b1

    #concatenate the two parts to form a new 6-component vector:
    return MyVectorType( (part1..., part2...) )
end

@btime cross_product2(a,b);
20.113 ns (1 allocation: 64 bytes)

How can this possibly be?
The definitions of cross_product1 and cross_product2 are exactly the same, I just copy-pasted and removed the type annotations. And the function calls are also exactly the same, so the type annotations should not make a difference.

The amount of memory allocated is the same across many runs, so its not a compilation thing.

Can anyone explain this?

2 Likes

This is absolutely bizarre. I can reproduce it.

1 Like

julia> let a=MyVectorType(rand(6)), b = MyVectorType(rand(6))
         (@allocations cross_product1(a,b)), (@allocations cross_product2(a,b))
       end
(0, 0)

julia> versioninfo()
Julia Version 1.10.0
Commit 3120989f39b (2023-12-25 18:01 UTC)

Build Info:
  Official https://julialang.org/ release
Platform Info:
  OS: Linux (x86_64-linux-gnu)
  CPU: 24 × AMD Ryzen 9 3900XT 12-Core Processor
  WORD_SIZE: 64
  LIBM: libopenlibm
  LLVM: libLLVM-15.0.7 (ORCJIT, znver2)
  Threads: 17 on 24 virtual cores
1 Like

See the same (reproduce allocations) on
julia> versioninfo()
Julia Version 1.10.0
Commit 3120989f39b (2023-12-25 18:01 UTC)
Build Info:
Official https://julialang.org/ release
Platform Info:
OS: macOS (arm64-apple-darwin22.4.0)
CPU: 24 × Apple M2 Ultra
WORD_SIZE: 64
LIBM: libopenlibm
LLVM: libLLVM-15.0.7 (ORCJIT, apple-m1)
Threads: 1 on 16 virtual cores

Allocation disappear when adding $s:

julia> @btime cross_product2(a,b);
  14.953 ns (1 allocation: 64 bytes)

julia> @btime cross_product2($a,$b);
  2.376 ns (0 allocations: 0 bytes)

Julia 1.10
OS: Linux (x86_64-linux-gnu)
CPU: 16 × 12th Gen Intel(R) Core™ i5-1240P

5 Likes

Allocation disappear when adding $s

This is good. So maybe the allocation is not from the function itself but from julia figuring out the types of the global variables?

The allocation also disappears if the types of the global variables are fixed:

c::MyVectorType = MyVectorType(rand(6))
d::MyVectorType = MyVectorType(rand(6))
@btime cross_product2(c,d);
  3.578 ns (0 allocations: 0 bytes)
2 Likes