在数据库和大量数据处理的场景下,对于程序要求极致的性能,本文尝试总结ClickHouse中针对目前硬件特性的一些优化方式。
PS:本文使用的例子并不是源自ClickHouse代码,而是为了说明问题设置了更简单的测试代码。
CPU分支预测
现代CPU执行一般采用pipeline技术提高效率,CPU执行命令一般分为:取指、译码、执行、取数据、写回等几个阶段,这几个阶段因为是不同的组件进行工作,所以是可以并行化的,这种并行化也叫pipeline。
对于顺序执行的指令,pipeline机制极大的提高了并行度,但是对于需要跳转的指令,到底需要把哪一个指令分支放到pipeline中呢?
这里有两个选择,一个是pipeline等待运行到分支指令处,选择好分支后,然后pipeline机制重新运行;另一种是不打断pipeline,预测应该走哪一个分支。第二种方式如果预测准确则会延续pipeline带来的高效性,如果预测错误则需要清空pipeline然后回退,带来性能损失。
目前常用的方式是进行分支预测,而分支预测方式是动态预测,主要思想是统计历史分支选择的概率,然后选择概率大的分支。
举个小例子:
int len = 100_000;
int[] array = new int[len];
Random random = new Random();
for(int i=0;i<len;i++){
array[i] = random.nextInt(len);
}
// 数组是否排序
if(sort) {
Arrays.sort(array);
}
// Test
long t1 = System.nanoTime();
int sum = 0;
for(int i=0;i<len;i++){
if(array[i]>len/2){
sum += array[i];
}
}
long t2 = System.nanoTime();
System.out.println("\tTime cost: " + (t2 - t1));
数组排序:
Time cost: 1378000
数组不排序:
Time cost: 4543900
上边代码中对于数组排序效率高呢还是不排序效率高?其实是排序效率更高,大概3倍于非排序,因为排序后代码”array[i]>len/2″部分的分支预测会极大的正确。
CPU CacheLine
当CPU每次从主存中取数据的时候,不是一个字节一个字节的取,而是每次取一个小的batch,这个小batch被称为CacheLine,一般的CacheLine是64位或者128位等。CPU CacheLine机制,其实跟磁盘预读的机制如出一辙,其背后的原理都是当访问一段数据的时候,接下来有极大的可能会访问到紧邻的一段数据,所以预读能减小访问慢存储的访问次数,从而大量提高性能。
所以当我们的程序能够一直满足这种局部性原理的时候,程序的效率将是非常高的,反之则会低下。以下是一个CacheLine命中与否的测试的小例子:
int len = 10_000;
int[][] matrix = new int[len][len];
Random random = new Random();
for(int i=0;i<len;i++){
for(int j=0;j<len;j++){
matrix[i][j] = random.nextInt(len);
}
}
int a;
long t1 = System.nanoTime();
//是否利用CPU CacheLine
if(useCacheLine){
for(int i=0;i<len;i++){
for(int j=0;j<len;j++){
a = matrix[i][j];
}
}
}else{
for(int i=0;i<len;i++){
for(int j=0;j<len;j++){
a = matrix[j][i];
}
}
}
long t2 = System.nanoTime();
System.out.println("\tTime cost: " + (t2-t1));
以下是是否利用CPU CacheLine(是否按照物理顺序访问主存)的性能差异,可以看到大概有36倍的差异。
利用CPU CacheLine
Time cost: 42393300
没有利用CPU CacheLine
Time cost: 1801404300
向量化执行
在大量数据处理领域,常见的执行引擎有Scalar和Vectorized两种,其中Scalar方式值得是在各个步骤(算子)中数据是一个一个处理,而和Vectorized方式指的是各个步骤中数据是按照batch的方式处理。下面的小例子展示了Scalar和Vectorized两中处理方式的对比,在例子中有scan和transform两个算子:
public class VectorExec {
public static void main(String[] args) {
VectorExec test = new VectorExec();
long t1 = System.nanoTime();
test.vectorExec();
long t2 = System.nanoTime();
test.scalarExec();
long t3 = System.nanoTime();
System.out.println("vector exec time cost: " + (t2-t1));
System.out.println("scalar exec time cost: " + (t3-t2));
}
int len = 100_000;
int batchSize = 1000;
private void vectorExec() {
for(int i=0;i<len/batchSize;i+=batchSize){
vectorTransform(vectorScan(i));
}
}
private int[] vectorScan(int pre){
int[] result = new int[batchSize];
for(int i=0;i<batchSize;i++){
result[i] = ++pre;
}
return result;
}
private int[] vectorTransform(int[] a){
int[] result = new int[a.length];
for(int i=0;i<a.length;i++){
result[i] += 1;
}
return result;
}
private void scalarExec() {
for(int i=0;i<len;i++){
transform(scan(i));
}
}
private int scan(int pre){
return ++pre;
}
private int transform(int a){
return a + 1;
}
}
以下是查询时间对比,可以看到Vectorized方式是Scalar方式的81倍。那么为什么会快这么多呢?
vector exec time cost: 72600
scalar exec time cost: 5887500
我们可以看到scan和transform两个算子之间是有函数调用的,其实上述处理模型十分符合大名鼎鼎的Valcano执行模型,也是在大量数据处理领域常用的模型,在该模型中算子之间的函数调用所占用的整体处理时间的比例是比较大的。
通过Vectorized方式:1.明显降低了函数调用的CPU load占比;2.同时batch处理方式,极大的提高了CPU CacheLine的命中率;3.batch方式能通过SIMD指令进一步提高执行效率。
本作品采用 知识共享署名 4.0 国际许可协议 进行许可, 转载时请注明原文链接。