BenchmarkTools 输出到 DataFrame

BenchmarkTools outputs to DataFrame

我正在尝试使用 BenchmarkTools 对函数的性能进行基准测试,如下例所示。我的目标是获取 @benchmark 作为 DataFrame 的输出。

在这个例子中,我正在对以下两个函数的性能进行基准测试:

"""Example function A: recodes negative values to 0"""
function negative_to_zero_a!(x::Array{<:Real,1}) 
    for (i, v) in enumerate(x)
        if v < 0
            x[i] = zero(x[i]) # uses 'zero()'
        end 
    end
end 

"""Example function B: recodes negative values to 0"""
function negative_to_zero_b!(x::Array{<:Real,1}) 
    for (i, v) in enumerate(x)
        if v < 0
            x[i] = 0 # does not use 'zero()'
        end 
    end
end 

这意味着改变以下向量:

int_a = [1, -2, 3, -4]
float_a = [1.0, -2.0, 3.0, -4.0]

int_b = copy(int_a)
float_b = copy(float_a)

然后我使用 BenchmarkTools 生成性能基准。

using BenchmarkTools

int_a_benchmark = @benchmark negative_to_zero_a!(int_a)
int_b_benchmark = @benchmark negative_to_zero_b!(int_b)

float_a_benchmark = @benchmark negative_to_zero_a!(float_a)
float_b_benchmark = @benchmark negative_to_zero_b!(float_b)

我现在想将四个 BenchmarkTools.Trial 对象中每一个的元素检索到类似于下面的 DataFrame 中。在该 DataFrame 中,每一行都包含给定 BenchmarkTools.Trial 对象的结果。例如

DataFrame("id" => ["int_a_benchmark", "int_b_benchmark", "float_a_benchmark", "float_b_benchmark"],  
          "minimum" => [15.1516, 15.631, 14.615, 14.271], 
          "median" => [15.916, 15.731, 15.916, 15.879], 
          "maximum" => [149.15, 104.108, 63.363, 116.181],
          "allocations" => [0, 0, 0, 0],
          "memory_bytes" => [0, 0, 0, 0])
4×6 DataFrame
 Row │ id                 minimum  median   maximum  allocations  memory_estimate 
     │ String             Float64  Float64  Float64  Int64        Int64
─────┼────────────────────────────────────────────────────────────────────────────
   1 │ int_a_benchmark    15.1516   15.916  149.15             0                0
   2 │ int_b_benchmark    15.631    15.731  104.108            0                0
   3 │ float_a_benchmark  14.615    15.916   63.363            0                0
   4 │ float_b_benchmark  14.271    15.879  116.181            0                0

如何将基准测试的结果检索到像这样的 DataFrame 中?

你可以做到,例如像这样:

julia> using Statistics, DataFrames, BenchmarkTools

julia> preprocess_trial(t::BenchmarkTools.Trial, id::AbstractString) =
           (id=id,
            minimum=minimum(t.times),
            median=median(t.times),
            maximum=maximum(t.times),
            allocations=t.allocs,
            memory_estimate=t.memory)
preprocess_trial (generic function with 1 method)

julia> output = DataFrame()
0×0 DataFrame

julia> for (fun, id) in [(sin, "sin"), (cos, "cos"), (log, "log")]
           push!(output, preprocess_trial(@benchmark(sin(1)), id))
       end

julia> output
3×6 DataFrame
 Row │ id      minimum  median   maximum  allocations  memory_estimate
     │ String  Float64  Float64  Float64  Int64        Int64
─────┼─────────────────────────────────────────────────────────────────
   1 │ sin       0.001    0.001      0.1            0                0
   2 │ cos       0.001    0.001      0.1            0                0
   3 │ log       0.001    0.001      0.1            0                0

与 Julia 一样,有多种方法可以做你想做的事。我在这里展示的可能不是最简单的方法,而是一种希望展示有趣方法的方法,它允许进行一些概括。

但在我们开始之前,请注意:您的基准测试不太正确,因为您的函数会改变参数。为了进行正确的基准测试,您应该在每个 运行 之前复制您的数据,并且在每次执行函数时也这样做。您可以在此处找到更多信息:https://juliaci.github.io/BenchmarkTools.jl/dev/manual/#Setup-and-teardown-phases

所以,现在,我们假设您准备了这样的基准测试

int_a_benchmark = @benchmark negative_to_zero_a!(a) setup=(a = copy($int_a)) evals=1
int_b_benchmark = @benchmark negative_to_zero_b!(b) setup=(b = copy($int_b)) evals=1

float_a_benchmark = @benchmark negative_to_zero_a!(a) setup=(a = copy($float_a)) evals=1
float_b_benchmark = @benchmark negative_to_zero_b!(b) setup=(b = copy($float_b)) evals=1

主要思路如下。如果我们可以将基准数据表示为 DataFrame,那么我们可以将它们组合在一起作为一个大型 DataFrame 并进行所有必要的计算。

当然可以用超级简单的方法做到这一点,只需通过命令

df = DataFrame(times = int_a_benchmark.times, gctimes = int_a_benchmark.gctimes)
df.memory .= int_a_benchmark.memory
df.allocs .= int_a_benchmark.allocs

但这太无聊而且太逐字了(但很简单,应该在 99% 的时间里完成)。如果我们可以 DataFrame(int_a_benchmark) 并立即得到结果,那就太好了。

事实证明,这是可能的,因为 DataFrames 支持 Tables.jl 接口来处理 table-like 数据。您可以在 Tables.jl 的手册中阅读详细信息,但通常您需要定义一些有意义的东西,例如列的名称,列访问器和包将完成其他所有工作。我在这里显示结果,没有进一步的解释。

using Tables

Tables.istable(::Type{<:BenchmarkTools.Trial}) = true
Tables.columnaccess(::Type{<:BenchmarkTools.Trial}) = true
Tables.columns(m::BenchmarkTools.Trial) = m
Tables.columnnames(m::BenchmarkTools.Trial) = [:times, :gctimes, :memory, :allocs]
Tables.schema(m::BenchmarkTools.Trial) = Tables.Schema(Tables.columnnames(m), (Float64, Float64, Int, Int))
function Tables.getcolumn(m::BenchmarkTools.Trial, i::Int)
    i == 1 && return m.times
    i == 2 && return m.gctimes
    i == 3 && return fill(m.memory, length(m.times))
    return fill(m.allocs, length(m.times))
end
Tables.getcolumn(m::BenchmarkTools.Trial, nm::Symbol) = Tables.getcolumn(m, nm == :times ? 1 : nm == :gctimes ? 2 : nm == :memory ? 3 : 4)

我们可以看到它确实有效(几乎是神奇的)

julia> DataFrame(int_a_benchmark)
10000×4 DataFrame
   Row │ times    gctimes  memory  allocs
       │ Float64  Float64  Int64   Int64
───────┼──────────────────────────────────
     1 │   309.0      0.0       0       0
     2 │    38.0      0.0       0       0
     3 │    25.0      0.0       0       0
     4 │    37.0      0.0       0       0
   ⋮   │    ⋮        ⋮       ⋮       ⋮

下一步是将所有数据帧合并到一个数据帧中。我们应该进行以下步骤:

  1. 将基准试验转换为数据框
  2. 添加带有相关基准名称的列 name
  3. 将它们全部组合在一起(使用 vcat 函数)

当然,您可以为每个数据帧一个接一个地执行所有这些步骤,但是它太长了(而且很无聊,是的)。相反,我们可以使用神奇的 mapreduce 函数,即所谓的 Do-Block syntaxmap部分将准备必要的数据帧,reduce将它们组合在一起

df_benchmark = mapreduce(vcat, zip([int_a_benchmark, int_b_benchmark, float_a_benchmark, float_b_benchmark],
        ["int_a_benchmark", "int_b_benchmark", "float_a_benchmark", "float_b_benchmark"])) do (x, y)
    df = DataFrame(x)
    df.name .= y
    df
end

现在是最后一部分。我们有漂亮的大型 DataFrame,我们希望对其进行聚合。为此,我们可以使用 DataFrames

Split-Apply-Combine 策略
julia> combine(groupby(df_benchmark, :name), 
                :times => minimum => :minimum,
                :times => median => :median,
                :times => maximum => :maximum,
                :allocs => first => :allocations,
                :memory => first => :memory_estimate)

4×6 DataFrame
 Row │ name               minimum  median   maximum  allocations  memory_estimate
     │ String             Float64  Float64  Float64  Int64        Int64
─────┼────────────────────────────────────────────────────────────────────────────
   1 │ int_a_benchmark       22.0     24.0   3252.0            0                0
   2 │ int_b_benchmark       20.0     23.0    489.0            0                0
   3 │ float_a_benchmark     21.0     23.0    134.0            0                0
   4 │ float_b_benchmark     21.0     23.0    129.0            0                0

作为奖励,在 Chain.jl 包的帮助下,最后的计算看起来会更好:

using Chain

@chain df_benchmark begin
    groupby(:name)
    combine(:times => minimum => :minimum,
            :times => median => :median,
            :times => maximum => :maximum,
            :allocs => first => :allocations,
            :memory => first => :memory_estimate)
end