性能优化方法论
优化从测量开始
先用有代表性的输入建立端到端基线,再拆分可疑阶段,判断时间花在计算、访存、文件系统还是同步上。每轮只改变一个主要因素,完成后用相同输入和运行条件重新测量。阶段耗时能提供线索,因果判断要依靠可对照的实验。
分析热路径时,可以连续问三个问题:每个元素摊销了多少访存和操作;下一次访存是否依赖上一次结果;每次昂贵操作处理了多少有效工作。访问是否可预测、访存能否并行,以及每次操作的粒度,通常比语法形式更接近性能原因。
三条判断线索
地址是否可预测
数组下标和连续地址通常能由当前循环或寄存器直接计算,处理器可以预取顺序数据。链表、树和哈希链则需要先读取一个节点,才能知道下一个节点的位置。后一种访问难以提前准备,容易让热路径等待内存返回。
可预测不等同于必须顺序扫描。一个小型平坦表如果能留在缓存中,按已知下标随机访问也很直接;关键是地址是否要经过额外读取才能得到,以及数据是否有合适的局部性。
访存之间是否独立
依赖链会把多次访存串行起来:
读取 a -> 根据 a 计算 b -> 读取 b -> 根据 b 计算 c -> 读取 c每一步都要等待前一步的结果。相反,多个互不依赖的表项或数据块可以同时发起读取,让处理器重叠等待时间。乱序执行和多级缓存能利用这种独立性,却无法消除算法本身的串行依赖。
这一点也适用于计算算法。CTR 模式可以独立生成不同计数器块的密钥流,适合并行处理;链式模式中后一块依赖前一块密文,就更难暴露同样的并行度。性能上的差异来自依赖结构,不只是代码写法。
每个计费单位做多少工作
热循环按什么单位付费,会决定固定成本被摊薄到多少数据上。常见的提升方向是:
比特 -> 符号 -> CPU 字 -> 数据块 -> 批次逐位合并可以改成按码字批量写入;逐条处理记录可以改为批量接口;频繁的小块 I/O 可以合并为缓冲写入。系统调用、锁、分配和跨模块调用也遵循同一原则:当每个元素都支付一次固定成本时,先判断能否按批次处理。
顺序访问与平坦随机访问
数据量远大于缓存、每个元素只需要处理一遍、元素之间没有必须串行的依赖时,顺序扫描通常是稳妥选择。它适用于复制、解析、哈希、压缩、过滤和聚合等工作,也便于预取和批量处理。
平坦随机访问在另一类场景中更合适:热表规模较小,能被目标缓存层反复利用;访问位置已经可算,彼此之间也没有依赖;或者少量热点元素需要频繁访问。此时为了“顺序”而扫描整张表,可能增加总访存量。
判断方式可以归纳为一组对照:
| 数据与访问特征 | 优先考虑 |
|---|---|
| 大数据集、单次遍历、元素独立 | 连续布局、顺序处理、批量读写 |
| 小型热表、索引已知、重复命中 | 平坦表直接索引 |
| 地址由前一次读取结果决定 | 缩短指针链、预取或调整算法 |
| 工作集超过缓存且有重复访问 | 分块处理,改善时间局部性 |
五项热路径审计
| 审计项 | 要问的问题 | 常见调整方向 |
|---|---|---|
| 计费单位 | 循环按比特、字节、记录还是批次工作? | 提高处理粒度,合并小操作 |
| 访存依赖 | 下一次地址是否要等上一次读取? | 展开独立工作流、预计算地址或更换算法 |
| 工作集 | 热数据总量与目标缓存层的关系如何? | 小表平坦索引,大数据分块或顺序处理 |
| 间接层数 | 一次逻辑访问要经过几次指针解引用? | 连续容器、平坦数组或结构分离 |
| 批处理 | 是否每个元素都触发调用、锁、分配或 I/O? | 缓冲、对象池、批量接口和合并提交 |
审计依赖链时,也要检查多个逻辑工作流是否反复写同一个位置。例如频率统计若让所有输入共同递增一个计数器,会形成写入竞争;分成多个局部计数器再合并,通常能让独立工作并行推进。
数据结构带来的间接访问
容器和抽象接口可能引入额外跳转。选择结构时,应结合数据规模、访问频率和维护成本,而不是只看接口是否方便。
| 语言 | 连续或平坦的形态 | 需要关注的间接成本 |
|---|---|---|
| C/C++ | 平坦数组、std::array、连续 vector、SoA | 哈希桶到节点的跳转、链表、vector<unique_ptr<T>>、虚调用 |
| C# / .NET | 值类型数组、Span<T>、Memory<T> | List<Class> 中的对象引用、Dictionary<K,V>、装箱和重复枚举 |
C++ 的哈希表、C# 的引用对象集合以及树形容器都可能在一次逻辑访问中增加指针读取。对于热路径,可比较连续数组、结构分离和更直接的索引方式;若结构优化收益有限,保留更易维护的抽象通常更合适。
同一原则也能映射到其他语言。Python 可以把逐元素循环交给 NumPy 等批量运算;SQL 可以用集合查询和批量写入代替逐行往返;C++ 与 C# 则可以从数据布局、迭代方式和分配频率入手。语言改变表达方式,数据访问和固定调用成本仍然存在。
从现象定位瓶颈
| 瓶颈类型 | 常见表现 | 优先检查 |
|---|---|---|
| 延迟受限 | 处理器等待数据,依赖链较长 | 指针跳转、地址依赖、算法串行部分 |
| 带宽受限 | 内存传输接近可用上限 | 重复读写、数据布局、工作集 |
| 计算受限 | 计算单元繁忙,访存未饱和 | 指令数、循环粒度、向量化和算法 |
| I/O 或同步受限 | 端到端速度明显低于计算阶段 | 文件数量、调用频率、队列、锁和批次大小 |
与硬件带宽比较时,先估算实际传输的字节数、读写次数和缓存命中情况。只比较理论峰值和程序吞吐,容易把计算、延迟或文件系统开销误判成带宽问题。
实践顺序
建立端到端基线 -> 按阶段拆分可疑路径 -> 检查粒度、依赖、工作集、间接层和批处理 -> 每轮只改变一个主要因素 -> 用相同输入重新测量 -> 确认瓶颈是否转移热路径可以用以下问题快速复查:
□ 每个元素承担多少次访存和固定调用?□ 下一次访存地址是否依赖上一次结果?□ 热工作集能否留在目标缓存层?□ 一次逻辑访问经过几层指针?□ 系统调用、锁和分配能否按批处理?□ 这次改动是否改善了端到端结果?优化的目标不是把所有数据结构都扁平化,也不是让所有访问都变成顺序扫描。先找出真实工作负载里的主要成本,再选择能减少该成本且仍易维护的结构。
部分信息可能已经过时
粤公网安备44011102484817号