数据结构分析
Block与RowBatch
doris算子之间数据流的传递单位Block-Column,是在原有Tuple-RowBatch数据结构的基础上改进而来的,两者的关系大致为下图所示:
总体来看,Block和RowBatch存储的都是数据的一部分,但两者设计的维度不同。Block以Column作为单位,按列来存储若干行的数据,简单理解就是把Impala中的Tuple变为Column,并把多个Column组装为一个Block,以列的方式来管理;RowBatch是以Tuple作为单位,把不同列的同一行数据组装为Tuple,每列的单个数据是不同的Slot,然后把多个Tuple组装为RowBatch。这样的好处是,类似聚合这种整个字段的计算可以直接在获取到Block后对列批量进行,而无需遍历RowBatch中的每个Slot进行计算,两者虽都需要进行一定程度的遍历,Column计算可以比较好地利用Cache,还可以通过SIMD指令来加速Column计算,而RowBatch的计算操作需要不停地分别遍历RowBatch中的行和Slot。
Block相关实现:
class Block {
private:
// ColumnsWithTypeAndName是std::vector<ColumnWithTypeAndName>的别名
using Container = ColumnsWithTypeAndName;
using IndexByName = phmap::flat_hash_map<String, size_t>;
Container data;
IndexByName index_by_name;
std::vector<bool> row_same_bit;
// ...一些记录性能的指标metric声明...
public:
void some_functions();
}
ColumnWithTypeAndName相关实现:
struct ColumnWithTypeAndName {
// 存储各个Column的数据
ColumnPtr column;
// 存储Column数据类型
DataTypePtr type;
// Column名称
String name;
void some_functions();
};
从实现上能看出,Block有三个主要成员:指向Column数据的指针、Column的数据类型指针、列名,Column指针基于Copy-On-Write模式,可以降低数据拷贝的次数,在多读少写的场景能够节约内存并提高效率。
COW(Copy-On-Write)
Doris中的基础数据结构Block使用了COW的思想,简单解释就是同一份数据有多个副本时,实际使用的是相同地址,一旦有哪个线程或对象对这部分数据进行了写操作,就将其变为独立的副本,从原始地址拷贝出来,这种实现对于多读少写的数据结构可以有比较大的内存上的优化,其实现略为复杂:
template <typename Derived>
class COW {
std::atomic_uint ref_counter;
protected:
// ...一些构造函数实现...
// 引用计数+1
void add_ref() { ++ref_counter; }
// 释放指针时引用计数-1
void release_ref() {
if (--ref_counter == 0) {
delete static_cast<const Derived*>(this);
}
}
Derived* derived() { return static_cast<Derived*>(this); }
const Derived* derived() const { return static_cast<const Derived*>(this); }
// COW的内部类,其构造函数中实现了引用计数,其子类用于外部使用
template <typename T>
class intrusive_ptr {
public:
intrusive_ptr() : t(nullptr) {}
// 构造时自动增加引用计数
intrusive_ptr(T* t, bool add_ref = true) : t(t) {
if (t && add_ref) {
((std::remove_const_t<T>*)t)->add_ref();
}
}
// 构造时自动增加引用计数
template <typename U>
intrusive_ptr(intrusive_ptr<U> const& rhs) : t(rhs.get()) {
if (t) {
((std::remove_const_t<T>*)t)->add_ref();
}
}
// 构造时自动增加引用计数
intrusive_ptr(intrusive_ptr const& rhs) : t(rhs.get()) {
if (t) {
((std::remove_const_t<T>*)t)->add_ref();
}
}
// 析构时自动减少引用计数
~intrusive_ptr() {
if (t) {
((std::remove_const_t<T>*)t)->release_ref();
}
}
// ...一些其他构造函数实现...
// ...一些常用运算符重载实现...
template <class U>
friend class intrusive_ptr;
// ...一些getter、指针相关常用成员函数实现...
private:
T* t;
};
protected:
// 针对可变对象使用
template <typename T>
class mutable_ptr : public intrusive_ptr<T> {
private:
// 可变指针
using Base = intrusive_ptr<T>;
template <typename>
friend class COW;
template <typename, typename>
friend class COWHelper;
explicit mutable_ptr(T* ptr) : Base(ptr) {}
public:
// 不允许拷贝构造
mutable_ptr(const mutable_ptr&) = delete;
/// 允许移动构造
mutable_ptr(mutable_ptr&&) = default;
mutable_ptr& operator=(mutable_ptr&&) = default;
// ...其他构造函数实现...
};
public:
using MutablePtr = mutable_ptr<Derived>;
unsigned int use_count() const { return ref_counter.load(); }
protected:
// 针对不可变对象使用
template <typename T>
class immutable_ptr : public intrusive_ptr<const T> {
private:
// 不可变指针
using Base = intrusive_ptr<const T>;
template <typename>
friend class COW;
template <typename, typename>
friend class COWHelper;
// 禁止隐式类型转换
explicit immutable_ptr(const T* ptr) : Base(ptr) {}
public:
// 允许拷贝构造
immutable_ptr(const immutable_ptr&) = default;
immutable_ptr& operator=(const immutable_ptr&) = default;
// 允许移动构造
immutable_ptr(immutable_ptr&&) = default;
immutable_ptr& operator=(immutable_ptr&&) = default;
// 允许从可变指针移动构造
template <typename U>
immutable_ptr(mutable_ptr<U>&& other) : Base(std::move(other)) {}
// 不允许从可变指针拷贝构造
template <typename U>
immutable_ptr(const mutable_ptr<U>&) = delete;
// ...其他构造函数实现...
};
public:
using Ptr = immutable_ptr<Derived>;
template <typename... Args>
static MutablePtr create(Args&&... args) {
return MutablePtr(new Derived(std::forward<Args>(args)...));
}
template <typename T>
static MutablePtr create(std::initializer_list<T>&& arg) {
return create(std::forward<std::initializer_list<T>>(arg));
}
public:
Ptr get_ptr() const { return Ptr(derived()); }
MutablePtr get_ptr() { return MutablePtr(derived()); }
protected:
// 通过mutate()调用
MutablePtr shallow_mutate() const {
// 如果引用计数大于1,就创建新对象,并返回其指针
if (this->use_count() > 1) {
return derived()->clone();
} else {
// 没有其他对象在引用,可直接返回其副本
return assume_mutable();
}
}
public:
// 尝试对当前对象进行修改
MutablePtr mutate() const&& { return shallow_mutate(); }
// 将当前对象直接进行修改,即看作是无对象正在引用
MutablePtr assume_mutable() const { return const_cast<COW*>(this)->get_ptr(); }
// 返回引用,也是直接修改当前对象
Derived& assume_mutable_ref() const { return const_cast<Derived&>(*derived()); }
protected:
// 可使用在不同上下文场景,const场景下作为immutable_ptr使用,非const场景通过assume_mutable_ref直接返回可变对象引用
template <typename T>
class chameleon_ptr {
private:
immutable_ptr<T> value;
public:
template <typename... Args>
chameleon_ptr(Args&&... args) : value(std::forward<Args>(args)...) {}
template <typename U>
chameleon_ptr(std::initializer_list<U>&& arg)
: value(std::forward<std::initializer_list<U>>(arg)) {}
const T* get() const { return value.get(); }
T* get() { return &value->assume_mutable_ref(); }
const T* operator->() const { return get(); }
T* operator->() { return get(); }
const T& operator*() const { return *value; }
T& operator*() { return value->assume_mutable_ref(); }
operator const immutable_ptr<T>&() const { return value; }
operator immutable_ptr<T>&() { return value; }
operator bool() const { return value != nullptr; }
bool operator!() const { return value == nullptr; }
bool operator==(const chameleon_ptr& rhs) const { return value == rhs.value; }
bool operator!=(const chameleon_ptr& rhs) const { return value != rhs.value; }
};
public:
using WrappedPtr = chameleon_ptr<Derived>;
};
// COW类的helper类,主要用于实现copy时clone
template <typename Base, typename Derived>
class COWHelper : public Base {
public:
// Ptr表示不可变对象指针
using Ptr = typename Base::template immutable_ptr<Derived>;
// MutablePtr表示可变对象指针
using MutablePtr = typename Base::template mutable_ptr<Derived>;
template <typename... Args>
static MutablePtr create(Args&&... args) {
return MutablePtr(new Derived(std::forward<Args>(args)...));
}
typename Base::MutablePtr clone() const override {
return typename Base::MutablePtr(new Derived(static_cast<const Derived&>(*this)));
}
protected:
MutablePtr shallow_mutate() const {
return MutablePtr(static_cast<Derived*>(Base::shallow_mutate().get()));
}
};
COW特性的实现大概分为四个部分,COW基类、具体实现了指针功能的intrusive_ptr内部类、继承了intrusive_ptr的可变对象指针mutable_ptr和不可变对象指针immutable_ptr、继承了COW类的COWHelper类以及chameleon_ptr类。COW基类仅实现了基础的引用计数,具体的COW功能由三个内部类intrusive_ptr、mutable_ptr和immutable_ptr实现,intrusive_ptr中实现了一些基础的指针功能,它的两个子类mutable_ptr和immutable_ptr分别实现了可变对象和不可变对象的特性,具体来说,两个子类中分别封装了T类型和const T类型的指针,通过COWHelper类进行不同的const上下文场景分配。在实际使用过程中,继承了COW类后,通过mutate()函数获取当前对象,根据引用计数来判断是否创建另外的副本。
聚合过程分析
聚合相关操作实现
Doris在聚合计算过程中使用了一种比较灵活的方式,事先声明了一个executor结构体,其中封装了多个std::function,分别代表执行阶段可能需要调用到的函数,在prepare阶段会使用std::bind将函数绑定到具体的实现上,根据是否开启streaming pre-agg、是否存在group by、是否存在distinct等条件来确定具体绑定什么函数。
struct executor {
vectorized_execute execute;
vectorized_pre_agg pre_agg;
vectorized_get_result get_result;
vectorized_closer close;
vectorized_update_memusage update_memusage;
};
这几个函数的大致调用关系过程可如下所示:
对应的,相关绑定过程:
下面选取几个函数进行分析。
有group by、不需要Finalize的聚合场景如distinct、非最终阶段的聚合计算时,get_result获取结果使用的函数是_get_with_serialized_key_result,下面简化其实现进行分析:
Status AggregationNode::_get_with_serialized_key_result(RuntimeState* state, Block* block,
bool* eos) {
MutableColumns key_columns;
MutableColumns value_columns;
//...进行一些初始化...
std::visit(
[&](auto&& agg_method) -> void {
//...进行一些初始化...
while (iter != _aggregate_data_container->end() &&
num_rows < state->batch_size()) {
keys[num_rows] = iter.get_key<KeyType>();
_values[num_rows] = iter.get_aggregate_data();
++iter;
++num_rows;
}
// 一次性将所有group by分组列插入目标Column中
agg_method.insert_keys_into_columns(keys, key_columns, num_rows, _probe_key_sz);
// 根据不同的聚合函数(SUM、AVG、COUNT等等)批量将聚合操作的结果(非group by分组列)插入目标Column中
for (size_t i = 0; i < _aggregate_evaluators.size(); ++i) {
_aggregate_evaluators[i]->insert_result_info_vec(
_values, _offsets_of_aggregate_states[i], value_columns[i].get(),
num_rows);
}
//...NULL值处理...
},
_agg_data->_aggregated_method_variant);
//...将聚合结果和group by分组列组装为最终结果...
return Status::OK();
}
_get_with_serialized_key_result的实现总体大概分为三部分,首先将所有的聚合数据取出来,数据按照聚合列和分组列分为key_columns和values_columns,均通过std::vector存储在_aggregate_data_container中,这个数据结构提供了类似std::vector的读写方式,可以进行弹性扩缩容。数据取出后,通过insert_keys_into_columns将分组列放入结果Column中,这个过程经过了类似向量化的实现改造,以String类型的分组列为例,keys是连续内存分布的数组,将其插入到key_columns是直接通过memcpy一次性进行的,这部分逻辑在优化前通过iter每取出一行数据,就进行一次分组列插入,相当于会多执行最多batch_size次插入。insert_result_info_vec也使用了类似的优化方式,按照不同的聚合函数进行value_columns的填充,两个column处理结束后,将其合并为一个完整的Block,作为这个AGG_NODE的输出。
启用streaming pre-agg时,会进行预聚合,流式预聚合相关实现:
Status AggregationNode::_pre_agg_with_serialized_key(doris::vectorized::Block* in_block,doris::vectorized::Block* out_block) {
// ...一些初始化...
// 获取key_column
for (size_t i = 0; i < key_size; ++i) {
int result_column_id = -1;
RETURN_IF_ERROR(_probe_expr_ctxs[i]->execute(in_block, &result_column_id));
in_block->get_by_position(result_column_id).column = in_block->get_by_position(result_column_id).column->convert_to_full_column_if_const();
key_columns[i] = in_block->get_by_position(result_column_id).column.get();
}
// ...一些初始化...
RETURN_IF_ERROR(std::visit(
[&](auto&& agg_method) -> Status {
if (auto& hash_tbl = agg_method.data; hash_tbl.add_elem_size_overflow(rows)) {
// 如果需要扩展预聚合哈希表,则跳过预聚合,说明中间结果过多,可能是分组字段排列太分散,聚合效果不好
if (!_should_expand_preagg_hash_tables()) {
// ...一些初始化...
// 定长算子,这里实现比较奇怪,执行计划生成时已经写死了这里为true
if (_use_fixed_length_serialization_opt) {
for (int i = 0; i < _aggregate_evaluators.size(); ++i) {
auto data_type = _aggregate_evaluators[i]->function()->get_serialized_type();
// 处理value_columns
if (mem_reuse) {
value_columns.emplace_back(std::move(*out_block->get_by_position(i + key_size).column).mutate());
} else {
value_columns.emplace_back(_aggregate_evaluators[i]->function()->create_serialize_column());
}
data_types.emplace_back(data_type);
}
for (int i = 0; i != _aggregate_evaluators.size(); ++i) {
// 逐个聚合函数进行value_columns的处理,分批进行,流式预聚合结束后会将分批的中间结果汇总
RETURN_IF_ERROR(_aggregate_evaluators[i]->streaming_agg_serialize_to_column(in_block, value_columns[i], rows,_agg_arena_pool.get()));
}
} else {
// 由于写死了定长算子,忽略变长实现
}
// out_block的数据非空,就使用内存重用
if (!mem_reuse) {
// out_block数据为空,直接写入
ColumnsWithTypeAndName columns_with_schema;
for (int i = 0; i < key_size; ++i) {
columns_with_schema.emplace_back(key_columns[i]->clone_resized(rows), _probe_expr_ctxs[i]->root()->data_type(), _probe_expr_ctxs[i]->root()->expr_name());
}
for (int i = 0; i < value_columns.size(); ++i) {
columns_with_schema.emplace_back(std::move(value_columns[i]),data_types[i], "");
}
out_block->swap(Block(columns_with_schema));
} else {
// out_block数据非空,利用COW创建新的Block拷贝,并将key_column的数据按列批量拷贝
for (int i = 0; i < key_size; ++i) {
std::move(*out_block->get_by_position(i).column).mutate()->insert_range_from(*key_columns[i], 0, rows);
}
}
}
}
return Status::OK();
},
_agg_data->_aggregated_method_variant));
// 如果需要扩展预聚合哈希表,在这里直接进行全量聚合,而不是流式预聚合
if (!ret_flag) {
RETURN_IF_CATCH_BAD_ALLOC(_emplace_into_hash_table(_places.data(), key_columns, rows));
for (int i = 0; i < _aggregate_evaluators.size(); ++i) {
RETURN_IF_ERROR(_aggregate_evaluators[i]->execute_batch_add(in_block, _offsets_of_aggregate_states[i], _places.data(), _agg_arena_pool.get(), _should_expand_hash_table));
}
}
return Status::OK();
}
以AVG为例,streaming pre-agg聚合流程相关整体实现:
— AggregationNode::get_next |-executor::pre_agg(实际上是_pre_agg_with_serialized_key)
|–VExprContext::execute // 获取key_column
|–AggFnEvaluator::streaming_agg_serialize_to_column //
进行value_column计算
|—AggregateFunctionAvg::streaming_agg_serialize_to_column |
|–executor::get_result(实际上是_get_with_serialized_key_result)
|—AggregationMethodKeysFixed::insert_keys_into_columns //
将key_column和value_column整合为结果Block
|----AggregationMethodKeysFixed::insert_key_into_columns
|-----IColumnDummy::insert_data
Block与聚合算子
下面简要分析聚合算子中Block数据结构的作用,首先来介绍聚合函数中的各种抽象实现。Doris的聚合函数均继承了IAggregateFunctionDataHelper,每种聚合函数实现自己的数据域和处理函数,在接口类中声明了各种虚函数用于处理聚合过程中的计算和序列化/反序列化,这里只列出一些比较重要的声明:
class IAggregateFunction {
public:
//...忽略一些函数声明...
// 将一些column数据add到聚合函数的数据域中,可能会包含一些计算的操作,如sum或count
virtual void add(AggregateDataPtr __restrict place, const IColumn** columns, size_t row_num,
Arena* arena) const = 0;
// merge聚合状态
virtual void merge_vec(const AggregateDataPtr* places, size_t offset, ConstAggregateDataPtr rhs,
Arena* arena, const size_t num_rows) const = 0;
// 数据传输前,进行序列化操作
virtual void serialize(ConstAggregateDataPtr __restrict place, BufferWritable& buf) const = 0;
virtual void serialize_vec(const std::vector<AggregateDataPtr>& places, size_t offset,
BufferWritable& buf, const size_t num_rows) const = 0;
// 反序列化数据
virtual void deserialize(AggregateDataPtr __restrict place, BufferReadable& buf,
Arena* arena) const = 0;
virtual void deserialize_vec(AggregateDataPtr places, const ColumnString* column, Arena* arena,
size_t num_rows) const = 0;
// 将给定的聚合结果放入Column
virtual void insert_result_into(ConstAggregateDataPtr __restrict place, IColumn& to) const = 0;
virtual void insert_result_into_vec(const std::vector<AggregateDataPtr>& places,
const size_t offset, IColumn& to,
const size_t num_rows) const = 0;
// 批量add,其实就是for循环add
virtual void add_batch(size_t batch_size, AggregateDataPtr* places, size_t place_offset,
const IColumn** columns, Arena* arena, bool agg_many = false) const = 0;
// 流式预聚合序列化处理
virtual void streaming_agg_serialize(const IColumn** columns, BufferWritable& buf,
const size_t num_rows, Arena* arena) const = 0;
//...忽略一些函数声明...
};
在函数声明中可看到Doris使用了自行实现的Arena类进行基于内存池的内存分配管理,Arena分配的是连续内存,提供了高效的访问效率,能够利用Cache且易于管理。如Distinct处理过程可能会高频访问待处理数据以进行去重,因此merge、add、反序列化等操作使用了Arena进行内存管理,以尽量将数据保持在Cache中,提高访问效率。Arena也使用了Doris自行实现的Allocator进行内存分配,根据Doris开发人员的说法,诸如此类内存分配、代码结构优化的调优可能比实现向量化本身有更好的性能优化效果。
数据域实现相对简单,如SUM聚合函数的数据域实现:
template <typename T>
struct AggregateFunctionSumData {
T sum {};
// 累加结果
void add(T value) { sum += value; }
// 合并其他SUM数据域的结果
void merge(const AggregateFunctionSumData& rhs) { sum += rhs.sum; }
// 序列化SUM数据
void write(BufferWritable& buf) const { write_binary(sum, buf); }
// 反序列化SUM数据
void read(BufferReadable& buf) { read_binary(sum, buf); }
///...忽略一些声明...
};
聚合以分批的方式进行,每次聚合一个batch的数据,根据不同的聚合函数实现,进行不同的处理函数,如SUM是比较简单的累加,AVG则涉及到Sum和Count,并在Finalize中进行Sum与Count相除的结果计算。
Scan过程分析
SCAN的整体执行过程也使用Block数据结构作为数据流的基本单位,也实现了Doris自身的延迟物化、filter等特性,在filter中引入了SIMD的一些执行逻辑,大致代码调用关系如下,下面进行展开分析。
VScanNode::get_next ScannerContext::get_block_from_queue // 从block
queue中获取block
— ScannerScheduler::_scanner_scan // scanner线程 VScanner::get_block |-VFileScanner::_get_block_impl |–ParquetReader::get_next_block
|—RowGroupReader::next_batch |----RowGroupReader::_do_lazy_read
|-----RowGroupReader::_read_column_data // 读取谓词Column,计算过滤信息
|-----RowGroupReader::_read_column_data // 读取其他Column
|-----RowGroupReader::_fill_partition_columns // 物化Column
|-----RowGroupReader::_fill_missing_columns // 填充默认值或NULL
|-----RowGroupReader::_build_pos_delete_filter // 构建过滤器
|------ScalarColumnReader::read_column_data
|-------ScalarColumnReader::_read_values
|--------LevelDecoder::get_next_run |---------RleDecoder::GetNextRun |
|–RowGroupReader::_filter_block // 对Scan得到的Block开始过滤
|—RowGroupReader::_filter_block_internal |----ColumnArray::filter
|-----ColumnArray::filter_generic |------ColumnVector::filter
|-------simd::bytes32_mask_to_bits32_mask //进行simd过滤,过滤掉不需要的数据
ScannerContext::append_blocks_to_queue // 将scanner线程获取到的block数组放入队列
Scan过程主要使用了下面几个类:
VScanner:负责对tablet中的一部分数据进行读取;
ScannerContext:负责协调同一VScanNode下多个VScanner,其中记录了VScanner的执行状态,并维护了一个block队列用于存放VScanner读取的数据;
ScannerScheduler:负责对VScanner进行调度和执行,ScannerContext是其调度的基本单元。其中定义了用于存放ScannerContext的pending队列和两种类型的线程池,即用于对ScannerContext进行调度的调度线程池和用于执行读取任务的执行线程池;
VScanNode:负责从ScannerContext中的block队列中读取数据; RowGroupReader:对file
reader的读取请求进行具体实现,进行数据物化,填充Block;
ScalarColumnReader/ArrayColummReader:对于标量或其他复杂类型进行数据读取的具体实现;
GenericReader:各种类型file reader的父类;
ColumnArray:基于IColumn::Filter对读取完成的数据进行过滤,使用SIMD优化;
Block:基础数据结构,利用Copy-on-Write技术封装了IColumn;
Doris的Scan过程大致与Impala类似,但也有所不同,Doris由scanner线程进行Scan的具体执行,并由ScannerScheduler进行scanner线程的调度,使用ScannerContext来管理Scan过程的上下文。ScanNode的读取过程类似生产者-消费者模型,scanner线程完成读取的数据会以Block的形式压入读取队列,然后在调用VScanNode::get_next时会从队列中拿出Block,并进行后续的过滤处理。
先来重点看scanner线程执行的部分,Doris在Scan时实现了Block的内存复用,每次申请空Block是从特定的pool中获取一个之前已经申请好内存的Block,Scan结束后Block会清空并放回pool,这样可以提升Scan时内存分配的效率,能够让Block尽量呆在Cache中。从被扫描表的元数据中可以得到TupleDescriptor(这个类和Impala中的相同,只是改了名字),随后根据被扫描表的TupleDescriptor来逐个遍历slot,然后利用slot中的字段名、类型等信息来构建空的IColumn对象。这里虽然复用的是Impala原有的Tuple数据结构,但Doris也实现了一些用来帮助转换为Block以及IColumn的helper函数,可以比较方便地利用Tuple来构造ColumnWithTypeAndName,即Block中的各个IColumn存储单位。
构造完成空Block后,会调用VFileScanner::_get_block_impl来对Block进行填充,根据当前文件的schema来使用不同的reader进行数据读取,支持Parquet、Iceberg、Orc、CSV、Json等类型的文件读取,这里先分析使用较为广泛的Parquet reader。Parquet的读取过程是逐个Column进行的,在正式读取数据之前,Doris会先根据Column类型进行判断,如果不是复杂类型且存在谓词,则进行谓词Column的预读操作,根据预读出来的数据和Scan中的谓词去计算一个filter_map,然后进行其他Column的读取,如果之前在预读数据中能够通过谓词过滤掉超过60%的数据,就会进入skip过程,表明这个Scan的谓词可能会过滤掉整个page,相关代码:
bool skip_whole_batch = false;
if (select_vector.has_filter() && select_vector.filter_ratio() > 0.6) {
// lazy read
size_t remaining_num_values = 0;
for (auto& range : read_ranges) {
remaining_num_values += range.last_row - range.first_row;
}
if (batch_size >= remaining_num_values &&
select_vector.can_filter_all(remaining_num_values)) {
select_vector.skip(remaining_num_values);
_current_row_index += _chunk_reader->remaining_num_values();
RETURN_IF_ERROR(_chunk_reader->skip_page());
*read_rows = remaining_num_values;
if (!_chunk_reader->has_next_page()) {
*eof = true;
}
return Status::OK();
}
skip_whole_batch = batch_size <= remaining_num_values && select_vector.can_filter_all(batch_size);
if (skip_whole_batch) {
select_vector.skip(batch_size);
}
}
谓词下推的page过滤完成后,根据不同的Parquet encoding level(字典、RLE编码)来读取Parquet文件,返回的数据会逐个Column进行物化以及默认值或NULL值的设置。读取完成后,会逐个Column进行基于SIMD的过滤,在filter_map的生成过程中,会根据带有谓词的Column的过滤结果来生成column_filter,并使用基于SIMD的bytes32_mask_to_bits32_mask函数来根据column_filter进行谓词过滤,这里会把所有数据过滤完成,区别于上文中仅进行的page过滤,相关代码:
template <typename T>
ColumnPtr ColumnVector<T>::filter(const IColumn::Filter& filt, ssize_t result_size_hint) const {
/*
...一些初始化和校验
*/
// filt是谓词Column扫描过程中生成的过滤器,是一个用于标记过滤行号的bit数组
const UInt8* filt_pos = filt.data();
const UInt8* filt_end = filt_pos + size;
const T* data_pos = data.data();
static constexpr size_t SIMD_BYTES = 32;
const UInt8* filt_end_sse = filt_pos + size / SIMD_BYTES * SIMD_BYTES;
// 在SIMD实现分析文档有类似案例,每次进行32个byte的过滤,剩余部分用串行方式完成
while (filt_pos < filt_end_sse) {
// mask是把filt_pos通过位运算转换得到的uint32整数,其二进制为filt_pos的32个uint8元素的值
uint32_t mask = simd::bytes32_mask_to_bits32_mask(filt_pos);
// 全1则全保留
if (0xFFFFFFFF == mask) {
res_data.insert(data_pos, data_pos + SIMD_BYTES);
} else {
while (mask) {
// 逐位判断,利用GCC的内置函数计算末尾0个数,从而确认1所在位置下标
const size_t idx = __builtin_ctzll(mask);
res_data.push_back_without_reserve(data_pos[idx]);
mask = mask & (mask - 1);
}
}
filt_pos += SIMD_BYTES;
data_pos += SIMD_BYTES;
}
// ...串行处理剩余数据...
return res;
}
整体来看,Doris的向量化执行引擎在Scan逻辑上体现的主要是基于数据结构的实现,数据的读取、处理、优化等操作均围绕基于Column的Block数据结构来实现,其线程调度方式与向量化无太大关系,但在2.0-alpha版本中提出的pipeline执行引擎中Doris的主要算子使用了Push式的数据流,SQL执行过程有了比较大的变化,从官方给出的描述来看,Doris能够比较好地解决复杂查询的fragment instance堆积问题。
总结
Doris的向量化引擎大致来看属于两部分的实现,一部分是以SIMD为主的细粒度优化,主要面向偏底层数据处理过程的加速,使用特定指令集的SIMD指令,可以提高定长数据结构批量处理的效率。另一部分是数据结构和执行引擎整体的重构改造,由于数据流的基础单位从RowBatch变为Block,所有算子均需要将内部处理逻辑从RowBatch-Tuple变为以Block-Column为基础的向量化架构。除此之外,Doris还实现了内存分配、弹性扩缩容数组、COW指针等特性,用于优化基础数据结构读写操作的执行效率以及其内存分配与释放。
参考文章
https://zhuanlan.zhihu.com/p/325632066
https://zhuanlan.zhihu.com/p/454141438
https://www.cnblogs.com/happenlee/p/14990049.html
https://www.6aiq.com/article/1666874991171
https://cloud.tencent.com/developer/article/2111901
https://blog.bcmeng.com/post/doris-bitmap.html
https://mp.ofweek.com/it/a056714509327
https://www.slidestalk.com/doris.apache/SQL82045?video
https://doris.apache.org/zh-CN/docs/dev/summary/basic-summary