表達式模塊作為在數據庫系統的查詢處理中幾乎任何地方都在使用的模塊,其實現的方式會極大影響數據庫執行query的速度,如PostgreSQL中不同的表達式計算方式將會給TPC-H Q1帶來5倍的性能差距,因此一個對查詢處理能力有追求的數據庫系統都會對自己的表達式性能做一些優化。所以我們決定觀察下各個開源數據庫的表達式實現,作為數據庫的開發者可以從中獲得一些啟示少走彎路,作為使用者則可以了解到部分相關的特性以更好的使用。
PostgreSQL
PostgreSQL主要基於以下兩點設計:
1. As much complexity as possible should be handled by expr init
2. Read-only make life much simpler
對於1,在一條query的執行中,表達式通常只會init一次,而表達式的evaluate會在query的執行過程中發生無數次,任何額外的指令重復無數次,其開銷都是不可忽略的。
對於2,應該是query caching和parallel query方面的考慮,在不可變的plan上做這些事情會比較簡單
表達式在代碼中的實現
相關類的定義
PostgreSQL的表達式模塊主要與以下幾個類有關:
- Expr: 表達式的邏輯表示,表達式會被表達為由Expr作為節點的一顆樹
- ExprState: 表達式執行中的核心部分,其中有幾個關鍵成員:
ExprEvalStep[] steps
表達式的指令序列,可以通過順序執行這些執行來執行表達式ExprStateEvalFunc evalfunc
表達式執行的入口,通過調用該函數進行表達式的執行<resvalue, resnull>
用來存放表達式的結果。需要注意的是ExprEvalStep
中也擁有這兩個成員,用來存放中間結果
表達式的執行流程
如果粗略的分,表達式的執行可以分為兩個階段: Init(復雜) 和 Eval(簡單),代碼里面的流程如下:
- 表達式初始化,由函數
ExecInitExpr()
完成
C
/* 'node' is the root of the expression tree to compile. */
/* 'parent' is the PlanState node that owns the expression. */
ExprState *
ExecInitExpr(Expr *node, PlanState *parent)
{
ExprState *state;
ExprEvalStep scratch = {0};
/* Special case: NULL expression produces a NULL ExprState pointer */
if (node == NULL)
return NULL;
/* Initialize ExprState with empty step list */
state = makeNode(ExprState);
state->expr = node;
state->parent = parent;
state->ext_params = NULL;
/* Insert EEOP_*_FETCHSOME steps as needed */
ExecInitExprSlots(state, (Node *) node);
/* Compile the expression proper */
ExecInitExprRec(node, state, &state->resvalue, &state->resnull);
/* Finally, append a DONE step */
scratch.opcode = EEOP_DONE;
ExprEvalPushStep(state, &scratch);
/* pick a suitable method to exec expr */
ExecReadyExpr(state);
return state;
}
這個函數主要做了以下幾點事情
- 創建一個ExprState,解析表達式的邏輯表示,生成對應的ExprEvalStep數組(調用ExecInitExprRec())。其中除了表達式對應的ExprEvalStep之前,還會在表達式開始前額外插入一些EEOP_*_FETCHSOME的step,這些step用於將對應field的值存入表達式(例如表達式t1.a > 5中的t1.a)
- 選擇恰當的執行方式,並做一些相對應的前置准備,主要有兩種執行方式:
- 解釋執行,這里由分為傳統的解釋執行(switch case)和computed goto兩種,由宏EEO_USE_COMPUTED_GOTO控制,在編譯時決定。
- 編譯執行,依靠LLVM將表達式編譯為機器碼執行。 2. 執行表達式,會依靠之前初始化時確定的執行方式:
- 解析執行,通過調用函數ExecInterpExprStillValid()Datum ExecInterpExprStillValid(ExprState *state, ExprContext *econtext, bool *isNull) { CheckExprStillValid(state, econtext); /* skip the check during further executions */ /* in general, state->evalfunc_private = ExecInterpExpr */ state->evalfunc = (ExprStateEvalFunc)state->evalfunc_private; /* and actually execute */ return state->evalfunc(state, econtext, isNull); }
函數ExecInterpExpr中包含了一個巨大的switch-case塊,其中的執行邏輯根據宏EEO_USE_COMPUTED_GOTO有所不同: - 如果EEO_USE_COMPUTED_GOTO未被定義,那么就是傳統的解析執行,這時ExprEvalStep::opcode表示一個enum ExprEvalOp,switch將根據opcode跳轉到合適的case執行
- 如果EEO_USE_COMPUTED_GOTO被定義,這時ExprEvalStep::opcode是一個指向某個case塊代碼的地址,執行時將不會反復通過switch執行,而將進行一連串的GOTO語句執行合適的邏輯
一個簡單的執行(調用一個函數)的例子:
/* ----------- Utils ----------- */ #if defined(EEO_USE_COMPUTED_GOTO) // ... #define EEO_SWITCH() #define EEO_CASE(name) CASE_##name: // goto op_func_addr #define EEO_DISPATCH() goto *((void *) op->opcode) #else /* !EEO_USE_COMPUTED_GOTO */ #define EEO_SWITCH() starteval: switch ((ExprEvalOp)op->opcode) #define EEO_CASE(name) case name: // return to EEO_SWITCH(), interpret opcode of next op #define EEO_DISPATCH() goto starteval #endif /* EEO_USE_COMPUTED_GOTO */ #define EEO_NEXT() \ do { \ op++; \ EEO_DISPATCH(); \ } while (0) /* ----------- In ExecInterpExpr ----------- */ #if defined(EEO_USE_COMPUTED_GOTO) // goto opcode of 1st ExecStep EEO_DISPATCH(); #endif EEO_SWITCH() { // ... EEO_CASE(EEOP_FUNCEXPR) { // fcinfo包含函數入參(由前置ExprEvalStep計算) FunctionCallInfo fcinfo = op->d.func.fcinfo_data; Datum d; fcinfo->isnull = false; d = op->d.func.fn_addr(fcinfo); *op->resvalue = d; *op->resnull = fcinfo->isnull; // EEO_NEXT() will call EEO_DISPATCH() (goto NEXT_LABEL) EEO_NEXT(); } }
- 編譯執行,通過調用函數ExecRunCompiledExpr()static Datum ExecRunCompiledExpr(ExprState *state, ExprContext *econtext, bool *isNull) { CompiledExprState *cstate = state->evalfunc_private; ExprStateEvalFunc func; CheckExprStillValid(state, econtext); llvm_enter_fatal_on_oom(); // get function ptr of expression func = (ExprStateEvalFunc) llvm_get_function(cstate->context, cstate->funcname); llvm_leave_fatal_on_oom(); Assert(func); /* remove indirection via this function for future calls */ state->evalfunc = func; return func(state, econtext, isNull); }
TiDB
與PG不同,TiDB的表達式看起來與MySQL比較相似,所有表達式都繼承自Expression interface(對應MySQL中的Item),Expression類擁有一系列eval接口(對應純虛函數Item::val_),表達式執行是后續遍歷表達式樹的過程,舉個例子
func (s *builtinArithmeticPlusIntSig) evalInt(row chunk.Row) (val int64, isNull bool, err error) {
a, isNull, err := s.args[0].EvalInt(s.ctx, row)
if isNull || err != nil {
return 0, isNull, err
}
b, isNull, err := s.args[1].EvalInt(s.ctx, row)
if isNull || err != nil {
return 0, isNull, err
}
// ...
return a + b, false, nil
}
對比下MySQL對應的實現
longlong Item_func_plus::int_op() {
longlong val0 = args[0]->val_int();
longlong val1 = args[1]->val_int();
longlong res = val0 + val1;
bool res_unsigned = false;
if ((null_value = args[0]->null_value || args[1]->null_value)) return 0;
// ...
return check_integer_overflow(res, res_unsigned);
}
這樣的實現好處是很容易保持行為與MySQL一致(表達式計算,隱式轉換),對大部分的函數只需要把MySQL的實現翻譯一下就可以了,不過比起在表達式上下了功夫的Postgre來說,性能可能會差一些(大量的函數調用),因此TiDB也采用了向量化執行表達式的方式來解決這個問題
TiDB的表達式向量化執行
目前的表達式實現
上文提到了TiDB這種與MySQL相似的表達式才運行時會產生大量函數調用,解決這個問題可以從兩個方面考慮:
- 減少表達式計算時的函數調用:利用JIT技術在運行時將表達式編譯成一個函數,減少函數調用
- 減少函數調用的開銷:利用向量化技術,表達式每次運算會同時計算若干行的結果,均攤了函數調用的開銷
Postgre提供了前者,而TiDB選擇了后者,以TiDB中簡單的filter為例,看看TiDB表達式的向量化實現
// SelectionExec represents a filter executor.
type SelectionExec struct {
baseExecutor
// batched: whether to use vectorized expressions
batched bool
filters []expression.Expression
// selected: result of vectorized expression
selected []bool
inputIter *chunk.Iterator4Chunk
inputRow chunk.Row
childResult *chunk.Chunk
memTracker *memory.Tracker
}
filter類的定義並不復雜,接下來看看表達式是怎么執行的,先確定是否能夠使用向量化表達式
func (e *SelectionExec) open(ctx context.Context) error {
// ...
// Vectorizable?
e.batched = expression.Vectorizable(e.filters)
if e.batched {
e.selected = make([]bool, 0, chunk.InitialCapacity)
}
e.inputIter = chunk.NewIterator4Chunk(e.childResult)
e.inputRow = e.inputIter.End()
return nil
}
然后執行表達式
// Next implements the Executor Next interface.
func (e *SelectionExec) Next(ctx context.Context, req *chunk.Chunk) error {
req.GrowAndReset(e.maxChunkSize)
if !e.batched {
// row by row
return e.unBatchedNext(ctx, req)
}
for {
// ...
// vectorized
e.selected, err = expression.VectorizedFilter(e.ctx, e.filters, e.inputIter, e.selected)
if err != nil {
return err
}
e.inputRow = e.inputIter.Begin()
}
}
在函數expression.VectorizedFilter中,如果Expression擁有vecEval*接口,就會調用這些接口進行批量計算,將結果存在selected中作為filter的結果,下面是TiDB向量化加法的實現
// unsigned a + b
func (b *builtinArithmeticPlusIntSig) plusUU(result *chunk.Column, lhi64s, rhi64s, resulti64s []int64) error {
// vectorized here
for i := 0; i < len(lhi64s); i++ {
if result.IsNull(i) {
continue
}
lh, rh := lhi64s[i], rhi64s[i]
// do overflow check...
resulti64s[i] = lh + rh
}
return nil
}
可以改進的地方
現在TiDB表達式向量化的batch size是固定的32。實際上不同的batch_size對性能會有所影響,主要取決於CPU cache的大小
將來一個可能做的改進是根據表達式與CPU cache大小計算出一個合適的batch size讓表達式的中間結果全部放在CPU的L1 cache中,同時最大程度減少函數調用的開銷
PolarDB In-memory Column Index(IMCI)
PolarDB IMCI作為PolarDB的列式索引用於加強其應對復雜查詢的能力,作為一個側重分析性能的組件,其表達式實現也采用了大量的優化技術
表達式優化,向量化與SIMD
IMCI的數據以列式存儲,因此向量化成為了很自然的選擇,與此同時,我們也采用了PostgreSQL解析執行表達式時的優化:
- 只讀的expression + 可讀寫的data slot
- 執行前消除遞歸,分解為若干ExprStep
另外,雲上運行的軟件與普通軟件不同,由於雲上硬件往往統一更新,因此對於雲上運行的軟件,我們可以利用機器硬件的特性進行優化,以一個簡單的IF(x > 0, a, b)表達式為例,一個可能的向量化實現
void IF_func::vec_val_int(Pred *pred, Expr val1, Expr val2, int32_t *dst) {
size_t batch_size = this->batch_size;
uint8_t *pred_val = return val1->vec_val_bool();
// we can push down the mask to val1 and val2
// but in this example, it's harmless
int32_t *val1_val = return val1->vec_val_bool();
int32_t *val2_val = return val1->vec_val_bool();
for (size_t i = 0; i < batch_size; i++) {
if (Utils::test_bit(pred_val, i)) {
dst[i] = val1_val[i];
} else {
dst[i] = val2_val[i];
}
}
}
這里會產生分支,可能會打斷CPU的流水線,但如果機器硬件CPU支持AVX512,這個函數可以利用AVX512 SIMD重寫
void IF_func::vec_val_int(Pred *pred, Expr val1, Expr val2, int32_t *dst) {
size_t batch_size = this->batch_size;
uint16_t *pred_val = return val1->vec_val_bool();
// we can push down the mask to val1 and val2
// but in this example, it's harmless
int32_t *val1_val = return val1->vec_val_bool();
int32_t *val2_val = return val1->vec_val_bool();
constexpr step = 64 / sizeof(int32_t);
for (size_t i = 0; i < batch_size; i++) {
size_t val_idx = (i * step);
auto val1_512 = _mm512_load_epi32(val1_val + val_idx);
auto val2_512 = _mm512_load_epi32(val2_val + val_idx);
_mm512_store_epi32(dst + val_idx, val2_512)
_mm512_mask_store_epi32(dst + val_idx, pred_val[i], val1_512)
}
}
借助CPU對帶mask指令的原生支持,我們能夠消除分支,並且減少了循環的次數,IMCI也利用SIMD指令對表達式進行了優化以最大程度利用硬件為表達式加速
Type Reduction
對於列式存儲的數據,因為同一列的數據排布在一起,相對於行式數據來說,壓縮取得效果會更好一些,另一方面,由於SIMD寄存器寬度是固定的,因此每一個數據越短,一條SIMD指令能夠處理的數據就越多,如果能夠在壓縮的數據上進行表達式計算,就可以加速我們的表達式,例如對於一個bigint
列,如果其數據都在int16范圍內,對於SIMD指令來說
_mm512_eval_epi64(...)
,一次處理8行數據_mm512_eval_epi16(...)
,一次處理32行數據
同樣的指令,處理了4倍的數據。
在IMCI中,我們會根據數據壓縮的情況在對表達式采取合適的優化以最大化SIMD指令的處理效率
SIMD對表達式的加速效果
下圖展示了SIMD與Type Reduction對表達式的加速效果
可以看出SIMD指令對於表達式的加速還是很明顯的。
向量化與JIT: 表達式的未來
上文已經介紹了目前數據庫系統中常見的兩種優化: 向量化與JIT編譯執行,如果單獨拆出來看這兩個技術的話,他們的優缺點如下:
- 向量化:簡單,通用,相較於最傳統的逐行解析執行來說足夠有效
- JIT編譯:可以對表達式做一些額外的優化(依靠編譯器),但是需要考慮代價(編譯時間)如果Query本身是很簡單的Query(IndexScan),那么這個代價會比較明顯,在AP查詢中這個代價就不太起眼
數據庫的表達式,或者更進一步來說,整條SQL實際上都是一段代碼,除了頂層的優化(數據庫的優化器)之外,一些微觀層面上的優化也許直接交給編譯器是更好的選擇,實際上現在也已經有將整條Query編譯為二進制代碼執行的數據庫出現(Hyper, NoisePage)。
另一方面實際上這兩種優化手段也並不對立,我們也可以編譯向量化執行的代碼,甚至依靠LLVM平台無關的IR,我們甚至可以實現跨平台使用SIMD指令,所以與其認為向量化和JIT是兩種優化,不如說向量化是一種實現方式,JIT則基於已有的實現通過編譯進行優化,不過雖然這么說,這兩個技術的結合依然會帶來一些問題:
- JIT在數據庫中的實現會比較麻煩,實際上相當於用另一種語言(LLVM IR)開發數據庫的執行器,會比較別扭,並且調試等操作都會更麻煩。
- 雖然兩個方向是正交的,但是兩個優化加在一起可能會造成1+1<2的效果,如果結合編譯時間和1,就會出現”值不值“的問題
對於第一個問題,PostgreSQL提供了一個解決方案,先將源碼編譯為LLVM IR,運行時讀取進內存供內核使用,如圖所示:
實際上,這是一個在已有的實現上添加JIT功能的示范,這樣可以極大程度應用已有的C語言實現,免於大量LLVM IR的手動編寫。
對於第二個問題,Hyper提供了一個可能的結合方案:在列式壓縮數據上的Scan采用了向量化技術,其上的算子使用tuple-at-a-time的編譯執行技術。
幾個還需要思考的地方:
- 對於PostgreSQL的JIT集成方式,對於表達式來說是很合理且方便的,但如果要借鑒並應用到一個已有的系統(不僅是表達式,還有執行框架),合理的代碼結構設計依然很重要。
- 對於Hyper的結合方式,我們能不能在向量化執行模型上進行類似的結合?對於一些很難使用向量化執行的場景(hash join, order by),也許可以利用編譯執行來加速執行。