Julia 朱莉娅:在复杂数据结构(如数据帧)上并行化操作

Julia 朱莉娅:在复杂数据结构(如数据帧)上并行化操作,julia,Julia,我想并行处理大量大型数据集。不幸的是,我从使用线程中得到的加速。@Threads是非常次线性的,如下面的简化示例所示 (我对朱莉娅很陌生,所以如果我错过了一些明显的东西,我向你道歉) 让我们创建一些虚拟输入数据-8个数据帧,每个数据帧有2个整数列和1000万行: using DataFrames n = 8 dfs = Vector{DataFrame}(undef, n) for i = 1:n dfs[i] = DataFrame(Dict("x1" =>

我想并行处理大量大型数据集。不幸的是,我从使用
线程中得到的加速。@Threads
是非常次线性的,如下面的简化示例所示

(我对朱莉娅很陌生,所以如果我错过了一些明显的东西,我向你道歉)

让我们创建一些虚拟输入数据-8个数据帧,每个数据帧有2个整数列和1000万行:

using DataFrames

n = 8
dfs = Vector{DataFrame}(undef, n)
for i = 1:n
    dfs[i] = DataFrame(Dict("x1" => rand(1:Int64(1e7), Int64(1e7)), "x2" => rand(1:Int64(1e7), Int64(1e7))))
end
现在对每个数据帧进行一些处理(按
x1
分组和求和
x2

最后,比较在单个数据帧上进行处理与在所有8个数据帧上并行进行处理的速度。我运行这个的机器有50个内核,Julia是用50个线程启动的,所以理想情况下应该不会有太大的时差

julia> dfs_res = Vector{DataFrame}(undef, n)

julia> @time for i = 1:1
           dfs_res[i] = process(dfs[i])
       end
  3.041048 seconds (57.24 M allocations: 1.979 GiB, 4.20% gc time)

julia> Threads.nthreads()
50

julia> @time Threads.@threads for i = 1:n
           dfs_res[i] = process(dfs[i])
       end
  5.603539 seconds (455.14 M allocations: 15.700 GiB, 39.11% gc time)

因此,每个数据集的并行运行时间几乎是两倍(数据集越多,情况就越糟)。我觉得这与低效的内存管理有关。第二次运行的GC时间相当长。我假设使用
unde
的预分配对于
DataFrame
s无效。我在Julia中看到的几乎所有并行处理示例都是在具有固定和先验已知大小的数字数组上完成的。然而,在R工作流中,数据集可能具有任意大小、列等。使用
mclappy
可以非常有效地完成这类工作。朱莉娅身上有什么相似之处(或不同但有效的模式)吗?我选择使用线程而不是多线程来避免复制数据(Julia似乎不支持像R/McLappy那样的fork进程模型)。

Julia中的多线程不能扩展到
16
线程之外。 因此,您需要使用多处理。 您的代码可能如下所示:

using DataFrames, Distributed
addprocs(4) # or 50
@everywhere using DataFrames, Distributed

n = 8
dfs = Vector{DataFrame}(undef, n)
for i = 1:n
    dfs[i] = DataFrame(Dict("x1" => rand(1:Int64(1e7), Int64(1e7)), "x2" => rand(1:Int64(1e7), Int64(1e7))))
end

@everywhere function process(df::DataFrame)::DataFrame
    combine([:x2] => sum, groupby(df, :x1))
end

dfs_res = @distributed (vcat) for i = 1:n
      df = process(dfs[i])
      (i, myid(), df)
end
在这种类型的代码中,重要的是在进程之间传输数据需要时间。因此,有时您可能只想在单独的worker上保持单独的
DataFrame
s。像往常一样-这取决于您的处理架构

编辑一些关于表演的注释 为了进行测试,请将代码放入函数中并使用
const
s(或使用BenchamrTools.jl)

这里是结果

julia> GC.gc();@time p1!(dres, dfs)
 30.840718 seconds (507.28 M allocations: 16.532 GiB, 6.42% gc time)

julia> GC.gc();@time p1!(dres, dfs)
 30.827676 seconds (505.66 M allocations: 16.451 GiB, 7.91% gc time)

julia> GC.gc();@time p2!(dres, dfs)
 18.002533 seconds (505.77 M allocations: 16.457 GiB, 23.69% gc time)

julia> GC.gc();@time p2!(dres, dfs)
 17.675169 seconds (505.66 M allocations: 16.451 GiB, 23.64% gc time)
为什么在8核机器上的差异只有大约2倍-因为我们大部分时间都在垃圾收集!(看看你问题中的输出——问题是一样的)
当您使用更少的RAM时,您将看到更好的多线程处理速度,最高可达3倍。

您是否设置了
JULIA_NUM_THREADS
Threads.nthreads()
为您输出了什么?是的,请参见上面的输出。它是
50
。请注意,在您的示例中,如果使用
:x2=>sum
而不是
[:x2]=>sum
,则操作速度更快,分配也更少。这是因为数据帧在单个列上有一条快速的通用缩减路径。我们可能会对此进行改进,以涵盖
[:x2]=>sum
,但一般来说,如果您知道您只有一列,最好不要使用向量。(另外,您的示例非常极端,因为每个组平均只有一行。因此,我认为这并不能真正代表大多数工作流——更多的组意味着更多的分配,因此GC时间更长,这不是多线程的。)谢谢!虽然我希望避免多处理的复制开销(我的实际用例要大得多),但在这个例子中,这种方法看起来确实更快。还要注意的是,在我上面的示例中,有效线程数是8,因此远低于您所说的16是不可行的。在您的示例中,您错误地测量了时间,因为您同时测量了编译时间和运行时。对于多线程代码,Julia的编译时间明显长于单线程代码,而对于分布式代码,编译时间甚至更长。请看一看:无论哪种情况,当您开始正确测量时,您都会对高达16线程的性能感到满意。我确实多次执行了每个命令—这不意味着它将在第一次执行后使用缓存的编译版本吗?否则,我怎样才能更好地衡量它呢?我添加了一些关于性能测试的评论。无论如何,在50个内核上运行时,您应该使用分布式而不是线程,有足够的内存以避免过多的GC,并在工作人员之间有一个良好的工作流和数据分发策略。感谢关于更精确地测量执行时间的指针。然而,基本结论仍然是一样的——多线程的次线性扩展。我认为这与内核/线程的数量无关,因为这是一个故意使用8的小示例。就GC达成一致。那么问题是——如何避免它?在本例中,我只使用DataFrames库的基本功能。
using DataFrames

const dfs = [DataFrame(Dict("x1" => rand(1:Int64(1e7), Int64(1e7)), "x2" => rand(1:Int64(1e7), Int64(1e7)))) for i in 1:8 ]

function process(df::DataFrame)::DataFrame
    combine([:x2] => sum, groupby(df, :x1))
end

function p1!(res, d)
    for i = 1:8
        res[i] = process(dfs[i])
    end
end


function p2!(res, d)
     Threads.@threads for i = 1:8
        res[i] = process(dfs[i])
    end
end

const dres = Vector{DataFrame}(undef, 8)

julia> GC.gc();@time p1!(dres, dfs)
 30.840718 seconds (507.28 M allocations: 16.532 GiB, 6.42% gc time)

julia> GC.gc();@time p1!(dres, dfs)
 30.827676 seconds (505.66 M allocations: 16.451 GiB, 7.91% gc time)

julia> GC.gc();@time p2!(dres, dfs)
 18.002533 seconds (505.77 M allocations: 16.457 GiB, 23.69% gc time)

julia> GC.gc();@time p2!(dres, dfs)
 17.675169 seconds (505.66 M allocations: 16.451 GiB, 23.64% gc time)