Rust 性能调优
Nugine
https://zhuanlan.zhihu.com/p/191655266?utm_source=wechat_session

最近我遇到一个性能下降问题,在动用各种工具折腾到接近放弃之时,又想出一个点子,获得了最高九倍的性能提升。为此专门写一篇文章,复盘一下性能调优的历程。
问题背景

在 ICPC 比赛中,选手阅读题目,编写程序,提交到在线评测系统(OJ)。OJ 会编译运行选手提交的程序,选手从标准输入读取题目数据,向标准输出写入答案。

在选手程序运行完毕后,OJ 会比对选手程序输出和标准答案,如果一致,则判定为通过(Accepted/AC),也可能格式错误(PresentationError/PE),如果不一致,则判定为错误(WrongAnswer/WA)。

当然也有其他可能结果,比如运行时错误(RE)、超时(TLE)、内存超限(MLE)、输出超限(OLE)等。

我用 Rust 编写了一个程序 ojcmp,用来比对选手答案和标准答案。

该项目的目标:

算法正确,任何情况下都不能出错极限性能,比 diff 更快资源占用尽可能小,以免影响评测系统

核心算法的目标:

时间复杂度 O(n+m),其中 n 是选手答案的长度,m 是标准答案的长度。空间复杂度 O(1),无论文件多大,都不会占用过多内存。

核心算法是上下文强相关的,难以并行化,就算能够并行,算法占用的 CPU 时间也不会减少,因此只能写单线程。

总结一下,需要往死里优化的就是一个纯函数,输入为两个字节流,输出为 AC/PE/WA。
算法正确性判定

在开始之前,要回答一个问题,什么样的选手答案可以判定为 AC/PE/WA?

我们规定:

选手答案和标准答案严格相等时,一定是 AC选手答案和标准答案的末尾的空白字符不会影响判定每一行末尾的空白字符不会影响判定若某一行中,非空白字符一致,但空白字符不一致,判定为 PE若某一行中,非空白字符不一致,判定为 WA

其中“空白字符”规定为 ASCII 空白字符。

举一些例子

judge!(AC, b"1\r\n\r\n\r\n", b"1 “);
judge!(AC, b"1 \n”, b"1");
judge!(PE, b"1 3\n", b"1 3\n");
judge!(PE, b"1\r3\n", b"1\t3\n");
judge!(WA, b"1", b"2");
judge!(WA, b"1\r\n", b"2\n");

因此,核心算法应该是一个以字节为单位的状态机,大循环套小循环即可。
性能调优

我们跳过对核心算法的说明,直接介绍调优手段。

  1. 回避 io::Result

说到字节流,我们自然会想到标准库的 std::io::Bytes,然而它在这里会带来严重的性能问题。

测试代码:

use std::fs::File;
use std::io::{self, BufRead, BufReader, Read};
use std::time::{Duration, Instant};fn time<R>(f: impl FnOnce() -> R) -> (R, Duration) {let t0 = Instant::now();let ans = f();(ans, t0.elapsed())
}fn test_bytes(reader: &mut impl BufRead) -> io::Result<u8> {let mut ans = 0;for byte in reader.bytes() {ans ^= byte?;}Ok(ans)
}fn test_block(reader: &mut impl BufRead) -> io::Result<u8> {let mut ans = 0;loop {match reader.fill_buf()? {[] => break,buf => {buf.iter().for_each(|&byte| ans ^= byte);let amt = buf.len();reader.consume(amt);}}}Ok(ans)
}fn main() -> io::Result<()> {let bytes_ret = {let mut reader = BufReader::new(File::open("Cargo.toml")?);time(|| test_bytes(&mut reader))};let block_ret = {let mut reader = BufReader::new(File::open("Cargo.toml")?);time(|| test_block(&mut reader))};let _ = dbg!(bytes_ret);let _ = dbg!(block_ret);Ok(())
}

测试结果:

[src/main.rs:45] bytes_ret = (Ok(123,),7.064µs,
)
[src/main.rs:46] block_ret = (Ok(123,),1.708µs,
)

读取一个字节流,对其中所有字节做异或,最后输出。

测试结果表明:std::io::Bytes 的速度远低于手动控制缓冲区。

Q: 明明计算次数都是 n,为什么速度不一致?

A: 因为 std::io::Result 实在是太大了。

截取 std::io::Error 的定义:

pub struct Error {repr: Repr,
}enum Repr {Os(i32),Simple(ErrorKind),Custom(Box<Custom>),
}

其中包含了一个指针,这意味着 std::io::Error 在 64 位机器上是 8 字节对齐,再加枚举标签,它会达到 16 字节。

std::io::Result 会再套一个枚举标签,让整体达到 24 字节。

如果读取文件中的每个字节都要被迫操作 24 字节大小的内存,显然对 CPU 极度不友好,性能会非常低。

PS: anyhow::Error 使用了自定义虚表,手动操控动态错误对象的布局,大小是一个指针,即 8 字节。

但核心算法以字符为单位,一定需要 next_byte 之类的操作,怎么回避 Result 过大的问题?

这里给出的解法是 try-catch

fn catch_io<R>(f: impl FnOnce() -> R + UnwindSafe) -> io::Result<R> {match catch_unwind(f) {Ok(ans) => Ok(ans),Err(payload) => match payload.downcast::<io::Error>() {Ok(e) => Err(*e),Err(payload) => resume_unwind(payload),},};
}

当出现 IO 错误时,用 panic!(e) 抛出错误,外层 catch IO 错误。

算法中需要判断 EOF,所以不能把 EOF 也当错误抛出。

这样就能让 next_byte 操作的返回值从 24 字节减小到 2 字节,一个字节表示数据,另一个表示是否 EOF。
2. 强制内联热点函数

ojcmp 的两个版本之间出现了高达 2 ~ 3 倍的性能差距,但算法明明是相同的。

我们可以使用火焰图观察函数耗时占比。

Rust 生态中有一个命令行工具 flamegraph,能够通过 perf 收集数据生成火焰图。
flamegraph​

github.com

v0.2.2

差异在于后者出现了深红色的 next_byte 调用。

再使用 perf annotate 查看指令。

发现在高频循环中出现了过多函数调用,因此解法是强制内联 next_byte。

加上 #[inline(always)],循环中确实内联了 next_byte 函数,后者性能提高 2 ~ 3 倍,与前者一致了。
3. 使用 unsafe

尝试使用 unsafe 跳过热点的数组边界检查,结果反而更慢了???

实测中出现这种反直觉的情况,只能说明数组边界检查的影响完全比不上其他影响因素。查看汇编也发现热点的边界检查仅占全部时钟周期的 2.5 %.

反复尝试后有了新发现:热点函数中使用裸指针代替数组索引,可以让耗时减少约三分之一。

pub struct ByteReader<R> {inner: R,buf: Box<[u8]>,pos: usize,len: usize,
}...fn next_byte(&mut self) -> Option<u8> {debug_assert!(self.len <= self.buf.len());if self.pos < self.len {let byte = self.buf[self.pos];self.pos += 1;Some(byte)} else {match self.inner.read(&mut *self.buf) {Ok(nread) => {if nread == 0 {None} else {let byte = self.buf[0];self.len = nread;self.pos = 1;Some(byte)}}Err(e) => panic!(e),}}
}
pub struct ByteReader<R> {inner: R,buf: Box<[u8]>,head: *const u8,tail: *const u8,
}
fn next_byte(&mut self) -> Option<u8> {if self.head != self.tail {unsafe {let byte = *self.head;self.head = self.head.add(1);Some(byte)}} else {match self.inner.read(&mut *self.buf) {Ok(nread) => {if nread == 0 {None} else {unsafe {let byte = *self.buf.as_ptr();self.head = self.buf.as_ptr().add(1);self.tail = self.buf.as_ptr().add(nread);Some(byte)}}}Err(e) => panic!(e),}}
}

然而,两种写法编译出的指令并无明显区别,真正的影响因素是什么?这个谜团留给读者。
4. 使用 VTune 观测微架构

尽量减小 ojcmp 两个版本之间的差异后,还是有 10% ~ 16% 左右的性能差距,但算法相同,IO 相同,已经想不到有什么因素可以影响性能了。
Kevin Wang:Rust真的比C慢吗?进一步分析queen微测评​
zhuanlan.zhihu.com图标

从这篇文章中受到启发,也许是 CPU 微架构的影响。

Intel 出了一套性能分析工具 VTune,可以看到微架构的执行情况。

Intel VTune Profiler​
software.intel.com

Front-End Bandwidth MITE 和 DSB Coverage 有明显差异,说明指令在内存中的布局影响了 CPU 微指令的转换和缓存,产生性能差距。

那么我们能不能手动控制指令布局呢?

经过多次尝试,得出了一个令人遗憾的结论:无法以可接受的代价来手动控制指令布局。

在一个比较复杂的算法函数中,编译器对指令的排列是不可依赖的,稍微改变写法,或者更改编译器版本,都会让结果发生变化。

手动写内联汇编,无论从性能还是可维护性来说,都是一个最坏的选择。
5. 优化算法策略

深入到微架构之时,单纯的代码优化就已经到头了,看起来也不得不接受 10% 上下的性能损失,等编译器更新再看运气。

状态机是个高频循环,又对数据有强依赖,难以优化分支,想到这里,我突然冒出一个新点子,能否加上类似分支预测的算法来针对特定数据进行优化?

再回顾一下问题背景:

比较选手答案与标准答案比较两个字节流以字节为单位的状态机

可以假设,有相当一部分情况下两个字节流完全相同。
块比较

如果检测到相同的字节占比过大,那么两个字节流完全相同的几率很大,此时可以把两个缓冲区直接拿出来比较。

memcmp 是经过高度优化的,分块比较的效率显然更高,但需要记录相同次数和比较次数,预测失败反而有可能降低效率。
短比较

在逐字节比较之前,两个字节流都向前获取 8 字节,如果相同就跳 8 个位置。

对于相同比例较大又不完全相同的情况,比如 LF 和 CRLF,这种策略会降低算法常数,有显著性能提升。

如果发现不同,又有相当大的可能性是答案错误(WA),格式错误(PE)在真实场景中非常少。一旦 WA 了,算法就会退出,仅当 PE 时这种策略才会拖慢速度。
分析

CPU 分支预测是基于分支跳转的概率,成功会有加速,失败会有惩罚。

上述两种优化策略都是基于数据分布,能有效减小算法耗时的期望,当预测失败时会降低性能,与分支预测类似。

实测性能:

块比较/原版:AC 905%短比较/原版:AC 185%短比较/原版:PE 79%

块比较的策略能够带来九倍的性能提升。短比较加速明显,失败的惩罚也可以接受。
总结

优化什么?

优化对象是纯函数,便于测定性能

为什么要优化?

项目目标之一是极限性能

如何优化?

回避 io::Result强制内联热点函数使用 unsafe (为什么裸指针比索引快?)使用 VTune 观测微架构优化算法策略

性能调优历程中使用的工具:

perfflamegraphVTune

未来的优化空间:

扣指令细节,优化代码用 mmap、madvise、fadvise 之类的系统调用优化 IO

Nugine: Rust 性能调优相关推荐

  1. hive性能调优实战pdf_Nginx 性能调优实战

    来自:Linux社区 1.Nginx运行工作进程数量 Nginx运行工作进程个数一般设置CPU的核心或者核心数x2.如果不了解cpu的核数,可以top命令之后按1看出来,也可以查看/proc/cpui ...

  2. jvm调优工具_JVM性能调优监控工具jps、jstack、jmap、jhat、hprof使用详解

    来自:ITeye博客, 作者:Josh_Persistence 链接:https://www.iteye.com/blog/josh-persistence-2161848 现实企业级Java应用开发 ...

  3. ELASTIC SEARCH 性能调优

    ELASTICSEARCH 性能调优建议 创建索引调优 1.在创建索引的使用使用批量的方式导入到ES. 2.使用多线程的方式导入数据库. 3.增加默认刷新时间. 默认的刷新时间是1秒钟,这样会产生太多 ...

  4. OCM_第十二天课程:Section6 —》数据库性能调优_ 资源管理器/执行计划

    注:本文为原著(其内容来自 腾科教育培训课堂).阅读本文注意事项如下: 1:所有文章的转载请标注本文出处. 2:本文非本人不得用于商业用途.违者将承当相应法律责任. 3:该系列文章目录列表: 一:&l ...

  5. Tomcat 和 JVM 的性能调优总结

    点击上方"方志朋",选择"设为星标" 回复"666"获取新整理的面试资料 来源:http://rrd.me/enKbC Tomcat性能调优 ...

  6. JVM解读-性能调优实例

    2019独角兽企业重金招聘Python工程师标准>>> JVM性能调优 1 堆设置调优 年轻代大小选择 响应时间优先的应用:尽可能设大,直到接近系统的最低响应时间限制(根据实际情况选 ...

  7. Java性能调优、LinkedIn容器部署、阿里移动性能调优——首届APMCon精彩演讲先睹为快...

    APMCon2016,在盛夏的8月等你. \\ 作为第一届APM垂直领域的技术大会,我们能拿出什么呈现给参会者? \\ 答案是,除了会场可以纳凉避暑之外,还有来自国内外顶级技术大拿带来的Java性能管 ...

  8. elasticsearch原理_ElasticSearch读写底层原理及性能调优

    ES写入/查询底层原理 1. Elasticsearch写入数据流程 客户端随机选择一个ES集群中的节点,发送POST/PUT请求,被选择的节点为协调节点(coordinating node) 协调节 ...

  9. “性能调优”坑惨了几十万程序员

    很多程序员觉得性能调优这块的JVM.Mysql不是什么大事,自己平时写代码写得好好的,不是很了解JVM好像也没什么的,认为得千万级甚至亿万级的大流量.大项目才用得上,其他一般场景根本用不到,直到遇见这 ...

  10. Linux性能调优用这个“必杀技”,稳了!

    " "这个系统好慢.网站又打不开了,太卡了,又没响应了!"相信大家都遇到过这种抱怨,这是应用系统出现了性能问题,需要性能调优. 性能调优,要求对计算机硬件.操作系统和应用 ...

最新文章

  1. 递归/回溯:Combination Sum II数组之和
  2. 在ubuntu系统中使用dpkg命令安装后缀名为deb的软件包
  3. java中怎样克隆,如何在Java中克隆列表?
  4. 区块链新经济蓝图及导读pdf_区块链加快产业数字化转型,区块链新零售模式为企业发展加码提速...
  5. 数组模拟栈和队列板子
  6. 电脑重启bootmgr_解决电脑开机出现bootmgr is compressed的两大妙招
  7. Android Studio的怪错:AndroidManifest.xml unresolve symbol package/connot resolve symbol/Animations
  8. 【OR】YALMIP 几何规划
  9. Windows NT 内核基本结构
  10. OpenStack巴塞罗那峰会,比拼技术更比拼用户体验
  11. 利用js书写正三角形
  12. wamp安装composer
  13. uva 12304(圆的相关函数模板)
  14. html文件如何做成链接,如何将文件做成超链接HTM网页?
  15. open judge1.7.14
  16. 使用Verilog来实现奇数分频
  17. C#读取Excel表格去掉空行
  18. 新手上路,如何迅速搭建一套源码系统
  19. 短视频平台盈利模式深度解析
  20. oracle表分区设计_Oracle数据库分区技术

热门文章

  1. VMware Harbor现已加入Rancher社区Catalog
  2. IntelliJ IDEA 将 Maven 构建的 Java 项目打包
  3. 【C/C++学院】0805-语音识别控制QQ/语音控制游戏
  4. JAVA JDBC常见面试题及答案
  5. maven错误相关(整理中)
  6. 【库】JavaScript——滚动条( 不是很完善 )
  7. linux最常用命令
  8. Linux系统启动的标准流程
  9. 使用Quartz.Net定时删除Log
  10. C#的一些方法读程序转c++