记录一些快速提高C++程序性能的方法。因为运行环境存在差异,样例CPU耗时结果仅供参考。
运行机器CPU信息:
Architecture: x86_64
CPU op-mode(s): 32-bit, 64-bit
Address sizes: 46 bits physical, 48 bits virtual
Byte Order: Little EndianCPU(s): 2
CPU MHz : 2500
Caches (sum of all):
L1d: 32 KiB (1 instance)
L1i: 32 KiB (1 instance)
L2: 1 MiB (1 instance)
L3: 33 MiB (1 instance)
编译与CPU耗时统计方式:
//gcc version 12.2.0
//g++ -std=c++11 -g -O2 -o test test.cpp
int main()
{
clock_t start = clock();
//测试函数
double elapsedTime = static_cast<double>(clock()-start) / CLOCKS_PER_SEC;
std::cout << elapsedTime << "s" << std::endl;
return 0;
}
一、std::vector相关
1、大小已知场景使用reserve()
先看一段代码,模拟场景把一个已知大小的数据转化到另一个vector存储:
class MyVector
{
public:
MyVector(int a, int b) : m_a(a), m_b(b) {}
int m_a;
int m_b;
};
void test_vector()
{
std::vector<MyVector> v;
for (size_t i = 0; i < 1000000; i++)
{
v.push_back(MyVector(i, i+1));
}
}
output:0.014506s
代码的问题在于push_back会根据当前大小触发vector扩容,导致额外的内存申请与拷贝。针对这种大小可以预见的情况,可以通过reserve一次预设好需要的内存:
void test_vector()
{
std::vector<MyVector> v;
v.reserve(1000000); //预设大小
for (size_t i = 0; i < 1000000; i++)
{
v.push_back(MyVector(i, i+1));
}
}
output:0.010358s
2、push_back()更进阶的方式emplace_back()
上述代码还有进一步的优化空间。push_back一般经历了两次内存操作:先是在进入push_back前申请内存构造,然后在调用push_back时拷贝数据进提供容器存储的连续内存。 emplace_back能解决这个问题,直接在vector容器的连续内存中构造,其核心原理使用了std::forward和placement new。我们调整例子程序使用emplace_back:
void test_vector()
{
std::vector<MyVector> v;
v.reserve(1000000); //预设大小
for (size_t i = 0; i < 1000000; i++)
{
//v.push_back(MyVector(i, i+1));
v.emplace_back(i, i+1);
}
}
output:0.004032s
其余支持emplace_back的stl容器也同理。
最后这里额外提一句,在容器对象使用大量堆内存特定场景下,使用push_back的移动语义效果也许会更佳或者直接在堆上创建对象,容器存储对象指针,这些都需要具体情况具体分析再选择使用。
二、分支预测(-O2编译)
linux内核中有这样的代码,在一些C/C++项目中也会遇见:
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
它们的主要目的是为了告诉程序分支走向的倾向,即优化分支预测,likely和unlikely可以理解成代码告诉编译器对分支预测优化。
除了编译器优化外,现代的CPU也对分支预测做了优化(这个是一个体系内容,涉及到CPU流水线与分支预测算法)。它的目的用于提高流水线执行效率,减少分支指令带来的性能损失,CPU优化与编译器优化在一定程度上互补。
我们用stackflow上一个经典讨论举例: why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array
void test_branchprediction()
{
// Generate data
const unsigned arraySize = 32768;
int data[arraySize];
for (unsigned c = 0; c < arraySize; ++c)
data[c] = std::rand() % 256;
// !!! With this, the next loop runs faster.
std::sort(data, data + arraySize);
// Test
long long sum = 0;
for (unsigned i = 0; i < 10000; ++i)
{
for (unsigned c = 0; c < arraySize; ++c)
{ // Primary loop
if (data[c] >= 128)
sum += data[c];
}
}
std::cout << sum << std::endl;
}
output with sort:0.093938s
output without sort:0.094279s
对比结果显示的CPU耗时,使用sort和不使用sort差别不大?主要原因在于我们在编译选项里加入了-O2,临时将编译选项-O2去掉后,重新编译,再次比对运行得到结果:
output with sort, without -O2:0.911555s
output without sort and -O2:2.76935s
结论:编译选项-O2为我们做了大量优化,这段要讨论的分支预测就是其中之一。对于例子test_branchprediction代码而言,除了分支预测相关优化外,还有循环展开,cpu流水指令的优化内容,这里不展开。(严格来讲,部分优化内容属于-O1,但-O1算-O2比较宽松的子集,所以我们笼统的归到-O2里)
三、CPU的Cacheline
涉及到Cacheline存在几个经典问题:
- 为什么数组顺序访问比链表快?
- 以下代码,为什么方式一比方式二快?
void test_cacheline()
{
//10K * 0.5K = 5M
const int row = 1024;
const int col = 512;
int **matrix = new int*[row];
for (int i = 0; i < row; ++i)
{
matrix[i] = new int[col];
}
int sum = 0;
//方式一,按行,从左到右依次遍历
for (int i = 0; i < row; ++i)
{
for (int j = 0; j < col; ++j)
{
sum += matrix[i][j];
}
}
//方式二,按列,从上到下依次遍历
for (int i = 0; i < col; ++i)
{
for (int j = 0; j < row; ++j)
{
sum += matrix[j][i];
}
}
std::cout << sum << std::endl;
}
上述两个问题能让你对Cacheline有基本的认识,但是对我们程序有什么帮助?首先需要了解最核心的一点:Cacheline一般称之为缓存行,是CPU将被访问内存地址周围的一块(行)内存加载到缓存当中,大多数情况默认大小是64个字节。简单来说就是:CPU如果需要访问一个4字节int,那么需要把内存中包含这个4个字节int和其余60字节的其他数据都加载到缓存当中,设计目的就是为了提高数据访问效率。
实际这里与Cacheline配套的知识还有很多,比如:CPU缓存分级,缓存层次结构,缓存可见性,缓存一致性等问题,内容太多不展开。那具体怎么利用这个Cacheline对实际代码做优化,看一个例子:
class Padding
{
public:
Padding() {locA = 0; locB = 0;}
int32_t locA;
int32_t paddingA[15]; //注释这行 without padding
int32_t locB;
int32_t paddingB[15]; //注释这行 without padding
};
class Padding P;
void thread_func(int s)
{
bool sA = (s == 0);
for (size_t i = 0; i < 10000000; i++)
{
if (sA)
{
++P.locA;
}
else
{
++P.locB;
}
}
}
void test_padding()
{
std::thread Thread1(thread_func, 0);
std::thread Thread2(thread_func, 1);
Thread1.join();
Thread2.join();
}
output with padding:0.008334s
output without padding:0.01627s
这里模仿了两个cpu同时对同一个Cacheline进行读写的场景,可以看到性能差距。但是会有人问,谁会这么写代码?太刻意了!继续改进一下这个例子再进行比较:
class PaddingA
{
public:
PaddingA() {locA = 0;}
int32_t locA;
};
class PaddingA PA1;
class PaddingA PA2;
class PaddingB
{
public:
PaddingB() {locB = 0;}
int32_t padding1[15];
int32_t locB;
int32_t padding2[15];
};
class PaddingB PB1;
class PaddingB PB2;
void thread_func1()
{
for (size_t i = 0; i < 100000000; i++)
{
++PA1.locA; //注释这行 with PaddingB
++PB1.locB;
}
}
void thread_func2()
{
for (size_t i = 0; i < 100000000; i++)
{
++PA2.locA; //注释这行 with PaddingB
++PB2.locB;
}
}
void test_paddingnew()
{
std::thread Thread1(thread_func1);
std::thread Thread2(thread_func2);
Thread1.join();
Thread2.join();
}
output with PaddingA:0.000236s
output with PaddingB:0.000173s
这个例子的场景是不是就实用多了,本质上来说还是在没有多线程竞争的情况下一个Cacheline大小的内容同时只希望被一个CPU缓存读写。为此可能会牺牲掉一些内存空间,换来时间。
其实Cacheline的利用和padding在很多工业级的源码中都有体现,比较容易翻到的比如:golang map中哈希桶的设计,以及cgo相关的数据结构里。
四、类构造初始化
对于类的构造,初始化列表的方式比构造函数赋值的方式更高效,例子:
class MyClassConstructA
{
public:
MyClassConstructA(int a, int b, const std::string &c) : m_a(a), m_b(b), m_c(c) {}
int m_a;
int m_b;
std::string m_c;
};
class MyClassConstructB
{
public:
MyClassConstructB(int a, int b, const std::string &c)
{
m_a = a;
m_b = b;
m_c = c;
}
int m_a;
int m_b;
std::string m_c;
};
void test_class_constructA()
{
for (size_t i = 0; i < 100000; ++i)
{
MyClassConstructA *stA = new MyClassConstructA(i, i, "433434324324234234");
}
}
void test_class_constructB()
{
for (size_t i = 0; i < 100000; ++i)
{
MyClassConstructB *stB = new MyClassConstructB(i, i, "433434324324234234");
}
}
output with MyClassConstructA:0.014475s
output with MyClassConstructB:0.016646s
主要是原因是列表初始化减少了不必要的对象构造和赋值操作。为了解释这个问题,用一个不太恰当的比喻:我们去买台式电脑,当所有配件确定后,是让卖家帮你整包装好,还是配件拿回来自己逐个拆包安装?对于我们个人而言,一般情况下肯定是前者能让我们更快的用上电脑。
(全文结束)
转载文章请注明出处:漫漫路 - lanindex.com