了解 Julia 中的多线程行为

Posted

技术标签:

【中文标题】了解 Julia 中的多线程行为【英文标题】:Understand multi-threading behavior in Julia 【发布时间】:2021-12-04 13:16:05 【问题描述】:

我试图,我注意到以下两个代码块在 Julia v1.6.3 中的行为不同(我在某些 script.jl 中在 Atom 中运行 Julia):

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,而第二个块会给我一些

我对 Julia 的并行计算(或一般的并行计算)完全陌生,所以如果能解释一下这里发生了什么以及为什么单个打印行会改变代码块的行为,我将不胜感激。

【问题讨论】:

【参考方案1】:

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

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

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

    对于快速、短时间运行的循环,请使用 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
    第二个选项是ReentrantLock,它通常更适合运行时间较长的循环(与SpinLock 相比,切换需要更长的时间),循环步骤内的时间不同(它不会像@ 那样花费 CPU 时间“旋转” 987654328@):
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
    如果您正在改变原始值(如您的情况),原子操作将是最快的(请注意我如何从 Atomic 获取值):
julia> acc2 = Threads.AtomicInt(0)
Base.Threads.AtomicInt64(0)

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

julia> acc2[]
1000

【讨论】:

我明白了。感谢您的详细解释!【参考方案2】:

您可能知道这一点,但在现实生活中所有这些都应该是in a function;如果您使用全局变量,您的性能将是灾难性的,而使用一个函数,您只需单线程实现就可以遥遥领先。虽然“慢速”编程语言的用户通常会立即使用并行性来提高性能,但对于 Julia,您最好的方法通常是首先分析单线程实现的性能(像分析器一样使用 tools)并修复您发现的任何问题.尤其是对于 Julia 的新手来说,以这种方式使您的代码速度提高十倍或一百倍的情况并不少见,在这种情况下,您可能会觉得这就是您所需要的。

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

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

julia> acc2 = Threads.AtomicInt(0)
Base.Threads.AtomicInt64(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.AtomicInt(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 的性能提示,分析代码的性能,并修复任何瓶颈 只有在用尽所有单线程选项后才能实现并行。

【讨论】:

以上是关于了解 Julia 中的多线程行为的主要内容,如果未能解决你的问题,请参考以下文章

Java中的多线程技术全面详解

Java中的多线程技术全面详解

编程思想之多线程与多进程——Java中的多线程

如果 GIL 存在,Python 中的多线程有啥意义?

Java中的多线程如何理解——精简

n叉树中的多线程