【问题标题】:How to make multi-threading in Julia scale with the number of threads?如何使 Julia 中的多线程随线程数缩放?
【发布时间】:2021-02-24 02:13:36
【问题描述】:

我知道有关 Julia 中的多线程性能的问题已经被问过(例如 here),但它们涉及相当复杂的代码,其中可能涉及很多事情。

在这里,我正在使用 Julia v1.5.3 在多个线程上运行一个非常简单的循环,与使用 Chapel 运行相同的循环相比,加速似乎并没有很好地扩展。

我想知道我做错了什么以及如何更有效地在 Julia 中运行多线程。

顺序码

using BenchmarkTools

function slow(n::Int, digits::String)
    total = 0.0
    for i in 1:n
        if !occursin(digits, string(i))
            total += 1.0 / i
        end
    end
    println("total = ", total)
end

@btime slow(Int64(1e8), "9")

时间:8.034s

在 4 个线程上与 Threads.@threads 共享内存并行

using BenchmarkTools
using Base.Threads

function slow(n::Int, digits::String)
    total = Atomic{Float64}(0)
    @threads for i in 1:n
        if !occursin(digits, string(i))
            atomic_add!(total, 1.0 / i)
        end
    end
    println("total = ", total)
end

@btime slow(Int64(1e8), "9")

时间:6.938s
加速:1.2

在 4 个线程上与 FLoops 共享内存并行

using BenchmarkTools
using FLoops

function slow(n::Int, digits::String)
    total = 0.0
    @floop for i in 1:n
        if !occursin(digits, string(i))
            @reduce(total += 1.0 / i)
        end
    end
    println("total = ", total)
end

@btime slow(Int64(1e8), "9")

时间:10.850 秒
没有加速:比顺序代码慢。

测试不同数量的线程(不同的硬件)

我在另一台机器上测试了顺序代码和Threads.@threads 代码,并尝试了不同数量的线程。

结果如下:

Number of threads Speedup
2 1.2
4 1.2
8 1.0 (no speedup)
16 0.9 (the code takes longer to run than the sequential code)

对于更繁重的计算(上面代码中的n = 1e9)可以最大限度地减少任何开销的相对影响,结果非常相似:

Number of threads Speedup
2 1.1
4 1.3
8 1.1
16 0.8 (the code takes longer to run than the sequential code)

比较:与Chapel 相同的循环显示完美缩放

使用 Chapel v1.23.0 运行代码:

use Time;
var watch: Timer;
config const n = 1e8: int;
config const digits = "9";
var total = 0.0;
watch.start();
forall i in 1..n with (+ reduce total) {
  if (i: string).find(digits) == -1 then
    total += 1.0 / i;
 }
watch.stop();
writef("total = %{###.###############} in %{##.##} seconds\n",
        total, watch.elapsed());

首次运行(与第一次 Julia 测试的硬件相同):

Number of threads Time (s) Speedup
1 13.33 n/a
2 7.34 1.8

第二次运行(相同硬件):

Number of threads Time (s) Speedup
1 13.59 n/a
2 6.83 2.0

第三次运行(不同的硬件):

Number of threads Time (s) Speedup
1 19.99 n/a
2 10.06 2.0
4 5.05 4.0
8 2.54 7.9
16 1.28 15.6

【问题讨论】:

  • 另外,等效的教堂代码是线程安全的吗? (我不太了解教堂)
  • 是的,它是线程安全的。
  • 与问题没有直接关系,但在自己计时时,我将 Chapel 中的计时计算转换为更紧凑/优雅的:total = + reduce [i in 1..n] if ( i: string).find(digits) == -1 then 1.0 / i else 0.0;
  • 感谢 Brad 提供的简洁紧凑的版本!我将在答案中保留我不太优雅的版本,因为它可能更容易被非教堂专家阅读。

标签: multithreading performance parallel-processing julia chapel


【解决方案1】:

有人可以进行比我更详细的分析,但幼稚的 Julia 线程执行不佳的主要原因是您在每次迭代中的“任务”太轻了。在这种情况下,使用原子锁将意味着巨大的开销,因为所有线程都过于频繁地等待锁。

由于您的 Chapel 代码正在执行 mapreduce,我们也可以在 Julia 中尝试并行 mapreduce:


julia> function slow(n::Int, digits::String)
           total = 0.0
           for i in 1:n
               if !occursin(digits, string(i))
                   total += 1.0 / i
               end
           end
           "total = $total"
       end
slow (generic function with 1 method)

julia> @btime slow(Int64(1e5), "9")
  6.021 ms (200006 allocations: 9.16 MiB)
"total = 9.692877792106202"

julia> using ThreadsX

julia> function slow_thread_thx(n::Int, digits::String)
           total = ThreadsX.mapreduce(+,1:n) do i
               if !occursin(digits, string(i))
                   1.0 / i
               else
                   0.0
               end
           end
           "total = $total"
       end

julia> @btime slow_thread_thx(Int64(1e5), "9")
  1.715 ms (200295 allocations: 9.17 MiB)
"total = 9.692877792106195"

有 4 个线程。我已经用其他数量的线程进行了测试,并确认缩放是相当线性的。

顺便说一句,作为一般提示,您应该尽量避免在基准代码中打印,因为重复计时会造成混乱,而且如果您的任务很快,STDIO 可能会花费不可忽略的时间。

【讨论】:

  • 感谢您提供有关打印的信息。这是否仅适用于 println() 的使用,还是也适用于您的打印方式?
  • 关于打印方式:这在 REPL 中有效,但在使用 julia script.jl 运行代码时没有输出(唯一的输出是时间)。所以确保结果正确不是很方便。
  • 我运行了你的代码,虽然加速肯定比使用@threads 更好(所以感谢你的建议!),它仍然没有扩大多少:对于 2、4、8 和16 个线程,我得到以下加速:1.4、1.7、2.1 和 1.6。
  • 您得到了合理的缩放比例,因为您的问题规模非常小 (n = 1e5) 并且受其他因素支配。如果像我的示例那样将计算增加到 n = 1e8,则缩放比例会崩溃)。
  • 我认为您在答案顶部的解释非常有趣,所以我用重复几次的大计算(而不是重复多次的小计算)对其进行了测试。如果这是正确的,它应该可以很好地扩展。但它也根本无法扩展。
【解决方案2】:

正如 jling 在 their answer 上的 cmets 中所建议的那样,这里的问题很可能是 Julia 代码分配了大量需要进行垃圾收集的内存。据我了解,Chapel 不是一种垃圾收集语言,这可以解释为什么这个例子更线性地扩展。作为对这个假设的一个小测试,我对执行相同操作但使用预分配的Vector{UInt8} 而不是String 的以下代码进行了基准测试:

using BenchmarkTools
using Transducers
using Distributed

function string_vector!(a::Vector{UInt8}, x::Unsigned)
    n = ndigits(x)
    length(a) < n && error("Vector too short")
    i = n
    @inbounds while i >= 1
        d, r = divrem(x, 0x0a)
        a[i] = 0x30 + r
        x = oftype(x, d)
        i -= 1
    end
    a
end

function slow_no_garbage(n::UInt, digits::String)
    digits = collect(codeunits(digits))
    thread_strings = [zeros(UInt8, 100) for _ in 1:Threads.nthreads()]
    fun(i) = if Base._searchindex(string_vector!(thread_strings[Threads.threadid()], i), digits, 1) == 0
            1.0 / i
        else
            0.0
        end
    total = foldxt(+, Map(fun), 0x1:n)
    "total = $total"
end
println(@btime slow_no_garbage(UInt(1e8), "9"))

我不建议使用此代码(特别是因为数字总是在增长,所以我没有正确清除迭代之间的线程缓冲区,尽管这很容易解决)。但是,它会导致线程数几乎呈线性缩放(答案末尾的表格)。

正如 jling 也提到的,如果创建了很多垃圾,分布可能比线程更好。以下两段代码sn-ps使用Transducers.jl先使用线程运行代码:

using BenchmarkTools
using Transducers

function slow_transducers(n::Int, digits::String)
    fun(i) = if !occursin(digits, string(i))
            1.0 / i
        else
            0.0
        end
    total = foldxt(+, Map(fun), 1:n)
    "total = $total"
end

println(@btime slow_transducers(Int64(1e8), "9"))

然后分发到单独的Julia进程(以进程数作为第一个命令行参数):

using BenchmarkTools
using Transducers
using Distributed

function slow_distributed(n::Int, digits::String)
    fun(i) = if !occursin(digits, string(i))
            1.0 / i
        else
            0.0
        end
    total = foldxd(+, Map(fun), 1:n)
   "total = $total"
end

addprocs(parse(Int, ARGS[1]))
println(@btime slow_distributed(Int64(1e8), "9"))

下表显示了在不同线程/进程数下运行所有​​版本的结果:

n jling slow_transducers slow_distributed slow_no_garbage Chapel
1 4.242 s 4.224 s 4.241 s 2.743 s 7.32 s
2 2.952 s 2.958 s 2.168 s 1.447 s 3.73 s
4 2.135 s 2.147 s 1.163 s 0.716105 s 1.9 s
8 1.742 s 1.741 s 0.859058 s 0.360469 s

加速:

n jling slow_transducers slow_distributed slow_no_garbage Chapel
1 1.0 1.0 1.0 1.0 1.0
2 1.43699 1.42799 1.95618 1.89565 1.96247
4 1.98689 1.9674 3.6466 3.83044 3.85263
8 2.43513 2.42619 4.9368 7.60953

【讨论】:

  • 感谢您提供更多的思考材料。请注意,我对 Distributed 不感兴趣,因为我正在尝试探索 Julia 中的多线程,而不是加速代码。也许我会更改我的问题的标题以使其更清楚。
  • 这将有助于解释为什么我们的一些测试可以很好地扩展,而另一些则根本没有。所以这非常有用。谢谢你。我们将对此进行更多测试。
  • 好吧,我们运行了一些没有任何字符串转换的循环,并且它们的缩放效果并没有更好。
  • 这很有趣,您能否分享一些此类代码的示例,将其添加到原始帖子或分享链接?它分配了很多内存吗?是否可以通过 slow_no_garbage 之类的预分配来修复?
  • 对不起,我的回复很慢。是的,我已经认为我应该用一个不涉及字符串转换的例子来编辑我的问题,因为每个人都在关注这个问题。我将更仔细地查看您的解决方案并在其他循环上对其进行测试,然后我将编辑问题。感谢您的建议。
猜你喜欢
  • 2011-06-20
  • 2022-08-16
  • 1970-01-01
  • 2021-12-04
  • 2023-03-28
  • 2013-02-02
  • 2023-03-13
  • 1970-01-01
  • 2016-05-15
相关资源
最近更新 更多