最近我们扩展了 TiDB 表达式计算框架,增加了向量化计算接口,初期的性能测试显示,多数表达式计算性能可大幅提升,部分甚至可提升 1~2 个数量级。为了让所有的表达式都能受益,我们需要为所有内建函数实现向量化计算。
表达式向量化
1. 如何访问和修改一个向量
定长类型:
Int64
、Uint64
、Float32
、Float64
、Decimal
、Time
、Duration
;
变长类型:
String
、Bytes
、JSON
、Set
、Enum
。
定长类型和变长类型数据在 Column 中有不同的组织方式,这使得他们有如下的特点:
定长类型的 Column 可以随机读写任意元素;
变长类型的 Column 可以随机读,但更改中间某元素后,可能需要移动该元素后续所有元素,导致随机写性能很差。
对于定长类型(如 int64
),我们在计算时会将其转成 Golang Slice(如 []int64
),然后直接读写这个 Slice。相比于调用 Column 的接口,需要的 CPU 指令更少,性能更好。同时,转换后的 Slice 仍然引用着 Column 中的内存,修改后不用将数据从 Slice 拷贝到 Column 中,开销降到了最低。
对于变长类型,元素长度不固定,且为了保证元素在内存中连续存放,所以不能直接用 Slice 的方式随机读写。我们规定变长类型数据以追加写(append
)的方式更新,用 Column 的 Get()
接口进行读取。
总的来说,变长和定长类型的读写方式如下:
int64
为例)
ResizeInt64s(size, isNull)
:
预分配 size 个元素的空间,并把所有位置的
null
标记都设置为
isNull
;
Int64s()
:
返回一个
[]int64
的 Slice,用于直接读写数据;
SetNull(rowID, isNull)
:
标记第
rowID
行为
isNull
。
string
为例)
ReserveString(size)
:
预估 size 个元素的空间,并预先分配内存;
AppendString(string)
: 追加一个 string 到向量末尾;
AppendNull()
:
追加一个
null
到向量末尾;
GetString(rowID)
:
读取下标为
rowID
的 string 数据。
IsNull(rowID)
,
MergeNulls(cols)
等,就交给大家自
己去探索了,后面会有这些方法的使用例子。
2. 表达式向量化计算框架
vectorized() bool
vecEvalXType(input *Chunk, result *Column) error
XType
可能表示 Int
, String
等,不同的函数需要实现不同的接口;
input
表示输入数据,类型为
*Chunk
;
result
用来存放结果数据。
vectorized()
来判断此表达式是否支持向量化计算,如果支持,则调用向量化接口,否则就走行式接口。
(2+6)*3
,只有当
MultiplyInt
和
PlusInt
函数都向量化后,它才能被向量化执行。
为函数实现向量化接口
vecEvalXType()
和
vectorized()
接口。
vectorized()
接口中返回
true
,表示该函数已经实现向量化计算;
vecEvalXType()
实现此函数的计算逻辑。
_vec.go
结尾的文件中,如果还没有这样的文件,欢迎新建一个,注意在文件头部加上 licence 说明。
builtinLog10Sig
为例:
expression/builtin_math.go
文件中,则向量化实现需放到文件
expression/builtin_math_vec.go
中;
builtinLog10Sig
原始的非向量化计算接口为
evalReal()
,那么我们需要为其实现对应的向量化接口为
vecEvalReal()
;
1. 如何获取和释放中间结果向量
存储表达式计算中间结果的向量可通过表达式内部对象 bufAllocator
的 get()
和 put()
来获取和释放,参考 PR/12014,以 builtinRepeatSig
的向量化实现为例:
buf2, err := b.bufAllocator.get(types.ETInt, n)
if err != nil {
return err
}
defer b.bufAllocator.put(buf2) // 注意释放之前申请的内存
2. 如何更新定长类型的结果
ResizeXType()
和
XTypes()
来初始化和获取用于存储定长类型数据的 Golang Slice,直接读写这个 Slice 来完成数据操作,另外也可以使用
SetNull()
来设置某个元素为
NULL
。代码参考
PR/12012
,以
builtinLog10Sig
的向量化实现为例:
f64s := result.Float64s()
for i := 0; i < n; i++ {
if isNull {
result.SetNull(i, true)
} else {
f64s[i] = math.Log10(f64s[i])
}
}
3. 如何更新变长类型的结果
ReserveXType()
来为变长类型预分配一段内存(降低 Golang runtime.growslice() 的开销),使用
AppendXType()
来追加一个变长类型的元素,使用
GetXType()
来读取一个变长类型的元素。代码参考
PR/12014
,以
builtinRepeatSig
的向量化实现为例:
result.ReserveString(n)
...
for i := 0; i < n; i++ {
str := buf.GetString(i)
if isNull {
result.AppendNull()
} else {
result.AppendString(strings.Repeat(str, int(num)))
}
}
4. 如何处理 Error
builtinCastIntAsDurationSig
的向量化实现为例:
for i := 0; i < n; i++ {
...
dur, err := types.NumberToDuration(i64s[i], int8(b.tp.Decimal))
if err != nil {
if types.ErrOverflow.Equal(err) {
err = b.ctx.GetSessionVars().StmtCtx.HandleOverflow(err, err) // 就地利用对应处理函数处理错误
}
if err != nil { // 如果处理不掉就抛出
return err
}
result.SetNull(i, true)
continue
}
...
}
5. 如何添加测试
expression/bench_test.go
文件中,被实现在
testVectorizedBuiltinFunc
和
benchmarkVectorizedBuiltinFunc
两个函数中。
builtin_XX_vec.go
文件增加了
builtin_XX_vec_test.go
测试文件。当我们为一个函数实现向量化后,需要在对应测试文件内的
vecBuiltinXXCases
变量中,增加一个或多个测试 case。下面我们为 log10 添加一个测试 case:
var vecBuiltinMathCases = map[string][]vecExprBenchCase {
ast.Log10: {
{types.ETReal, []types.EvalType{types.ETReal}, nil},
},
}
具体来说,上面结构体中的三个字段分别表示:
1. 该函数的返回值类型;
2. 该函数所有参数的类型;
3. 是否使用自定义的数据生成方法(dataGener),nil
表示使用默认的随机生成方法。
对于某些复杂的函数,你可自己实现 dataGener 来生成数据。目前我们已经实现了几个简单的 dataGener,代码在 expression/bench_test.go
中,可直接使用。
添加好 case 后,在 expression 目录下运行测试指令:
# 功能测试
GO111MODULE=on go test -check.f TestVectorizedBuiltinMathFunc
# 性能测试
go test -v -benchmem -bench=BenchmarkVectorizedBuiltinMathFunc -run=BenchmarkVectorizedBuiltinMathFunc
在你的 PR Description 中,请把性能测试结果附上。不同配置的机器,性能测试结果可能不同,我们对机器配置无任何要求,你只需在 PR 中带上你本地机器的测试结果,让我们对向量化前后的性能有一个对比即可。
如何成为 Contributor
vecEvalXType()
和
vectorized()
的方法;
make dev
,保证所有 test 都能通过;
? 文中划线部分均有跳转,点击【阅读原文】查看原版