Vector

向量化(Vectorization) 是 OLAP 的核心技术。在《MonetDB/X100: Hyper-Pipelining Query Execution》论文中,介绍了使用向量化技术带来的 性能提升,其主要原因是:

  1. 传统的以 tuple-at-a-time 为单位的 Pull 模型,CPU 大部份消耗在 tuple 的运输(pull)上,实际的计算占比 < 10%(图中为8.8%)。参见下图: img.png
  2. 即使在这 8.8% 的计算中,其 IPC(Instructions Per Cycle) 也不高,只有 0.8。即平均每个时钟周期只执行了 0.8 条指令。而现在的服务器 CPU 是多发射架构,intel skylake 每个时钟周期理论上可以执行4条指令。 这与 tuple-at-a-time 的计算模型有关。 img.png 来源:https://zhuanlan.zhihu.com/p/645343994 (具体的发射大小文档待查)
  3. 现代的 CPU 支持 SIMD 指令集,可以在一个时钟周期内执行多个相同操作的指令,这样可以提高计算效率。128/256/512 位的 SIMD 寄存器,可以在单条 指令中操作 4/8/16 个 32-bit 的计算 或者 2/4/8 个 64-bit 的计算。
  4. tuple-at-a-time 的计算模型,对 CPU 的缓存友好性不好。无论是 I-cache 还是 D-cache,这些都会影响 IPC 的提升。

使用向量化优化后,

  1. 从 tuple-at-a-time 的 Pull 模型,改变为 morsel-at-a-time 的 Push 模型,每个 morsel 的大小可以很好的匹配 CPU 的缓存行大小。从而 提升缓存的命中率。
  2. morsel-at-a-time 的模式,大大的减少了处理循环,提高了有效运算的占比。
  3. 结合 SIMD 指令,从原来的 0.8 的 IPC 显著提升到等效的 16-64, 计算处理也得以大幅度提升。

本文以 duckdb 的示例为例,介绍 Vector 的数据结构设计,常用的向量化操作,以及对这一块性能优化的一些思考。

DuckDB Vector 数据结构

参考文档:Vector官网介绍资料

class Vector { VectorType vector_type; // FLAT_VECTOR, FSST_VECTOR, CONSTANT_VECTOR, DICTIONARY_VECTOR, SEQUENCE_VECTOR LogicalType type; // boolean, integer, date, varchar, etc. data_ptr_t data; // uint8_t*, 指向数据的指钨 ValidityMask validity; // 标识某个元素是否是 null shared_ptr<VectorBuffer> buffer; // data 的容器 shared_ptr<VectorBuffer> auxliary; // 向量的动态数据部份的数据容器 }

以基础的 FlatVector(integer) 为例,其内存布局如下:

vector_type: i8 -- FLAT_VECTOR type: [i8, 24] - id: i8 -- INTEGER - physical_type: i8 _ type_info : nullptr [i8,16] data: i32* -- 指向 buffer.data 中区域 validity: [i8, 32] - validity_mask: u64* -- bitmap,其 owner 由 validity_data 持有 - validity_data: shared_ptr<ValidityBuffer> - target_count: u64 = 2048 buffer: shared_ptr<VectorBuffer> -- owner data - buffer_type - aux_data: nullptr - data: nullptr -- 管理 vector.data 的所有权 auxliary: nullptr -- FlatVector(integer) 不需要额外的存储空间
  • data: T_ITEM* 这里的 T_ITEM 是向量中元素的值类型,对 INTEGER 等定长类型,其值类型是 i32。

    • 对 VARCHAR,使用的是 string_t 结构,定义如下:

      union { // 16 bytes struct { // length > 12 时存储前4个字符,后面的字符存储在 auxliary 中 uint32_t length; char prefix[4]; char *ptr; } pointer; struct { // length <= 12 时,直接存储在 inlined 中 uint32_t length; char inlined[12]; } inlined; } value;

      思考: 如果 string_t 调整为 24 或者 32 字节大小,则 INLINE 可以存储 12/20 字节,在进行字符串的比较时,是否会有更好的性能?

      对 VARCHAR 类型来说,string_t 是定长的,可以作为数组形式存储在 buffer 中(vector.data 是对 buffer 中的引用),而变长部份 则存储在 auxliary 中。 这个存储设计非常类似于编程语言中的 stack 和 heap: stack 中存储定长部份,heap 中存储变长部份。

    • List 类型。DuckDB 支持 List ,不仅在 SQL 级别支持 list 数据类型及一系列的 list 操作函数,在引擎内部,也会依赖 List 类型来处理计算。

      struct list_entry_t { // list_entrt_t 是定长的,存储在 buffer 中 idx_t offset; // 指向在 auxliary 中的变长部份开始位置 idx_t length; // list 的长度 };

      思考:这种设计就无法支持 list 的动态添加了。在将 list 作为聚合函数时,随着新的数据加入,list会不断的增长,这个就需要使用其他的数据结构来支持了。

      • 目前还没有看懂这一块的向量的构建过程。
  • buffer: 数据的生命周期。

    (我之前的C++经验还停留在 C++ 11之前的手动new/delete是经验,现在通过智能指针后,delete操作就基本上不需要了)这种模式是引用智能指针后 C++ 的常见的内存管理模式,rust 的内存管理思想与这个也是高度一致的。虽然 Rust 的设计是从 C++ 的最佳实践中衍生出来的,不过,不妨碍我用 Rust 来 反向理解 C++。

    buffer 与 data 之间的关系满足:(在 Vector.Resize 方法中有这个假设:)

    // data: unique_ptr<[T_ITEM]>; // T_ITEM 数组 // data = buffer->data.get() auto new_data = make_unsafe_uniq_array_uninitialized<data_t>(target_size); // new [T_ITEM; n] memcpy(new_data.get(), resize_info_entry.data, old_size); // resize_info_entry.buffer->SetData(std::move(new_data)); // buffer->data = new_data resize_info_entry.vec.data = resize_info_entry.buffer->GetData(); // data = buffer->data.get()
    • TODO 似乎有一些子类型的 VectorBuffer 不满足这个契约,例如 DictionaryBuffer 通过 sel_vector 来访问 data, VectorChildBuffer 通过重载的 data: Vecotr 来管理数据。这些子类型似乎并不满足 Vector::Resize 中的这个契约。
  • auxliary: