浅谈性能优化(基于C++)

4 篇文章 0 订阅
4 篇文章 3 订阅

本文主要针对C++的性能优化方法展开讨论。虽然这些方法也适用于一些其他语言,但由于C++经常用于底层操作,提供了更多的优化空间;相比之下,诸如Python、Kotlin等高级语言由于其抽象程度更高,优化空间较少。

性能优化原理

要实现性能优化,需要从硬件和软件层面了解优化的实现原理,尤其是围绕运算和存储两个方面。

CPU三级缓存机制

首先来看CPU的三级缓存机制,这是了解性能优化的关键概念。下图展示了CPU三级缓存的结构:

cpu三级缓存

从图中可以看出,越靠近CPU的内存,存储速度越快但空间越小。这涉及到一个关键概念——缓存命中率。CPU对内存的调用逻辑是首先查看L1数据高速缓存是否命中数据,如果没有,再查看L2高速缓存,最后是L3级调用(此时命中率最低)。因此,如何提高CPU的缓存命中率非常重要。

提高缓存命中率

为了提高缓存命中率,可以考虑以下几种方法:

  1. 数据局部性:尽量让数据使用集中在一小块内存区域内。
  2. 批量处理:一次处理尽可能多的数据以减少缓存的更新频率。
  3. 避免跨行访问:尽量避免数据结构导致跨行访问。

CPU对于不同运算的运算速度

不同的运算在CPU上的速度是不同的:

  1. 逻辑运算(如AND, OR, NOT, XOR):这些运算由专用电路实现,速度最快。
  2. 比较运算:使用专门的数字比较器,速度较快。
  3. 赋值运算:由于处理器频繁调用,也被高度优化。
  4. 算术运算
    • 加(+)、减(-)、移位操作(<<, >>):速度较快,接近逻辑运算速度。
    • 乘法(*):相对较慢。
    • 除法(/):最慢。
  5. 浮点运算:速度比整数运算慢得多。

CPU流水线

在大多数CPU中,命令的执行依赖于CPU流水线(Pipeline)。下图展示了一个4级流水线,缓存行(Cache Line,一般是L1,一级缓存)上存放等待执行的指令:

四级流水线

  • Clock 1:取出绿色指令放入流水线。
  • Clock 2:取出紫色指令,解码绿色指令。
  • Clock 3:取出蓝色指令,解码紫色指令,执行绿色指令。
  • Clock 4:取出红色指令,解码蓝色指令,执行紫色指令,回写绿色指令。
  • Clock 5:流水线空出一级,继续执行类似的流程。

CPU会尝试预测接下来的操作并将其置入流水线中。如果操作非线性,且包含频繁的逻辑判断,可能会中断流水线,使所有操作失效,从而降低处理速度。

利用GPU和NPU进行加速

GPU(图形处理单元)擅长处理大量简单的并行任务,适合图像处理等操作。

NPU(神经网络处理器)则是为加速深度学习算法而构建的,可以在AI工作负载中提供比CPU和GPU更高的性能。

代码处优化

优化代码的方法包括:

  1. 减少循环开销:精简循环体,减少不必要的操作。
  2. 减少函数调用:使用内联函数(inline)减少函数调用开销。
  3. 避免不必要的内存分配:尽量减少动态内存分配,使用栈内存或预分配内存。

编译时优化

修改编译时优化级别也能提升性能:

  1. -O0:调试版本,生成的汇编代码与实际代码更好对应,附加调试信息。
  2. -O2:发布版本,启用内联、循环展开、指令顺序优化、常量替换等技术优化代码性能。
  3. -O3:激进优化,过度循环展开、内联等,可能导致可执行文件变大,甚至栈溢出。

对于ARM内核,使用armcc编译器通常比gcc有更好的性能优化。

性能优化方法

基于上述性能优化原理,可以采用以下优化方法:

1. 结合代码处性能优化和CPU三级缓存中提高命中率的方法

以矩阵计算为例,常见的边界问题会用到判断(如if),但if判断是CPU流水线的敌人,会中断CPU的分支预测,拖慢处理速度。以下是一个优化示例:

原始代码

#include <stdio.h>

#define ROWS 10
#define COLS 10

void matrix_calc(int matrix[ROWS][COLS]) {
    for (int i = 0; i < COLS; ++i) {
        for (int j = 0; j < ROWS; ++j) {
            if (i == 0 || i == ROWS - 1 || j == 0 || j == COLS - 1) {
                matrix[i][j]--;
            } else {
                matrix[i][j]++;
            }
        }
    }
}

优化后代码

#include <stdio.h>
#include <immintrin.h>

#define ROWS 16
#define COLS 16

void matrix_calc_simd(int matrix[ROWS][COLS]) {
    __m128i mask = _mm_set1_epi32(0x0000FFFF); // 用于判断是否为边界
    __m128i inc = _mm_set1_epi32(1);
    __m128i dec = _mm_set1_epi32(-1);

    for (int i = 0; i < ROWS; i += 4) {
        for (int j = 0; j < COLS; j += 4) {
            __m128i data = _mm_loadu_si128((__m128i*)&matrix[i][j]);

            __m128i row_mask = _mm_set1_epi32(i == 0 || i == ROWS - 1 ? 0xFFFFFFFF : 0);
            __m128i col_mask = _mm_set1_epi32(j == 0 || j == COLS - 1 ? 0xFFFFFFFF : 0);

            __m128i boundary_mask = _mm_or_si128(row_mask, col_mask);

            __m128i result = _mm_blendv_epi8(data, _mm_add_epi32(data, inc), boundary_mask);
            result = _mm_blendv_epi8(result, _mm_sub_epi32(data, dec), boundary_mask);

            _mm_storeu_si128((__m128i*)&matrix[i][j], result);
        }
    }
}

在优化后的代码中,利用了SIMD指令集一次处理多个数据,提高了计算效率。SIMD指令集(单指令多数据)是性能优化的一个好办法。更多信息可以参考相关文章

如果去掉SIMD操作后,代码如下:

#include <stdio.h>

#define ROWS 10
#define COLS 10

void matrix_calc(int matrix[ROWS][COLS]) {
    for (int i = 0; i < ROWS; i++) {
        matrix[i][0]--;
        matrix[i][COLS-1]--;

        matrix[0][i]--;
        matrix[ROWS-1][i]--;

        for (int j = 1; j < COLS - 1; j += 2) {
            matrix[i][j]++;
            matrix[i][j

+1]++;
        }
    }
}

该代码的优化在于:

  • 首先,将边界值的计算放在循环外面处理,这样就不必在每次迭代中检查是否在边界上,减少了判断次数。
  • 然后,对于非边界上的元素,采用批量增加的方式,每次处理两个数据,加快了计算速度。

2. 滑动窗口

有聪明的小伙伴就发现了,这个词好像在我们计算机网络这门课听说过,但是听的迷迷糊糊的,我们可以理解为一个一直移动的框,举个例子,我们如果要计算一个很长的无序数组的某n个数和,比如说一个数组a内容为{1,7,3,2,4,5,8,0,2,9,1,3,5,7,8,1......}
而我们需要计算每5个数的和。
正常而言,我们需要计算第一个数就是1+7+3+2+4,第二个数就是7+3+2+4+5,第三个数就是3+2+4+5+8…大家发现了什么,其实每次计算哦独有一部分是重复的!这一部分就是滑动窗口。
于是乎我们可以先做一次计算,比如第一个数是1+7+3+2+4 = 17,那么第二个数是多少呢?就是第一个数-a[0]+a[5],也就是17-1+5 =21,第三个数是第二个数-a[1]+a[6]=22,我们会惊奇的发现,通过这种方法我们省下了3个运算!
给个例子,比如在计算无重复字符的最长子串问题中,原代码如下
问题描述: 给定一个字符串 s ,请你找出其中不含有重复字符的 最长子串 的长度。

#include <iostream>
#include <unordered_set>
#include <string>

using namespace std;

int lengthOfLongestSubstring(string s) {
    int n = s.size();
    unordered_set<char> set;
    int ans = 0, i = 0, j = 0;
    while (j < n) {
        if (set.count(s[j]) == 0) {
            set.insert(s[j++]);
            ans = max(ans, j - i);
        } else {
            set.erase(s[i++]);
        }
    }
    return ans;
}

int main() {
    string s = "abcabcbb";
    cout << lengthOfLongestSubstring(s) << endl;  // 输出 3
    return 0;
}

我们完全可以利用滑动窗口优化,每次滑动窗口就是最长子串

#include <iostream>
#include <unordered_map>
#include <vector>
#include <string>

using namespace std;

vector<int> findAnagrams(string s, string p) {
    vector<int> res;
    if (s.size() < p.size()) return res;
    unordered_map<char, int> need, window;
    for (char c : p) need[c]++;
    int left = 0, right = 0, valid = 0;
    while (right < s.size()) {
        char c = s[right];
        right++;
        // 窗口内字符数量变化
        if (need.count(c)) {
            window[c]++;
            if (window[c] == need[c]) valid++;
        }
        // 判断窗口是否有效
        while (right - left == p.size()) {
            if (valid == need.size()) res.push_back(left);
            // 左指针向右移动
            char d = s[left];
            left++;
            if (need.count(d)) {
                if (window[d] == need[d]) valid--;
                window[d]--;
            }
        }
    }
    return res;
}

3. 映射表

这也是一种常见的优化手段,多用于图像处理,比如,我想让RGB图像的绿色值都加1,封顶是绿色为256
传统方法下操作如下

void adjustBrightness(uint8_t* imageData, int width, int height, int brightness) {
    for (int y = 0; y < height; ++y) {
        for (int x = 0; x < width; ++x) {
            for (int c = 0; c < 3; ++c) { // RGB 三个通道
                int pixel = imageData[(y * width + x) * 3 + c];
                pixel = std::min(std::max(pixel + brightness, 0), 255);
                imageData[(y * width + x) * 3 + c] = pixel;
            }
        }
    }
}

可以看到,再循环中每次循环都进行了比较和运算操作,虽然单次的运算操作速度很快,但是架不住数量多啊!
这时候我们可以将已知值进行与计算(计算256次)

vector<uint8_t> createGreenLUT() {
    vector<uint8_t> lut(256);
    for (int i = 0; i < 256; ++i) {
        lut[i] = min(i + 1, 255);
    }
    return lut;
}

// 假设 imageData 是一个 uint8_t 的数组,存储了图像的像素数据
void applyGreenLUT(uint8_t* imageData, int width, int height, vector<uint8_t>& lut) {
    for (int y = 0; y < height; ++y) {
        for (int x = 0; x < width; ++x) {
            int index = (y * width + x) * 3 + 1; // 假设 RGB 格式,绿色通道在第二个位置
            imageData[index] = lut[imageData[index]];
        }
    }
}

此时我们就大大降低了计算数量!

4. 量化数据

前面提到,浮点数运算会大大拖慢运算速度,因此我们可以考虑对浮点数据进行量化,将其量化为更小的数据类型。
比如我们已知浮点数的范围为0.1-0.8,那么我们就可以将其量化为8位无符号整数,取值范围为0-255
代码如下

#include <iostream>

int quantize(float value) {
    // 假设浮点数范围为0.1-0.8,量化为8位无符号整数
    float min_val = 0.1;
    float max_val = 0.8;
    int quant_level = 255;
    int quantized_value = round((value - min_val) / (max_val - min_val) * quant_level);
    return quantized_value;
}

int main() {
    float f = 0.5;
    int q = quantize(f);
    std::cout << "Quantized value: " << q << std::endl;
    return 0;
}

此时对循环读取等操作都变得更加友好了,这种方法被广泛用于诸如ai模型优化等方面,可以将一个浮点数40mb的ai模型给优化成10mb!但是在使用时需要将其反量化为40mb的大小,这就涉及一个点——精度损失。

float dequantize(int quantized_value, float min_val, float max_val, int quant_level) {
    // 反量化公式:
    // dequantized_value = quantized_value * (max_val - min_val) / quant_level + min_val
    return quantized_value * (max_val - min_val) / quant_level + min_val;
}

我们将其中的数字进行反量化后发现原本的0.1-0.8经过这一过程后变为了

Original: 0.1, Quantized: 0, Dequantized: 0.1, Error: 0
Original: 0.2, Quantized: 36, Dequantized: 0.198824, Error: 0.00117648
Original: 0.3, Quantized: 73, Dequantized: 0.300392, Error: 0.000392139
Original: 0.4, Quantized: 109, Dequantized: 0.399216, Error: 0.000784338
Original: 0.5, Quantized: 146, Dequantized: 0.500784, Error: 0.000784338
Original: 0.6, Quantized: 182, Dequantized: 0.599608, Error: 0.000392139
Original: 0.7, Quantized: 219, Dequantized: 0.701177, Error: 0.00117648

可以看到或多或少都出现了误差,但是误差范围都在小数点后三位以后,对精度要求不是很高的情况下可以使用该方法。

5. 其他优化策略

可以多用内敛函数inline减少函数调用,多用引用少用指针提高内存利用率,在做运算时用乘法移位代替除法,用加法移位代替乘法等等

6. 最后的手段:多线程

现代计算机的处理器大都支持多线程操作,可以在同一时间处理多个任务,这也能大大提高处理效率。

7. 硬件层面的,善用GPU和NPU加速

利用OpenGL等硬件加速手段来辅助加快运行速度

8. 编译器编译时参数优化

优化选项

-O1, -O2, -O3

GCC编译器的-O1, -O2, -O3优化选项可以自动进行一些优化操作,例如内联函数、循环展开、指令调度等。你可以根据实际需求选择合适的优化级别。一般来说,-O3优化选项提供最高级别的优化,但同时可能增加代码的编译时间和执行文件的体积。

-flto

链接时优化(Link Time Optimization, -flto)可以在链接阶段进一步优化代码,特别是当多个源文件链接在一起时。这个选项可以在编译时使用-flto参数,然后在链接时也需要使用这个选项。例如:

g++ -O3 -flto -o myprogram main.cpp module1.cpp module2.cpp

-march=native

-march=native选项会让编译器针对当前机器的硬件架构生成优化的代码。这个选项可以启用处理器支持的所有优化指令,例如SIMD指令集。使用示例:

g++ -O3 -march=native -o myprogram main.cpp

-ffast-math

-ffast-math选项启用对浮点运算的激进优化。这个选项会让编译器忽略一些浮点运算的标准规则,以提高代码执行速度。但需要注意,使用这个选项可能导致结果精度下降或不符合IEEE 754标准。

结论

性能优化是一个复杂的过程,需要综合考虑硬件架构、软件算法和编译器优化。不同的应用场景需要不同的优化策略。通过结合数据局部性、批量处理、编译器优化选项以及使用SIMD指令等方法,可以大大提高程序的运行效率。这次只是浅探一下,更深入的内容还需要再进行探索。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值