InfluxData 正在使用 c2goasm 和 SIMD 在 Go 中构建 Apache Arrow 的快速实现

导航至

InfluxData 很高兴宣布我们为 Apache Arrow 项目做出的贡献。本质上,我们正在贡献我们已经开始的工作:开发 Apache Arrow 的 Go 实现。我们相信开源,并致力于以有意义的方式参与和贡献开源社区。我们出于多种原因对 Apache Arrow 产生了兴趣,我们将在下面更详细地描述这些原因,并且将我们的初步努力贡献给 Apache 软件基金会,以确保社区在存储库中保持关注。

Apache Arrow 为平面和分层数据指定了一种标准化的、语言无关的、列式内存格式,该格式经过组织,可在现代硬件上高效执行分析操作。它还提供计算库以及零拷贝流式消息传递和进程间通信。正如我们一直在开发 InfluxDB 的新查询处理引擎和语言,目前称为 Flux f.k.a. IFQL,Arrow 提供了一种在数据库和查询处理引擎之间交换数据的卓越方式,同时还为 InfluxData 参与更广泛的数据处理和分析工具生态系统提供了额外的手段。

为什么选择 Arrow?

Flux f.k.a. IFQL 的众多目标之一是使用行业标准工具实现高效查询和分析数据的新方法。一个这样的例子是 pandas,一个开源库,为数据分析和可视化提供高级功能。另一个是 Apache Spark,一个可扩展的数据处理引擎。我们发现这些以及许多其他开源项目和商业软件产品都在采用 Apache Arrow 来解决高效共享列式数据的挑战。Apache Arrow 的使命宣言定义了许多与 InfluxData 团队产生共鸣的目标

Apache Arrow 是一个用于内存数据的跨语言开发平台。它为平面和分层数据指定了一种标准化的语言无关的列式内存格式,该格式经过组织,可在现代硬件上高效执行分析操作。它还提供计算库以及零拷贝流式消息传递和进程间通信。目前支持的语言包括 C、C++、Java、JavaScript、Python 和 Ruby。

具体来说

  • 标准化:数据科学和分析领域的许多项目都在采用 Arrow,因为它解决了一组常见的设计问题,包括如何高效地交换大型数据集。早期采用者的例子包括 pandas 和 Spark,并且列表 持续增长
  • 性能:规范明确指出性能是 存在的理由。Arrow 数据结构旨在在现代处理器上高效工作,从而可以使用单指令多数据 (SIMD) 等功能。
  • 语言无关:C/C++、Python、Java 和 Javascript 的成熟库已经存在,Ruby 和 Go 的库正在积极开发中。更多的库意味着更多处理数据的方法。

我们还将 Apache Arrow 视为参与并贡献于将面临类似挑战的社区的机会。共同的问题,解决一半。

InfluxData 中的 Apache Arrow

我们已经确定了 InfluxDB 将从 Apache Arrow 中受益的几个领域

  • 表示内存中的 TSM 列式数据,
  • 使用 SIMD 数学内核执行聚合,以及
  • InfluxDB 和 Flux f.k.a. IFQL 之间的数据通信协议。

对于 Flux f.k.a. IFQL

  • 表示块数据结构,
  • 使用 SIMD 数学内核执行聚合,以及
  • 客户端的主要通信协议。

未来,我们预计用户可以创建一个 Jupyter Notebook,在 Python 中执行 Flux f.k.a. IFQL 查询,并在 pandas 中高效地操作数据,几乎没有开销。

Go 中的 Apache Arrow

在撰写本文时,Go 实现支持以下功能

内存管理

  • 分配为 64 字节对齐,并填充为 8 字节

数组和构建器支持

原始类型

  • 有符号和无符号 8 位、16 位、32 位和 64 位整数
  • 32 位和 64 位浮点数
  • 打包的 LSB 布尔值
  • 变长二进制数组

参数类型

  • 时间戳

类型元数据

  • 数据类型

SIMD 数学内核

  • SIMD 优化的 Sum 操作,适用于 64 位浮点数、整数和无符号整数数组

使用这个奇怪的技巧,无需汇编即可为您的 Go 代码进行 SIMD 优化!

在我们分享魔法之前,让我们更深入地探讨一下为什么 SIMD 或单指令多数据是相关的。Apache Arrow 中的大多数数据结构都占据连续的内存块作为数组或向量,这绝非偶然。使用特殊指令,当今的许多 CPU 可以并行处理像这样紧密打包的数据,从而提高特定算法和操作的性能。更妙的是,编译器内置了许多高级优化,例如 自动向量化,无需开发人员编写任何汇编代码即可利用这些功能。在编译期间,编译器可能会识别出将数组作为自动向量化候选对象的循环,并生成更有效利用 SIMD 指令的机器代码。唉,Go 编译器缺乏这些优化,让我们不得不自力更生。我们可以用汇编语言编写这些例程,但这已经够难的了,更不用说还要使用 Go 深奥的 Plan 9 语法了。更糟糕的是,为了针对特定架构以汇编语言编写最佳代码,您必须熟悉其他问题,如指令调度、数据依赖性、AVX-SSE 过渡惩罚 等。

clang + c2goasm = ??

c2goasm 是由 minio 开发的一个很棒的命令行工具,它可以将用 C/C++ 编写的函数的汇编输出转换为 Go Plan 9 汇编器可以理解的东西。这些 不是 与 CGO 相同的东西,并且与调用任何其他 Go 函数一样高效。以 Go 汇编语言编写的例程的一个缺点是它们无法内联,因此重要的是它们要完成足够的工作来抵消函数调用的开销。发布公告 中的示例使用了内在函数,这些内在函数是编译器扩展,可提供对处理器特定功能的访问,例如宽数据类型 (__m256) 和映射到处理器指令的函数 (_mm256_load_ps)。与编写纯汇编代码相比,使用内在函数允许开发人员将高级 C 代码与低级处理器功能混合使用,同时仍然允许编译器执行一组有限的优化。

我们的 第一个实验 是采用一个 Go 函数,该函数对 64 位浮点数切片求和,并确定我们是否可以使用 c2goasm 来改进它。我们对 1,000 个元素的切片进行了基准测试,因为它们与 InfluxDB 中 TSM 块的最大大小相匹配。基准测试是在 2017 年初的 MacBook Pro 上以 2.9GHz 运行收集的。

Go 中的参考实现以 1200 纳秒/操作或 6,664 MB/秒 的速度运行

func SumFloat64(buf []float64) float64 {
	acc := float64(0)
	for i := range buf {
		acc += buf[i]
	}
	return acc
}

按照与 c2goasm 帖子类似的方法,我们使用 AVX2 内在函数生成了这个简化的实现

void sum_float64_avx_intrinsics(double buf[], size_t len, double *res) {
    __m256d acc = _mm256_set1_pd(0);
    for (int i = 0; i < len; i += 4) {
        __m256d v = _mm256_load_pd(&buf[i]);
        acc = _mm256_add_pd(acc, v);
    }

    acc = _mm256_hadd_pd(acc, acc); // a[0] = a[0] + a[1], a[2] = a[2] + a[3]
    *res = _mm256_cvtsd_f64(acc) + _mm_cvtsd_f64(_mm256_extractf128_pd(acc, 1));
}
此版本以 255 纳秒/操作或 31,369 MB/秒 的速率对 1,000 个 64 位浮点数(C 中的 double)求和 – 4.7 倍是一个方便的改进。Intel x86 AVX2 内在函数是一组特定的扩展,用于使用单个指令处理 256 位数据或 4×64 位浮点值。这里发生了很多事情,让我们总结一下代码的功能
  • 将累加器 acc(一种表示 4×64 位浮点元素的数据类型)初始化为 0
  • 对于每次迭代
  • 将接下来的 4 个 64 位浮点元素从 buf 加载到 v
  • v 的相应元素添加到 acc,即 acc[0] += v[0]acc[1] += v[1]acc[2] += v[2]acc[3] += v[3]
  • 如果在 buf 中有更多元素,则递增 4 并重新启动循环
  • acc[0]+acc[1]+acc[2]+acc[3] 求和
  • 转换为 double 并返回值
值得注意的是,使用内在函数有一些缺点
  • 理解此功能所需的认知负荷远高于 Go 或纯 C 版本
  • 我们需要使用 SSE4 内在函数进行单独的实现,因为在不支持 AVX2 扩展的机器上调用此函数将导致 SEGFAULT
在某些情况下,使用内在函数或编写汇编代码是最佳选择,但对于像这样的简单循环,我们决定探索另一种替代方案。早些时候,我们提到了自动向量化,所以让我们看看优化编译器可以使用纯 C 版本(类似于 Go)做些什么
void sum_float64(double buf[], int len, double *res) {
    double acc = 0.0;
    for(int i = 0; i < len; i++) {
        acc += buf[i];
    }
    *res = acc;
}
1,000 个浮点数求和仅需 58 纳秒,速率为 137 GB/秒1。还不错,当我们所做的只是指定一些编译器标志来启用优化,包括循环向量化、循环展开和生成 AVX2 指令。通过编写可移植的 C/C++ 版本,我们可以生成 SSE4 版本或以完全不同的架构(如 ARM64)为目标,只需对编译器标志进行少量更改;这种好处怎么强调都不过分。根据 Intel Core i7 6920HQ 的规格,它的最大内存带宽为 34.1 GB/秒。137 GB/秒 远高于这个数字,那么发生了什么?缓存。我们可以将惊人的速度归因于驻留在处理器缓存之一中的数据。因此,您越早对从主内存读取的数据进行操作,就越有可能从缓存中受益

自动化代码生成

从 C 源代码到最终的 Go 汇编需要几个步骤
  1. 使用正确的编译器标志执行 clang 以生成基本汇编,生成 foo_ARCH.s
  2. 执行 c2goasm 以将 foo_ARCH.s 转换为 Go 汇编
  3. 为每个目标架构(例如 SSE4、AVX2 或 ARM64)重复步骤 1 和 2
如果“A”更改,则构建“B”;如果“B”更改,则构建“C”。听起来像是 make 的工作,这正是我们所做的。每当我们更新 C 源代码时,我们只需运行 make generate 即可更新依赖文件。我们还将生成的汇编文件检入到存储库中,以确保 Go 包是 go gettable 的。

在 Go 中使用这些优化

如果在不支持这些扩展的处理器上调用 AVX2 版本的函数,您的程序将会崩溃,这并不理想。解决此问题的方法是确定运行时可用的处理器功能,并调用相应的函数,必要时回退到纯 Go 版本。Go 运行时在许多地方都这样做,使用 internal/cpu 包,我们采用了类似的方法,并进行了一些改进。在启动时,最有效的函数是根据可用的处理器功能选择的。但是,如果存在名为 INTEL_DISABLE_EXT 的环境变量,则禁用任何指定的优化。如果您对此感兴趣,我们已在存储库中记录了该功能。例如,要禁用 AVX2 并为假设的应用程序 myapp 使用下一组最佳功能
$ INTEL_DISABLE_EXT=AVX2 myapp

结论

要达到与 Apache Arrow 的 C++ 实现相同的功能,还有很多工作要做,我们期待分享我们未来的贡献。