深度優化 | PolarDB-X 基於向量化 SIMD 指令的探索

背景

PolarDB-X作爲一款雲原生分佈式數據庫,具有在線事務及分析的處理能力(HTAP)、計算存儲分離、全局二級索引等重要特性。在HTAP方面,PolarDB-X對於AP引擎的向量化已經有了諸多探索和實踐,例如實現了列式內存佈局,MPP,面向列存的執行器等高級特性(參考PolarDB-X 向量化執行引擎(1)PolarDB-X 向量化引擎(2) )。

PolarDB-X正在全面自研列存節點Columnar,負責提供列式存儲數據,結合行列混存 + 分佈式計算節點構建HTAP新架構。近期即將正式上線公有云,未來也會同步發佈開源。

另外,在面向列存場景最典型的就是向量化,SIMD指令作爲向量化中的關鍵一環,已經被諸多主流AP引擎用來提升計算速度。然而由於Java語言本身的限制,PolarDB-X CN計算引擎無法在JDK 17前主動調用SIMD指令,而在JDK 17版本中Java官方提供了對SIMD指令的封裝,即Vector API。本文將介紹PolarDB-X對於向量化SIMD指令的探索和實踐,包括基本用法及實現原理,以及在具體算子實現中的思考和沉澱。

SIMD簡介

SIMD(Single Instruction Multiple Data) 是一種處理器指令類型,即單個指令可以同時處理多個數據

以下爲加法的標量(Scalar)與SIMD(Vector)兩種執行方式:

爲了支持SIMD編程,CPU提供了一系列的特殊寄存器與指令

  • 寄存器:
    • SSE指令集中的128位寄存器XMM0-XMM15
    • AVX指令集中的256位寄存器YMM0-YMM15
  • 算術運算:PADDB,計算兩組 8 bits 整型的和, 每組包含16 個 8 bits (SSE2),可同時用於計算 unsigned 和 signed 類型
  • 比較運算:PCMPEQB,比較兩組 8 bits 是否相等, 每組包含16 個 8 bits(SSE2), 32 個 (AVX2), 64 個 (AVX512BW)
  • 位運算
    • PAND:對兩個寄存器的值作按位與(AND)
    • POR:同 PAND,OR 操作
    • PXOR:同 PAND,XOR 操作
  • Load/Store指令
    • MOVAPS:每次移動128bits的值

Vector API簡介

在JDK 17以前,Java並不能主動的調用SIMD指令,但是在JDK 17版本中,Java官方提供了對SIMD指令的封裝- Vector API。Vector API提供了IntVector, LongVector等寄存器,其會根據底層CPU自動選擇合適的指令集,這使得開發人員無需考慮具體的CPU架構來快速進行SIMD編程。同時它也提供了add, sub, xor, or等操作來進行SIMD運算,以及fromArray, intoArray等來一次性讀取多位數據。

Vector API的基本用法

我們以一個數組相加的例子來快速入門Vector API

在Vector API中,每一個Vector代表一個寄存器,其可以存放若干個元素,取決於寄存器的大小和元素類型,例如當寄存器大小爲128位時,可以存放4個int類型(每個int佔32位)

public class LongSumBenchmark {
    //定義SPECIES,表示Vector的類型
    private static final VectorSpecies<Long> SPECIES = LongVector.SPECIES_PREFERRED;
    
    private int count;
    private long[] longArr;
    private long[] longArr2;
    private long[] longArr3;
    public void normalSum() {
        for (int i = 0; i < longArr.length; i++) {
            longArr3[i] = longArr[i] + longArr2[i];
        }
    }
    public void vectorSum() {
        int i;
        int batchSize = longArr.length;
        int end = SPECIES.loopBound(batchSize); //通過loopBound獲取到對齊後的上限
        for (i = 0; i < end; i += SPECIES.length()) {
            //fromArray(SPECIES, longArr, i)表示從longArr的第i個位置元素開始取出SPECIES.length()個元素
            LongVector va = LongVector.fromArray(SPECIES, longArr, i);
            LongVector vb = LongVector.fromArray(SPECIES, longArr2, i);
            LongVector vc = va.add(vb); //調用add函數,使用SIMD指令求和
            //intoArray(longArr3, i)表示將vc寄存器中的內容存入longArr3中i偏移量開始的元素
            vc.intoArray(longArr3, i);
        }
        for(; i < batchSize; ++i) { //剩餘的部分需要手動處理
            longArr3[i] = (longArr[i] + longArr2[i]);
        }
    }
}

FMA計算

爲了展現Vector API對計算性能的提升,我們復現了FMA計算的例子

FMA加法是指:c = c + a[i] * b[i]。其中a和b都是float/double類型的數組

@Benchmark
public double normalSum() {
    double sum = 0;
    for (int i = 0; i < doubleArr.length; i++) {
        sum += doubleArr[i] * doubleArr2[i];
    }
    return sum;
}
@Benchmark
public double vectorSum() {
    var sum = DoubleVector.zero(SPECIES);
    int i;
    int batchSize = doubleArr.length;
    var upperBound = SPECIES.loopBound(doubleArr.length);
    for (i = 0; i < upperBound; i += SPECIES.length()) {
        DoubleVector va = DoubleVector.fromArray(SPECIES, doubleArr, i);
        DoubleVector vb = DoubleVector.fromArray(SPECIES, doubleArr2, i);
        sum = va.fma(vb, sum);
    }
    var c = sum.reduceLanes(VectorOperators.ADD);
    for(; i < batchSize; ++i) {
        doubleArr3[i] = (doubleArr[i] + doubleArr2[i]);
    }
    return c;
}

測試環境:隨機生成1000/10w個雙精度浮點數。

測試結果:向量化執行比標量執行快了2倍

結果分析:

  • Vector API的執行結果:只有一條指令vfmadd231pd %ymm0,%ymm2,%ymm3: 將ymm2和ymm3中的雙精度浮點數相乘,和ymm1中的數據相加,並把結果放到ymm1中
0x00007f764d363902:   vfmadd231pd %ymm0,%ymm2,%ymm3
  • 標量執行的結果:將乘法和加法拆成了兩條指令vmulsd和vaddsd
0x00007f000133050d:   vmulsd 0x10(%rax,%r13,8),%xmm0,%xmm0
0x00007f0001330514:   vaddsd %xmm0,%xmm1,%xmm1

由測試結果可以看出,對於FMA計算場景,Vector API將原本需要兩條指令的vmulsd和vaddsd合併爲了一條指令vfmadd。

但需要注意:FMA計算的優化無法用在數據庫中,因爲PolarDB-X是將乘法和加法拆爲兩個算子來執行的

使用Vector API實現基礎SIMD操作

在這裏我們將演示如何使用Vector API實現《Rethinking SIMD Vectorization for In-Memory Databases》論文中的Gather和Scatter運算

1.Gather:Vector API的fromArray操作提供了對Gather操作的封裝,我們只需要傳入對應的參數即可

a.標量實現

public void gather(int[] source, int[] indexes, int count, int[] target) {
    for (int i = 0; i < count; i++) {
        target[i] = source[indexes[i]];
    }
}

b.SIMD實現

public void gather(int[] source, int[] indexes, int count, int[] target) {
    final int laneSize = INTEGER_VECTOR_SPECIES.length();
    final int indexVectorLimit = count / laneSize * laneSize;
    int indexPos = 0
    for (; indexPos < indexVectorLimit; indexPos += laneSize) {
        IntVector av = IntVector.fromArray(INTEGER_VECTOR_SPECIES, source, 0, indexes, indexPos);
        av.intoArray(target, indexPos);
    }
    if (indexPos < indexLimit) {
        scalarPrimitives.gather(source, indexes, indexPos, indexLimit - indexPos, target, targetPos);
    }
}

2.Scatter實現:與Gather同理,Vector的intoArray運算提供了對Scatter運算的封裝。

a.標量實現

public void scatter(long[] source, long[] target, int[] scatterMap, int copySize) {
    for (int i = 0; i < copySize; i++) {
        target[scatterMap[i]] = source[i];
    }
}

b.SIMD實現

public void scatter(int[] source, int[] target, int[] scatterMap, int copySize) {
    int laneSize = SIMDHandles.INT_VECTOR_LANE_SIZE; //每次SIMD能處理的位數
    final int indexVectorLimit = copySize / laneSize * laneSize;
    int index = 0;
    for (; index < indexVectorLimit; index += laneSize) {
        IntVector dataInVector = IntVector.fromArray(INTEGER_VECTOR_SPECIES, source, index); //從source[index]位置取出K個數字
        dataInVector.intoArray(target, 0, scatterMap, index); 
    }
    if (index < copySize) {
        scalarPrimitives.scatter(source, target, scatterMap, index, copySize - index);
    }
}

Vector API實現原理

以下面的向量化相加爲例,我們探索一下add函數的實現

LongVector va = LongVector.fromArray(SPECIES, longArr, i);
LongVector vb = LongVector.fromArray(SPECIES, longArr2, i);
LongVector vc = va.add(vb);

JDK層面

在JDK層面Java並沒有做任何的優化,其底層實現就是對Vector中的每個元素調用了apply函數,而apply函數指向了一個綁定的函數,該函數的實現爲標量加法。

顯然,這麼做甚至會增加執行的開銷,那Vector API的高性能從何談起呢?

1.add函數的實現

@Override
@ForceInline
public final LongVector add(Vector<Long> v) {
    return lanewise(ADD, v);
}

2.最終調用b0pTemplate函數進行計算

@ForceInline
final
LongVector bOpTemplate(Vector<Long> o,
                                 FBinOp f) {
    long[] res = new long[length()];
    long[] vec1 = this.vec();
    long[] vec2 = ((LongVector)o).vec();
    for (int i = 0; i < res.length; i++) {
        res[i] = f.apply(i, vec1[i], vec2[i]);
    }
    return vectorFactory(res);
}

3.apply綁定了標量執行的函數實現

LongVector lanewiseTemplate(VectorOperators.Binary op,
                                      Vector<Long> v) {
    LongVector that = (LongVector) v;
    that.check(this);
  ....
    int opc = opCode(op);
    return VectorSupport.binaryOp(
        opc, getClass(), long.class, length(),
        this, that,
        BIN_IMPL.find(op, opc, (opc_) -> {
          switch (opc_) {
            case VECTOR_OP_ADD: return (v0, v1) ->
                    v0.bOp(v1, (i, a, b) -> (long)(a + b));
            case VECTOR_OP_SUB: return (v0, v1) ->
                    v0.bOp(v1, (i, a, b) -> (long)(a - b));
            case VECTOR_OP_MUL: return (v0, v1) ->
                    v0.bOp(v1, (i, a, b) -> (long)(a * b));
            case VECTOR_OP_DIV: return (v0, v1) ->
                    v0.bOp(v1, (i, a, b) -> (long)(a / b));
            ....
            }}));
}

JVM層面

1.前置知識:JIT與C2編譯器

這裏需要簡單講一下Java的即時編譯(JIT)。Java的執行過程整體可以分爲解釋執行和編譯執行(JIT),第一步由javac將源碼編譯成字節碼並進行解釋執行,在解釋執行的過程中,JVM會對程序的運行信息進行收集,對於其中的熱點代碼通過編譯器(默認爲C2)進行編譯,將字節碼直接轉化爲機器碼,然後進行編譯執行。

怎麼樣纔會被認爲是熱點代碼呢?JVM中會設置一個閾值,當方法或者代碼塊的在一定時間內的調用次數超過這個閾值時就會被認爲是熱點代碼。

JIT的執行流程如下

2.Vector API在JVM層面的實現

通過查找,我們找到了Vector API的first commit,從該commit中我們發現Vector API的實現中有大量對JVM內核的修改。

首先,其在c2compiler中增加了Vector API相關的intrinsic(src/hotspot/share/opto/c2compiler.cpp)

當觸發JIT時,Vector API相關的代碼會替換爲JVM層面的intrinsic,並使用SIMD指令優化。具體來說,當Vector API的代碼被JIT後,其會將語法樹中原本的IR節點替換爲intrinsic的實現,即將method替換爲intrinsic的方法。

//---------------------------make_vm_intrinsic----------------------------
CallGenerator* Compile::make_vm_intrinsic(ciMethod* m, bool is_virtual) {
  vmIntrinsicID id = m->intrinsic_id();
    
  C2Compiler* compiler = (C2Compiler*)CompileBroker::compiler(CompLevel_full_optimization);
  bool is_available = false;
  methodHandle mh(THREAD, m->get_Method());
  is_available = compiler != NULL && compiler->is_intrinsic_supported(mh, is_virtual) &&
                   !C->directive()->is_intrinsic_disabled(mh) &&
                   !vmIntrinsics::is_disabled_by_flags(mh);
  if (is_available) {
    return new LibraryIntrinsic(m, is_virtual,
                                vmIntrinsics::predicates_needed(id),
                                vmIntrinsics::does_virtual_dispatch(id),
                                id);
  } else {
    return NULL;
  }
}

最終的SIMD指令在C2的彙編器中生成實現

void Assembler::addpd(XMMRegister dst, XMMRegister src) {
  NOT_LP64(assert(VM_Version::supports_sse2(), ""));
  InstructionAttr attributes(AVX_128bit, /* rex_w */ VM_Version::supports_evex(), /* legacy_mode */ false, /* no_mask_reg */ true, /* uses_vl */ true);
  attributes.set_rex_vex_w_reverted();
  int encode = simd_prefix_and_encode(dst, dst, src, VEX_SIMD_66, VEX_OPCODE_0F, &attributes);
  emit_int16(0x58, (0xC0 | encode));
}

表達式計算

Long數組相加

Vector API的最大優勢就是加速計算,因此接下來我們會探索其可能能夠帶來性能提升的場景。

首先我們對前文中給出的Long數組相加的場景進行了Benchmark,可以看到在數組相加場景下標量執行和SIMD執行相差不大,通過對彙編指令的追蹤,我們發現不論是SIMD執行還是標量執行最終都會生成vpaddq這條指令

1.SIMD執行

0x00007f3eb13602ae:   vpaddq 0x10(%r11,%rbx,8),%ymm3,%ymm3;

2.標量執行

0x00007fa6fd33259a:   vpaddq 0x10(%r8,%rbp,8),%ymm4,%ymm4

vpaddq:AVX512指令集。將(%r11 + %rbx * 8)開始的16個字節的數據和ymm3寄存器中的數據相加,寫到ymm3寄存器中

自動向量化(auto-vectorization)

我們發現,哪怕沒有顯示的使用Vector API,向量化加法的代碼也會被進行向量化,這源於Java的自動向量化(auto-vectorization)機制。

Java自動向量化的實現與Vector API類似,其會在JIT編譯的時候檢查代碼能否使用SIMD指令進行運算,如果可以即替換爲SIMD實現。

但是自動向量化僅能處理比較簡單的計算,對於複雜計算仍然需要手動SIMD(使用Vector API)。

例如自動向量化已知的限制有:

  1. 只支持自增的for循環
  2. 只支持Int/Long類型(Short/Byte/Char 通過int間接支持)
  3. 循環的上限必須是常量

通過對一些成熟OLAP系統(例如ClickHouse)的調研,以及我們線上實際場景的探索,我們使用Vector API重新實現了一批算子,這裏我們將會展示4種比較有代表性的場景。

大小寫轉化

使用SIMD實現大小寫轉化的思路比較簡單,我們只需要調用compare方法進行比較,使用lanewise方法進行異或即可。這裏引入了VectorMask,可以理解爲一個boolean類型的寄存器,裏面含有n個0/1。

@Benchmark
public void normal() {
    final long Mask = 'A' ^ 'a';
    for (int i = 0; i < charArr.length; i++) {
        if(charArr[i] >= 'A' && charArr[i] <= 'Z') {
            charArr[i] ^= Mask;
        }
    }
}
@Benchmark
public void vector() {
    final long Mask = 'A' ^ 'a';
    int i;
    int batchSize = charArr.length;
    int end = SPECIES.loopBound(charArr.length);
    for (i = 0; i < end; i += SPECIES.length()) {
        ByteVector from = ByteVector.fromArray(SPECIES, charArr, i);
        VectorMask mask = from.compare(VectorOperators.GE, 'A', from.compare(VectorOperators.LE, 'Z'));
        ByteVector to = from.lanewise(VectorOperators.XOR, Mask, mask); //這裏傳入了mask,表示只對mask=1的位置執行xor
        to.intoArray(charArr, i);
    }
    for(; i < batchSize; ++i) {
        if(charArr[i] >= 'A' && charArr[i] <= 'Z') {
            charArr[i] ^= Mask;
        }
    }
}

Benchmark

測試環境:隨機生成1000和10w個byte類型的字母來進行Benchmark

測試結果:在count=10w的場景下快了50x,原因在於ByteSpecies的長度爲32,同時SIMD的執行方式沒有分支預測失敗flush流水線的開銷。

SIMD Filter

使用SIMD指令實現Filter可以使用Gather運算讀取數組中的元素,並使用compare方法進行比較,最後採用位運算的方式記錄下滿足條件的下標。

@Benchmark
public void normal() {
    newSize = 0;
    for (int i = 0; i < intArr.length; i++) {
        if(intArr[sel[i]] <= intArr2[sel[i]) {
            sel[newSize++] = i;
        }
    }
}
@Benchmark
public void vector() {
    newSize = 0;
    int i;
    int batchSize = intArr.length;
    int end = SPECIES.loopBound(intArr.length);
    for (i = 0; i < end; i += SPECIES.length()) {
        IntVector va = IntVector.fromArray(SPECIES, intArr, 0, sel, i);
        IntVector vb = IntVector.fromArray(SPECIES, intArr2, 0, sel, i);
        VectorMask<Integer> vc = va.compare(VectorOperators.LE, vb);
        if(!vc.anyTrue()) {
            continue;
        }
        int res = (int) vc.toLong();
        while(res != 0) {
            int last = res & -res; //找到二進制中最後一個1對應的冪次, 例如12 = 1100, 12 & (-12) = 4
            sel[newSize++] = i + Integer.numberOfTrailingZeros(last); //numberOfTrailingZeros(2^i) = i
            res ^= last;
        }
    }
    for(; i < batchSize; ++i) {
        int j = sel[i];
        if(intArr[j] <= intArr2[j]) {
            sel[newSize++] = i;
        }
    }
}

Benchmark

測試環境:隨機生成1000和10w個int類型的整數來進行Benchmark

結果分析:在count=1000時SIMD的性能反而下降,這是因爲此時函數並沒有JIT,而是採用了JDK層面的標量執行。在count=10w時方法已經被JIT,因此性能會有25%的提升。

Local Exchange

什麼是Local Exchange算子

Exchange算子是PolarDB-X MPP執行模式中進行數據shuffle的重要組件,關於其背景可以參考文章PolarDB-X 並行計算框架 - 知乎這裏不再贅述。在這裏,我們主要來優化Local Exchange算子中的LocalPartitionExchanger執行模式。本次實踐中,我們通過向量化算子 + SIMD Scatter指令的方式實現了35%的性能提升。

LocalPartitionExchanger算子可以簡單理解爲: 對於某個下游pipline中的driver,其會對Chunk中的每行數據計算對應的partition分區,並將相同partition分區的數據重新組裝爲一個新的Chunk餵給上游driver。

對於這一段表述不理解的話也沒關係,本文的重點在於介紹使用SIMD指令優化代碼邏輯的方式,因此可以放心繼續閱讀下文。

非向量化版本(PolarDB-X現有版本)

PolarDB-X現有版本沿用了行存執行模型下的Local Exchange算子,其基本思想是row by row的逐行枚舉、逐行計算partition並寫入對應的Chunk,這一執行模式的缺點在於其既不能有效的利用列式內存佈局,同時appendTo操作會帶來大量的時間開銷。

舊版本的詳細執行流程爲:

1.計算position(行號)對應的partition:使用了n個鏈表來保存每個partition對應的position list

a.(partitionGenerator.getPartition可以簡單的理解爲對position對應的數據進行hash運算得到目標partition的位置)

for (int position = 0; position < keyChunk.getPositionCount(); position++) {
    int partition = partitionGenerator.getPartition(keyChunk, position);
    partitionAssignments[partition].add(position);
}

2.buildChunk:生成對應partition的Chunk

a.先枚舉partition

      1. 枚舉partition對應的position(position表示行)
        1. 枚舉該行對應的列block
          1. 調用builder的appendTo來爲ChunkBuilder添加元素
          2. appendTo有一次虛函數調用,性能開銷極大!

Map<Integer, Chunk> partitionChunks = new HashMap<>();
for (int partition = 0; partition < executors.size(); partition++) { //枚舉partition
    List<Integer> positions = partitionAssignments[partition]; //獲取到對應的positions = partitionAssignments[partition]
    ChunkBuilder builder = new ChunkBuilder(types, positions.size(), context);
    Chunk partitionedChunk;
    for (Integer pos : positions) { //枚舉對應的position(行)
        for (int i = 0; i < chunk.getBlockCount(); i++) { //枚舉對應的列
            builder.appendTo(chunk.getBlock(i), i, pos); //將pos行,i列位置的元素添加到對應的ChunkBuilder
        }
    }
    partitionedChunk = builder.build();
    partitionChunks.put(partition, partitionedChunk);
}

Local Exchange SIMD優化

思路:

1.對appendTo的優化: appendTo操作開銷極大,嘗試用更高效的方法拷貝數據, 例如system.arrayCopy

2.對行式枚舉模式的優化:逐行枚舉的方式受限,因爲每一列的數據在不同的Block內,不可能攢批copy。同時逐行枚舉的方式對訪存不友好。那麼考慮先枚舉列。

    1. 問題: 每一個partition對應的行並不連續
    2. 解決方案:如下圖所示,我們希望求出positionMapping數組使得相同partition的行能夠被遷移到連續的行中,這樣以來就可以使用Scatter運算進行加速。
afterValue[positionMapping[i]] = preValue[i]

3.問題:如何知道positionMapping?

    1. 其實很簡單,線性掃描一遍,記錄下每個partition的size, 記爲partitoinSize[]
    2. 知道了size,就可以求出每個partition在最終數組中的偏移量paritionOffset[]
    3. 已知offset就可以邊遍歷邊求出positionMapping:只需要記錄出該行對應的partition的offset,然後讓offset自增即可。這一過程看代碼會更直觀。

具體步驟

  1. 計算positionMapping[]

a.計算每個位置對應的partition以及每個partition的size

private void scatterAssignmentNoneBucketPageInBatch(Chunk keyChunk, int positionStart, int batchSize) {
    int[] partitions = scatterMemoryContext.getPartitionsBuffer();
    for (int i = 0; i < batchSize; i++) {
        int partition = partitionGenerator.getPartition(keyChunk, i + positionStart);
        partitions[i] = partition;
        ++paritionSize[partition]; //統計每個partition的size
    }
}

b.計算每個partition對應的offset

private void prepareDestScatterMapStartOffset() {
    int startOffset = 0;
    for (int i = 0; i < partitionNum; i++) {
        partitionOffset[i] = startOffset; //統計每個partition對應的偏移量
        startOffset += partitionSize[i];
    }
}

c.計算positionMapping

protected void prepareLengthsPerPartitionInBatch(int batchSize, int[] partitions, int[] destScatterMap) {
    for (int i = 0; i < batchSize; ++i) {
        positionMapping[i] = partitionOffset[partitions[i]]++; //獲取每個位置scatter之後對應的內存地址
    }
}

2.使用scatter運算

a.vectorizedSIMDScatterAppendTo中會判斷該Block是否支持SIMD執行,並調用Block的copyPositions_scatter_simd方法,這裏以IntBlock爲例

protected void scatterCopyNoneBucketPageInBatch(Chunk keyChunk, int positionStart, int batchSize) {
    Block[] sourceBlocks = keyChunk.getBlocksDirectly(); //獲取到所有的block
    for (int col = 0; col < types.size(); col++) { //枚舉所有列
        Block sourceBlock = sourceBlocks[col]; //獲取到block
        for (int i = 0; i < chunkBuilders.length; i++) {
            blockBuilders[i] = chunkBuilders[i].getBlockBuilder(col); //獲取到所有partition的pageBuilder
        }
        vectorizedSIMDScatterAppendTo(sourceBlock, scatterMemoryContext, blockBuilders);
    }
}

 

public void copyPositions_scatter_simd(ScatterMemoryContext scatterMemoryContext, BlockBuilder[] blockBuilders) {
    //存放目的數據的buffer
    int[] afterValue = scatterMemoryContext.getafterValue();
    //存放原始數據的buffer
    int[] preValue = scatterMemoryContext.getpreValue();
    //positionMapping
    int[] positionMapping = scatterMemoryContext.getpositionMapping();
    //每個partition的size
    int[] partitionSize = scatterMemoryContext.getpartitionSize();
    //使用scatter的操作
    VectorizedPrimitives.SIMD_PRIMITIVES_HANDLER.scatter(preValue, afterValue, positionMapping, 0, batchSize);
    int start = 0;
    for (int partition = 0; partition < blockBuilders.length; partition++) {
        IntegerBlockBuilder unsafeBlockBuilder = (IntegerBlockBuilder) blockBuilders[partition];
        //注意這裏writeBatchInts的實現
        unsafeBlockBuilder.writeBatchInts(afterValue, bufferNullsBuffer, start, partitionSize[partition]);
        start += partitionSize[partition];
    }
}

3.使用writeBatchInts來實現內存拷貝

public IntegerBlockBuilder writeBatchInts(int[] ints, boolean[] nulls, int sourceIndex, int count) {
    values.addElements(getPositionCount(), ints, sourceIndex, count);
    valueIsNull.addElements(getPositionCount(), nulls, sourceIndex, count);
    return this;
}

addElements的底層使用了System.arraycopy命令來批量的進行內存複製

System.arraycopy是JVM的內置函數,其實現效率遠遠快於調用append接口來逐個添加數據

@IntrinsicCandidate
public static native void arraycopy(Object src,  int  srcPos,
                                    Object dest, int destPos,
                                    int length);

Benchmark

測試環境:我們設置parition的數量爲3,並向Local Exchange算子輸入100個大小爲1024,包含4列int的Chunk來進行Benchmark

結果分析:

我們發現單純的向量化算法並不會有性能提升,原因在於Local Exchange算子的瓶頸在於appendTo操作

而SIMD Scatter + System.arrayCopy的方式實現了35%的性能提升。

SIMD Hash Join

SIMD思路

在CMU 15-721中提到了SIMD Hash Probe的實現,我們將用Vector API來複現這一過程。

首先來回顧開放地址法的SIMD Hash Probe過程,其基本思路是一次性對4個位置的元素進行probe。

1.計算出4個位置的hash值

2.通過Gather運算讀取到hash表中對應位置的元素

3.通過SIMD的compare運算比較有無hash衝突

4.前3步都比較好理解,接下來我們需要讓有hash衝突的元素尋址到下一個位置繼續解hash衝突,這一步可以由SIMD加法來實現。重點在於如何讓沒有衝突的元素移走,把數組中後面的元素放進來繼續匹配呢?(例如我們希望把下圖中的k1, k4分別替換爲k5, k6)

解決方案:

a.使用expand運算來進行selective load運算

buildPositionVec = buildPositionVec.expand(probePosition, probeOffset, probeMask);

b.expand運算可以將probeMask當中爲1的n個位置元素替換爲hashPosition數組從probeOffset位置開始的n個元素。

      1. 例如在上面的例子中數據的變化爲
      2. hashPosition = [h1, h2, h3, h4, h5, h6, h7, h8.....]
      3. probeOffset = 4
      4. probeMask = [1, 0, 0, 1]
      5. 入參: [h1, h2, h3, h4]
      6. 出參: [h5, h2, h3, h6]

小插曲: 如何實現更強大的expand

在上述代碼中,我們的expand運算傳入了3個參數,但是openJDK官方的expand函數最多有一個入參(openJDK的expand用法不能傳入數組,只能在Vector之間做expand)

 

這得益於阿里雲強大的自研能力,我們與阿里雲JVM團隊進行了積極的溝通,JVM團隊的同學幫助我們實現了更豐富的expand的運算。

驗證

PolarDB-X的Hash方式並不是開放尋址法,而是布穀鳥Hash,但其SIMD的原理類似,這裏給出對布穀鳥Hash的SIMD Hash Probe改造過程。

@Benchmark
public void normal() {
    for(int i = 0; i < count; i++) {
        int joinedPosition = 0;
        int matchedPosition = (int) hashTable.keys[hashedProbeKey[i]];
        while (matchedPosition != LIST_END) {
            if (buildKey[matchedPosition] == probeKey[i]) {
                joinedPosition = matchedPosition;
                break;
            }
            matchedPosition = (int) positionLinks[matchedPosition];
        }
        joinedPositions[i] = joinedPosition;
    }
}
@Benchmark
public void vector() {
    int probeOffset = 0;
    VectorMask<Long> probeMask;
    VectorMask<Integer> intProbeMask;
    LongVector probeValueVec = LongVector.fromArray(LongVector.SPECIES_512, probeKey, probeOffset);
    // step 1: 使用Gather運算從hashTable的keys中讀取數據. int matchedPosition = hashTable.keys[hashedProbeKey[i]];
    LongVector buildPositionVec = LongVector.fromArray(LongVector.SPECIES_512, hashTable.keys, 0, hashedProbeKey, probeOffset);
    IntVector probeIndexVec = IntVector.fromArray(IntVector.SPECIES_256, index, probeOffset);
    probeOffset += 8;
    while(probeOffset + LongVector.SPECIES_512.length() < count){
        // step 2: 計算buildPositionVec中爲0的位置單獨處理
        VectorMask<Long> emptyMask = buildPositionVec.compare(VectorOperators.EQ, 0);
        // step 3: 使用Gather運算計算出buildKey[matchedPosition[i]]的值
        IntVector intbuildPositionVec = buildPositionVec.castShape(IntVector.SPECIES_256, 0).reinterpretAsInts();
        LongVector buildValueVec = LongVector.fromArray(LongVector.SPECIES_512, intbuildPositionVec, buildKey);
        // step 4: 使用SIMD compare來進行比較. if (buildKey[matchedPosition] == probeKey[i])
        VectorMask<Long> valueEQMask = probeValueVec.compare(VectorOperators.EQ, buildValueVec, emptyMask.not());
        // step 5: 計算出probe過程成功找到目標位置的Mask
        probeMask = valueEQMask.or(emptyMask);
        intProbeMask = probeMask.cast(IntVector.SPECIES_256);
        // step 6: 使用scatter運算將匹配的position寫入joinedPosition. joinedPositions[i] = matchedPosition;
        buildPositionVec.intoArray(joinedPositions, probeIndexVec, valueEQMask);
        // step 7: 處理hash衝突. matchedPosition = positionLinks[matchedPosition];
        buildPositionVec = LongVector.fromArray(LongVector.SPECIES_512, intbuildPositionVec, positionLinks);
        // step 8: 使用expand運算獲取到下一個probe vector
        buildPositionVec = buildPositionVec.expand(probePosition, probeOffset, probeMask);
        probeValueVec = probeValueVec.expand(probeKey, probeOffset, probeMask);
        probeIndexVec = probeIndexVec.expand(index, probeOffset, intProbeMask);
        // step 9: 更新probeOffset
        probeOffset += probeMask.trueCount();
    }
    scalarProcessVector(probeValueVec, buildPositionVec, probeIndexVec, joinedPositions); //處理最後一個Vector
    if (probeOffset < count) {
        processBatchX(hashedProbeKey, probeOffset, count, joinedPositions);
    }
}

Benchmark

測試環境:在Build端我們向Hash表中插入了100w個大小在0-1000範圍的元素來模擬Hash衝突,Probe時計算探測100w個元素所需要的時間

測試結果:雖然我們實現了SIMD Hash Probe,但由於現有的Vector API對類型的支持並不充分(代碼中含有大量的類型轉化),因此實測結果並不優秀,甚至有2倍多的性能下降。

但拋開Java Vector API本身帶來的性能下降,使用SIMD指令來優化Hash Probe也許並不是明智之舉,這是因爲Hash Join的瓶頸不在於計算,而在於訪存。《Improving hash join performance through prefetching》寫到數據庫的Hash Join算法有73%的開銷在CPU cache miss上,這也解釋了SIMD指令沒有優化的原因。

在PolarDB-X內部對TPC-H Q9的測試中發現浪費在cache miss上的CPU Cycle達到了計算的10倍之多,因此我們將對Hash Join的優化轉移到了cache miss。PolarDB-X已經嘗試使用prefetch指令預取(由阿里雲JVM團隊提供對Java的增強),向量化Hash Probe等方式優化cache miss,相關的優化成果會在後續的文章中展示。

總結

本篇文章我們首先介紹了Vector API的用法與實現原理,並着重探索了其在數據庫場景下的應用,在以計算爲瓶頸的大小寫轉化中實現了50倍的性能提升,在Filter算子中實現了25%的性能提升,在Local Exchange算子中實現了35%的性能提升。同時我們也討論了Vector API在Hash Probe這種以cache miss爲瓶頸的算子中的侷限性。

PolarDB-X作爲一款分佈式HTAP數據庫,AP引擎的性能優化一直是我們的重點工作內容。我們不僅僅着眼於業內的常見優化,對於行列混存架構、向量化SIMD指令等無人涉及的“深水區”也在積極的探索當中。敬請大家期待後續 PolarDB-X 列存引擎在公有云和開源的正式發佈。

作者:鴻爲

點擊立即免費試用雲產品 開啓雲上實踐之旅!

原文鏈接

本文爲阿里雲原創內容,未經允許不得轉載。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章