文章目录
原文: Apache arrow 极致模块化、可组合的数据平台
背景
Apache-Arrow 是 Wes McKinney 大佬在2016年开启的一个项目, 用于解决 他创建的Pandas 的一堆问题.
Pandas最核心的几个问题如下:
- 缺少统一的内存数据管理方式, pandas每对接一个外部系统都需要单独实现一套数据转化工具, 比如将pandas的数据格式转为 spark的 dataframe, 性能极差.
- 内存数据处理无法高效利用现代计算硬件: CPU/GPU/FPGA, 比如向量化能力较差, 无法高效利用SIMD指令.
- 数据格式的扩展性较差, 因为都是耦合在 pandas内部(BlockManager).
- 大数据集的支持度不高, 数据处理以及传递链路上存在较多的内存拷贝, 导致一份数据集在内存中会放大多倍.
于是, Wes 在2015年和Kudu,Spark,Drill等项目出来的一些人和Apache 软件基金会, 从2016-2020年 Wes 分别创立了两家非营利性的数据科学公司 Ursa Labs 和 VoltronData, 并且后来拿到了1.1亿美金的首轮融资. 2023年11月 的时候 Wes选择从 VoltronData 卸任首席架构师加入到 Posit 继续全新的数据科学领域的一些新的挑战. 因为 Arrow 社区在 VoltronData 的把控下每一个子方向都有比较靠谱的负责人 且 未来的发展方向也都比较明确, Wes觉得交给他们没有什么问题, 如果自己继续呆在这里反而没法发挥自身最大的价值. 再加上 做 2016-2020年开发 arrow 期间 Wes也和 Posit的前身 RStudio 深度合作过, 利用 arrow 打通了 R语言 和 Python语言这两种数据科学领域顶级工具之间的数据交换通道, 也和 RStudio 的创始人 建立了深厚的感情(都是为了让数据科学领域更好的发展, 而不是争论到底python还是R 更适合做数据分析之类的无意义的争论).
Arrow 是 数据分析领域的资深大佬对整个数据计算体系的多年实践要解决的痛点下 诞生的, 从设计之初就是为了成为下一个数据科学领域的核心工具而存在, 未来数据分析领域是 arrow-native的.
基本架构
前面介绍了arrow 诞生的背景是服务于数据分析领域, Wes对 其基本功能的描述是 Arrow是用于加速内存数据交换和处理、支持多语言的工具集.
之前不同的数据处理系统之间都会有一套独立的数据转换方式 以及 传输过程中大量的数据拷贝, 如今都可以被 Arrow统一处理.
Arrow 在架构设计上也是走 LLVM 风格, 每一个组件都可以独立为用户所用. 因为考虑的是通用的计算平台, 组件内部拥有极强的可扩展性,组件之间又可以互相搭配使用.
基本组件如下图:
- 多语言支持: C++ 和 rust 都是语言native的(所有C++的功能都会同步到rust). 在这两种语言的基础上支持了更多的其他语言, 比如C++基础上支持C语言,又可以利用cgo和jni支持go以及java等.
- Columnar Format: 支持高性能的列式内存数据格式, 包括基本的类型系统以及完备的向量化的数据管理结构
- IPC protocols: 支持了进程间通信的数据格式且可以以 flatbuffer 的扁平格式写入到文件. 加速进程间通信的数据读取/写入 以及 零拷贝传输.
- Query Engine Components: 支持了计算引擎需要的一些基础组件. 比如 compute下的一些向量化的计算Function, Function之上的 Expression 表达式处理框架, 还有C++库实现的 Acero 以及 Rust库实现的 DataFusion 计算引擎, 并且支持了像是 scan/sort/hashjoin/agg/project/filter 这样的算子. 再结合 substrait 这样的可以识别逻辑查询计划的组件, 就能够将arrow的计算能力接入到各种各样的分析性数据库中, 简直不要太爽.
- IO / File Format Backends: Arrow 支持了基本的文件格式 和 文件数据的访问源. 比如 parquet/ORC 这样的列存文件格式 从 S3/HDFS 等远端存储读取.
- Flight & FlightSQL : 跨服务器的网络传输协议, Flight拥有 RPC 能力, FlightSQL 结合 ADBC 可以零拷贝的SQL协议.
- JIT Expression Compilation: Gandiva 子项目 结合 LLVM-JIT 功能实现了 Arrow 表达式执行期间的Runtime和SIMD优化.
这一些组件内部都是可以扩展的, 比如 Expression 框架下可以快速扩展自己的function, Acero 内部的 ExecNode也可以扩展自己的算子; 而且这一些组件之间是可以组合的(比如 内存格式+表达式计算框架), 从底层的数据读写 到上层的query执行 + FlightSQL 全链路都可以是零拷贝的高性能.
没错, 这一些你都可以直接白嫖!
Columnar Format
Arrow 的内存格式以及相关的数据结构和周边计算功能的设计都是服务于列式内存格式的.
列式数据的内存表示可以用一块连续的内存区域来表示拥有相同类型的一列数据, 这样就能够更加高效得利用 CPU-cache 以及 SIMD指令.
Arrow 在内存格式的设计中主要有几个数据结构:
-
Buffer. 数据内存格式存储实际数据的最底层数据结构, 主要维护了一段连续的内存区域.主要有以下几个字段:
const uint8_t* data_; // 表示一段连续内存区域的字节数组 且不保存实际的数据 // 仅指向一个实际数据存储区域的地址,实现数据访问的零拷贝. int64_t size_; // 这段连续内存区域有效数据的大小 int64_t capacity_; // 这段内存区域分配的空间大小, size和capacity之间可以填充0
除了这几个字段之外, 还有像是
is_cpu_
这样的标识,用来区分 GPU/CPU device, 只有cpu能够利用 Buffer 提供的随机访问的能力. Buffer 提供了一些 比如Slice 这样的接口, 用户提供 offset+length 就可以随机访问任意一份Buffer区域的数据. 也提供了一些修改/拷贝 Buffer数据的接口, 方便用户对当前数据做一些独立性的操作.需要注意的是Buffer 本身并不提供内存对齐的能力, arrow 通过
MemoryManager
来管理buffer 占用的内存区域, buffer初始化时指向的内存是否对齐会由MemoryManager
来决定. 除了基本的Buffer之外还提供了像是ResizableBuffer
和MutableBuffer
用来对buffer 指向的数据或者存储空间进行操作. -
类型系统 DataType 和 Array.
DataType
是Arrow提供的一套通用的类型系统,支持了:- Scalar Types(单值类型) :
- Boolean
- [u]int[8,16,32,64], Decimal, Float, Double
- Data, Time, TimeStamp
- UTF8, String, Binary
- …
- Nested Types(嵌套类型) :
- Struct
- List
- Union
- Map
…
- Dictionary Type 字典类型
- REE(Run-Ended Encoding) Arrow将这种重复值较多的数据集编码方式也作为了一个单独的数据类型.算是嵌套类型的一种.
- UDT(User Defined Types)
- Scalar Types(单值类型) :
这一些数据类型的基础数据结构如下:
/*
* ListType LargeListType FixedListType
* │ │ │
* │ ▼ │
* └───────► BaseListType◄────┘ StructType UnionType RunEndEncodedType
* │ │ │ │
* │ │ │ │
* └───────────────────┴───►NestedTyp◄────┴───────────────────┘ ExtensionType
* │ │
* │ │
* └──────────────► DataType ◄─────────────────────┘
* ▲
* │
* ┌────────────────┬──────────────────────┬────►FixedWidthType ◄───┬─────────────────────┬─────────────────┐
* │ │ │ │ │ │
* │ │ │ │ │ │
* │ │ │ │ │ │
* DictionaryType PrimitiveType ┌─►NumberType◄──┐ TemporaType FixedSizeBinaryType BaseBinaryType
* │ │ ▲ ▲ ▲ ▲
* │ │ │ │ │ │
* IntegerType FloatingPointType ┌─►DateType◄──┐ DecimalType StringType LargeStringType
* │ │ ▲ ▲
* │ │ │ │
* Date32Type Date64Type 128Type, 256Type
*/
自 DataType 继承的各种数据类型已经按照分类划分好了, 每一种数据类型对应的 Array数据部分的存储内存布局则是由 DataTypeLayout
决定, 主要的几种 layout 布局如下:
- FIXED_WIDTH 单值定长类型, 只需要有一个buffer保存就好了, 根据offset就能取值
- VARIABLE_WIDTH 变长类型, 需要两个buffer, 一个保存值的len, 另一个保存原值.
- BITMAP, bitmap buffer, 保存是否有空值
- ALWAYS_NULL 全为空
每一种 DataType 可能是以上几种的组合, 比如
BinaryType
, 其layout 如下:
return DataTypeLayout({
DataTypeLayout::Bitmap(),
DataTypeLayout::FixedWidth(sizeof(offset_type)),
DataTypeLayout::VariableWidth()});
DataTypeLayout 仅表示基本的Scalar类型有内存布局, 嵌套类型是在 Scalar 类型的基础上通过维护嵌套的父子关系来实现 嵌套类型, 具体实现是
ArrayData
数据结构提供的child_data
字段, 保存当前ArrayData的孩子 ArrayData 数组. 这样所有的嵌套类型都可以用统一的方式来管理其内部拥有上下级关系的单值类型的数据.
再看看 Array 这个结构, 而DataType可以理解为数据的描述信息, Array则直接保存列式的数据, 每一个Array对应一个 DataType描述的列式数据, Array的底层管理了一个或者多个Buffer, 选择的Buffer布局是根据运行时前面描述的 DataType::layout()
决定的.
如下是一个实际运行时Array的基本结构:
实际的数据管理结构是 ArrayData
, 对于每一种前面介绍到的数据类型都会有对应的 Array(比如 StringArray, NumericArray – 处理数值类型的数据存储等) 以及 ArrayBuilder.
像是 DictionaryArray, 会有一个 indices int类型的数组来标识原本Array中值在 dicionary Array中的序列, 以及 实际的压缩后的数值类型的Array.
// 如下 StringArray:
["foo", "bar", "foo", "