了解 Julia 中的多线程行为

Understand multi-threading behavior in Julia

我正在尝试了解 Julia 中的多线程行为,我注意到以下两个代码块在 Julia v1.6.3 中的行为不同(我在某些方面 运行ning Julia in Atom script.jl):

acc = 0
Threads.@threads for i in 1:1000
         global acc
         println(Threads.threadid(), ",", acc)
         acc += 1
      end
acc

acc = 0
Threads.@threads for i in 1:1000
         global acc
         acc += 1
      end
acc

请注意,唯一的区别是我在后一种情况下去掉了“println(Threads.threadid(),”,”,acc)”。结果,每次我 运行 第一个块都会给我 1000,第二个块会给我一些 <1000 的数字(由于竞争条件)。

我对 Julia 的并行计算(或一般的并行计算)完全陌生,因此非常感谢任何解释这里发生的事情以及为什么单个打印行会改变代码块的行为。

您有多个线程同时改变状态 acc,您最终会遇到竞争条件。

然而,与加法运算相比,println 需要相对较长的时间,并且 println 及时发生,因此对于小循环,您很有可能观察到“正确”的结果。但是,您的两个循环都不正确。

当多个线程改变完全相同的共享状态时,您需要引入锁定或使用原子变量。

  1. 对于快速、短的 运行 循环使用 SpinLock 如:
julia> acc = 0;

julia> u = Threads.SpinLock();

julia> Threads.@threads for i in 1:1000
                global acc
                Threads.lock(u) do
                    acc += 1
                end
             end

julia> acc
1000
  1. 第二个选项是 ReentrantLock,它通常更适合较长的 运行 循环(切换时间比 SpinLock 长得多),在循环步骤中具有异质时间(它确实不要像 SpinLock):
  2. 那样花费 CPU 时间“旋转”
julia> acc = 0
0

julia> u = ReentrantLock();

julia> Threads.@threads for i in 1:1000
                global acc
                Threads.lock(u) do
                    acc += 1
                end
             end

julia> acc
1000
  1. 如果你正在改变一个原始值(就像你的情况)原子操作将是最快的(请注意我如何从 Atomic 中获取值):
julia> acc2 = Threads.Atomic{Int}(0)
Base.Threads.Atomic{Int64}(0)

julia> Threads.@threads for i in 1:1000
                global acc2
                Threads.atomic_add!(acc2, 1)
             end

julia> acc2[]
1000

你可能知道这一点,但在现实生活中,所有这些都应该像探查器一样 in a function; your performance will be disastrous if you use a global variable, and with a function you'd be miles ahead with just a single-threaded implementation. While users of "slow" programming languages often reach for parallelism immediately to speed performance, with Julia usually your best approach is to first analyze the performance of a single-threaded implementation (using tools 并修复你发现的任何问题。特别是对于 Julia 的新手,通过这种方式使您的代码速度提高十倍或一百倍的情况并不少见,在这种情况下,您可能会觉得这就是您所需要的。

确实,有时单线程实现会更快,因为线程会引入其自身的开销。我们可以在这里很容易地说明这一点。我将对上面的代码进行一个修改:我将添加 i % 2,而不是每次迭代都加 1,如果 i 是奇数则加 1,如果 [=16= 则加 0 =] 是偶数。我这样做是因为一旦你把它放在一个函数中,如果你所做的只是加 1,Julia 的编译就足够聪明,可以弄清楚你在做什么,只是 return 没有实际 运行 的答案]循环;我们想要 运行 循环,所以我们必须让它更复杂一点,这样编译器就无法提前找出答案。

首先,让我们尝试上面最快的线程实现(我用 julia -t4 启动 Julia 以使用 4 个线程):

julia> acc2 = Threads.Atomic{Int}(0)
Base.Threads.Atomic{Int64}(0)

julia> @btime Threads.@threads for i in 1:1000
           global acc2
           Threads.atomic_add!(acc2, i % 2)
       end
  12.983 μs (21 allocations: 1.86 KiB)

julia> @btime Threads.@threads for i in 1:1000000
           global acc2
           Threads.atomic_add!(acc2, i % 2)
       end
  27.532 ms (22 allocations: 1.89 KiB)

这是快还是慢?让我们先把它放在一个函数中,看看它是否有帮助:

julia> function lockadd(n)
           acc = Threads.Atomic{Int}(0)
           Threads.@threads for i = 1:n
               Threads.atomic_add!(acc, i % 2)
           end
           return acc[]
       end
lockadd (generic function with 1 method)

julia> @btime lockadd(1000)
  9.737 μs (22 allocations: 1.88 KiB)
500

julia> @btime lockadd(1000000)
  13.356 ms (22 allocations: 1.88 KiB)
500000

因此,通过将其放入函数中,我们获得了 2 倍(在更大的工作上)。然而,更好的线程策略是无锁线程:给每个线程自己的 acc,然后在最后添加所有单独的 accs

julia> function threadedadd(n)
           accs = zeros(Int, Threads.nthreads())
           Threads.@threads for i = 1:n
               accs[Threads.threadid()] += i % 2
           end
           return sum(accs)
       end
threadedadd (generic function with 1 method)

julia> using BenchmarkTools

julia> @btime threadedadd(1000)
  2.967 μs (22 allocations: 1.97 KiB)
500

julia> @btime threadedadd(1000000)
  56.852 μs (22 allocations: 1.97 KiB)
500000

对于更长的循环,我们获得了超过 200 倍的性能!这确实是一个非常好的加速。

但是,让我们尝试一个简单的单线程实现:

julia> function addacc(n)
           acc = 0
           for i in 1:n
               acc += i % 2
           end
           return acc
       end
addacc (generic function with 1 method)

julia> @btime addacc(1000)
  43.218 ns (0 allocations: 0 bytes)
500

julia> @btime addacc(1000000)
  41.068 μs (0 allocations: 0 bytes)
500000

这比小作业上的线程实现快 70 倍,甚至在大作业上也更快。为了完整起见,让我们将其与使用全局状态的相同代码进行比较:

julia> @btime for i in 1:1000
           global acc
           acc += i % 2
       end
  20.158 μs (1000 allocations: 15.62 KiB)

julia> @btime for i in 1:1000000
           global acc
           acc += i % 2
       end
  20.455 ms (1000000 allocations: 15.26 MiB)

太可怕了。

当然,在某些情况下并行性会产生影响,但它通常用于更复杂的任务。除非您已经优化了单线程实现,否则您仍然不应该使用它。

所以故事的两个重要寓意:

  • 阅读 Julia 的性能提示,分析代码的性能并修复任何瓶颈
  • 只有在您用尽所有单线程选项后才能达到并行性。