让 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 除外,在 Windows 上它也被禁用)。鉴于 Arrow 应用程序通常处理大量数据,我们还发现,使用 jemalloc 和 mimalloc 等项目提供的内存分配器,其性能明显优于默认的系统分配器。即使如此,如果需要,这也可以禁用。

为了演示最小构建,我们提供了一个 Dockerfile,它可用于构建项目,只需要 CMake 和 C++ 编译器,零依赖。此外,我们还提供了一个 示例,说明如何在另一个 CMake 项目中将 Arrow 作为外部项目依赖项包含在内。

CMake 中灵活的依赖项配置

作为改进基于 CMake 的构建系统的一部分,我们使构建依赖项的配置灵活且一致,以满足不同用户的需求。在某些情况下,开发人员希望 Arrow 针对外部包管理器(例如基于 Debian 的 Linux 发行版中的 apt)提供的依赖项进行构建。在其他情况下,开发人员可能希望避免系统库的任何怪癖,并将所有依赖项与 Arrow 构建一起构建。

对于每个包,CMake 选项 ${Library}_SOURCE 可以设置为以下三个值之一

  • SYSTEM,当依赖项由外部提供时(例如 Linux 发行版或 Homebrew)
  • BUNDLED,当您希望在构建 Arrow 时从源代码构建依赖项,然后与生成的库静态链接时
  • AUTO,尝试 SYSTEM 方法,但如果找不到依赖项,则回退到 BUNDLED

我们还为开发人员使用 conda 或 Homebrew 包管理器时的常见情况提供了 CONDABREW 源类型。可以使用 CMake 选项 ARROW_DEPENDENCY_SOURCE 在单个依赖项基础上或全局配置这些依赖项源。AUTO 是默认值,它允许通过尽可能使用预构建的系统库来加快构建速度,但即使系统上并非所有依赖项都可用,构建仍然可以成功。

减少外部依赖

另一个重点领域是审核我们的依赖项。我们仔细检查并找到了可以在不损失有用功能且无需重写大量代码或将太多代码复制到代码库的情况下删除外部依赖项的地方。

我们已经消除了 Boost 作为核心 Arrow 库的依赖项,并且在其他组件(Gandiva、Parquet 等)中,Boost 的使用已大大减少。此外,在 Arrow 构建中“捆绑”构建 Boost 时,我们将下载的 Boost 包精简到所需的最低限度,减少了 90% 的下载大小。

我们引入了几个小型依赖项,例如 double-conversion 和 uriparser 库,因此无需单独下载和构建它们。

我们还编译了 Flatbuffers 和 Thrift 定义(分别用于实现 Arrow 和 Parquet 格式),并将生成的 C++ 代码签入到 Arrow 存储库中。这意味着 Flatbuffers 不再是 Arrow 的构建或运行时依赖项,我们只需要 Thrift C++ 库,而不需要 Thrift 编译器,后者对 flex 和 bison 还有额外的依赖项。

C++ 库大小缩减

随着 C++ 代码库规模的增长,编译时间和 C++ 编译器生成的二进制代码量也会增加。在过去的几个月中,我们开始分析 Arrow 库的编译时间和生成的代码大小。这带来了显着的尺寸减小(自 0.17.0 以来代码尺寸减小了 30% 以上)。我们还重构了头文件,以避免包含不需要的头文件,从而减轻了 C++ 编译器的负担并缩短了编译时间。

Python wheel 包

对 Python 包索引 (PyPI) 上的二进制 wheel 包的期望是它们是独立的,并且除了其他 Python 包之外没有外部依赖项。此外,pyarrow 的每个用户可能需要项目中的不同内容。一些用户只想读取 Parquet 文件并将它们转换为 pandas 数据帧,而另一些用户则希望使用 Flight 来移动大型数据集。因此,从项目一开始,“pyarrow”wheel 包就是一个相当全面的构建,其中包含了我们实际维护的尽可能多的可选组件。

一个全面的 wheel 包有一些缺点:最显着的缺点是它很大。此外,由于与 C++ 共享库相关的混乱,在几个版本中,wheel 包会在磁盘上创建每个 C++ 库的两个副本,导致磁盘使用量翻倍。这给在 AWS Lambda 等空间受限的环境中使用 pyarrow 的人带来了问题。

在 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,您必须先单独安装 C++ 库,然后再安装 R 包。虽然 Python wheel 即使在 Linux 上也包含二进制库,但 CRAN 只托管必须在用户计算机上编译安装的源代码包。这导致 Linux 用户的体验不太理想。

从 0.16 版本开始,Linux 上的源代码包安装会自动处理其 C++ 依赖项。默认情况下,该包会执行一个捆绑脚本,该脚本下载并构建 Arrow C++ 库,除了 R 所需的依赖项之外,没有其他系统依赖项。在许多常见的 Linux 发行版和版本中,可以通过设置环境变量来下载预构建的静态 C++ 库以包含在包中,从而加快此过程。

为了配合这些改进并确保它们在各种平台上都能成功运行,我们在我们的持续集成系统中添加了广泛的夜间构建。这些也很容易扩展——我们只需要一个包含 R 的 Docker 镜像,就可以将新环境插入到我们的常规夜间测试中。

从那时起,我们一直在不断改进安装体验,并寻找减少构建时间和包大小的方法。上面讨论的 C++ 库改进对 R 包有所帮助,因为大多数 R 包的安装要么构建要么包含 C++ 库。在 R 包本身中,我们一直在寻找只包含所需内容而不包含其他内容的方法。这些努力 resulted in 更小的下载量和已安装的软件包大小。从 0.17.1 到 1.0.0,macOS 和 Windows CRAN 二进制文件的已安装库大小减少了 10%,与 0.16.0 相比,Linux 的预构建静态 C++ 库减少了 33%,尽管添加了许多新功能。

C 接口

最后,我们观察到一些项目可能希望生成或使用 Arrow 格式的子集,并且不想承担任何额外的代码依赖项。还有一些情况下,两个库需要共享内存中的 Arrow 数据结构,但无法依赖于通用的 Arrow 库,例如参考 C++ 实现。为了解决这些用例,我们设计了C 接口,以提供一种轻量级的方式在 C 级别交换 Arrow 数据,而无需任何内存复制。

使用 C 接口时,开发人员填充简单的 C 数据结构,其中包含有关 Arrow 数据结构的模式(数据类型)信息以及构成数据的内存片段的地址。这允许库在内存中轻松地插入在一起,而无需任何共享代码(C 接口结构定义除外)。大多数编程语言都能够操作 C 结构,因此即使不必编写或编译 C 代码也可以使用此接口。我们已经使用 C 接口通过reticulate在 Python 和 R 之间传输内存中的数据结构

Arrow C 接口的一个令人兴奋的用例是将 Arrow 导入和导出添加到通常包含 C API 的数据库驱动程序库中。

展望未来

随着项目的发展,我们将继续努力使构建过程尽可能快速和可靠。如果您发现我们可以进一步改进的方法,或者您遇到问题,请在我们的邮件列表中提出或报告问题