库设计#

在高层面上,cuDF 分为三层,每一层都有其独特的目的:

  1. 框架层:面向用户的类似pandas的数据结构实现,如DataFrameSeries

  2. 列层:用于弥合与我们的低级实现之间差距的核心内部数据结构。

  3. Cython层:围绕快速的C++ libcudf库的封装。

在本文档中,我们将回顾每一层、它们的作用以及必要的权衡。 最后,我们将这些部分结合起来,以提供对项目更全面的看法。

框架层#

../../_images/frame_class_diagram.png

这个类图展示了Frame层主要组件之间的关系: Frame层中的所有类都继承自该层中的一个或两个基类:FrameBaseIndex。 同名的 Frame 类本质上是一个由列数据组成的简单表格数据结构。 某些类型的 Frame 包含索引;特别是任何 DataFrameSeries 都有一个索引。 然而,作为列数据的通用容器,Frame 也是大多数索引类型的父类。

BaseIndex,同时,本质上是一个抽象基类,编码了pandas.Index API。 BaseIndex的各种子类根据其底层数据以特定方式实现此API。 例如,RangeIndex避免了实际物化列,而MultiIndex包含多个列。 大多数其他索引类由给定类型的单列组成,例如字符串或日期时间。 因此,使用单个抽象父类提供了我们支持这些不同类型所需的灵活性。

在完成这些准备工作之后,让我们更深入地探讨一下。

框架#

Frame 暴露了许多所有 pandas 数据结构共有的方法。 任何在 SeriesDataFrameIndex 之间具有相同 API 的方法都应在此定义。 此外,任何可以用于在这些类之间共享代码的(内部)方法也可以在此定义。

Frame 的主要内部子类是 IndexedFrame,这是一个带有索引的 FrameIndexedFrame 表示上述提到的第一种对象类型:带索引的表格。 特别是,IndexedFrameDataFrameSeries 的父类。 任何为这两个类定义的 pandas 方法都应在此处定义。

Frame 的第二个内部子类是 SingleColumnFrame。 正如你可能推测的那样,它是一个具有单列数据的 Frame。 这个类是大多数索引类型以及 Series 的父类(注意这里的菱形继承模式)。 虽然 IndexedFrame 提供了大量的功能,但这个类要简单得多。 它添加了一些由所有一维 pandas 对象提供的简单 API,并在需要时扁平化输出。

索引#

虽然我们之前已经强调了一些索引的特殊情况,但让我们首先从这里的基本情况开始。 BaseIndex 旨在成为一个纯抽象类,即它的所有方法都应该简单地引发 NotImplementedError。 实际上,BaseIndex 确实有一小部分方法的具体实现。 然而,目前这些实现中的许多并不适用于所有子类,最终将被移除。

几乎所有索引都是 Index 的子类,这是一个单列索引,具有以下类层次结构:

class Index(SingleColumnFrame, BaseIndex)

整数、浮点数或字符串索引都由单列数据组成。 大多数Index方法都是从Frame继承的,这省去了我们重写它们的麻烦。

我们现在考虑这个模型的三个主要例外:

  • 一个 RangeIndex 不是由数据列支持的,因此它直接继承自 BaseIndex。 在可能的情况下,它的方法有特殊的实现,旨在避免具体化列。 如果这种实现不可行,我们会退而求其次,首先将其转换为 int64 类型的 Index

  • 一个 MultiIndex多个数据列支持。因此,它的继承层次结构看起来像 class MultiIndex(Frame, BaseIndex)。它的一些更类似于 Frame 的方法可能会被继承,但许多其他方法必须重新实现,因为在许多情况下,MultiIndex 不应该表现得像 Frame

  • 为了在不同索引类之间共享构造函数逻辑,我们将BaseIndex定义为所有索引的父类。Index继承自BaseIndex,但它伪装成BaseIndex以匹配pandas。

列层#

cuDF 堆栈的下一层是列层。 这一层形成了类似 pandas 的 API 与我们底层数据布局之间的桥梁。 列层中的主要对象是 ColumnAccessor 和各种 Column 类。 Column 是 cuDF 的核心数据结构,表示特定数据类型的单列数据。 ColumnAccessor 是一个类似字典的接口,用于访问一系列 ColumnFrame 拥有一个 ColumnAccessor

列访问器#

ColumnAccessor的主要目的是封装pandas列选择语义。可以通过索引或标签选择或插入列,基于标签的选择与pandas一样灵活。例如,可以使用元组分层选择列或通过通配符选择列。ColumnAccessor还支持由groupbys等操作产生的MultiIndex列。

#

在底层,cuDF 是围绕 Apache Arrow 格式 构建的。 这种数据格式既有利于高性能算法,也适合库之间的数据交换。 Column 类封装了我们对该数据格式的实现。 一个 Column 由以下部分组成:

  • 一个数据类型,指定每个元素的类型。

  • 一个数据缓冲区,可能存储列元素的数据。某些列类型没有数据缓冲区,而是将数据存储在子列中。

  • 一个掩码缓冲区,其位表示每个元素的有效性(空或非空)。 空值是Arrow数据模型中的一个核心概念。 元素全部有效的列可能没有掩码缓冲区。 掩码缓冲区被填充到64字节。

  • 它的子元素,一个用于表示复杂类型(如结构体或列表)的列元组。

  • 一个 size 表示列中元素的数量。

  • 一个整数 offset 用于表示列的第一个元素,该列是另一列的“切片”。 然后,列的大小给出了切片的范围,而不是底层缓冲区的大小。 不是切片的列的偏移量为0。

有关这些字段的更多信息可以在Apache Arrow 列式格式的文档中找到,这是 cuDF Column 所基于的。

Column 类在 Cython 中实现,以便与 libcudf 的 C++ 数据结构进行互操作。 大多数高级功能在 ColumnBase 子类中实现。 这些函数依赖 Column API 来调用 libcudf API 并将其结果转换为 Python。 这种分离使得 ColumnBase 可以在纯 Python 中实现,从而简化了开发和调试。

ColumnBase 提供了一些标准方法,而其他方法仅对特定类型的数据有意义。 因此,我们有各种 ColumnBase 的子类,如 NumericalColumnStringColumnDatetimeColumn。 大多数特定于数据类型的决策应在特定 Column 子类的级别处理。 每种类型的 Column 仅实现该数据类型支持的方法。

不同类型的ColumnBase根据Arrow格式在内存中的存储方式也不同。 例如,一个包含1000个int32元素且包含空值的NumericalColumn由以下部分组成:

  1. 一个大小为4000字节的数据缓冲区(sizeof(int32) * 1000)

  2. 一个大小为128字节的掩码缓冲区(1000/8填充到64字节的倍数)

  3. 没有子列

再举一个例子,支持Series ['do', 'you', 'have', 'any', 'cheese?']StringColumn由以下部分组成:

  1. 无数据缓冲区

  2. 没有掩码缓冲区,因为Series中没有空值

  3. 两个子列:

    • 一列UTF-8字符 ['d', 'o', 'y', 'o', 'u', 'h', ..., '?']

    • 一列指向字符列的“偏移量”(在这种情况下, [0, 2, 5, 9, 12, 19])

数据类型#

cuDF 使用 dtypes 来表示不同类型的数据。 由于高效的 GPU 算法需要预先了解数据布局, cuDF 不支持任意的 object 数据类型,而是定义了一些常见用例的自定义类型:

  • ListDtype: 列表,其中列中每个列表的每个元素都是相同类型的

  • StructDtype: 字典,其中给定的键始终映射到相同类型的值

  • CategoricalDtype: 类似于 pandas 的分类数据类型,不同之处在于类别存储在设备内存中

  • DecimalDtype: 定点数

  • IntervalDtype: 区间

请注意,数据类型和Column类之间存在多对一的映射关系。 例如,所有数值类型(不同宽度的浮点数和整数)都使用NumericalColumn进行管理。

缓冲区#

Column 由一个或多个 Buffer 组成。 一个 Buffer 表示由另一个对象拥有的单个、连续的设备内存分配。 从预先存在的设备内存分配(如 CuPy 数组)构造的 Buffer 将查看该内存。 相反,当从主机对象构造时, Buffer 使用 rmm.DeviceBuffer 来分配新内存。 然后将数据从主机对象复制到新分配的设备内存中。 您可以在此处阅读更多关于 使用 RMM 进行设备内存分配 的信息。

溢出到主机内存#

设置环境变量 CUDF_SPILL=on 可以启用从设备到主机的自动缓冲溢出(和“反溢出”),以支持超出内存的计算,即在对象占用的内存超过GPU可用内存时进行计算。

可以通过两种方式启用溢出(默认情况下是禁用的):

  • 设置环境变量 CUDF_SPILL=on,或者

  • 通过执行 cudf.set_option("spill", True) 来设置 cudf 中的 spill 选项。

此外,参数包括:

  • CUDF_SPILL_ON_DEMAND=ON / cudf.set_option("spill_on_demand", True),它注册了一个RMM内存不足错误处理程序,该程序会释放缓冲区以释放内存。如果启用了溢出,则按需溢出默认启用

  • CUDF_SPILL_DEVICE_LIMIT= / cudf.set_option("spill_device_limit", ),设置设备内存限制为字节。这会引入适度的开销,并且默认情况下是禁用的。此外,这是一个限制。如果太多缓冲区无法溢出,内存使用量可能会超过限制。

设计#

溢出由两个组件组成:

  • 一个新的缓冲区子类,SpillableBuffer,它实现了将其数据从主机内存移动到设备内存的功能。

  • 一个溢出管理器,用于跟踪所有SpillableBuffer实例,并根据需求进行溢出。当启用溢出时,cudf中会使用一个全局溢出管理器,这使得as_buffer()返回SpillableBuffer而不是默认的Buffer实例。

访问 Buffer.get_ptr(...),我们获取缓冲区的设备内存指针。在 Buffer 的情况下这是没有问题的,但当访问 SpillableBuffer.get_ptr(...) 时会发生什么,它可能已经将其设备内存溢出。在这种情况下,SpillableBuffer 需要在返回其设备内存指针之前取消溢出。此外,当这个设备内存指针正在使用(或可能被使用)时,SpillableBuffer 不能将其内存溢出回主机内存,因为这样做会使设备指针无效。

为了解决这个问题,我们将SpillableBuffer标记为不可溢出,我们说缓冲区已经被暴露。如果设备指针暴露给外部项目,这可能是永久性的,或者在libcudf访问设备内存时是临时的。

SpillableBuffer.get_ptr(...) 返回缓冲区内存的设备指针,但如果在 acquire_spill_lock 装饰器/上下文中调用,缓冲区仅在装饰器/上下文中运行时被标记为不可溢出。

统计#

cuDF 支持溢出统计,这对于性能分析和识别导致缓冲区不可溢出的代码非常有用。

信息收集分为三个层次:

  1. 禁用(无开销)。

  2. 收集持续时间和溢出字节数的统计信息(非常低的开销)。

  3. 收集每次可溢出缓冲区永久暴露的统计信息(可能的高开销)。

统计可以通过两种方式启用(默认情况下是禁用的):

  • 设置环境变量 CUDF_SPILL_STATS=,或者

  • 通过执行 cudf.set_option("spill_stats", ) 来设置 cudf 中的 spill_stats 选项。

可以通过溢出管理器访问统计信息,如下所示:

>>> import cudf
>>> from cudf.core.buffer.spill_manager import get_global_manager
>>> stats = get_global_manager().statistics
>>> print(stats)
    Spill Statistics (level=1):
     Spilling (level >= 1):
      gpu => cpu: 24B in 0.0033

要让dask中的每个工作进程打印溢出统计信息,可以这样做:

    def spill_info():
        from cudf.core.buffer.spill_manager import get_global_manager
        print(get_global_manager().statistics)
    client.submit(spill_info)

Cython层#

cuDF 的最低层次是通过 Cython 与 libcudf 的交互。 Cython 层由两个组件组成:C++ 绑定和 Cython 包装器。 第一个组件包括 .pxd 文件, 这些是 Cython 声明文件,用于将 C++ 头文件的内容暴露给其他 Cython 文件。 第二个组件包括用于此功能的 Cython 包装器。 这些包装器是必要的,以便将此功能暴露给纯 Python 代码。 它们还负责将 cuDF 对象转换为它们的 libcudf 等效对象,并调用 libcudf 函数。

使用这一层的cuDF需要对libcudf的API有一定的熟悉度。 libcudf围绕两个主要对象构建,它们的名称在很大程度上是不言自明的:columntablelibcudf还定义了相应的非拥有“视图”类型column_viewtable_viewlibcudf的API通常接受视图并返回拥有类型。

大多数 cuDF Cython 包装器涉及将 cudf.Column 对象转换为 column_viewtable_view 对象, 使用这些参数调用 libcudf API,然后从结果中构造新的 cudf.Column。 当代码到达这一层时,所有关于 pandas 兼容性的问题应该已经解决。 这些函数应尽可能接近 libcudf API 的简单包装器。

将所有内容整合在一起#

到目前为止,我们的讨论假设所有cuDF函数都严格遵循这些层次的线性下降。然而,应该清楚的是,在许多情况下,这种方法并不合适。许多常见的Frame操作不是针对单个列进行操作,而是针对整个Frame进行操作。因此,实际上我们在cuDF中有两种不同的常见实现模式。

  1. 第一种模式是针对单独作用于Frame列的操作。 这组任务包括归约和扫描(sum/cumsum)。 这些操作通常通过循环存储在FrameColumnAccessor中的列来实现。

  2. 第二种模式适用于涉及同时对多列进行操作的操作。 这一组包括许多核心操作,如分组或合并。 这些操作完全绕过了列层,直接从框架转到Cython。

pandas API 还包括许多辅助对象,例如 GroupByRollingResampler。 cuDF 实现了具有相同 API 的相应对象。 在内部,这些对象通常通过组合与 Frame 层的 cuDF 对象进行交互。 然而,出于性能原因,它们经常访问 Frame 及其子类的内部属性和方法。

写时复制#

本节描述了写时复制功能的内部实现细节。 建议开发者在阅读下面的内部实现之前,先熟悉面向用户的文档

核心的写时复制实现依赖于ExposureTrackedBufferBufferOwner的跟踪功能。

BufferOwner 跟踪其底层内存的内部和外部引用。内部引用通过维护对底层内存的每个 ExposureTrackedBuffer弱引用 来跟踪。外部引用通过底层内存的“暴露”状态来跟踪。如果设备指针(整数或 void*)已经传递给 cudf 外部的库,则认为缓冲区已暴露。在这种情况下,我们无法知道数据是否被第三方修改。

ExposureTrackedBufferBuffer 的一个子类,它表示曝光跟踪缓冲区底层内存的一个切片

当cudf选项"copy_on_write"True时,as_buffer返回一个ExposureTrackedBuffer。正是这个类决定了在对Column执行写操作时是否进行复制(见下文)。如果多个切片指向相同的基础内存,则在尝试修改时必须进行复制。

向第三方库暴露时的急切复制#

如果一个Column/ExposureTrackedBuffer通过__cuda_array_interface__暴露给第三方库,我们将无法再跟踪缓冲区是否发生了修改。因此,每当有人通过__cuda_array_interface__访问数据时,我们会通过调用.make_single_owner_inplace来急切地触发复制,以确保生成底层数据的真实副本,并且该切片是唯一的所有者。任何未来的复制请求也必须触发真正的物理复制(因为我们无法跟踪第三方对象的生命周期)。为了处理这种情况,我们还将Column/ExposureTrackedBuffer标记为已暴露,从而表明任何未来的浅复制请求将触发真正的物理复制,而不是写时复制的浅复制。

获取只读对象#

只读对象对于不会改变数据的操作非常有用。这可以通过调用.get_ptr(mode="read")并使用cuda_array_interface_wrapper来包装__cuda_array_interface__对象来实现。 即使多个ExposureTrackedBuffer指向同一个ExposureTrackedBufferOwner,这也不会触发深拷贝。此API应仅在代理对象的生命周期限制在cudf的内部代码执行时使用。将其传递给外部库或面向用户的API将导致未跟踪的引用和未定义的写时复制行为。我们目前将此API用于设备到主机的复制,例如在ColumnBase.data_array_view(mode="read")中,该函数用于Column.values_host

内部访问原始数据指针#

由于在启用写时复制时访问与缓冲区关联的原始指针是不安全的,除了上述的只读代理对象外,访问指针是通过Buffer.get_ptr进行的。该方法接受一个模式参数,调用者通过该参数指示他们将如何访问与缓冲区关联的数据。如果只需要只读访问(mode="read"),这表明调用者无意通过此指针修改缓冲区。在这种情况下,任何浅拷贝都不会被解除链接。相反,如果需要修改,可以传递mode="write",从而触发任何浅拷贝的解除链接。

可变宽度数据类型#

弱引用仅针对固定宽度的数据类型实现,因为这些是唯一可以在原地进行突变的列类型。 对于可变宽度数据类型的深拷贝请求,总是返回列的浅拷贝,因为这些类型不支持数据的真正原地突变。 在内部,我们使用_mimic_inplace来模拟原地突变,但生成的数据始终是基础数据的深拷贝。

示例#

当启用写时复制时,对SeriesDataFrame进行浅拷贝不会立即创建数据的副本。相反,它会生成一个视图,该视图将在对其任何副本执行写操作时延迟复制。

让我们创建一个系列:

>>> import cudf
>>> cudf.set_option("copy_on_write", True)
>>> s1 = cudf.Series([1, 2, 3, 4])

复制 s1:

>>> s2 = s1.copy(deep=False)

再复制一份,但是是s2的副本:

>>> s3 = s2.copy(deep=False)

查看数据和内存地址显示它们都指向相同的设备内存:

>>> s1
0    1
1    2
2    3
3    4
dtype: int64
>>> s2
0    1
1    2
2    3
3    4
dtype: int64
>>> s3
0    1
1    2
2    3
3    4
dtype: int64

>>> s1.data._ptr
139796315897856
>>> s2.data._ptr
139796315897856
>>> s3.data._ptr
139796315897856

现在,当我们在其中一个执行写操作时,比如在 s2 上,会在设备上为 s2 创建一个新的副本,然后进行修改:

>>> s2[0:2] = 10
>>> s2
0    10
1    10
2     3
3     4
dtype: int64
>>> s1
0    1
1    2
2    3
3    4
dtype: int64
>>> s3
0    1
1    2
2    3
3    4
dtype: int64

如果我们检查数据的内存地址,s1s3 仍然共享相同的地址,但 s2 有一个新的地址:

>>> s1.data._ptr
139796315897856
>>> s3.data._ptr
139796315897856
>>> s2.data._ptr
139796315899392

现在,对 s1 执行写操作将触发设备内存上的新副本,因为 s3 中共享了一个弱引用:

>>> s1[0:2] = 11
>>> s1
0    11
1    11
2     3
3     4
dtype: int64
>>> s2
0    10
1    10
2     3
3     4
dtype: int64
>>> s3
0    1
1    2
2    3
3    4
dtype: int64

如果我们检查数据的内存地址,s2s3 的地址保持不变,但由于在写入过程中执行的复制操作,s1 的内存地址发生了变化:

>>> s2.data._ptr
139796315899392
>>> s3.data._ptr
139796315897856
>>> s1.data._ptr
139796315879723

cuDF 的写时复制实现受到这里记录的 pandas 提案的启发:

  1. Google doc

  2. Github issue