Optimizing your code

Carlos Castillo Passi, PhD

13 Jan 2025

Recap of the previous session

Julia’s multiple dispatch is what makes it so powerful

Mulitple dispatch

Julia is compiled

Note

Using function is fundamental for to generate performant code. If you want something to be fast, put it inside a function!

Sin 1: Overtyping your code

function solve(m0::Vector{Float64}, dt::Float64, tmax::Float64, method::ForwardEuler)
  ...
end

vs

function solve(m0, dt, tmax, method::ForwardEuler)
  ...
end

This approach provides no performance benefit, only use for dispatch! 1 This does not enable to write general code.

Sin 1: Overtyping your code

Sin 2: Create structs with abstract types

Sin 3: Type piracy

In Julia, you can extend any function based on a custom type. Only extend a function for a given type if:

  • The type belongs to you.
  • The function belongs to you.

Sin 3: Type piracy

Measuring performance

We will use the following function

function profile_test(n)
    for i = 1:n
        A = randn(100,100,20)
        m = maximum(A)
        Am = mapslices(sum, A; dims=2)
        B = A[:,:,5]
        Bsort = mapslices(sort, B; dims=1)
        b = rand(100)
        C = B.*b
    end
end

# compilation
@profview profile_test(1)
# pure runtime
@profview profile_test(10)

@time

The time macro is used as a basic benchmarking tool.

@profview

In VSCode, this is imported by default.

@benchmark (from BenchmarkTools)

If you want fancy histograms. Gold standard.

Julia compiler

Julia compiler

Introspection tools are useful!

Runtime vs compiletime

Making your code fast

Array indexing (column-major)

Array indexing (column-major)

In Julia, multi-dimensional arrays are stored as a long column. So iterating in the first index is more efficient.

julia> A = zeros(1000, 1000)
julia> @time for i in axes(A, 2)
           b += A[1, i]
       end
  0.000165 seconds (3.98 k allocations: 77.766 KiB)

julia> @time for i in axes(A, 1)
           b += A[i, 1]
       end
  0.000084 seconds (3.98 k allocations: 77.766 KiB)

Array view

By default this generates a copy of the array’s data

a = x[1:10]

To pass it as a “pointer” to the data, it is recommended to use view.

a = @view x[1:10]
a = view(x, 1:10)

Do not use untyped containers

In memory if the container is not typed, each element also need to store its type.

Do not use untyped containers

function f()
    numbers = [] # Same as Any[]
    for i in 1:10
        push!(numbers, i)
    end
    return sum(numbers)
end

Do not use untyped containers

Solution: - Specify container type, {numbers = Int[]

Using global variables inside a function

x = 10
f(n) = n + x

julia> @descend f(10)
f(n) @ Main REPL[26]:1
1 f(n::Int64)::Any = n::Int64 + x::Any

Using global variables inside a function

Solutions:

  • Make globals const (can’t change type) const x = 10
  • Write self-contained functions f(n) = n + 10

Avoid abstract field types

struct MyType
    x::Number
    y # Same as ::Any
end
f(a::MyType) = a.x ^ 2 + sqrt(a.x)
a = MyType(3.0, "test")
@code_warntype f(a)

Avoid abstract field types

Solution:

  • Concrete typing
struct MyTypeConcrete
    x::Float64
    y::String
end
  • Parametric types
struct MyTypeParametric{A <: Number, B <: AbstractString}
    x::A 
    y::B 
end

Avoid struct padding

struct Test1
   x::UInt32
   y::UInt64
   z::UInt32
end

struct Test2
   x::UInt32
   y::UInt32
   z::UInt64
end

about(Test1(1,1,1))
about(Test2(1,1,1))

Avoid changing variable types

function f()
    x = 1
    for i = 1:10
        x /= rand()
    end
    return x
end
@code_warntype f()

Avoid changing variable types

Solution:

Initialize with correct type

function f()
    x = 1.0
    for i = 1:10
        x /= rand()
    end
    return x
end
@code_warntype f()