注:本文档主要针对单个二进制文件的通用性能调优,不涉及分布式系统与机器学习的硬件性能调优。内容基于 https://abseil.io/fast/hints.html 翻译并参考其他资料整理。
核心原则
提前思考性能优化的重要性:在编写代码时,我们不应过早进行微观优化。但是,当遇到几种实现方式在可读性和复杂性上相差无几时,应该毫不犹豫地选择性能更优的方案。
我们需要培养对代码性能的直觉,在动手编码前就尝试估算其开销。这种粗略估算能帮助我们快速识别潜在的瓶颈,并引导我们进行更深入的分析。
性能估算方法
1. 估算操作数量
首先要估算程序需要执行的各种底层操作的数量,例如:
- 磁盘寻道次数(在Linux环境下可使用
iostat -x 观察 await 和 svctm 指标)
- 网络往返时间(RTT,可使用
ping、tracert/traceroute、cURL 等工具测量)
- 传输的字节数等
2. 计算总成本
接下来,将每种昂贵操作的数量与其大致成本(时间/资源)相乘,然后将所有结果相加,得到总成本的粗略估计。
3. 系统资源成本
上述估算方法有助于我们量化系统在各类资源使用上的成本,为优化指明方向。
延迟参考表
以下是每一位追求性能的开发者都应熟知的延迟数据(数值为近似值):
| 操作 |
延迟 |
| L1 cache reference |
0.5 ns |
| L2 cache reference |
3 ns |
| Branch mispredict |
5 ns |
| Mutex lock/unlock (uncontended) |
15 ns |
| Main memory reference |
50 ns |
| Compress 1K bytes with Snappy |
1,000 ns |
| Read 4KB from SSD |
20,000 ns |
| Round trip within same datacenter |
50,000 ns |
| Read 1MB sequentially from memory |
64,000 ns |
| Read 1MB over 100 Gbps network |
100,000 ns |
| Read 1MB from SSD |
1,000,000 ns |
| Disk seek |
5,000,000 ns |
| Read 1MB sequentially from disk |
10,000,000 ns |
| Send packet CA->Netherlands->CA |
150,000,000 ns |
延迟表解读
1. CPU缓存层(纳秒级)
- L1 cache reference (0.5 ns):CPU从离核心最近的一级缓存读取数据。
- L2 cache reference (3 ns):从二级缓存读取数据,速度比L1慢约6倍。
- Branch mispredict (5 ns):分支预测失败带来的惩罚。现代CPU采用流水线技术并会预测代码执行路径,一旦猜错就必须清空流水线,重新开始。
- Mutex lock/unlock (uncontended) (15 ns):在多线程环境中获取一个未被占用的锁所需的时间。
2. 内存部分
- Main memory reference (50 ns):发生缓存未命中(Cache Miss)时,从主内存(RAM)读取数据,这比访问L1缓存慢100倍。
- 缓存局部性原理:处理器在短时间内倾向于重复访问相同或相邻的数据。
- 时间局部性:如果一个数据项被访问,那么它在不久的将来很可能被再次访问。
- 空间局部性:如果一个存储单元被访问,那么它附近的存储单元也很快可能被访问。理解并利用好内存管理中的局部性原理是性能优化的关键。
3. I/O与存储(微秒/毫秒级)
- Read 4KB from SSD (20,000 ns / 20 µs):从SSD随机读取小块数据,比访问内存慢约400倍。
- Compress 1K bytes with Snappy (1,000 ns / 1 µs):使用Snappy压缩1KB数据。这个例子说明,有时CPU计算(如压缩)可能比从慢速存储读取数据更快。
- Disk seek (5,000,000 ns / 5 ms):机械硬盘的磁头寻道时间,是性能的“杀手”。
- Read 1MB sequentially from disk (10,000,000 ns / 10 ms):从机械硬盘顺序读取1MB数据。
4. 网络层(毫秒级)
- Round trip within same datacenter (50,000 ns / 50 µs):同一数据中心内的网络往返延迟。
- Send packet CA->Netherlands->CA (150,000,000 ns / 150 ms):数据包从加州到荷兰再返回加州的跨洋网络往返延迟,凸显了地理距离对网络性能的巨大影响。
无明显瓶颈时的优化策略
当性能分析显示CPU使用曲线平稳,没有明显瓶颈时,可以尝试以下策略:
- 多做局部小优化:在多个子系统中分别进行1%-2%的微小优化,累积起来可能带来显著的全局提升。
- 重构顶部附近循环:优化调用栈顶部附近的循环,因为它们执行的次数最多。
- 优化整体结构与算法:审视是否有更优的算法或架构可以替代当前方案。
- 替换过于通用的代码:用更贴合当前场景的自定义实现替换掉那些为了通用性而牺牲性能的库代码或模板。
- 减少内存分配频率:高频的内存分配与释放是常见的性能陷阱。
- 从硬件角度思考:使用如
perf 等性能分析工具,从CPU指令、缓存命中率等底层指标寻找优化空间。
常见优化方案
1. 优化批量处理接口
问题:高频调用单个对象处理接口时,循环内的锁竞争、虚函数派发、错误处理等固定开销会被重复计算,造成浪费。
优化:提供批量处理接口(Bulk API),将固定开销平摊到整个批量操作中。
// 旧:每次只能查一个ID
util::StatusOr<LiveTensor> Lookup(const TensorIdProto& id);
// 调用端必须写循环:
for (const auto& id : ids) {
auto result = manager.Lookup(id); // 每次调用都要加锁、解锁、处理错误
}
// 新:批量接口
bool LookupMany(const std::vector<TensorIdProto>& ids,
std::vector<LiveTensor>* results);
2. 正则表达式处理优化
// 问题:每轮循环都重新编译正则表达式,开销巨大
for (const std::string& text : inputs) {
RE2 re("pattern"); // 在循环内部构造RE2对象
if (RE2::FullMatch(text, re)) { ... }
}
// 优化:将正则对象设置为静态,只编译一次
static const RE2* re = new RE2("pattern");
for (const std::string& text : inputs) {
if (RE2::FullMatch(text, *re)) {...} // 使用预编译好的对象
}
3. 使用智能指针及复用对象
// 旧:每次循环都创建和析构对象,涉及内存分配与释放
while (HandleRequest()) {
MyProto response; // 每次循环创建新对象
// ... 填充数据 ...
} // 循环结束,对象析构
// 优化:在循环外创建对象,循环内复用
MyProto response; // 在循环外创建
while (HandleRequest()) {
response.Clear(); // 重置对象状态,但不释放底层内存
// ... 填充数据 ... // 复用已有的内存,避免malloc/free开销
}
4. 空间换时间/时间换空间
- 引入缓存机制,存储计算结果以避免重复计算,是典型的“以空间换时间”。
5. 优化函数参数
优先使用视图类型(如 std::string_view、std::span<T>、absl::FunctionRef<R(Args...)>),避免不必要的对象复制。
// 传统:const std::string&
// 缺点:当传入字符串字面量时,需要先构造一个临时的std::string对象
void LogMessageOld(const std::string& msg){
std::cout << "[Old] Log: " << msg << std::endl;
}
// 现代C++:std::string_view
// 优点:轻量级对象{指针, 长度},不涉及内存分配,可直接引用字符串字面量或string内容
void LogMessageNew(std::string_view msg){
std::cout << "[New] Log: " << msg << std::endl;
}
使用 string_view 在某些场景下性能差距可达数倍。
6. 优化数据结构表现形式
6.1 更紧凑的数据结构
- 降低内存占用。
- 减少所需的缓存行(Cache Line)访问次数。
- 降低内存总线带宽使用。
6.2 优化内存布局
- 重新排列结构体字段,以减少因内存对齐产生的填充(Padding)。
- 在满足业务需求的前提下,使用
int16_t、uint8_t 等较小的数值类型。
- 对字段排序,使经常被一起访问的字段在内存中靠近。
- 分离“热”的只读字段与“热”的可变字段,避免因修改导致整个缓存行失效(False Sharing)。
- 将极少访问的“冷”数据移开(例如放在结构体末尾、通过指针间接访问、或存入单独的数组)。
- 考虑使用位域(bit-field)或自定义编码来压缩数据。
热数据:被频繁访问或修改,对响应延迟敏感,应存放在内存或高性能NVMe SSD中。
冷数据:极少被访问,基本不再更新,数据量庞大,对响应时间要求不高,可存放在大容量机械硬盘或磁带库中。
6.3 使用索引而非指针
在64位系统中,一个指针占用8字节。使用32位(4字节)或更小的整数索引,可以显著减少内存使用并提高空间局部性。
// 传统指针方案
struct Node {
int data; // 4字节
// Padding 4字节(为了满足8字节对齐)
Node* next; // 8字节
}; // 总计16字节
// 索引方案
struct IndexedNode {
int data; // 4字节
uint32_t next_index; // 4字节
static constexpr uint32_t NullIndex = 0xFFFFFFFF;
}; // 总计8字节,无padding,完美对齐
区别:
- 指针:需要满足8字节对齐,导致结构体内产生填充浪费。
- 索引:元素紧密排列,无空间浪费,尤其适合将大量节点存储在
std::vector<IndexedNode> 中的场景。
6.4 批量存储
尽可能使用 std::vector、std::array 等连续存储容器进行批量分配,避免为大量小对象(如链表节点)单独调用内存分配器。
7. 减少循环体重复耗时操作
- 边界检查优化:在模块的输入边界一次性完成格式或有效性检查,而不是在内部的热循环中反复检查。
- 耗时计算外移:将循环内不变但耗时的计算(如函数调用、复杂表达式求值)移到循环外部。
// 旧:每次循环都调用 dimensions() 和 data()
for (int64 i = 0; i < src_shape.dimensions(dimension_numbers.front()); ++i) {
// 使用 src_buffer.data() 和 dst_buffer.data()
}
// 新:将不变的计算提到循环外
int64 dim_front = src_shape.dimensions(dimension_numbers.front());
const uint8* src_buffer_data = src_buffer.data();
uint8* dst_buffer_data = dst_buffer.data();
for (int64 i = 0; i < dim_front; ++i) {
// 使用 src_buffer_data 和 dst_buffer_data
}
- 推迟昂贵计算:如果某些计算结果并非每次循环都必须,可以延迟到真正需要时才计算。
8. 合理利用对象
8.1 使用全局单例
对于不可变且构造成本较高的对象,可以使用返回静态局部变量的函数来提供全局单例,避免重复构造。
// 旧方案:每次调用都可能构造新的shared_ptr<DeviceInfo>
LiveTensor::LiveTensor(tf::Tensor t, std::shared_ptr<const DeviceInfo> dinfo,
bool is_batched)
: tensor(std::move(t)),
device_info(dinfo ? std::move(dinfo) : std::make_shared<DeviceInfo>()),
is_batched(is_batched) {}
// 新方案:使用全局空设备的单例
static const std::shared_ptr<DeviceInfo>& empty_device_info(){
static const std::shared_ptr<DeviceInfo> result =
std::make_shared<DeviceInfo>();
return result;
}
LiveTensor::LiveTensor(tf::Tensor t, std::shared_ptr<const DeviceInfo> dinfo,
bool is_batched)
: tensor(std::move(t)), is_batched(is_batched) {
if (dinfo) {
device_info = std::move(dinfo);
} else {
device_info = empty_device_info(); // 复用全局单例对象
}
}
8.2 重用临时对象
将循环内部声明的容器(如 std::vector、std::string)或复杂对象提升到循环外部,在每次迭代中复用其内存。
8.3 使用缓存
对重复计算且结果确定的逻辑使用缓存(如 std::unordered_map 或 LRU Cache),用空间换取时间。
性能优化是一个需要持续学习和实践的领域,以上是一些在C++开发中常见的思路与模式。掌握这些基础后,结合性能剖析工具(Profiler)进行针对性分析,才能事半功倍。更多的算法优化与系统设计讨论,欢迎在云栈社区交流。