# Atomic much slower than @spawn-ed tasks

**URL:** https://discourse.julialang.org/t/atomic-much-slower-than-spawn-ed-tasks/103055
**Category:** Performance
**Tags:** parallel
**Created:** [August 22, 2023, 9:33am UTC](https://discourse.julialang.org/t/atomic-much-slower-than-spawn-ed-tasks/103055 "2023-08-22T09:33:58Z")
**Posts on this page:** 5
**Page:** 1

<div class="post-metadata">

### Author: ![AdaemmerP](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/adaemmerp/32/52351_2.png) [@AdaemmerP](https://discourse.julialang.org/u/AdaemmerP)
#### Post date: [August 22, 2023, 9:33am UTC](https://discourse.julialang.org/t/atomic-much-slower-than-spawn-ed-tasks/103055/1 "2023-08-22T09:33:58Z")

</div>

The Julia manual offers two solutions to avoid a race condition when using `@threads`, namely `@spawn`-ed tasks and atomic operations. The manual states that the atomic approach “may be more performant depending on the characteristics of the operations”. However, for this example, the atomic approach is significantly slower. What is the reason for this huge discrepancy? When should the atomic approach be used? The results below are based on using 4 threads:

```julia
function sum_single(a)
    s = 0
    for i in a
        s += i
    end
    s
end

```

```julia
function sum_multi_good(a)
    chunks = Iterators.partition(a, length(a) ÷ Threads.nthreads())
    tasks = map(chunks) do chunk
        Threads.@spawn sum_single(chunk)
    end
    chunk_sums = fetch.(tasks)
    return sum_single(chunk_sums)
end
@btime sum_multi_good(1:100_000_000)
2.217 μs (35 allocations: 2.56 KiB)
5000000050000000

```

```julia
function sum_atomic!(a)
    atomic = Threads.Atomic{Int}(0)
    Threads.@threads for id in a
        Threads.atomic_add!(atomic, id)
    end
    return atomic[]
end

@btime sum_atomic!(1:100_000_000)
760.684 ms (26 allocations: 2.33 KiB)
5000000050000000

```

---

<div class="post-metadata">

### Author: ![oxinabox](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/oxinabox/32/206603_2.png) [@oxinabox](https://discourse.julialang.org/u/oxinabox)
#### Post date: [August 22, 2023, 10:22am UTC](https://discourse.julialang.org/t/atomic-much-slower-than-spawn-ed-tasks/103055/2 "2023-08-22T10:22:34Z")

</div>

There isn’t much more to it than:  
_Atomic operations are slow._  
(just like locks are slow – and indeed a lot of locks are built on atomics).

So if you can write your solution to in a way that minimizes needing them you are almost always better to do so.

But sometimes you can’t write your problem in a way that avoids them.

You could imagine a variabt of your code which uses an `atomic_add` to do the chunk of sums.

---

<div class="post-metadata">

### Author: ![algunion](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/algunion/32/51630_2.png) [@algunion](https://discourse.julialang.org/u/algunion)
#### Post date: [August 22, 2023, 12:00pm UTC](https://discourse.julialang.org/t/atomic-much-slower-than-spawn-ed-tasks/103055/3 "2023-08-22T12:00:25Z")

</div>

Your (manual) scenario here is in the category of _non-multithreading is better anyway_. Because there is no _real_ computation taking place, you end up paying for the overhead without any real multithreading benefit.

There are scenarios where no flavor of multithreading is going to produce a benefit (or when multithreading is doing more harm than good).

When adding some _real work_, we can see the two approaches become indistinguishable (at least in this scenario):

```julia
using BenchmarkTools

function costly(x)
    z = 0.0
    for _ in 1:2_000_000
        z += rand()
    end
    x
end

function sum_single(a)
    s = 0
    for i in a
        s += costly(i)
    end
    s
end

function sum_multi_good(a)
    chunks = Iterators.partition(a, length(a) ÷ Threads.nthreads())
    tasks = map(chunks) do chunk
        Threads.@spawn sum_single(chunk)
    end
    chunk_sums = fetch.(tasks)
    return sum_single(chunk_sums)
end
@info "sum_multi_good: "
@btime sum_multi_good(1:10000)

function sum_atomic!(a)
    atomic = Threads.Atomic{Int}(0)
    Threads.@threads for id in a
        Threads.atomic_add!(atomic, costly(id))
    end
    return atomic[]
end

@info "sum_atomic!: "
@btime sum_atomic!(1:10000)

@info "sum_single: "
@time "regular: " sum_single(1:10000)

```

Running with `julia -t 4 atomicplay.jl` will produce (on my machine):

```julia
[ Info: sum_multi_good: 
  4.382 s (37 allocations: 2.61 KiB)
[ Info: sum_atomic!: 
  4.370 s (27 allocations: 2.36 KiB)
[ Info: sum_single: 
regular: : 16.596185 seconds

```

---

<div class="post-metadata">

### Author: ![Oscar\_Smith](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/oscar_smith/32/25343_2.png) [@Oscar\_Smith](https://discourse.julialang.org/u/Oscar_Smith)
#### Post date: [August 22, 2023, 1:01pm UTC](https://discourse.julialang.org/t/atomic-much-slower-than-spawn-ed-tasks/103055/4 "2023-08-22T13:01:21Z")

</div>

note that sleeping is a bad example because sleeping doesn’t take compute, so when your program sleeps it will instantly schedule another task to run on the core

---

<div class="post-metadata">

### Author: ![algunion](https://sea2.discourse-cdn.com/julialang/user_avatar/discourse.julialang.org/algunion/32/51630_2.png) [@algunion](https://discourse.julialang.org/u/algunion)
#### Post date: [August 22, 2023, 1:09pm UTC](https://discourse.julialang.org/t/atomic-much-slower-than-spawn-ed-tasks/103055/5 "2023-08-22T13:09:04Z")

</div>

> [@Oscar\_Smith](#):
>
> note that sleeping is a bad example because sleeping doesn’t take compute, so when your program sleeps it will instantly schedule another task to run on the core

I don’t think there is a real difference in this scenario - but I changed the example to avoid `sleep` altogether.
