让 Arrow C++ 构建更简单、更小、更快
发布 2020年7月29日
作者 Apache Arrow PMC (pmc)
在过去四年半的时间里,我们致力于为 C++ 中的高性能分析应用程序构建一个“开箱即用”的开发平台。随着项目范围的扩大,我们有时会增加额外的库依赖项,以支持各种系统和数据处理任务。
虽然这些依赖项帮助我们解决了难题,但在某些情况下,它们增加了依赖 Arrow 的项目的复杂性。因此,一些项目对依赖 Arrow C++ 库感到担忧,特别是如果他们对 Arrow 库功能的使用有限。事实上,在 Arrow 项目开发的早期阶段,依赖管理问题确实给早期采用者带来了麻烦。
我们希望开发人员相信他们可以使用和依赖我们的库,并且这样做不会给他们自己的项目维护或用户增加负担。在过去一年中,我们开展了许多重要的项目,以适应人们希望依赖 Arrow C++ 的不同方式。我们旨在让构建过程默认简单,无需特殊环境设置,同时又对需要专业化的用户提供高度可配置性。这包括一个针对希望使用 Arrow C++ 核心但不承担任何传递性依赖的项目提供的零依赖选项。我们还致力于让构建更快、更紧凑,即使我们不断增加新功能。
这篇文章涵盖了我们在 C++ 库以及依赖它们的 Arrow Python 和 R 包中所做的许多努力。与一年前相比,构建体验在更广泛的平台上更加可靠,需要更少的依赖项,并产生了更小的包大小。
最小默认构建选项
对于将 Arrow 作为依赖项使用的人来说,一个棘手的问题是许多可选的项目组件在构建时默认启用,因此需要这些可选组件的额外依赖项。我们没有期望用户一个一个地禁用可选组件,而是将所有可选组件的默认设置为OFF,以便默认配置是一个无依赖的最小核心构建。
默认情况下唯一启用的第三方库是 jemalloc,这是项目推荐的内存分配器(在 Windows 上也禁用)。鉴于 Arrow 应用程序通常处理大量数据,我们发现使用 jemalloc 和 mimalloc 等项目提供的内存分配器比默认系统分配器产生了显著更好的性能。即便如此,如果需要,也可以禁用它。
为了演示最小构建,我们提供了一个 Dockerfile,可用于构建项目,仅需要 CMake 和 C++ 编译器,零依赖。此外,我们还提供了一个将 Arrow 作为外部项目依赖项包含在另一个 CMake 项目中的 示例。
CMake 中灵活的依赖配置
作为改进我们基于 CMake 的构建系统的一部分,我们使构建依赖项的配置对于不同用户的需求既灵活又一致。在某些情况下,开发人员希望 Arrow 针对由外部包管理器(例如基于 Debian 的 Linux 发行版中的 apt)提供的依赖项进行构建。在其他情况下,开发人员可能希望避免系统库的任何怪癖,并将所有依赖项与 Arrow 构建一起构建。
对于每个包,${Library}_SOURCE CMake 选项可以设置为三个值之一
-
SYSTEM,当依赖项由外部提供时(例如由 Linux 发行版或 Homebrew 提供) -
BUNDLED,当您希望在构建 Arrow 时从源代码构建依赖项,然后与生成的库静态链接时 -
AUTO,它尝试SYSTEM方法,但如果找不到依赖项,则回退到BUNDLED
我们还为开发人员使用 conda 或 Homebrew 包管理器时的常见场景提供了 CONDA 和 BREW 源类型。这些依赖源可以单独配置,也可以使用 ARROW_DEPENDENCY_SOURCE CMake 选项全局配置。AUTO 是默认设置,它通过尽可能使用预构建的系统库来加快构建速度,但即使系统上没有所有依赖项,也能成功构建。
减少外部依赖
另一个重点领域是审查我们的依赖项。我们仔细检查并找到了可以删除外部依赖项的地方,而不会丢失有用的功能,也无需重写太多或将太多代码复制到我们的代码库中。
我们已经消除了 Boost 作为核心 Arrow 库的依赖,在其他组件(Gandiva、Parquet 等)中,Boost 的使用也大大减少了。此外,当在 Arrow 构建中“捆绑”构建 Boost 时,我们将下载的 Boost 包精简到所需的最小部分,从而将下载大小减少了 90%。
我们引入了一些小的依赖项,例如 double-conversion 和 uriparser 库,这样它们就不需要单独下载和构建了。
我们还编译了 Flatbuffers 和 Thrift 定义(分别用于实现 Arrow 和 Parquet 格式),并将生成的 C++ 代码提交到 Arrow 仓库。这意味着 Flatbuffers 不再是 Arrow 的构建或运行时依赖,我们只需要 Thrift C++ 库,而不是 Thrift 编译器,Thrift 编译器对 flex 和 bison 有额外的依赖。
C++ 库大小缩减
随着 C++ 代码库规模的增长,编译时间以及 C++ 编译器生成的二进制代码量也随之增加。在过去的几个月里,我们开始分析 Arrow 库的编译时间以及生成的代码大小。这带来了显著的大小缩减(自 0.17.0 版本以来,代码大小缩减了 30% 以上)。我们还重构了头文件,以避免包含不必要的头文件,从而减轻了 C++ 编译器的负载并缩短了编译时间。
Python wheel 包
Python Package Index (PyPI) 上的二进制 wheel 包的期望是它们是自包含的,除了其他 Python 包之外没有外部依赖项。此外,pyarrow 的每个用户可能需要项目中的不同功能。一些用户只想读取 Parquet 文件并将其转换为 pandas 数据框,而另一些用户则希望使用 Flight 来移动大型数据集。因此,“pyarrow” wheel 包从项目一开始就是一个相当全面的构建,包含了我们维护的尽可能多的可选组件。
一个全面的 wheel 包有一些缺点:对用户来说最明显的是它很大。此外,由于与 C++ 共享库相关的失误,在几个版本中,wheel 包会在磁盘上创建每个 C++ 库的两个副本,导致磁盘使用量翻倍。这给在 AWS Lambda 等空间受限环境中使用的用户带来了问题。
在 1.0.0 版本中,我们实施了一些更改,将 wheel 包的大小(无论是 .whl 格式还是安装在磁盘上)减少了约 75%
- 解决了导致每个共享库在 site-packages 目录中创建两个副本的问题。
- 禁用 Gandiva,这需要 LLVM 运行时,这是最大的静态链接依赖项。Gandiva 现在仍然对 conda 用户可用——它只是没有包含在 wheel 包中——我们希望将来将其打包为单独的
pyarrow-llvm包。 - 如上所述,减小 C++ 共享库的大小
现在 pyarrow 的大小与 NumPy 差不多,因此 Python 项目更容易将其作为硬依赖项,而无需担心磁盘占用过大。
展望未来,我们已经讨论了将 pyarrow 分解为多个 wheel 包的策略,这是一种“中心辐射”模型,其中一些可选部分作为单独的 wheel 包安装,因此只需要“核心”功能的人只需安装一个小包。然而,这将是一个重要的项目,所以目前我们专注于改进全面的 wheel 包。
R 包
为 R 打包 Arrow 涉及与 Python wheel 包类似的挑战,尽管技术细节是独一无二的。就像 pip install pyarrow 应该在任何地方都能正常工作一样,R 中的 install.packages("arrow") 也应该如此,我们为此投入了大量的精力。由于 R 包依赖于一个活跃开发的 C++ 库,这并非易事,特别是对于 Linux 上 C++ 编译器和标准库的所有组合。
在去年首次发布的 CRAN 版本 0.14.1 中,只有 Windows 和 macOS 的二进制包开箱即用。对于 Linux,您必须在安装 R 包之前单独安装 C++ 库。虽然 Python wheel 包即使在 Linux 上也包含二进制库,但 CRAN 只托管必须在用户机器上安装时编译的源包。这导致 Linux 用户的体验不尽理想。
从 0.16 版本开始,Linux 上的源包安装会自动处理其 C++ 依赖项。默认情况下,该包执行一个捆绑脚本,该脚本下载并构建 Arrow C++ 库,除了 R 所需的之外,没有系统依赖项。在许多常见的 Linux 发行版和版本上,可以通过设置环境变量来加快速度,以下载预构建的静态 C++ 库以包含在包中。
为了配合这些改进并确保它们在广泛的平台上成功,我们增加了广泛的夜间构建到我们的持续集成系统中。这些也易于扩展——我们只需要一个包含 R 的 Docker 镜像,我们就可以将新环境插入到我们的常规夜间测试中。
从那时起,我们不断改进安装体验,并寻找减少构建时间和包大小的方法。上面讨论的 C++ 库改进对 R 包有所帮助,因为大多数 R 包的安装都构建或以其他方式包含了 C++ 库。在 R 包本身中,我们一直在寻找只包含所需内容而不包含更多内容的方法。这些努力导致下载量和安装包大小的减少。从 0.17.1 到 1.0.0,macOS 和 Windows CRAN 二进制文件的安装库大小下降了 10%,而 Linux 的预构建静态 C++ 库比 0.16.0 小了 33%,尽管增加了许多新功能。
C 接口
最后,我们观察到一些项目可能希望生成或使用 Arrow 格式的子集,并且不希望承担任何额外的代码依赖。还有一些场景,两个库需要共享内存中的 Arrow 数据结构,但无法依赖于一个常见的 Arrow 库,例如参考 C++ 实现。为了解决这些用例,我们设计了 C 接口,以提供一种轻量级的方式,在 C 级别交换 Arrow 数据,无需任何内存复制。
使用 C 接口时,开发人员填充简单的 C 数据结构,这些结构包含有关 Arrow 数据结构的模式(数据类型)信息以及构成数据的内存片段的地址。这使得库可以轻松地在内存中连接起来,而无需任何共享代码(除了 C 接口结构定义)。大多数编程语言都能够操作 C 结构,因此即使不必编写或编译 C 代码,也可以使用此接口。我们已使用 C 接口通过 reticulate 在内存中在 Python 和 R 之间传输数据结构。
Arrow C 接口的一个令人兴奋的用例是向通常包含 C API 的数据库驱动程序库添加 Arrow 导入和导出功能。
展望未来
随着项目的发展,我们将继续努力使构建过程尽可能快速和可靠。如果您发现我们可以进一步改进的方法,或者您遇到问题,请在我们的邮件列表上提出或报告问题。