库设计#
注意
此页面已严重过时!它将在 25.04 版本中更新,以反映 cuDF 的当前状态。包括 libcudf、pylibcudf、cudf classic、cudf.pandas 和 cudf.polars。
从较高层面来看,cuDF 由三个层组成,每个层都有其独特用途
Frame 层:面向用户的 pandas 类数据结构(如
DataFrame
和Series
)的实现。Column 层:用于弥合与低层实现之间差距的核心内部数据结构。
Cython 层:围绕快速 C++ 库
libcudf
的封装器。
本文档将回顾这些层、它们的作用以及必要的权衡。最后,我们将这些部分联系起来,提供更全面的项目视图。
Frame 层#

此类图显示了 Frame 层主要组件之间的关系:Frame 层中的所有类都继承自此层的两个基类中的一个或两个:Frame
和 BaseIndex
。Frame
类本身核心是一个简单表格数据结构,由列式数据组成。某些类型的 Frame
包含索引;特别是,任何 DataFrame
或 Series
都具有索引。然而,作为列式数据的通用容器,Frame
也是大多数索引类型的父类。
同时,BaseIndex
本质上是一个抽象基类,编码了 pandas.Index
API。BaseIndex
的各种子类根据其底层数据以特定方式实现此 API。例如,RangeIndex
避免实际实例化列,而 MultiIndex
包含 多个 列。大多数其他索引类由给定类型(例如字符串或日期时间)的单列组成。因此,使用单个抽象父类提供了支持这些不同类型所需的灵活性。
有了这些初步知识,让我们深入探讨一下。
Frames#
Frame
暴露了所有 pandas 数据结构共有的许多方法。在 Series
、DataFrame
和 Index
中具有相同 API 的任何方法都应在此处定义。此外,可用于在这些类之间共享代码的任何(内部)方法也可以在此处定义。
Frame
的主要内部子类是 IndexedFrame
,它是一个带索引的 Frame
。IndexedFrame
表示上述第一类对象:带索引的表。特别是,IndexedFrame
是 DataFrame
和 Series
的父类。为这两个类定义的任何 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 的核心数据结构,表示特定数据类型的单列数据。ColumnAccessor
是 Column
序列的字典式接口。Frame
拥有一个 ColumnAccessor
。
ColumnAccessor#
ColumnAccessor
的主要目的是封装 pandas 列选择语义。可以按索引或标签选择或插入列,基于标签的选择与 pandas 一样灵活。例如,可以按层次结构(使用元组)或通过通配符选择列。ColumnAccessor
s 还支持由 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
的各种子类,如 NumericalColumn
、StringColumn
和 DatetimeColumn
。大多数数据类型特定的决策应在特定 Column
子类级别处理。每种类型的 Column
仅实现该数据类型支持的方法。
不同类型的 ColumnBase
也根据 Arrow 格式在内存中以不同方式存储。例如,一个包含 1000 个 int32
元素并包含 null 的 NumericalColumn
由以下部分组成
大小为 4000 字节(sizeof(int32) * 1000)的数据缓冲区
大小为 128 字节(1000/8 填充到 64 字节的倍数)的掩码缓冲区
无子列
再例如,支持 Series ['do', 'you', 'have', 'any', 'cheese?']
的 StringColumn
由以下部分组成
无数据缓冲区
无掩码缓冲区,因为 Series 中没有 null 值
两个子列
一个 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
进行管理。
缓冲区#
Column
s 又由一个或多个 Buffer
s 组成。一个 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 支持溢出统计,这对于性能分析和识别导致缓冲区不可溢出的代码非常有用。
存在三个级别的信息收集
禁用(无开销)。
收集溢出持续时间和字节数的统计信息(非常低的开销)。
收集可溢出缓冲区每次被永久暴露的统计信息(潜在高开销)。
可以通过两种方式启用统计信息(默认禁用)
设置环境变量
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
是围绕两个主要对象构建的,它们的名称很大程度上是自解释的:column
和 table
。libcudf
还定义了相应的非拥有“视图”类型 column_view
和 table_view
。libcudf
API 通常接受视图并返回拥有类型。
大多数 cuDF Cython 封装器涉及将 cudf.Column
对象转换为 column_view
或 table_view
对象,使用这些参数调用 libcudf
API,然后从结果构造新的 cudf.Column
。当代码到达这一层时,所有关于 pandas 兼容性的问题都应该已经解决。这些函数应该尽可能接近对 libcudf
API 的简单封装。
整合所有部分#
至此,我们的讨论假定所有 cuDF 函数都严格遵循通过这些层的线性下降。然而,显然在许多情况下,这种方法并不适用。许多常见的 Frame
操作不是作用于单个列,而是作用于整个 Frame
。因此,实际上我们在 cuDF 中有两种不同的常见实现模式。
第一种模式适用于分别作用于
Frame
列的操作。此组包括约简和扫描(sum
/cumsum
)等任务。这些操作通常通过遍历存储在Frame
的ColumnAccessor
中的列来实现。第二种模式适用于同时作用于多个列的操作。此组包括许多核心操作,如分组或合并。这些操作完全绕过 Column 层,直接从 Frame 到 Cython。
pandas API 还包括一些辅助对象,例如 GroupBy
、Rolling
和 Resampler
。cuDF 实现了具有相同 API 的对应对象。在内部,这些对象通常通过组合与 Frame 层的 cuDF 对象交互。然而,出于性能原因,它们经常访问 Frame
及其子类的内部属性和方法。
写时复制#
本节介绍写时复制功能的内部实现细节。建议开发人员在阅读下面的内部细节之前,先熟悉此功能的用户文档。
核心写时复制实现依赖于 ExposureTrackedBuffer
和 BufferOwner
的跟踪功能。
BufferOwner
跟踪对其底层内存的内部和外部引用。内部引用通过维护对底层内存的每个 ExposureTrackedBuffer
的弱引用来跟踪。外部引用通过底层内存的“暴露”状态来跟踪。如果设备指针(整数或 void*)已交出给 cudf 外部的库,则该缓冲区被视为已暴露。在这种情况下,我们无法知道第三方是否正在修改数据。
ExposureTrackedBuffer
是 Buffer
的子类,表示暴露跟踪缓冲区的底层内存的 切片。
当 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
模拟就地修改,但结果数据始终是底层数据的深层复制。
示例#
启用写时复制时,对 Series
或 DataFrame
进行浅层复制不会立即创建数据的副本。相反,它会生成一个视图,当对其任何副本执行写操作时,该视图将被惰性复制。
让我们创建一个 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
如果我们检查数据的内存地址,s1
和 s3
仍然共享相同的地址,但 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
如果我们检查数据的内存地址,s2
和 s3
的地址保持不变,但 s1
的内存地址因写入期间执行的复制操作而发生变化
>>> s2.data._ptr
139796315899392
>>> s3.data._ptr
139796315897856
>>> s1.data._ptr
139796315879723
cuDF 的写时复制实现受此处记录的 pandas 提案的启发