关于rust:Databend-源码性能调优实践实践篇-1

7次阅读

共计 4129 个字符,预计需要花费 11 分钟才能阅读完成。

在这里次要向大家做一个 Databend 性能调优相干的分享,共会分为三次向大家介绍,如下所示:

1、根底篇:代码调优的前置常识(https://mp.weixin.qq.com/s/WT…)
2、实际篇 1:Databend 源码性能调优实际
3、实际篇 2:Databend 的 Group By 聚合查问为什么跑的这么快?

Databend 是基于 Rust 语言开发面向云原生设计的数据仓库。Rust 是近几年比拟火的零碎编程语言,它具备零额定开销的形象老本,并且有贴近 C++ 的运行性能。

但这并不意味着应用 Rust 实现的代码肯定比应用 Python 实现的开发要快,在没有了解实质的状况下,Rust 仍然能写出低效率的代码,这其实取决于编程人员的程度,只是 Rust 让咱们更容易写出高效的代码。

上面将依据多个例子,来讲讲编程中常见的一些优化点,这些优化点多以 Rust 实现为例,但其原理能够使用在其余语言中。

本系列课程的视频回放,能够点击 https://www.bilibili.com/vide… 查看。

inline vs no inline

在 Rust 中,有三个 hint 去做代码的内联,如下所示:

#[inline],
#[inline(never)],
#[inline(always)]

在写代码时,咱们也尽量通知编译器咱们所冀望采纳的形式。

内联有以下长处:

a. 内联作为一种优化转换,它能够取代对函数的调用,节俭代码的开销。

毛病

a. 代码二进制体积变大,使得编译工夫更糟。

b. 泛型函数可能会导致内联代码收缩.

注:

a. 对于公有函数可能不须要内联,因为 LLVM 具备启发式内联。

b. 公有函数具备隐式内联。

Case 1

下图所示代码展现了两段函数 default_crc32() 和 inlined_crc32(),这两段代码所实现的性能为做一个 crc32 的哈希,在 default_crc32() 是间接调用一个 crc32 fast 的哈希库,算出哈希值后,对数组进行一次遍历,失去其加总的后果;而在 inlined_crc32() 中,在 new() 函数、update() 函数退出了 inline。他们的运行工夫如下:

default_crc32 time: [16.184 ms 16.196 ms 16.209 ms]

inlined_crc32 time: [1.6317 ms 1.6353 ms 1.6396 ms]

从数据中,咱们能够显著的看出应用内联后性能有靠近 10 倍的改善。

Case 2

在后面一个例子中,大家能够感触到内联对代码性能带来的晋升。而不失当的应用,同时也会让性能更差。但内联不是银弹,上面这段代码是应用了 inline(never)这段代码。当咱们不应用这个 hint 时候,咱们的整个函数会被编译器优化成 inline 的,进一步会导致 aggregate 外面的子函数就不会被 inline 了,但咱们的程序执行耗时大多在子函数中,因为子函数中应用了 loop 循环执行 CPU 密集型操作。所以咱们须要显式地通知编译器不要 inline 外层函数,来达到 10% 左右的性能晋升。

No extra allocation/copy

在写代码时候,可能会存在着一些额定的调配及拷贝。咱们能够通过对其进行优化,也能晋升咱们代码的性能。

Case 3

select sum(number) from numbers_mt(10000000000)

再来看第三个例子,这个例子源于 Databend 开发初期,当咱们生成 numbers 表时求一个到 10000000000 的加总值。在这个代码中通过生成一个 Vec<u64> 的数组,咱们将其转换成 Arrow 格局时,咱们调用了 UInt64 Array::from() 这个函数,尽管咱们将 data move 进去了,但这个函数外部还是调用了内存拷贝,通过 perf 工具能够看到 SQL 执行的开销多在内存拷贝中。

咱们优化的形式是去掉多余的内存拷贝,应用了 unsafe 去生成 Arrydata,而后生成 Arrow 格局的 Array,最初让整体性能晋升了 2-3 倍。


Case 4.1

cast(number as Varchar) 这个例子是将一个 NumberArray 转换成 StringArray。在右边的代码中,逻辑是遍历 NumberArray 将每行的数值转换为字符串,而后返回一个迭代器,迭代器再去生成 StringArray。但这段代码产生了十分多的额定开销,因为他遍历到每一个元素时,都会产生一个部分的 Vec<u8>,这里的 Vec<u8> 是十分离散的,并且应用一次后就会被抛弃,在这里就会产生大量的小内存调配和回收,进而导致性能变差。

在第一个版本的优化中,咱们在生成了一个部分的 buffer,在数值转为 string 的过程中,咱们把序列化写入到这个 buffer 中,再通过 builder 去把数据 push 进 arrow 的内存格局中。这使得其性能晋升至两倍,但这个 case 并没有被优化到极致。

Case 4.2

通过 Case 4.1 的优化,咱们持续对其优化。因为在上述 Case 中咱们用到了一个 buffer,但其实咱们能够间接移除 buffer。这里咱们用一个闭包函数,在数值转 String 的过程中,间接往 arrow 的内存模型中进行写入,无需额定的内存调配和拷贝,比照前一个版本,能够再晋升两倍左右的性能。


Case 5

Databend 在开发的过程中,也会有一些些晚期的代码写的不够好,比方咱们在收集内存调配的时候,咱们个 MemoryTracker 变量存储在 thread_local 中,去获取 runtime_tracker 的时候,右边的代码多了一个 Arc 的 clone,左边的代码是返回的援用,尽管 clone 自身开销并不是很大,然而在 SQL 执行过程中,咱们会长期地去调配很多小的内存,所以每一次调配的都会去调用这个 get 办法,就会大大降低其性能。

另外,咱们还做了一次优化是,并不是每次内存调配都会去实时反馈到全局统计变量中,因为多线程去更新全局变量须要同步操作,所以咱们这里做了统计值的线程外部缓冲更新,防止小内存调配统计影响性能。

Less extra function call

Case 6

上面的例子是 Arrow 中的一个排序函数,例如在 SQL:

排序的列是 a,但其余的列也要跟着 a 的程序走,arrow 的执行流程是先将索引依照 a 进行排序,上面的例子就是索引排序的过程,排序咱们须要依照 a 列在不同 index 中的值进行比拟,而后依据后果决定是否替换 index。

右边的实现是闭包捕捉了 array,但咱们依据 index 那对应的值须要调用 array.values().as_slice().get_unchecked(), 这里有 3 次函数调用,如果 array 的长度很长,这个调用开销的放大是十分可观的。咱们的优化过程也非常简单,咱们把 values 先援用到一个变量中,之后闭包就会间接去捕捉 values。这个优化大概能够达到 25% 的性能晋升。

Less branch prediction miss

大家应该都相熟分支预测能够利用 CPU 乱序执行来晋升性能。然而分支预测失败了,返回会大大影响性能性能。请大家来看以下的几个例子。

Case 7

上面的例子是在一个循环中,每次循环都会调用 unwrap 函数,这个 unwrap 函数在 Rust 其实就是一次 if else 封装,因而这里会有分支预测的开销。分支预测通常来说是有缓存的,但在这个函数中,分支预测的缓存仿佛工作的不太敌对。

对他优化的形式是迭代器生成封装到一个办法中,从而去除多余的分支预测。

Case 8

这个例子是 abs 函数的实现。在这里咱们是去对 int8 这个函数进行一次遍历并求绝对值,map 判断了 null 的状况,如果原始值是 null,后果仍然须要为 null。Arrow 的内存模型是有两个 array 的,它的第一个 array 是一个 int8 的数组,第二个 array 是一个 bitmap。咱们的优化形式是先疏忽 null,间接遍历 int8 数组,进行 abs 的 transform,而后将输出的 bitmap 进行 clone 给后果数组,从而去除了分支预测。因为相似函数会很多,所以咱们封装了多个 apply 函数进行数组到数组的转换。

Case 9

这里来自 ClickHouse 的一个例子。

SQL:

这个 SQL 通常会有 2 个分支预测,一个判断 number > 3,第二个判断 number 是否有 null 值。在向量化 sum 中,咱们用一些位操作来优化分支开销。

SIMD
SIMD 指的是单指令多数据执行。接下来咱们会讲到两个对于 simd 的例子。

Case 10:simd sum

在咱们的列数据库中,十分罕用的就是向量化执行。首先,咱们先来看第一段代码,做的是对 double 数组的一次求和。理想化的状况的是,咱们的 CPU 执行的如果最佳流水线,ADD 执行的次数是 N 次。

古代的 CPU 中通常有一些非凡的优化指令集,比方 SSE,AVX,通过这些指令对数据进行批量的求和,这样能够让整体性能晋升至 4 倍左右。


AVXAdd 的函数实现看起来比拟俊俏,通常来说咱们不须要显示的应用 AVXAdd,因为古代的编译器曾经足够智能优化下面的代码成 AVXAdd 形式执行了,比方 GCC 的主动向量化。

Case 11: null sum in arrow2

求和运算中,咱们通常要疏忽 null 值,这样可能会毁坏主动向量化的优化,因为 null 值是 runtime 能力感知的。Rust 有一些向量化的 crate 十分有用,比如说 Arrow2 中应用了 packed_simd2, Rust 也行将迎来规范的 simd 库。上面的例子是对含有 null 值得数组求和向量化代码。

整体的原理是将数据按 chunk 分桶在一个 simd 位宽中,而后使用向量化的 select 将非 null 的值抽出到一个新的 selected 中求和。

主动向量化

通常来说,咱们不会被动编写向量化的代码,而是让循环中的逻辑尽量简略(不引入分支),编译器往往能给咱们优化出高效的代码,比方在 not nullable 的 array 中进行求和,主动向量化的代码反而比手动向量化的代码更加高效。

通过以上的 11 个例子,心愿你曾经对代码调优有了更深刻的理解,在下一次的分享中咱们将介绍 Databend 的 group by 查问为什么能跑的这么快。

想要理解更多资讯,欢送关注公众号:Databend

正文完
 0