基准测试 cuDF#

此仓库中基准测试的目标是测量各种cuDF API的性能。 cuDF中的基准测试是使用 pytest-benchmark插件到 pytest Python测试框架编写的。 使用pytest-benchmark为熟悉pytest的开发者提供了无缝体验。 我们包括公共API和内部函数的基准测试。 前者为我们提供了性能的宏观视图,特别是与pandas相比。 后者帮助我们量化和最小化Python绑定的开销。

注意

我们当前的基准测试完全集中在测量运行时间上。 然而,在某些情况下,最小化内存占用同样重要。 未来,我们可能会更新我们的基准测试,以包括内存使用量的测量。

基准组织#

在顶层基准测试中,分为internalAPI目录。 API基准测试用于我们期望用户使用的公共功能。 内部基准测试捕获了没有稳定性保证的cuDF内部性能。

在每个目录中,基准测试根据函数类型进行组织。 cuDF 中的函数通常分为两类:

  1. 类的方法,如 DataFrameSeries

  2. 对上述类进行操作的免费函数,如 cudf.merge

前者应组织到名为bench_class.py的文件中。 例如,DataFrame.eval的基准测试应放在API/bench_dataframe.py中。 基准测试应尽可能在类层次结构的最高级别上编写。 例如,所有类都支持take方法,因此这些基准测试应放在API/bench_frame_or_index.py中。 如果一个方法对于不同的类有稍微不同的API,基准测试应使用最小的共同API, 除非开发人员期望某些参数触发具有非常不同性能特征的代码路径。 一个例子是DataFrame.where,它支持广泛的输入(如其他DataFrames),而其他类不支持。 因此,除了所有FrameIndex类的通用基准测试外,我们还有单独的DataFrame基准测试。

注意

pytest 不支持有两个同名的基准测试文件,即使它们位于不同的目录中。 因此,公共类的内部方法的基准测试应放在以 _internal 为后缀的文件中。 例如,DataFrame._apply_boolean_mask 的基准测试应放在 internal/bench_dataframe_internal.py 中。

自由函数具有更大的灵活性。 一般来说,它们应该被分组到包含相似功能的基准文件中。 例如,I/O基准测试都可以放在bench_io.py中。 目前,这些分组由开发人员自行决定。

运行基准测试#

默认情况下,pytest 会发现以 test_ 为前缀的测试文件和函数。 对于基准测试,我们配置 pytest 以使用 bench_ 前缀进行搜索。 安装 pytest-benchmark 后,运行基准测试就像运行 pytest 一样简单。

当基准测试运行时,默认行为是将结果以表格形式输出到终端。 一个常见的需求是比较更改前后的基准测试性能。 我们可以通过使用 --benchmark-autosave 选项保存输出来生成这些比较。 使用此选项时,基准测试运行后,输出将包含一行:

Saved benchmark data in: /path/to/XXXX_*.json

XXXX 是一个四位数的标识符,用于识别基准测试。 如果用户愿意,也可以使用 --benchmark-save=NAME 选项, 这样可以更好地控制生成的文件名。 给定两个基准测试运行 XXXXYYYY,可以使用以下方式进行比较

pytest-benchmark compare XXXX YYYY

请注意,比较使用的是pytest-benchmark命令,而不是pytest命令。 pytest-benchmark有许多额外的选项可以用来自定义输出。 下一行包含一个有用的示例,但开发人员应该尝试找到有用的输出。

pytest-benchmark compare XXXX YYYY --sort="name" --columns=Mean --name=short --group-by=param

更多详情,请参阅pytest-benchmark 文档

基准测试内容#

基准配置#

基准测试必须支持与pandas进行比较作为测试运行。 为了满足这些要求,编写基准测试时必须遵循以下规则:

  1. 从配置模块导入 cudfcupy

        from ..common.config import cudf, cupy # 这样做
        import cudf, cupy # 不要这样做
    

    这样可以分别替换为 pandasnumpy

  2. 避免硬编码基准数据集的大小,而是使用config.py中公布的大小。 这使得可以在小数据集上以“测试”模式运行基准测试,这将更快。

编写基准测试#

正如基准测试应该根据层次结构中的最高级别类来编写,它们也应该尽可能少地假设数据的性质。例如,除非存在有意义的功能差异,否则基准测试不应关心数据的dtype或可空性。在这些方面不同的对象对于大多数基准测试应该是可互换的。以这种方式编写基准测试的目标是自动对具有不同属性的对象进行基准测试。我们通过benchmark_with_object装饰器支持这种用例。

这个装饰器的使用最好通过示例来演示:

@benchmark_with_object(cls="dataframe", dtype="int", cols=6)
def bench_foo(benchmark, dataframe):
    benchmark(dataframe.foo)

在上面的例子中,bench_foo 将针对包含六列整数数据的DataFrames运行。 装饰器允许自动参数化以下对象属性:

  • cls: 特定类的对象,例如 DataFrame

  • dtype: 特定数据类型的对象。

  • nulls: 包含和不包含空值的对象。

  • cols: 具有特定列数的对象。

  • rows: 具有特定行数的对象。

在示例中,由于我们没有指定行数或可空性,它将为每个有效的行数以及可空和不可空的数据运行一次。所有参数的有效集合(例如行数)存储在common/config.py文件中。这个装饰器允许开发者编写一个适用于多种类型对象的通用基准测试,然后让该基准测试自动为所有感兴趣的对象运行。

参数化测试#

benchmark_with_object 装饰器涵盖了大多数用例,并自动保证了基准测试的覆盖范围。 然而,许多基准测试将需要更定制的对象。 在某些情况下,这些对象将是调用方法的主要目标。 例如,基准测试可能需要一个具有特定数据分布的 Series。 在其他情况下,这些对象将作为参数传递给其他函数。 一个例子是 DataFrame.where,它接受多种类型的对象来进行过滤。

在第一种情况下,fixtures 应遵循某些规则。 编写 fixtures 时,开发者应使数据大小依赖于基准测试配置。 benchmarks/common/config.py 文件定义了在基准测试中使用的标准数据大小。 这些数据大小可以为了调试目的进行调整(参见下面的 测试基准)。 Fixture 的大小应相对于配置模块中定义的 NUM_ROWS 和/或 NUM_COLS 变量。 这些规则确保了这些 fixtures 与 benchmark_with_object 提供的 fixtures 之间的一致性。

与pandas比较#

对cuDF进行基准测试的一个重要方面是将其与pandas进行比较。 我们经常希望生成定量比较,因此我们需要尽可能简化这一过程。 我们的基准测试通过设置环境变量CUDF_BENCHMARKS_USE_PANDAS来支持这一点。 当检测到此变量时,所有基准测试将自动使用pandas而不是cuDF运行。 因此,只需运行基准测试两次,一次设置变量,一次不设置,就可以轻松生成比较。 请注意,此变量仅影响API基准测试,而不影响内部基准测试, 因为后者甚至不能保证是有效的pandas代码。

注意

CUDF_BENCHMARKS_USE_PANDAS 有效地将 cudf 重新映射为 pandas,并将 cupy 重新映射为 numpy。 这是通过在 common.config.py 中为这些模块设置别名来实现的。 这种别名设置是为什么开发者必须从 config.py 导入这些包的关键原因。

测试基准#

基准测试需要与cuDF中的API更改保持同步。 然而,我们不能简单地在CI中运行基准测试。 这样做会消耗太多资源,并且会显著减慢开发周期。

为了平衡这些问题,我们的基准测试也支持在“测试”模式下运行。 为此,开发者可以设置CUDF_BENCHMARKS_DEBUG_ONLY环境变量。 当基准测试使用此变量运行时,所有数据大小都设置为最小值,并且数据大小的数量也会减少。 我们的CI测试利用这一点来确保基准测试代码保持有效。

注意

benchmark_with_object提供的对象遵循在common/config.py中定义的NUM_ROWSNUM_COLSCUDF_BENCHMARKS_DEBUG_ONLY通过有条件地重新定义这些值来工作。 这就是为什么开发人员在定义自定义夹具或案例时使用这些变量至关重要。

性能分析#

虽然严格来说不是我们基准测试套件的一部分,但性能分析是一个常见的需求,因此我们在这里提供一些指导。 以下是两种简单的基准测试性能分析方法(可能还有其他方法):

  1. pytest-profiling 插件。

  2. py-spy 包。

使用前者就像在pytest调用中添加--profile(或--profile-svg)参数一样简单。 后者则需要从py-spy中调用pytest,如下所示:

py-spy record -- pytest bench_foo.py

每个工具都有不同的优势,并提供略有不同的信息。 开发者应该尝试两者,看看哪种方法适用于特定的工作流程。 也鼓励开发者分享他们发现的有用替代方案。

高级主题#

本节讨论了一些关于cuDF基准测试如何工作的底层细节。 这些内容对于典型的开发者或基准测试编写者通常不是必需的。 这些信息主要面向希望扩展可以轻松进行基准测试的对象类型的开发者。

理解 benchmark_with_object#

在底层,benchmark_with_object 由两个关键部分组成,夹具联合和一些装饰器魔法。

夹具联合#

夹具联合是pytest_cases的一个特性。 夹具联合是一个夹具,当用作测试函数参数时, 将触发测试为联合中包含的每个夹具运行一次。 由于大多数cuDF基准测试可以使用相同的相对较小的对象集运行, 我们的基准测试生成可能夹具的笛卡尔积,然后创建所有可能的联合。

此功能对我们的基准设计至关重要。 对于每个相关的参数组合(大小、可空性等),我们通过编程生成一个新的夹具。 生成的夹具根据以下方案明确命名: {classname}_dtype_{dtype}[_nulls_{true|false}][[_cols_{num_cols}]_rows_{num_rows}]。 如果夹具名称不包含特定组件,则它表示该组件的所有值的并集。 例如,考虑夹具 dataframe_dtype_int_rows_100。 此夹具是具有不同列数的可空和不可空 DataFrame 的并集。

benchmark_with_object 装饰器#

上述联合的长名称在编写测试时很繁琐。此外,将这些信息嵌入名称中意味着,为了更改使用的参数,整个基准测试需要替换夹具名称。benchmark_with_object 装饰器是解决此问题的方法。当在测试函数上使用时,它基本上用真实的夹具替换函数参数名称。在我们上面的原始示例中

@benchmark_with_object(cls="dataframe", dtype="int", cols=6)
def bench_foo(benchmark, dataframe):
    benchmark(dataframe.foo)

在功能上等同于

def bench_foo(benchmark, dataframe_dtype_int_cols_6):
    benchmark(dataframe_dtype_int_cols_6.foo)