Gonum中內積運算Plan9匯編代碼如下:

#include "textflag.h"

#define HADDPS_SUM_SUM LONG $0xC07C0FF2 // @ HADDPS X0, X0

#define X_PTR SI
#define Y_PTR DI
#define LEN CX
#define TAIL BX
#define IDX AX
#define SUM X0
#define P_SUM X1

// func DotUnitary32(x, y []float32) (sum float32)
TEXT ·DotUnitary32(SB), NOSPLIT, $0
MOVQ x_base+0(FP), X_PTR // X_PTR = &x
MOVQ y_base+24(FP), Y_PTR // Y_PTR = &y
PXOR SUM, SUM // SUM = 0
MOVQ x_len+8(FP), LEN // LEN = min( len(x), len(y) )
CMPQ y_len+32(FP), LEN
CMOVQLE y_len+32(FP), LEN
CMPQ LEN, $0
JE dot_end

XORQ IDX, IDX
MOVQ Y_PTR, DX
ANDQ $0xF, DX // Align on 16-byte boundary for MULPS
JZ dot_no_trim // if DX == 0 { goto dot_no_trim }
SUBQ $16, DX

dot_align: // Trim first value(s) in unaligned buffer do {
MOVSS (X_PTR)(IDX*4), X2 // X2 = x[i]
MULSS (Y_PTR)(IDX*4), X2 // X2 *= y[i]
ADDSS X2, SUM // SUM += X2
INCQ IDX // IDX++
DECQ LEN
JZ dot_end // if --TAIL == 0 { return }
ADDQ $4, DX
JNZ dot_align // } while --DX > 0

dot_no_trim:
PXOR P_SUM, P_SUM // P_SUM = 0 for pipelining
MOVQ LEN, TAIL
ANDQ $0xF, TAIL // TAIL = LEN % 16
SHRQ $4, LEN // LEN = floor( LEN / 16 )
JZ dot_tail4_start // if LEN == 0 { goto dot_tail4_start }

dot_loop: // Loop unrolled 16x do {
MOVUPS (X_PTR)(IDX*4), X2 // X_i = x[i:i+1]
MOVUPS 16(X_PTR)(IDX*4), X3
MOVUPS 32(X_PTR)(IDX*4), X4
MOVUPS 48(X_PTR)(IDX*4), X5

MULPS (Y_PTR)(IDX*4), X2 // X_i *= y[i:i+1]
MULPS 16(Y_PTR)(IDX*4), X3
MULPS 32(Y_PTR)(IDX*4), X4
MULPS 48(Y_PTR)(IDX*4), X5

ADDPS X2, SUM // SUM += X_i
ADDPS X3, P_SUM
ADDPS X4, SUM
ADDPS X5, P_SUM

ADDQ $16, IDX // IDX += 16
DECQ LEN
JNZ dot_loop // } while --LEN > 0

ADDPS P_SUM, SUM // SUM += P_SUM
CMPQ TAIL, $0 // if TAIL == 0 { return }
JE dot_end

dot_tail4_start: // Reset loop counter for 4-wide tail loop
MOVQ TAIL, LEN // LEN = floor( TAIL / 4 )
SHRQ $2, LEN
JZ dot_tail_start // if LEN == 0 { goto dot_tail_start }

dot_tail4_loop: // Loop unrolled 4x do {
MOVUPS (X_PTR)(IDX*4), X2 // X_i = x[i:i+1]
MULPS (Y_PTR)(IDX*4), X2 // X_i *= y[i:i+1]
ADDPS X2, SUM // SUM += X_i
ADDQ $4, IDX // i += 4
DECQ LEN
JNZ dot_tail4_loop // } while --LEN > 0

dot_tail_start: // Reset loop counter for 1-wide tail loop
ANDQ $3, TAIL // TAIL = TAIL % 4
JZ dot_end // if TAIL == 0 { return }

dot_tail: // do {
MOVSS (X_PTR)(IDX*4), X2 // X2 = x[i]
MULSS (Y_PTR)(IDX*4), X2 // X2 *= y[i]
ADDSS X2, SUM // psum += X2
INCQ IDX // IDX++
DECQ TAIL
JNZ dot_tail // } while --TAIL > 0

dot_end:
HADDPS_SUM_SUM // SUM = \sum{ SUM[i] }
HADDPS_SUM_SUM
MOVSS SUM, sum+48(FP) // return SUM
RET

?Golang如何調用Plan9匯編呢?

  1. 先將匯編函數保存到后綴為.s的匯編文件中。
  2. 然后在同級目錄下新建一個.go文件,在文件中聲明函數,如以上匯編函數聲明如下,業務代碼直接調用該函數即可。
func DotUnitary32(x, y []float32) (sum float32)

 Gonum的計算性能怎么樣呢?采用了并行計算,內積運算性能是原來的8倍,是滿足要求的,具體測試結果在2.4章節中會統一進行對比測試。那既然性能滿足要求,是不是就可以了?

因為Gonum的計算函數有限,并不能完全覆蓋到我們需要的一些函數,如余弦和歐式距離計算,或者在標準的計算過程中加一些自定義的計算公式,Gonum是做不到的。

受到Gonum并行計算的啟發,想到是否可以使用SIMD(單指令多數據流)指令集來加速計算。

2.2 SIMD計算

SIMD單指令流多數據流(SingleInstruction Multiple Data,SIMD)是一種采用一個控制器來控制多個處理器,同時對一組數據(又稱“數據向量”)中的每一個分別執行相同的操作從而實現空間上的并行性的技術。在微處理器中,單指令流多數據流技術則是一個控制器控制多個平行的處理微元,例如Intel的MMX或SSE以及AMD的3D Now!技術。

目前Intel處理器支持的SIMD技術包括MMX,SSE,AVX.,MMX提供了8個64bit的寄存器進行SIMD操作,SSE系列提供了128bit的8個寄存器進行SIMD指令操作,AVX指令則支持256bit的SIMD操作。目前SIMD指令可以有四種方法進行使用分別是匯編語言,C++類,編譯器Intrisincs和自動矢量化。SIMD intrinsics有些類似于C語言中的函數,可以被其它的代碼直接調用,相比匯編語言來說更容易使用。

Intel官網的SIMD intrinsics指令集查詢

https://www.intel.com/content/www/us/en/docs/intrinsics-guide/index.html

為了使用與SIMD技術相關的intrinsics,首先需要包含那些定義了數據類型和函數的頭文件。

#include <mmintrin.h> //MMX   __m64 定義
#include <xmmintrin.h> //SSE(include mmintrin.h) __m128 定義
#include <emmintrin.h> //SSE2(include xmmintrin.h) __m128i ,__m128d 類型 定義
#include <pmmintrin.h> //SSE3(include emmintrin.h)
#include <tmmintrin.h>//SSSE3(include pmmintrin.h)
#include <smmintrin.h>//SSE4.1(include tmmintrin.h)
#include <nmmintrin.h>//SSE4.2(include smmintrin.h)
#include <wmmintrin.h>//AES(include nmmintrin.h)
#include <immintrin.h>//AVX(include wmmintrin.h) __m256 ,__m256i ,__m256d 類型定義
#include <intrin.h>//(include immintrin.h) 所有架構

 關鍵的SIMD AVX2指令集(256位寄存器),可以利用這些常見的指令進行自定義計算。

查看機器支持的指令集兩種方式

lscpu // 查看flags標志中支持的所有指令集
gcc -mavx2 -dM -E - < /dev/null|egrep AVX // 查看是否支持AVX
gcc -msse4 -dM -E - < /dev/null|egrep SSE // 查看是否支持SSE

2.2.1 內積距離計算

使用SIMD AVX2指令進行256維向量的內積距離計算,計算公式如下:

SIMD代碼如下,256位寄存器一次可加載8個32位浮點數。

void VecInnerProductAVX2(const float* x, const float* y, float* z) {
int d = 256;
__m256 msum1 = _mm256_setzero_ps();
// 加載數據并計算乘積和累計和
while (d >= 8) {
__m256 mx = _mm256_loadu_ps(x);
x += 8;
__m256 my = _mm256_loadu_ps(y);
y += 8;
msum1 = _mm256_add_ps(msum1, _mm256_mul_ps(mx, my));
d -= 8;
}
// 將寄存器中的結果求和并賦值給新數組返回
__m128 msum2 = _mm256_extractf128_ps(msum1, 1);
msum2 = _mm_add_ps(msum2, _mm256_extractf128_ps(msum1, 0));

msum2 = _mm_hadd_ps(msum2, msum2);
msum2 = _mm_hadd_ps(msum2, msum2);
_mm_storeu_ps(z, msum2);
}

2.2.2 歐式距離計算

使用SIMD AVX2指令進行256維向量的歐式距離計算,計算公式如下:

SIMD代碼如下:

void VecEuclideanDistanceAVX2(const float* x, const float* y, float* z) {
int d = 256;
__m256 msum1 = _mm256_setzero_ps();
// 加載數據并計算差值平方累計和
while (d >= 8) {
__m256 mx = _mm256_loadu_ps(x);
x += 8;
__m256 my = _mm256_loadu_ps(y);
y += 8;
__m256 sub = _mm256_sub_ps(mx, my);
msum1 = _mm256_add_ps(msum1, _mm256_mul_ps(sub, sub));
d -= 8;
}
// 將寄存器中的結果求和并開平方根,最終結果賦值給新數組返回
__m128 msum2 = _mm256_extractf128_ps(msum1, 1);
msum2 = _mm_add_ps(msum2, _mm256_extractf128_ps(msum1, 0));

msum2 = _mm_hadd_ps(msum2, msum2);
msum2 = _mm_hadd_ps(msum2, msum2);
_mm_storeu_ps(z, _mm_sqrt_ps(msum2));
}

2.2.3 余弦距離計算

使用SIMD AVX2指令進行256維向量的余弦距離計算,計算公式如下:

SIMD代碼如下:

void VecCosineAVX2(const float* x, const float* y, float* z) {
int d = 256;
__m256 msum1 = _mm256_setzero_ps();
__m256 msumx = _mm256_setzero_ps();
__m256 msumy = _mm256_setzero_ps();
// 加載數據并計算x和y的內積、模長
while (d >= 8) {
__m256 mx = _mm256_loadu_ps(x);
x += 8;
__m256 my = _mm256_loadu_ps(y);
y += 8;
msum1 = _mm256_add_ps(msum1, _mm256_mul_ps(mx, my));
msumx = _mm256_add_ps(msumx, _mm256_mul_ps(mx, mx));
msumy = _mm256_add_ps(msumy, _mm256_mul_ps(my, my));
d -= 8;
}
// 將寄存器msum1中的內積結果求和
__m128 msum2 = _mm256_extractf128_ps(msum1, 1);
msum2 = _mm_add_ps(msum2, _mm256_extractf128_ps(msum1, 0));
msum2 = _mm_hadd_ps(msum2, msum2);
msum2 = _mm_hadd_ps(msum2, msum2);
// 將寄存器msumx中的x向量模長結果求和
__m128 msumx2 = _mm256_extractf128_ps(msumx, 1);
msumx2 = _mm_add_ps(msumx2, _mm256_extractf128_ps(msumx, 0));
msumx2 = _mm_hadd_ps(msumx2, msumx2);
msumx2 = _mm_hadd_ps(msumx2, msumx2);
// 將寄存器msumy中的y向量模長結果求和
__m128 msumy2 = _mm256_extractf128_ps(msumy, 1);
msumy2 = _mm_add_ps(msumy2, _mm256_extractf128_ps(msumy, 0));
msumy2 = _mm_hadd_ps(msumy2, msumy2);
msumy2 = _mm_hadd_ps(msumy2, msumy2);
// 根據內積、x模長和y模長計算余弦距離
__m128 result = _mm_div_ps(msum2, _mm_mul_ps(_mm_sqrt_ps(msumx2), _mm_sqrt_ps(msumy2)));
_mm_storeu_ps(z, result);
}

 一個寄存器一次加載8個32位的浮點數,理論上性能應該是原來的8倍,實際上經過測試這個猜想也得到了驗證,詳細數據在2.4節中給出。 

為什么這些函數不直接返回結果,而把結果存在一個數組中呢?若C或C++調用這些函數可以直接返回結果,但是若使用Golang進行調用,需要進行一些轉換,為什么要這么做?接下來將介紹Golang如何調用SIMD函數。

2.3 Golang調用SIMD

2.3.1 CGO調用

SIMD函數是使用C編寫的,Golang調用C函數,最容易想到的就是采用Golang提供的CGO方式進行C函數調用。

/*
#cgo CFLAGS: -mavx2
#include <immintrin.h>

float vecInnerProduct(float* x, float* y) {
// 此次省略內積運算過程,返回值可以直接返回,不用放到額外的數組中
// _mm_storeu_ps(z, msum2);
return _mm_cvtss_f32(msum2);
}
*/
import "C"

func VecInnerProductByCGo(userVector []float32, itemVector []float32) float32 {
// Golang的floa和C的float互轉
return float32(C.vecInnerProduct((*C.float)(&userVector[0]), (*C.float)(&itemVector[0])))
}

 CGO這種方式確實是可以的,但是存在Golang和C之間不同語言的上下文切換,存在性能問題,肯定不能完全發揮出SIMD所能提升的8倍,經過測試,CGO這種方式只能是未優化的普通計算的2倍左右,這種方式不能滿足我們的業務需求,詳細數據在2.4節中給出。

2.3.1 Plan9匯編調用

Golang是可以直接調用Plan9匯編的,但是C寫的SIMD函數怎么轉Plan9匯編呢?

Github上發現一個開源的項目c2goasm,它可以將C函數匯編轉成Plan9匯編,c2goasm的本質也是調用asm2plan9s工具將C的匯編轉成Plan9匯編。

c2goasm項目地址:https://github.com/minio/c2goasm

asm2plan9s項目地址:https://github.com/minio/asm2plan9s

(1)C轉C匯編

可以將SIMD函數先使用Clang編譯成C的匯編,如將simd.c編譯成simd.s匯編,編譯命令如下:

clang -S -O1 -mavx2 -mfma -masm=intel -mno-red-zone -mstackrealign -mllvm -inline-threshold=1000 -fno-asynchronous-unwind-tables -fno-exceptions -fno-rtti -c simd.c -o simd.s

 注意:

SIMD內積運算的匯編代碼如下:

  .globl        VecInnerProductAVX2     # -- Begin function VecInnerProductAVX2
.p2align 4, 0x90
.type VecInnerProductAVX2,@function
VecInnerProductAVX2: # @VecInnerProductAVX2
# %bb.0:
push rbp
mov rbp, rsp
and rsp, -8
vxorps xmm0, xmm0, xmm0
mov eax, 264
xor ecx, ecx
.p2align 4, 0x90
.LBB1_1: # =>This Inner Loop Header: Depth=1
vmovups ymm1, ymmword ptr [rdi + rcx]
vmulps ymm1, ymm1, ymmword ptr [rsi + rcx]
vaddps ymm0, ymm0, ymm1
add rcx, 32
add eax, -8
cmp eax, 15
ja .LBB1_1
# %bb.2:
vextractf128 xmm1, ymm0, 1
vaddps xmm0, xmm1, xmm0
vhaddps xmm0, xmm0, xmm0
vhaddps xmm0, xmm0, xmm0
vmovups xmmword ptr [rdx], xmm0
mov rsp, rbp
pop rbp
vzeroupper
ret
.Lfunc_end1:
.size VecInnerProductAVX2, .Lfunc_end1-VecInnerProductAVX2
# -- End function

 (2)C匯編轉Plan9匯編

1. 編譯c2goasm,生成可執行文件c2goasm,并添加到PATH路徑。

git clone git@github.com:minio/c2goasm.git
go mod init c2goasm
go build

 2. 下載asm2plan9s可執行文件,并添加到PATH路徑。

go install github.com/minio/asm2plan9s

3. 使用c2goasm工具將SIMD的匯編文件simd.s轉成plan9匯編simd_avx2.s 。

c2goasm -a simd.s simd_avx2.s

4. 將SIMD內積運算的C匯編代碼通過c2goasm轉成Plan9匯編如下,默認會在函數名前加一個下劃線。

TEXT ·_VecInnerProductAVX2(SB), $0-24

MOVQ x+0(FP), DI
MOVQ y+8(FP), SI
MOVQ z+16(FP), DX

LONG $0xc057f8c5 // vxorps xmm0, xmm0, xmm0
LONG $0x000108b8; BYTE $0x00 // mov eax, 264
WORD $0xc931 // xor ecx, ecx
LBB2_1:
QUAD $0x0000000f8c10fcc5; BYTE $0x00 // vmovups ymm1, yword [rdi + rcx]
QUAD $0x0000000e8c59f4c5; BYTE $0x00 // vmulps ymm1, ymm1, yword [rsi + rcx]
LONG $0xc158fcc5 // vaddps ymm0, ymm0, ymm1
LONG $0x20c18348 // add rcx, 32
WORD $0xc083; BYTE $0xf8 // add eax, -8
WORD $0xf883; BYTE $0x0f // cmp eax, 15
JA LBB2_1
LONG $0x197de3c4; WORD $0x01c1 // vextractf128 xmm1, ymm0, 1
LONG $0xc058f0c5 // vaddps xmm0, xmm1, xmm0
LONG $0xc07cfbc5 // vhaddps xmm0, xmm0, xmm0
LONG $0xc07cfbc5 // vhaddps xmm0, xmm0, xmm0
LONG $0x4211f8c5; BYTE $0x10 // vmovups oword [rdx], xmm0
VZEROUPPER
RET

 asm2plan9s生成以上Plan9匯編指令的源代碼參考:https://github.com/minio/asm2plan9s/blob/master/yasm.go中的函數toPlan9sYasm。

關鍵Plan9匯編指令

(3)Golang調用Plan9匯編

需要提前創建一個與目標匯編文件(simd_avx2.s)同名的go文件(如simd_avx2.go),聲明C語言中的函數(帶下劃線),函數入參個數與原來C源碼中的入參個數相等,參數需要是64位的,若有返回值,返回值的名字不能省略。

//go:noescape
func _VecInnerProductAVX2(x, y, z *float32)

 其它業務函數直接調用該函數即可,嘗試過這些距離計算函數直接返回結果,最終拿不到結果,而有些函數可以直接返回結果,暫時還沒有發現c2goasm轉換后的調用規律,有興趣的小伙伴可以研究討論。

2.4 對比測試

實驗環境

運行環境CPU類型操作系統CPU內存
支持AVX2的CVMIntel XeonCentOS Linux release 8.2.2.2004 (Core)4核8G

根據Gonum32、CGO方式調用SIMD,Golang調用Plan9匯編SIMD三種計算方式,對比未優化的普通內積計算,計算能力對比如下圖:

內積計算能力和時延相比未優化的普通內積計算均有提升,結果如下:

注意:

對未優化的比普通內積計算,CPU資源使用對比如下圖:

從圖中看出,SIMD-Plan9匯編的內積運算CPU資源使用最低。

另外,對于余弦距離和歐式距離也進行了相同測試,測試結果與內積距離的性能提升結果基本一致。

綜合計算能力、時延和CPU資源等方面,SIMD-Plan9匯編方案綜合性能較優,因此可以采用此方案對線上服務進行優化。

3 小結

本文主要介紹了在當前的向量檢索業務挑戰的背景下,研究了如何在內存中進行本地向量檢索的探索流程,對探索的多種方案也進行了壓測,最終得出了綜合性能較優的SIMD-Plan9匯編方案。

但實際上向量檢索的流程還有前置的向量過濾(可選流程)和后置的檢索結果排序,這兩個方面也有進一步優化的空間,以及整體優化后的效果將在下一篇文章《向量檢索研究系列:本地向量檢索(下)》中進行詳細介紹。

向量檢索研究系列:本地向量檢索(下)

1 背景

上一篇文章介紹了如何加快向量相似度計算,但是一般的向量檢索流程還包括對計算結果進行排序,以及有必要的話,在計算相似度之前可以對向量庫中的向量進行過濾篩選(可選流程)。

本地向量檢索在過濾和排序這兩個方面也有進一步優化的空間,本文將介紹一下過濾方案和排序方案。

2 過濾優化

本地向量計算是先把向量加載內存再計算,經過SIMD優化,計算速度快了,但是否還能進一步減少待計算相似度的向量集呢?

舉個例子,一個用戶向量本來要和向量集所有1000個向量進行相似度計算,是否可以在內存中通過對向量進行屬性過濾,讓用戶向量只需要和向量集中500個向量進行相似度計算,這樣可以加快總體的向量檢索速度。

2.1 向量過濾

把廣告通過模型轉成向量后,向量應該關聯廣告的一些基本信息,廣告檢索條件是基于這些廣告屬性的,檢索的時候可以根據檢索條件在向量關聯的廣告信息中進行向量的篩選過濾。

廣告信息和檢索條件:

基于內存進行向量過濾暫時有想到如下三種方案:

方案一:內存對象

方案二:內存Bitmap

方案三:內存倒排索引

對這三種方案的QPS和資源占用情況進行了測試,測試結果如下圖:

100萬數據量以下方案三倒排索引的綜合性能較優。

2.2 向量存儲

線上倒排索引需要考慮向量存儲,實現方案分為離線刷入數據到Redis在線從Redis讀取數據到內存兩個階段。

在離線刷入數據到Redis階段,有兩種刷入方案:

方案一:如下圖左側所示,使用單個Hash存儲,Hash的Key和Field存儲條件,Value存儲向量列表,同時對這些向量列表進行zip和base64壓縮,浮點數的壓縮率不高,僅2倍的壓縮率。因為有些廣告會在多個條件中出現,因此向量也會在多個Filed中出現,所以會存在向量冗余。

方案二:如下圖右側所示,使用一個Hash存儲索引條件和廣告ID列表,用多個單獨的Key/value存儲廣告ID和對應的向量。若在Redis把這些單獨的向量Key用一個Hash進行存儲,則會出現大Key,請求這些大Key會導致某些節點壓力過高,響應速度變慢,而使用單獨的Key存儲可以分散請求壓力,提高后臺服務請求Redis速度。

后臺服務從Redis讀取向量數據到內存,實驗10萬個廣告,使用方案二,存儲向量需要內存270M,存儲倒排索引3M。如果線上4個版本的向量進行AB實驗,則內存總占用約1G

Redis中多個單獨的Key和Value讀到內存后被存儲在一個兩層的Map中。

綜合刷入數據和讀取數據兩個階段,兩種方案的優缺點如下:

方案一

方案二

因此方案二的Redis存儲方式更有利于線上服務存儲和更新廣告向量。

3 排序優化

向量過濾和相似度計算完之后,對結果取TopK的排序是否耗時?能否優化?

3.1 全量排序

把Golang官方的排序算法(快排+堆排序+插入排序)時間和SIMD相似度計算時間進行對比,如下圖:

可見,排序運算時間 > 內積運算時間,Golang默認的排序算法不滿足需求。

向量是浮點數數組,內積計算的結果是浮點數,浮點數結果排序方案對比:

基數排序常用于整數排序,基于浮點數的基數排序也是本小節的重點,其改造核心思想如下:

浮點數基數排序的大致流程如下,可參考下圖數字表標識順序:

  1. 將待排序的浮點數轉成二進制,并分成多段。
  2. 將所有浮點數的第1段映射到桶里面,段的二進制位數決定了桶的大小,如8位二進制段對應的桶大小為256。
  3. 在桶里面確定浮點數的相對位置。
  4. 根據這個相對位置再進行浮點數第2段排序,重復步驟2~3。
  5. 直至所有分段都分桶完成并確定元素相對位置后已經得到浮點數的大致順序,因為負數帶符號位,最高位為1,負數會在數組的后面,需要將負數反轉至數組頭部即可得到最終排序好的浮點數數組。

上面提到需要對浮點數的二進制進行分段,到底分多少段比較合適呢?

根據算法流程,得出時間復雜度公式:O(d*(n+2^(32/d))+n),其中d為浮點數分段個數,n為待排序數據量,括號中三個時間的相加,分別代表著分桶、確定元素相對位置、將原數組元素按順序放到新數組中。32表示是32位的浮點數。

浮點數分段數時間復雜度待排序數量的合適取值范圍
12n+2^32n > 2,147,418,112
24n+2^1732512 < n <= 2,147,418,112
48n+2^10124 < n <= 32512
816n+2^74 < n <= 124
1632n+2^61 =< n <= 4
3264n+2^60

注意:這僅是理論上的估算值,對分段趨勢的一個大概判斷。同時也在代碼層面對分2段、4段、8段進行了測試,其排序時間對比如下圖:

可以看出,數據量越大,分段數越少排序越快,這和表格中的分段趨勢估算一致。在5萬數據量以下,分4段的效果最好,大于5萬時,分2段的效果較好。

數據量非常大的時候是否能并行排序?

并行浮點數基數排序思想:

對于上面提到的幾種排序算法進行了測試,同樣也和SIMD內積運算時間進行對比,其測試結果如下:

上圖中各排序方案性能:并行浮點數基數排序 > 浮點數基數排序 > Golang官方排序 > 堆排序

浮點數基數排序是Golang官方排序的4~5倍,并行浮點數基數排序是Golang官方排序的1~11倍。并行浮點數基數排序在數據量比較小的時候反而性能沒有浮點數基數排序好,因為并行也存在性能損耗。

此時可以看出浮點數基數排序時間已經比SIMD相似度計算時間要短,已經滿足我們的業務需求。

3.2 局部排序

前面提到的排序都是對全量的數據進行排序,然后對結果取TopK,如果只對部分數據進行排序拿到TopK結果,不關心其它數據順序,因此可以考慮對現有排序算法進行局部排序改造。

局部排序改造思想

方案一:冒泡排序

方案二:快速排序.

方案三:堆排序

對這幾種局部排序在不同的數據量下取TopK(k=1000)進行了測試,結果如下:

算法\數據量20001萬2萬5萬10萬100萬
冒泡排序TopK2.3μs12μs114μs205.087ms321.765ms2530ms
快速排序TopK1403μs8505μs17135μs43.705ms88.822ms883ms
堆排序TopK59μs246μs335μs0.436ms0.551ms1.364ms

從表格中可以得出以下結論:

根據當前的業務數據量和數據增長趨勢,選擇堆排序的局部排序算法比較合適

4. 方法論

實踐過程中的經驗不僅能優化當前業務,也能沉淀成可復制的方法論,應用到更廣的業務場景,為更多的業務賦能。

  1. 本地向量檢索方案可以為100萬以下數據量的業務提供快速、高性能且穩定的向量檢索方案。
  2. SIMD自定義編程可以在應用到其它偏數學計算的業務,加速計算。
  3. 倒排索引和Bitmap的內存過濾方案可以為其它數據過濾場景提供思路。
  4. 浮點數基數排序局部排序算法可應用到業務的其它排序場景,加速排序。

5 總結

經本地向量檢索和計算優化后,召回和粗排服務的時延都有大幅度下降,隨著QPS和廣告數的增長,線上服務仍能輕松處理請求,可支撐更大規模的業務發展。

文章轉自微信公眾號@IEG增長中臺技術團隊

上一篇:

零基礎入門:Ollama調用快速上手指南

下一篇:

從人臉識別到情感分析,這有50個機器學習實用API
#你可能也喜歡這些API文章!

我們有何不同?

API服務商零注冊

多API并行試用

數據驅動選型,提升決策效率

查看全部API→
??

熱門場景實測,選對API

#AI文本生成大模型API

對比大模型API的內容創意新穎性、情感共鳴力、商業轉化潛力

25個渠道
一鍵對比試用API 限時免費

#AI深度推理大模型API

對比大模型API的邏輯推理準確性、分析深度、可視化建議合理性

10個渠道
一鍵對比試用API 限時免費