一些快速提高C++程序性能的方法

记录一些快速提高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 Endian

CPU(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

Leave a Comment

Your email address will not be published.