ClickHouse原始碼筆記3:函式呼叫的向量化實現

HappenLee發表於2021-02-22

分享一下筆者研讀ClickHouse原始碼時分析函式呼叫的實現,重點在於分析Clickhouse查詢層實現的介面,以及Clickhouse是如何利用這些介面更好的實現向量化的。本文的原始碼分析基於ClickHouse v19.16.2.2的版本。

1.舉個例子

下面是一個簡單的SQL語句
SELECT a, abs(b) FROM test

這裡呼叫一個abs的函式,我們先開啟ClickHouse的Debug日誌看一下執行計劃。(當前ClickHouse不支援使用Explain語句來檢視執行計劃,這個確實是很蛋疼的~~)

ClickHouse的執行PipeLine

這裡分為了3個流

  • ExpressionBlockInputStream: 最頂層的Expression,實現了Projection,這個和我們今天主題無關,本質上就是實現一個簡單列的改名操作。比如 select a as aaa from test這裡將列名從a改為aaa.
  • ExpressionBlockInputStream: 第二個ExpressionBlockInputStream就是我們關注的重點的,後面的章節會詳細的剖析它。它主要完成了下面兩件事情
      1. b列執行函式abs,生成新的一列資料abs(b)
      1. remove column b, 將 b列刪除。新的Block為a, abs(b)
  • TinyLogBlockInputStream: 儲存引擎的讀取流,這裡標識了底層表的儲存引擎為append onlyTinyLog

從上面的執行計劃可以看出,Clickhouse的表示式計算是由ExpressionBlockInputStream來完成的,而這個類是一個很強大的類,可以實現:Projection, Join, Apply_Function, Add Column, Remove Column等。

2. 實現流程的梳理

  • ExpressionBlockInputSteam readImpl()的實現
    直接上程式碼,看一下ExpressionBlockInputStream的讀取方法的實現
Block ExpressionBlockInputStream::readImpl()
{
    Block res = children.back()->read();
    if (res)
        expression->execute(res);
    return res;
}

這裡的實現很簡單,就是不停從底層的流讀取資料Block,Block可以理解為Doris之中的Batch,相當一組資料,然後在Block之上執行表示式計算,之後返回給上節點。所以這裡的重點就在於表示式計算的實現類ExpressionActions的指標expression,它封裝了一組表示式的Action,在Block上依次執行這些Action

  • Action excute的實現
    Action支援多種操作,包含了:
enum Type {
        ADD_COLUMN,
        REMOVE_COLUMN,
        COPY_COLUMN,

        APPLY_FUNCTION,
        ARRAY_JOIN,
        JOIN,

        PROJECT,
        ADD_ALIASES,
    };

這裡我們重點關注的是函式執行的實現,可以直接定位到APPLY_FUNCTION的程式碼:

case APPLY_FUNCTION:
        {
            1. 從Block之中篩選出對應的引數陣列
            ColumnNumbers arguments(argument_names.size());
            for (size_t i = 0; i < argument_names.size(); ++i)
            {
                arguments[i] = block.getPositionByName(argument_names[i]);
            }
            
            2.新建一個結果的列,對應函式的結果會寫入結果列,把結果列寫入的Block之中
            size_t num_columns_without_result = block.columns();
            block.insert({ nullptr, result_type, result_name});
            
            3.呼叫對應的函式指標,執行函式呼叫
            function->execute(block, arguments, num_columns_without_result, input_rows_count, dry_run);

這裡我保留一部分關鍵的執行路徑程式碼,並新增了對應的中文註釋。
選出了函式執行的引數,並新增了新的一個空列用於儲存函式abs(b)的最終結果,新的列的偏移量就是num_columns_without_result指定的。
新增了新的一個空列

接下來這裡我們這裡重點關注Function的execute介面的引數就可以了:

  • block:實際儲存的資料
  • arguments:列的引數偏移量
  • num_columns_without_result:函式計算結果的寫入列
  • input_rows_count: block之中的資料行數

這裡本質上是呼叫了介面IFunction的介面,它的子類需要實現對應的excuteImpl的方法:

class IFunction : public std::enable_shared_from_this<IFunction>,
                  public FunctionBuilderImpl, public IFunctionBase, public PreparedFunctionImpl
{
public:
    /// TODO: make const
    void executeImpl(Block & block, const ColumnNumbers & arguments, size_t result, size_t input_rows_count) override = 0;

而最終的實現是IFunction的子類:FunctionUnaryArithmetic實現了該方法,該方法的核心程式碼如下:

                if (auto col = checkAndGetColumn<ColumnVector<T0>>(block.getByPosition(arguments[0]).column.get()))
                {
                    auto col_res = ColumnVector<typename Op<T0>::ResultType>::create();
                    auto & vec_res = col_res->getData();
                    vec_res.resize(col->getData().size());
                    UnaryOperationImpl<T0, Op<T0>>::vector(col->getData(), vec_res);
                    block.getByPosition(result).column = std::move(col_res);
                    return true;
                }

這裡最為核心的是,將arguments的列作為引數列取出為變數col, 而col_res建立了個新的列,存放result的結果。這裡最重要的方法就是
UnaryOperationImpl<T0, Op<T0>>::vector,從名字上也能看出,它實現了函式的向量化計算,我們繼續看這部分程式碼:

    static void NO_INLINE vector(const ArrayA & a, ArrayC & c)
    {
        size_t size = a.size();
        for (size_t i = 0; i < size; ++i)
            c[i] = Op::apply(a[i]);
    }

顯然,這就是一個完美的向量化優化程式碼,沒有任何if, switch, break的分支跳轉語句,for迴圈的長度也是已知的。這裡的Op::apply就是我們們呼叫的AbsImpl::apply函式的實現:

template <typename A>
struct AbsImpl
{
    static inline NO_SANITIZE_UNDEFINED ResultType apply(A a)
    {
        if constexpr (IsDecimalNumber<A>)
            return a < 0 ? A(-a) : a;
        else if constexpr (std::is_integral_v<A> && std::is_signed_v<A>)
            return a < 0 ? static_cast<ResultType>(~a) + 1 : a;
        else if constexpr (std::is_integral_v<A> && std::is_unsigned_v<A>)
            return static_cast<ResultType>(a);
        else if constexpr (std::is_floating_point_v<A>)
            return static_cast<ResultType>(std::abs(a));
    }

走的這裡,相當於走完了整個函式呼叫的流程。而其他多引數的函式的實現也是大同小異,如:

struct BinaryOperationImplBase
{
    using ResultType = ResultType_;

    static void NO_INLINE vector_vector(const PaddedPODArray<A> & a, const PaddedPODArray<B> & b, PaddedPODArray<ResultType> & c)
    {
        size_t size = a.size();
        for (size_t i = 0; i < size; ++i)
            c[i] = Op::template apply<ResultType>(a[i], b[i]);
    }

而執行完成abs(b)函式之後,b列就沒有用處了,Clickhouse會呼叫另一個Action:REMOVE_COLUM在Block之中刪除b列,這樣就得到了我們所需要的兩個列a, abs(b)組成的新的Block。
計算的最終結果

3.要點梳理

第二小節梳理完成了一整個函式呼叫的流程,這裡重點梳理一下實現向量化函式調要點:

  1. ClickHouse的計算是純粹函數語言程式設計式的計算,不會改變原先的列狀態,而是產生一組新的列。
  2. 各個函式的實現需要繼承IFunction的介面,實現execute 的方法,該方法基於Block進行執行。
  3. 最終繼承IFunction介面的實現類都需要override的execute方法,並真正實現對應的函式vectoer的呼叫,這裡Clickhouse確保了For迴圈的長度是已知的,同時沒有對應跳轉語句,確保了編譯器進行向量化優化時有足夠的親和度。(這裡可以開啟gcc的編譯flag:-fopt-info-vec或者clang的編譯選項:-Rpass=loop-vectorize來檢視實際原始碼的向量化情況)

4. 小結

好了,到這裡也就把ClickHouse函式呼叫的程式碼梳理完了。
除了abs函式外,其他的函式的執行也是同樣通過類似的方式依次來實現和處理的,原始碼閱讀的步驟也可以參照筆者的分析流程來參考。
筆者是一個ClickHouse的初學者,對ClickHouse有興趣的同學,歡迎多多指教,交流。

5. 參考資料

官方文件
ClickHouse原始碼

相關文章