ClickHouse原始碼筆記4:FilterBlockInputStream, 探尋where,having的實現

HappenLee發表於2021-03-01

書接上文,本篇繼續分享ClickHouse原始碼中一個重要的流,FilterBlockInputStream的實現,重點在於分析Clickhouse是如何在執行引擎實現向量化的Filter操作符,而利用這個Filter操作符的,就可以實現where, having的資料過濾。
話不多說,準備發車~~ 本文的原始碼分析基於ClickHouse v19.16.2.2的版本。

1.Selection的實現

Selection是關係代數之中重要的一個的一個運算,通常也會用σ符合來selection的實現。

而在SQL語句之中,實現Selection運算的便是:wherehaving。而本文就要從一個簡單的SQL語句出發,帶領大家一同梳理Clickhouse的原始碼,來探究它是如何實現選擇運算的。

先看如下的查詢
SELECT * FROM test where a > 3 and b < 1;

這裡掃描了test表,並且需要篩選出了a列大於3且b列小於1的行。老規矩,我們們先嚐試開啟ClickHouse的Debug日誌看一下具體的執行的pipeline。(ClickHouse 20.6之後的版本,終於支援了使用Explain語句來檢視執行計劃,真是千呼萬喚始出來啊~~)

ClickHouse執行的Pipeline

這裡分為了4個流,而我們們需要關注的流就是Filter流,它實現了從儲存引擎的資料讀取資料,並且執行函式運算,並最終實現資料過濾的邏輯。

所以Clickhouse的表示式計算並不單單隻由ExpressionBlockInputStream來完成的,而FilterBlockInputStream同樣也需要包含Expression進行表示式的向量化的計算與過濾。
吐槽時間私以為這樣的實現並不優雅,如果在Filter上層再套一層ExpressionBlockinputStream結構上會更加清晰。不過這樣的實現可能會導致額外的效能損耗,Clickhouse為了實現查詢的高效執行可謂是『喪心病狂』, 後續分析聚合函式的實現時,我們會見到更為Dirty的程式碼。

2. FilterBlockInputStream的原始碼剖析

  • FilterBlockInputStream readImpl()的實現
    直接上程式碼看一下FilterBlockInputStream的資料讀取方法吧,這部分程式碼比較多。我們拆解出來梳理
 /// Determine position of filter column.
    header = input->getHeader();
    expression->execute(header);

    filter_column = header.getPositionByName(filter_column_name);
    auto & column_elem = header.safeGetByPosition(filter_column);

    /// Isn't the filter already constant?
    if (column_elem.column)
        constant_filter_description = ConstantFilterDescription(*column_elem.column);

首先,構造FilterBlockInputStream時會首先讀取下一級流的Block Header。通過Header來分析是否有常量列滿足always truealways false的邏輯,來設定ConstantFilterDescription。比如存在全部是null列的過濾列,無論進行什麼表示式計算,結果都是false。如果這樣的話,就直接放回空的block給上層流就ok了。

if (expression->checkColumnIsAlwaysFalse(filter_column_name))
        return {};

// Function: checkColumnIsAlwaysFalse
for (auto & action : actions)
    {
        if (action.type == action.APPLY_FUNCTION && action.function_base)
        {
            auto name = action.function_base->getName();
            if ((name == "in" || name == "globalIn")
                && action.result_name == column_name
                && action.argument_names.size() > 1)
            {
                set_to_check = action.argument_names[1];
            }
        }
    }

接下來解析FilterBlockInputStream之中所有的表示式,查詢是否有inglobalin的函式呼叫,並且其第二個引數set為空,那麼同樣表示表示式alwaysFalse也可以直接返回為空的Block。

比如說有如下查詢:select * from test2 where a in (select a from test2 where a > 10)
而這個子查詢select a from test2 where a > 10返回的是空集的話,那麼就會被直接過濾了,返回空的block。

接下來進入一個while迴圈,不斷從底層的流讀取資料,並進行對應的表示式計算。這裡我刪去了一些冗餘的程式碼:

while (1)
    {
        res = children.back()->read();

        expression->execute(res);

        size_t columns = res.columns();
        ColumnPtr column = res.safeGetByPosition(filter_column).column;

這裡的實現很簡單,就是不停從底層的流讀取資料Block,通過表示式計算生成filter_column列。這個列是一組bool列,標識了對應的行是否還應該存在。

舉個例子,如果有如下查詢select * from test where a > 10 and b < 2。ClickHouse的表示式會生成如下執行流程如下(注意:ClickHouse遵從函數語言程式設計的邏輯,任意函式呼叫都會生成新的一列):

1. add const column : 10
2. function call : a > 10 (生成一組新生成的bool列,列名為`a > 10`)
3. remove const  column : 10
4. add const column : 2
5. function call : b  <  2 (生成一組新生成的bool列,列名為`b < 2`)
6. remove const column : 2 
7. call function : a > 10 and b < 2 (生成一組新生成的bool列,列名為`a > 10 and b < 2`)
8. remove column : a > 10
9. remove column : b < 2

而最終新生成的這列就是我們後續需要用到過濾最終結果的filter_column列了。

接下來就進入最核心的一部分程式碼了,遍歷Block之中除了const columnfilter_column列的所有列,進行實際的資料過濾。IColumn介面中實現了一個介面為filter,也就是說,每一個列型別都需要實現一個過濾方法,用一組bool陣列來過濾列資料。

     /** Removes elements that don't match the filter.
      * Is used in WHERE and HAVING operations.
      * If result_size_hint > 0, then makes advance reserve(result_size_hint) for the result column;
      *  if 0, then don't makes reserve(),
      *  otherwise (i.e. < 0), makes reserve() using size of source column.
      */
    using Filter = PaddedPODArray<UInt8>;
    virtual Ptr filter(const Filter & filt, ssize_t result_size_hint) const = 0;

我們直接跳到子類的實現中來看一下:

template <typename T>
ColumnPtr ColumnVector<T>::filter(const IColumn::Filter & filt, ssize_t result_size_hint) const
{
    const UInt8 * filt_pos = filt.data();
    const UInt8 * filt_end = filt_pos + size;
    const T * data_pos = data.data();

    while (filt_pos < filt_end)
    {
        if (*filt_pos)
            res_data.push_back(*data_pos);

        ++filt_pos;
        ++data_pos;
    }

    return res;
}

這之中最為核心的就是這個while迴圈,遍歷bool陣列,然後將合法資料塞進一個新的列之中,最終新的列替換舊的列,就完成了一列資料的過濾。之後對於剩餘的列依次按照上述流程過一遍就完成了整個block的過濾。這裡也可以看到,這個while迴圈也是一組很簡單,沒有control flow break的一段程式碼,能夠給予編譯器向量化優化的空間很大。當然,ClickHouse還提供了一個手工呼叫向量化API的過濾版本程式碼:

#ifdef __SSE2__
    /** A slightly more optimized version.
        * Based on the assumption that often pieces of consecutive values
        *  completely pass or do not pass the filter.
        * Therefore, we will optimistically check the parts of `SIMD_BYTES` values.
        */

    static constexpr size_t SIMD_BYTES = 16;
    const __m128i zero16 = _mm_setzero_si128();
    const UInt8 * filt_end_sse = filt_pos + size / SIMD_BYTES * SIMD_BYTES;

    while (filt_pos < filt_end_sse)
    {
        int mask = _mm_movemask_epi8(_mm_cmpgt_epi8(_mm_loadu_si128(reinterpret_cast<const __m128i *>(filt_pos)), zero16));

        if (0 == mask)
        {
            /// Nothing is inserted.
        }
        else if (0xFFFF == mask)
        {
            res_data.insert(data_pos, data_pos + SIMD_BYTES);
        }
        else
        {
            for (size_t i = 0; i < SIMD_BYTES; ++i)
                if (filt_pos[i])
                    res_data.push_back(data_pos[i]);
        }

        filt_pos += SIMD_BYTES;
        data_pos += SIMD_BYTES;
    }
#endif

熟悉向量化操作的同學應該會覺得上面的程式碼很簡單。每一操作了16個bool陣列,只是筆者比較好奇的是,原則上用avx2或者是avx512應該能夠寫出一批次處理更多資料的向量化程式碼,為啥沒做呢?所以可能某些支援了指令集avx2avx512指令集上編譯的上述程式碼,不開啟SSE2的編譯巨集定義,通過編譯器來實現自動向量化,可能執行速度會更快。

到這裡,基本上完成了整個資料的過濾流程。當然,事情還沒結束,我們的filter_column列通常是不需要再保留的,所以最後ClickHouse還會通過一段程式碼剔除這個列:

Block FilterBlockInputStream::removeFilterIfNeed(Block && block)
{
    if (block && remove_filter)
        block.erase(static_cast<size_t>(filter_column));

    return std::move(block);
}

3.要點梳理

第二小節梳理完成了一整個filter資料流程的程式碼梳理,這裡重點梳理一下實現向量化執行的要點:

  1. ClickHouse的計算是純粹函數語言程式設計式的計算,不會改變原先的列狀態,而是產生一組新的列。我們需要使用這部分新生成bool列來進一步過濾資料。
  2. 通過對最終統一對bool列的處理,不僅保證了Cache的親和度,同時也保證了程式碼的簡潔,給自動化向量化優化提供了儘可能多的空間。最後手工編寫了可以用編譯巨集開啟的向量化程式碼,進一步保證了高效的向量化實現。
  3. 短路執行 vs 向量化執行 這部分ClickHouse的表示式過濾的執行邏輯與Doris完全不同。由於Clickhouse需要向量查詢執行,所以函式表示式無法短路執行。 比如,如果你寫 WHERE a > 1 AND b < 2,從上面的分析來看,ClickHouse都兩個表示式都會進行計算,即使是所有的列都不滿足a > 1但是b < 2也會進行計算。而當前Doris目前在儲存引擎的列存過濾的表示式沒有實現向量化過濾,而是通過短路過濾的形式。這兩種方式目前看起來並沒有絕對的優劣:顯然,短路執行 vs 向量化執行的表現,很大程度上效能的表現取決於表示式的過濾率。

4. 小結

好了,到這裡也就把ClickHouse函式資料過濾的程式碼梳理完了。
所以看到這裡,大家相比對於ClickHouse之中如何高效的實現where, having有新的理解。
筆者是一個ClickHouse的初學者,對ClickHouse有興趣的同學,歡迎多多指教,交流。

5. 參考資料

官方文件
ClickHouse原始碼

相關文章