本文档为 libcuspatial C++ 代码贡献者提供指南。开发者还应参考以下附加文件,以进一步了解 libcuspatial 的最佳实践文档。
libcuspatial 是一个 C++ 库,提供 GPU 加速的数据并行算法,用于处理地理空间和时空数据。libcuspatial 提供了各种空间关系算法,包括距离计算、包含性(例如,点在多边形内测试)、边界框计算和空间索引。
libcuspatial 目前有两种接口。第一种是基于 libcudf 数据类型(CUDA Dataframe 库的 C++ API)的 C++ API。在本文档中,我们将其称为“基于列的 API”。基于列的 API 将空间数据表示为类型擦除列的表格。
第二种 API 是 cuSpatial 仅头文件 C++ API,它独立于 libcudf,并将数据表示为结构数组(例如,二维点)。仅头文件 API 使用迭代器进行输入和输出,风格类似于 C++ 标准模板库 (STL) 和 Thrust。
本节定义了 libcuspatial 中使用的术语。对于 libcudf 特定的术语,例如 Column、Table 等,请参阅 libcudf 开发者指南。
待办事项:添加术语
外部/公共 libcuspatial API 根据功能分组,存放在 cuspatial/cpp/include/cuspatial/
目录下名称恰当的头文件中。例如,cuspatial/cpp/include/cuspatial/distance.hpp
包含与距离计算相关的公共 API 函数的声明。请注意,.hpp
文件扩展名用于指示可从 .cpp
源文件包含的 C++ 头文件。
头文件应使用 #pragma once
包含守卫。
包含实现 API 的源文件的文件夹应与 API 头文件的名称保持一致。例如,cuspatial/cpp/include/cuspatial/trajectory.hpp
中的 API 实现位于 cuspatial/cpp/src/trajectory
中。这条规则显然不适用于仅头文件 API,因为头文件就是源文件。
同样,单元测试和基准测试也位于与 API 头文件名称对应的文件夹中,例如 distance.hpp 的测试位于 cuspatial/cpp/tests/distance/
中,基准测试位于 cuspatial/cpp/benchmarks/distance/
中。
包含 detail
命名空间定义的内部 API 头文件,这些定义在 libcuspatial 内部的翻译单元之间使用,应放在 include/cuspatial/detail
中。
仅头文件 API 文件和基于列的 API 头文件一起存储在 include/cuspatial
中。前者使用 .cuh
扩展名,因为它们几乎普遍需要 CUDA 编译。后者使用 .hpp
扩展名,因为它们可以使用标准 C++ 编译器编译。
.hpp
: C++ 头文件.cpp
: C++ 源文件.cu
: CUDA C++ 源文件.cuh
: 包含 CUDA 设备代码的头文件仅在必要时使用 .cu
和 .cuh
。一个好的指标是包含了只有 nvcc
才能识别的 __device__
和其他符号。另一个指标是使用了设备执行策略 (在 libcuspatial 中始终是 rmm::exec_policy
) 的 Thrust 算法 API。
libcuspatial 代码对所有名称使用 snake_case (下划线分隔式),少数情况除外:模板参数、单元测试和测试用例名称可以使用 Pascal case(帕斯卡命名法),也称为 UpperCamelCase (大驼峰命名法)。我们不使用 Hungarian notation (匈牙利命名法),除非有时命名设备数据变量及其对应的主机副本(例如 d_data
和 h_data
)。私有成员变量通常以前导下划线开头。
示例
C++ 格式通过 clang-format
enforced (强制执行)。您应该在您的机器上配置 clang-format
,使用 cuspatial/cpp/.clang-format
配置文件,并在提交所有更改的代码之前运行 clang-format
。最简单的方法是配置您的编辑器在保存时格式化,或者使用 pre-commit
。
本文档中未讨论且无法自动强制执行的代码风格方面通常在代码审查期间发现,或不强制执行。
通常,我们建议遵循 C++ Core Guidelines (C++ 核心指南)。我们还建议观看 Sean Parent 的 C++ Seasoning 演讲,并努力遵循他的规则:“没有裸循环 (No raw loops)。没有裸指针 (No raw pointers)。没有裸同步原语 (No raw synchronization primitives)。”在可能的情况下,我们还添加了第四条规则:“没有裸内核 (No raw kernels)。”
std::shared_ptr
和 std::unique_ptr
),而非裸指针。文档在 文档指南 中讨论。
如上所述,尽可能优先使用算法而非裸循环。然而,并非总是能够避免裸循环。C++ 基于范围的 for 循环可以使裸循环更清晰,cuSpatial 为此目的使用了 Ranger。Ranger 提供带有可传递给基于范围 for 循环的迭代器的范围助手。特别重要的是 ranger::grid_stride_range()
,它可用于使用 CUDA 网格的所有线程并行迭代范围。
编写自定义内核时,网格步进范围有助于确保内核适应各种网格形状,特别是在总线程数少于数据项数时。与此相反,
网格步进循环确保即使线程数少于 n,也能处理所有数据
使用 Ranger,代码更加清晰且不易出错
以下指南适用于组织 #include
行。
clang-format
将尊重分组并在组内按字典顺序排序每个包含项。<thrust/...>
,然后是随 cuSpatial 安装的依赖项的包含,最后是标准库头文件(例如 <string>
, <iostream>
)。<>
代替 ""
。clangd
这样的工具通常会在可能的情况下自动插入包含项,但它们通常会搞错分组和括号。"
来包含来自同一相对源目录的本地头文件。这只应发生在源文件和非公共头文件中。否则,在包含的头文件名前后使用尖括号 <>
。..
的相对路径。当从与包含文件不在同一目录的源路径中包含(内部)头文件时,带有 ..
的路径是必需的,因为源路径不随 -I
传递。src
目录中的头文件。如果您发现自己在这样做,请讨论将包含的内部头文件(部分)移至公共头文件。仅头文件 libcuspatial API 不关心应用程序用于存储其数据的容器类型,因为仅头文件 API 基于迭代器(参见迭代器要求)。另一方面,基于 cuDF 的 cuSpatial API 使用 cuDF Column 和 Table 来存储和访问应用程序数据。
有关 cuDF 数据结构(包括视图)的更多信息,请参阅 libcudf 开发者指南。
资源所有权是 libcudf 中的一个重要概念,因此也是基于 cuDF 的 libcuspatial API 中的重要概念。简而言之,“拥有的”对象拥有资源(例如设备内存)。它在构造期间获取资源,并在销毁时释放资源 (RAII)。“非拥有的”对象不拥有资源。libcudf 中任何带有 *_view
后缀的类都是非拥有的。更多详细信息请参见 libcudf++
演示文稿。
基于 cuDF 的 libcuspatial 函数通常接受视图作为输入(column_view
或 table_view
),并生成指向拥有对象的 unique_ptr
作为输出。例如,
rmm::device_memory_resource
)libcuspatial 通过 RMM 内存资源 (MR) 或 CUDA MR 分配所有设备内存。这两种类型都可以通过 rmm::device_async_resource_ref
参数传递给 libcuspatial 函数。有关详细信息,请参阅 RMM 文档。
RMM 为每个设备提供一个“默认”内存资源,可以通过 rmm::mr::get_current_device_resource()
和 rmm::mr::set_current_device_resource(...)
函数分别访问和更新。所有内存资源参数应默认使用 rmm::mr::get_current_device_resource()
的返回值。
内存资源通过 resource ref 参数传递。Resource ref 是内存资源包装器,使消费者能够指定他们期望的资源属性。这些定义在 libcu++ 的 cuda::mr
命名空间中,但 RMM 在 rmm/resource_ref.hpp
中提供了一些便利的包装器。
rmm::device_resource_ref
接受提供同步分配设备可访问内存的内存资源。rmm::device_async_resource_ref
接受提供流有序分配设备可访问内存的内存资源。rmm::host_resource_ref
接受提供同步分配主机可访问内存的内存资源。rmm::host_async_resource_ref
接受提供流有序分配主机可访问内存的内存资源。rmm::host_device_resource_ref
接受提供同步分配主机和设备可访问内存的内存资源。rmm::host_device_async_resource_ref
接受提供流有序分配主机和设备可访问内存的内存资源。有关详细信息,请参阅 libcu++ 关于 resource_ref
的文档。
本节详细介绍 cuSpatial API 函数的结构和实现。
libcuspatial 的基于列的 API 旨在与其他 RAPIDS 库(特别是 cuDF)无缝集成。为此,此 API 使用 cudf::column
和 cudf::table
数据结构作为输入和输出。这使得 cuSpatial 能够提供 Python 和其他语言 API(例如 Java),与 cuDF 和 cuML 等其他 RAPIDS 库的 API 无缝集成。这允许用户将空间数据查询和转换集成到端到端 GPU 加速的数据分析和机器学习工作流中。
基于列的 API 函数传递输入和返回输出的首选风格如下:
column_view const&
table_view const&
scalar const&
const&
传递mutable_column_view&
mutable_table_view&
std::unique_ptr<column>
std::unique_ptr<table>
std::unique_ptr<scalar>
以下是一个基于列的 API 函数示例。
要点
cudf::column_view
。这是一个类型擦除的容器,因此必须在运行时确定数据类型。unique_ptr<cudf::column>
。detail
版本 API。这遵循了 libcudf 的做法,未来可能会改变。有时函数需要有多个输出。C++ 中有几种方法可以做到这一点(包括为输出创建一个 struct
)。一种方便的方法是使用 std::tie
和 std::pair
。请注意,传递给 std::pair
的对象会调用对象的复制构造函数或移动构造函数,最好是移动非平凡可复制的对象(对于删除了复制构造函数的类型,如 std::unique_ptr
,则是必需的)。
功能相关的多个列输出(例如 x 和 y 坐标)应合并到一个 table
中。
注意:如果不是因为 Cython 不支持 std::tuple
,则可以使用 std::tuple
。因此,libcuspatial 公共基于列的 API 必须使用 std::pair
,并且仅限于返回两种不同类型的对象。同种类型的多个对象可以通过 std::vector<T>
返回。
或者,可以使用 C++17 结构化绑定 来分解多个返回值
注意,编译器可能不支持在 lambda 中捕获结构化绑定中定义的别名。可以通过使用带有初始化器的捕获来解决此问题
对于不使用 libcudf 或其他 RAPIDS API 的 C++ 用户和开发者来说,依赖 libcudf 可能会成为采用 libcuspatial 的障碍。libcudf 是一个非常大的库,构建它需要很长时间。
因此,libcuspatial 提供了一个不依赖于 libcudf 的仅头文件 C++ API。仅头文件 API 是一个基于迭代器的接口。这有许多优点。
这类 API 的主要缺点是:
基于列的 C++ API 是位于仅头文件 API 之上的简单层。这种方法保护基于列的 API 用户免受缺点的影响,同时保持了仅头文件 API 用户的优点。
所有数组输入和输出都是迭代器类型模板,以实现 API 的通用应用。一个示例函数很有帮助。
需要注意几个要点。
std::transform
)非常相似。cuspatial::vec_2d<T>
类型 (include/cuspatial/vec_2d.hpp)。这通过函数体中的 static_assert
强制执行。(// 通用的二维向量类型。)T
),默认等于 LonLatItA
迭代的类型的基本 value_type
。libcuspatial 为此提供了 iterator_vec_base_type
trait 助手。a_lonlat_first
和 a_lonlat_last
)。这类似于 STL API。std::transform
的启发,尽管与 transform
一样,许多 cuSpatial API 的使用不需要使用此返回的迭代器。rmm::device_async_resource_ref
,用于输出内存分配。在仅头文件 API 中,只要可能,输出数据应写入引用由 API 调用者分配的数据的输出迭代器。在这种情况下,多个“返回值”简单地写入多个输出迭代器。通常,此类 API 返回一个位于主要输出迭代器末尾之后一个位置的迭代器(采用 std::transform()
的风格)。
在输出大小取决于数据的函数中,API 可以分配输出数据并将其作为 rmm::device_uvector
或包含 device_uvector
的其他数据结构返回。
所有输入和输出迭代器都必须是设备可访问且具有随机访问能力的。它们必须满足 C++ LegacyRandomAccessIterator (旧版随机访问迭代器) 的要求。输出迭代器必须是可变的 (mutable)。
CUDA 流尚未在公共基于列的 libcuspatial API 中暴露。执行 GPU 工作或分配 GPU 内存的仅头文件 libcuspatial API 应接受一个流参数。
为了方便将来在公共基于列的 API 中使用流的过渡,所有分配设备内存或执行 GPU 工作(包括内核、Thrust 算法或任何可以接受流的)的 libcuspatial API 都应使用默认流(例如,流 0)上的异步 API 实现。
推荐的模式是让外部 API 的定义调用 detail
命名空间中的内部 API。内部 detail
API 具有与公共 API 相同的参数,外加一个没有默认值的 rmm::cuda_stream_view
参数放在末尾。如果 detail API 也接受内存资源参数,流参数理想情况下应放在内存资源参数之前。公共 API 将调用 detail API 并提供 rmm::cuda_stream_default
。实现应完全包含在 detail
API 定义中,并且只使用带有流参数的异步版本的 CUDA API。
为了使 detail
API 可以从其他 libcuspatial 函数调用,它可以暴露在放置在 cuspatial/cpp/include/detail/
目录中的头文件中。
例如
注意: 仅在必要时才同步流非常重要。例如,当 API 返回一个非指针值,该值是异步设备到主机复制的结果时,用于复制的流应在返回之前同步。然而,当返回列时,不应同步流,因为这样做会在将来添加异步 API 到 libcuspatial 时破坏异步性。
注意: 绝不应使用 cudaDeviceSynchronize()
。这会限制使用 libcuspatial API 进行多流/多线程工作的能力。
为了帮助性能优化和调试,所有计算密集型 libcuspatial 函数都应有相应的 NVTX 范围。在 libcuspatial 中,我们有一个便利宏 CUSPATIAL_FUNC_RANGE()
,它会自动标注包含函数的生命周期,并使用函数名作为 NVTX 范围的名称。有关 NVTX 的更多信息,请参见此处。
(注意:cuSpatial 尚未需要内部流创建。)以下指导来自 libcudf 的文档。在实现 libcuspatial 功能时,有时使用流内部实现算法重叠可能是有利的。然而,动态创建流可能会很昂贵。RMM 有一个流池类,可以帮助避免动态流创建。但是,这尚未在 libcuspatial 中暴露,因此目前 libcuspatial 功能应避免创建流(即使效率略低)。最好留下一个 // TODO:
注释,表明在哪里使用流会更有益。
libcuspatial 中使用设备内存资源来抽象和控制设备内存的分配方式。
任何分配并将内存返回给用户的 libcuspatial API 必须接受 rmm::device_async_resource_ref
作为最后一个参数。在 API 内部,必须使用此内存资源为返回的对象分配任何内存。因此,应将其传递给其输出将被返回的函数。示例
这条规则自动适用于所有分配内存的 detail API。任何 detail API 都可以被任何公共 API 调用,因此可能会分配并返回给用户的内存。为了支持此类用例,所有分配内存资源的 detail API 都应接受一个 mr
参数。调用者负责根据需要传递提供的 mr
或 rmm::mr::get_current_device_resource()
。
并非 libcuspatial API 中分配的所有内存都返回给调用者。算法通常必须分配临时、刮擦内存用于中间结果。对于临时内存分配,始终使用从 rmm::mr::get_current_device_resource()
获取的默认资源。示例
libcuspatial 代码避免使用裸指针和直接内存分配。使用基于 内存资源 构建的 RMM 类进行设备内存分配,并进行自动化生命周期管理。
使用内存资源分配指定数量的无类型、未初始化设备内存的字节。如果未明确提供 rmm::device_async_resource_ref
,则使用 rmm::mr::get_current_device_resource()
。
rmm::device_buffer
在流上是可移动和可复制的。复制会在指定的流上对 device_buffer
的设备内存执行深拷贝,而移动则将设备内存的所有权从一个 device_buffer
转移到另一个。
分配指定类型的单个元素并初始化为指定值。将其用于设备内核的标量输入/输出,例如归约结果、null 计数等。这实际上是长度为 1 的 rmm::device_vector<T>
的一个便利包装器。
分配指定类型指定数量的元素。如果未提供初始化值,则所有元素都将默认初始化(这会触发内核启动)。
注意:(待办事项:这在 libcuspatial 中尚不完全正确,但我们应该为此努力。以下内容复制自 libcudf 的开发者指南。)我们已从 libcuspatial 中删除了所有 rmm::device_vector
和 thrust::device_vector
的使用,在新代码中应谨慎使用。相反,请使用 rmm::device_uvector
以及 device_factories.hpp
中的实用工厂函数。这些实用函数能够从主机端向量创建 uvector
,或创建零初始化的 uvector
,使其使用起来像 device_vector
一样方便。避免使用 device_vector
有许多好处,如下一节关于 rmm::device_uvector
的描述。
类似于 device_vector
,在设备内存中分配连续的元素集,但有一些关键区别:
T
限制为平凡可复制类型。cuda_stream_view
来指定执行操作的流)。这提高了使用非默认流时的安全性。thrust/device_vector.hpp
不同,device_uvector.hpp
不包含任何 __device__
代码,这意味着 device_uvector
可以在 .cpp
文件中使用,而不仅仅在 .cu
文件中使用。所有公共 libcuspatial API 都应放在 cuspatial
命名空间中。示例
顶层 cuspatial
命名空间对于大多数公共 API 来说已经足够。但是,为了逻辑上对大量函数进行分组,可以使用进一步的命名空间。
许多函数不用于公共用途,因此根据情况将其放在 detail
命名空间或匿名命名空间中。
将在多个翻译单元(即源文件)之间使用的函数或对象应在内部头文件中暴露,并放在 detail
命名空间中。示例
libcuspatial 不断发展,以提高性能并更好地满足用户的需求。因此,我们有时需要破坏或完全移除 API,以响应对我们提供功能的新的和改进的理解。保持自由地这样做对于使 libcuspatial 成为一个能够快速适应用户需求的灵活库至关重要。因此,我们并非总是在发布破坏性更改之前提供警告或任何提前通知。在尽最大努力的基础上,libcuspatial 团队将通知用户我们预计会产生重大或广泛影响的更改。
在可能的情况下,在移除之前使用 deprecated (已弃用) 属性指示即将移除的 API,并使用 Doxygen 的 deprecated 命令 进行文档记录。如果弃用的 API 有替代 API,请在弃用消息和弃用文档中提及替代方案。引入弃用的 Pull Request 应标记为“deprecation”,以便在后续版本中更容易发现和移除。
使用 CUSPATIAL_EXPECTS
宏来强制执行正确执行所需的运行时条件。
第一个参数是预期在正常条件下解析为 true
的条件表达式。如果条件评估为 false
,则表示发生了错误,并抛出一个 cuspatial::logic_error
实例。CUSPATIAL_EXPECTS
的第二个参数是发生的错误的简短描述,用于异常的 what()
消息。
有时,特定的代码路径如果被执行到,无论如何都应该表示错误。例如,switch
语句的 default
情况通常表示无效的替代方案。对于此类错误,请使用 CUSPATIAL_FAIL
宏。这实际上等同于调用 CUSPATIAL_EXPECTS(false, reason)
。
示例
CUSPATIAL_CUDA_TRY
宏检查 CUDA 运行时 API 函数是否成功完成。如果 CUDA API 返回值不是 cudaSuccess
,此宏会抛出 cuspatial::cuda_error
异常。抛出的异常在其 what()
消息中包含 CUDA 错误代码的描述。CUSPATIAL_CUDA_TRY( cudaMemcpy(&dst, &src, num_bytes) );
示例
static_assert
来强制执行编译时条件。例如,template <typename T>
cudf::column
在 void*
设备内存中存储“类型擦除”的数据(用于列和标量)。这种类型擦除使得能够与其他语言和类型系统(如 Python 和 Java)互操作。为了确定类型,函数必须使用存储在列 type()
中的运行时信息,通过将 void*
转换为相应的 T*
来重构数据类型 T
。这种所谓的类型分派在 libcudf 和基于列的 libcuspatial API 中普遍存在。cudf::type_dispatcher
是一个核心实用程序,它自动化了将 data_type
中的运行时类型信息映射到具体 C++ 类型的过程。有关详细信息,请参阅 libcudf 开发者指南。