库设计#

注意

此页面已严重过时!它将在 25.04 版本中更新,以反映 cuDF 的当前状态。包括 libcudf、pylibcudf、cudf classic、cudf.pandas 和 cudf.polars。

从较高层面来看,cuDF 由三个层组成,每个层都有其独特用途

  1. Frame 层:面向用户的 pandas 类数据结构(如 DataFrameSeries)的实现。

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

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

本文档将回顾这些层、它们的作用以及必要的权衡。最后,我们将这些部分联系起来,提供更全面的项目视图。

Frame 层#

../../_images/frame_class_diagram.png

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

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

有了这些初步知识,让我们深入探讨一下。

Frames#

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。

Column 层#

cuDF 堆栈中的下一层是 Column 层。该层构成了 pandas 类 API 与底层数据布局之间的连接。Column 层中的主要对象是 ColumnAccessor 和各种 Column 类。Column 是 cuDF 的核心数据结构,表示特定数据类型的单列数据。ColumnAccessorColumn 序列的字典式接口。Frame 拥有一个 ColumnAccessor

ColumnAccessor#

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

Columns#

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

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

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

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

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

  • 一个 大小,表示列中的元素数量。

  • 一个整数 偏移量,用于表示作为另一个列的“切片”的列的第一个元素。列的大小然后给出切片的范围,而不是底层缓冲区的大小。非切片的列偏移量为 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 元素并包含 null 的 NumericalColumn 由以下部分组成

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

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

  3. 无子列

再例如,支持 Series ['do', 'you', 'have', 'any', 'cheese?']StringColumn 由以下部分组成

  1. 无数据缓冲区

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

  3. 两个子列

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

    • 一个字符列的“偏移量”列(在此例中为 [0, 2, 5, 9, 12, 19]

数据类型#

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

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

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

  • CategoricalDtype:类似于 pandas 分类数据类型,但类别存储在设备内存中

  • DecimalDtype:定点数

  • IntervalDtype:区间

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

缓冲区#

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

溢出到主机内存#

设置环境变量 CUDF_SPILL=on 可以启用缓冲区从设备到主机的自动溢出(和“取消溢出”),从而实现内存不足时的计算,即对占用内存超过 GPU 可用内存的对象进行计算。

可以通过两种方式启用溢出(默认禁用)

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

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

此外,参数包括

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

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

设计#

溢出由两个组件组成

  • 一个新的缓冲区子类 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=<statistics-level>,或

  • cudf 中通过 cudf.set_option("spill_stats", <statistics-level>) 设置 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 中的每个 worker 打印溢出统计信息,请执行如下操作

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

Cython 层#

注意

截至 25.02 版本,Cython 层中的大部分功能已移至 pylibcudf。剩余的只有 Column 层,它将在未来版本中移除。

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. 第二种模式适用于同时作用于多个列的操作。此组包括许多核心操作,如分组或合并。这些操作完全绕过 Column 层,直接从 Frame 到 Cython。

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

写时复制#

本节介绍写时复制功能的内部实现细节。建议开发人员在阅读下面的内部细节之前,先熟悉此功能的用户文档

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

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

ExposureTrackedBufferBuffer 的子类,表示暴露跟踪缓冲区的底层内存的 切片

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

暴露给第三方库时的立即复制#

如果通过 __cuda_array_interface__Column/ExposureTrackedBuffer 暴露给第三方库,我们将无法再跟踪缓冲区是否发生过修改。因此,无论何时有人通过 __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 参数,调用者通过该参数指示他们将如何访问与缓冲区关联的数据。如果仅需要只读访问(mode="read"),则表示调用者无意通过此指针修改缓冲区。相反,如果需要修改,可以传递 mode="write",这将导致任何浅复制解除链接。

变宽数据类型#

弱引用仅针对固定宽度数据类型实现,因为它们是唯一可以在原位置修改的列类型。对变宽数据类型进行深层复制的请求始终返回 Columns 的浅层复制,因为这些类型不支持数据的实际就地修改。在内部,我们使用 _mimic_inplace 模拟就地修改,但结果数据始终是底层数据的深层复制。

示例#

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

让我们创建一个 Series

>>> 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 文档

  2. Github 问题