基准测试很难¶
起因¶
准备购买一些云服务器使用,然后就想着应该先对各家的服务器进行一些测试, 根据测试结果再做决定,在测试的时候遇到了一些有意思的事情,于是便有了本文。
正文¶
如果想要知道一个系统的性能如何,最好的方法便是对这个系统进行基准测试,使用测试结果去预测实际的性能。
备注
理论上: 如果选择的基准跟实际情况(使用的场景)一致,那么基准测试的结果就可以作为实际运行的结果。 当然,这在现实中即使不是不可能的,也是很难达到的。
在基准测试中: 测试是很容易的,但是基准的选择很困难。
在进行基准测试之前,要做的第一件事情就是确定好比较的基准(标准),没有基准(标准),比较根本就无从谈起。
对于云服务器来说, 个人认为比较重要的指标有:
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() };
}
这段代码应该是被编译器给优化掉了。
查看编译生成的可执行文件代码:
这里面根本就没有循环读取内存的逻辑,上面的代码被编译器优化之后相当于被转换成了以下的逻辑:
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测试的时候没有考虑文件系统缓存的影响), 测试结果完全可以满足现实需要。
因为在日常应用中我们关心的是怎么样找到更好的(通过比较基准测试数据),而不关心测试结果在理论上的正确性。
参考资料¶
Updated on 15 二月 2021
本文从2月13号开始写作,于今日完成发布
Updated on 28 二月 2021
性能测试工具发布: 奇遇科技服务器性能测试工具(预览版)