基准测试很难

起因

准备购买一些云服务器使用,然后就想着应该先对各家的服务器进行一些测试, 根据测试结果再做决定,在测试的时候遇到了一些有意思的事情,于是便有了本文。

正文

如果想要知道一个系统的性能如何,最好的方法便是对这个系统进行基准测试,使用测试结果去预测实际的性能。

注解

理论上: 如果选择的基准跟实际情况(使用的场景)一致,那么基准测试的结果就可以作为实际运行的结果。 当然,这在现实中即使不是不可能的,也是很难达到的。

在基准测试中: 测试是很容易的,但是基准的选择很困难。

在进行基准测试之前,要做的第一件事情就是确定好比较的基准(标准),没有基准(标准),比较根本就无从谈起。

对于云服务器来说, 个人认为比较重要的指标有:

  • CPU 性能

  • 内存 读写速度

  • 磁盘 I/O速度

  • 网络 性能

注解

本文不涉及 CPU 性能(测试过程没有遇到什么问题) & 网络性能(还没有来得及做) 的测试。

内存读取测试

内存的性能测试一般分为以下两种形式:

  • 顺序读写(测试)

  • 随机读写(测试)

警告

实际应用中的软件一般既不是只有顺序读取,也不是只有随机读取,而是介于这两种之间的某种读取方式。

刚开始我认为这个问题应该很简单,写个小程序读写内存,记录测试结果就行了。

刚开始我写了如下的代码(使用 Rust 0 语言):

let mem_size = 1024 * 1024 * 1024; // 1GB 大小的内存
let mut mem = Vec::<u8>::with_capacity(mem_size); // 分配内存到 mem 中
let ptr = mem.as_mut_ptr(); // 获取内存的基指针

// 开始时间
let start_time = time::Instant::now();
// 顺序读取 1GB 的内存
for i in 0..mem_size {
    let _ = unsafe { ptr.add(i).read() };
}
// 计算并且打印出消耗的时间
let use_time = time::Instant::now() - start_time;
println!("use time: {:?}", use_time);

如果运行上面这个程序,你觉得结果会是怎样的? 会使用 1ns, 1us, 1ms 还是 1s ??

实际上, 在我的计算机上运行, 上面的程序的结果是:

use time: 29ns

读取 1GB 的内存显然不可能在这么短的时间内完成, 明显这个结果有问题(虽然它实实在在发生了)。

注解

当 期望/假设 跟实际不一致的时候,大多数时候应该都是 假设/期望 有问题,而不是现实有问题。

理论(好吧,你也可以说是直觉)告诉我,如果要在 29ns 这么短的时间内完成, 那么这个程序就不可能完整的读取这 1GB的内存, 也就是说:

for i in 0..mem_size {
    let _ = unsafe { ptr.add(i).read() };
}

这段代码应该是被编译器给优化掉了。

查看编译生成的可执行文件代码:

https://cdn.jsdelivr.net/gh/QiYuTechDev/static@main/blog/202102/benchmark/rust_code.png

这里面根本就没有循环读取内存的逻辑,上面的代码被编译器优化之后相当于被转换成了以下的逻辑:

let mem_size = 1024 * 1024 * 1024; // 1GB 大小的内存
let mut mem = Vec::<u8>::with_capacity(mem_size); // 分配内存到 mem 中
let ptr = mem.as_mut_ptr(); // 获取内存的基指针
// panic if ptr is null

// 开始时间
let start_time = time::Instant::now();
// 顺序读取 1GB 的内存
// opt out: for i in 0..mem_size {
// opt out:    let _ = unsafe { ptr.add(i).read() };
// opt out: }
// 计算并且打印出消耗的时间
let use_time = time::Instant::now() - start_time;
println!("use time: {:?}", use_time);

为了能让测试进行下去,我们需要做的就是防止编译器优化。

let mem_size = 1024 * 1024 * 1024; // 1GB 大小的内存
let mut mem = Vec::<u8>::with_capacity(mem_size); // 分配内存到 mem 中
let ptr = mem.as_mut_ptr(); // 获取内存的基指针

let mut r = 0;

// 开始时间
let start_time = time::Instant::now();
// 顺序读取 1GB 的内存
for i in 0..mem_size {
    // 强制使用 读取的返回值, 这样 编译器 就无法优化
    r += unsafe { ptr.add(i).read() };
}
// 计算并且打印出消耗的时间
let use_time = time::Instant::now() - start_time;
// 这儿必须 打印出: r, 否则 Rust 编译器会认为 r 这个值没有使用
// 进而导致认为: unsafe { ptr.add(i).read() } 读取语句没有任何作用(被优化)
println!("use time: {:?}, r={}", use_time, r);

修改为上面的代码之后,运行结果如下:

use time: 738.944212ms, r=0

这应该是一个合理的运行结果了,但是这段代码却引入了额外的操作: r += {} 虽然只多了一个 \(+\) 操作,但是对于本身就很性能敏感的测试, 这会导致误差偏离过大。

小技巧

使用汇编语言直接编写 CPU 指令也许可以解决(至少可以缓解)这个问题。

因为 Rust 内联汇编在当前的稳定版中不可用,所以我就暂缓了改成汇编的想法。

结论

编译器的优化导致我们的意图(读取内存)没有传递到实际执行的层面,进而导致我们的测量结果不准确。

磁盘读写测试

对于 磁盘IO 来说一般至少应该测试:

  • 顺序 读写

  • 顺序重 读写

  • 随机 读写

  • 跳跃 读写

  • 倒序 读写

当我进行磁盘IO测试的时候,发现一些 读/写 测试的结果完全是”荒谬”的。

比如:

读取 512KB 的数据仅仅使用了 0.1ms 。

出现这种结果的原因是因为 Linux 的文件系统做了缓存(在内存有富余的时候,会缓存在未来最可能使用的一些文件)。

小技巧

当您测试磁盘 IO 的时候,测试文件的大小应该至少为可用内存的两倍,尽量降低文件系统缓存对测试结果的影响。

注解

很多网上的磁盘IO测评结果都是不准确的,大多数是因为他们的测试文件太小,而且一般只测试了顺序读写。

注解

有时候缓存 (Cache) 也会伤害性能。

对于 Intel Optane(傲腾) 存储技术来说, 在一般情况下,使用内存缓存数据会引起性能的下降。

结论

文件系统的优化导致我们的测量结果不准确。

困难来自于何处?

个人认为基准测试不准确的困难来自于:

系统有过多的 抽象/优化 层。

比如测试 磁盘 IO 的性能, 那么这时候应该要求文件系统,甚至整个操作系统的配置都一样, 这样才能保证测试结果的可复现性。

警告

现实中,根本就不可能保证整个操作系统的 运行时参数 完全一致(保证 静态配置参数 一致还是比较容易的)。

因为操作系统中的随机数生成器的墒依赖于外部环境(键盘事件,CPU系统内部时钟等等),基本无法保证这些都是一致的。

结论

对于日常应用来说,只要选择了 “正确”(涵盖了所有重要的指标) 的基准, 编写的测试代码没有 重大漏洞 (比如: 在内存测试的时候忘记了编译器的优化,磁盘IO测试的时候没有考虑文件系统缓存的影响), 测试结果完全可以满足现实需要。

因为在日常应用中我们关心的是怎么样找到更好的(通过比较基准测试数据),而不关心测试结果在理论上的正确性。

参考资料

0

Rust Lang

Updated on 15 February 2021

本文从2月13号开始写作,于今日完成发布

Updated on 28 February 2021

性能测试工具发布: 奇遇科技服务器性能测试工具(预览版)