Performance of loops

Matthijs_1971
Fri, 16 Aug 2024 13:04:17 +0000
New to Julia

Julia has several ways to define loops or repeat code, each with advantages and disadvantages. I am looking at the performance of some also inspired by previous posts on this forum.

#!/usr/bin/env julia

# https://discourse.julialang.org/t/map-vs-loops-array-comprehensions-in-julia-1-0/16137
# Note, this thread is from 2018.

import BenchmarkTools
import Random

function loop_with_map(x)
    x = map(a -> 2 * a, x)
    return x
end

function loop_with_map_dot(x)
    x .= map(a -> 2 * a, x)     # Slowest
    return x
end

function loop_list_comprehension(x)
    # A list comprehension creates a new local scope and is therefore slow.
    x = [ 2 * val for val in x]
    return x
end

function loop_list_comprehension_dot(x)
    # A list comprehension creates a new local scope and is therefore slow.
    x .= [ 2 * val for val in x]
    return x
end

function for_loop(x)
    for i in range(1, length(x))
        x[i] = 2 * x[i]
    end
    return x
end

function for_loop_opt(x)
    for i in eachindex(x)
        @inbounds x[i] = 2 * x[i]
    end
    return x
end

function loop_via_broadcast(x)
    @. x = 2 * x
    return x
end

function setup_function(n)
    return Random.rand(Int64, n)
end

for n in [100, 1000, 10_000, 100_000, 1_000_000]
    println("\n\nn = ", n)
    BenchmarkTools.@btime loop_with_map(x_vec)                  setup = (x_vec = setup_function($n)) # Slow
    BenchmarkTools.@btime loop_with_map_dot(x_vec)              setup = (x_vec = setup_function($n)) # Very slow
    BenchmarkTools.@btime loop_list_comprehension(x_vec)        setup = (x_vec = setup_function($n)) # Slow
    BenchmarkTools.@btime loop_list_comprehension_dot(x_vec)    setup = (x_vec = setup_function($n)) # Very slow
    BenchmarkTools.@btime for_loop(x_vec)                       setup = (x_vec = setup_function($n)) # Fast
    BenchmarkTools.@btime for_loop_opt(x_vec)                   setup = (x_vec = setup_function($n)) # Fast
    BenchmarkTools.@btime loop_via_broadcast(x_vec)             setup = (x_vec = setup_function($n)) # Fast
end

Output:

2024_08_16 14:18:30 ~/temp $ loop_performance.jl


n = 100
  89.105 ns (1 allocation: 896 bytes)
  163.902 ns (1 allocation: 896 bytes)
  138.964 ns (1 allocation: 896 bytes)
  163.821 ns (1 allocation: 896 bytes)
  12.209 ns (0 allocations: 0 bytes)
  11.278 ns (0 allocations: 0 bytes)
  12.198 ns (0 allocations: 0 bytes)


n = 1000
  1.049 μs (1 allocation: 7.94 KiB)
  1.180 μs (1 allocation: 7.94 KiB)
  1.119 μs (1 allocation: 7.94 KiB)
  1.140 μs (1 allocation: 7.94 KiB)
  80.231 ns (0 allocations: 0 bytes)
  80.838 ns (0 allocations: 0 bytes)
  81.080 ns (0 allocations: 0 bytes)


n = 10000
  7.885 μs (2 allocations: 78.17 KiB)
  11.209 μs (2 allocations: 78.17 KiB)
  8.743 μs (2 allocations: 78.17 KiB)
  11.228 μs (2 allocations: 78.17 KiB)
  1.239 μs (0 allocations: 0 bytes)
  1.215 μs (0 allocations: 0 bytes)
  1.306 μs (0 allocations: 0 bytes)


n = 100000
  116.843 μs (2 allocations: 781.30 KiB)
  171.743 μs (2 allocations: 781.30 KiB)
  119.491 μs (2 allocations: 781.30 KiB)
  174.575 μs (2 allocations: 781.30 KiB)
  15.298 μs (0 allocations: 0 bytes)
  14.419 μs (0 allocations: 0 bytes)
  15.221 μs (0 allocations: 0 bytes)


n = 1000000
  1.428 ms (2 allocations: 7.63 MiB)
  2.420 ms (2 allocations: 7.63 MiB)
  1.420 ms (2 allocations: 7.63 MiB)
  2.573 ms (2 allocations: 7.63 MiB)
  696.670 μs (0 allocations: 0 bytes)
  664.293 μs (0 allocations: 0 bytes)
  698.923 μs (0 allocations: 0 bytes)
2024_08_16 14:21:31 ~/temp $

Questions:

1. loop_with_map: map has been reported to be slow before, but a long time ago. I am surprised that it is slow today. Am I doing something wrong?

2. loop_with_map_dot: This uses broadcasting to place the result of the calculation back in x. The idea is that no new memory for x has to be allocated; memory allocation is slow. Obviously this makes loop_with_map_dot slower, not faster. I think the reason is that map already allocates memory and that the broadcast makes that the old memory of x has to be filled instead of just making x point to the memory created by map. More on broadcasting below. Correct?

3. loop_list_comprehension: List comprehensions create their own scope which makes them slow. But I expected this creation of a new scope to take fixed time, i.e. independent of the number of times the loop is executed. What is going on?

4. loop_list_comprehension_dot: Just the same as in loop_with_map_dot, the broadcast serves to re-use the memory that x points to, but as a result all of the values have to be pumped over. Correct?

5. for_loop_opt: Skips index checks. Why is this faster than loop_via_broadcast? It suggests (falsely?) that loop_via_broadcast does a range check while this can’t fail since there is only one container involved. If two with different lengths were involved, I can imagine that a range check makes sense.

6. loop_via_broadcast: here the re-use of the memory of x actually works. This has been clarified here. Now I wonder if this is the only time this works, in essence.

31 posts - 11 participants