C High Performance Coding

#语句

#减少memset使用

对于一个C语言结构体Msg,初始化有以下方式:

  1. Msg m = {0};

  2. memset_s 非常耗时

  3. 复合字面量 可由编译器优化成更好的初始化形式

    1
    2
    3
    4
    
    #define INI_STRUCTRUE(sPtr) do{                  \
        static const __typeof__(*(sPtr)) tmp = {0};  \
        *(sPtr) = tmp;                               \
     } while(0)
    
    1. 大对象优化成memset
      1
      2
      
      # 跳转memset代码执行
      bl memset
      
    2. 对212字节以内小对象优化成每次64位写,对内存分块写入
      1
      2
      3
      4
      5
      
      # stp是ARM架构下的存储指令,表示“存储配对”,将两个8字节的值存储到内存
      # xzr是ARM中的零寄存器,表示常量值0
      stp xzr, xzr, [x1]
      stp xzr, xzr, [x1, #16]
      ...
      
  4. 对于大结构体,可采用**数据标志位****的方式或字符串结束符标识方法:

    1
    2
    3
    4
    
    struct Msg{
        ...
        bool msgIsValid;
    };
    

#消除重复计算

使用局部变量存储表达式或者函数结果,特别是循环的条件

1
2
3
4
5
6
7
8
9
for (int i = 0; i < x * x + y; ++i) {
    //Do something
}

// Modify
int boundary = x * x + y;
for (int i = 0; i < boundary; ++i) {
    //Do something
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// 常见的遍历数组的循环,每次都计算数组长度
for (i = 0; i < sizeof(arr) / sizeof(arr[0]); ++i) {
    arr[i] = i;
}

// Modify
int arrLength = sizeof(arr) / sizeof(arr[0]);
for (i = 0; i < arrLength; ++i) {
    arr[i] = i;
}
1
2
3
4
5
6
7
8
double result1 = pow(x, 2) + pow(y, 2);
double result2 = 2 * pow(x, 2) - 3 * pow(y, 2);

// Modify
double xSquared = pow(x, 2);
double ySquared = pow(y, 2);
double result1 = xSquared + ySquared;
double result2 = 2 * xSquared - 3 * ySquared;

#满足精度条件下,整数运算代替浮点运算

1
2
for (int i = 0; i < 3; ++i) { ret += (n * 0.8); }
for (int i = 0; i < 3; ++i) { ret += (n * 4 / 5); } // Modify

#表达式

#利用短路,简单判断放前面

&&的短路:

1
2
3
4
5
6
7
8
9
// 假设要判断一个数在某个范围内且是偶数
// 合理利用短路的写法
if (num >= 0 && num <= 10 && num % 2 == 0) {
    printf("满足条件,在范围内且是偶数\n");
}
// 交换顺序后的错误写法
if (num % 2 == 0 && num >= 0 && num <= 10) {
    printf("满足条件,在范围内且是偶数\n");
}

||的短路:

1
2
3
4
5
6
7
8
// 合理利用短路特性的写法
if (value == 0 || Func(value) > 100) {
    ;
}
// 交换顺序后的写法(虽然逻辑上可能等价,但没高效利用短路)
if (Func(value) > 100 || value == 0) {
    ;
}

#控制语句

#优先高命中分支

编译器对高概率指令进行联排,有利于指令cache的命中率,对cache友好。

1
2
3
4
5
if (tmp == 0) {
    // Do something
} else {
    // Do something
}

编译器认为==的概率低,所以

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
.main:
  ...
  ...
  beq L1    # if的语句被抽出
  else      # else的代码块放正常流程
  ...

.L1:
  # ==的代码块抽出,beq才会跳到这里执行,cache不友好
  ...
  ...

#合理运用分支预测

1
2
3
4
5
6
7
8
#define LIKELY(x) __builtin_expect(!!(x), 1)
#define UNLIKELY(x) __builtin_expect(!!(x), 0)

if (LIKELY(tmp == 0)) {
    // Do something
} else {
    // Do something
}

这样写后==的放在正常指令后,人为告诉编译器这是高概率分支,CPU提前装载高概率的指令。

#switch替代if...else...

在分支少的时候,他们的效率相同。在分支多的时候(gcc规定为18个分支)switch会被优化成跳转表,前提是switch的case是连续的

  • 1,2,3 √
  • 2,5,7 ×

建议if...else...超过3个分支使用switch替代,代码更加clean.

#合理的循环体变量

使用unsigned时要注意是否会因为溢出造成死循环。

1
2
3
4
for (unsigned char i = 0; i < 256; i++) {
    ;
    // i的范围是0~255
}

unsigned charunsigned short会多一个取低位和前置归零操作:

1
for(unsigned char i = 0; ...)

其汇编指令可能为:

1
2
3
test    i, i            # 测试 i 是否为零
and     i, 255          # 限制 i 的值在 0 到 255 之间
add     i, 1            # 执行递增操作

因为U8U16会被扩展成U32,这样编译出来的指令会多出and指令,建议改为:

1
for(unsigned int i = 0; ...)

#多层循环应外小内大,行优先

只有内外次数差很多的时候才考虑这个优化

一个循环至少包括三条指令

  1. 自增:递增循环变量。
    1
    2
    
    # i++
    add w19, w19, 1
    
  2. 比较:检查循环变量是否满足退出条件。
    1
    2
    
    # i < imm
    w19, imm
    
  3. 跳转:如果未满足条件,跳转到循环开始的地方继续执行。
    1
    
    bne .L2
    

外层循环1000次,内层循环10次,总指令数:

  • 外层循环的指令数:1000 * 3 = 3000
  • 内层循环的指令数:10 * 3 * 1000 = 30000

外层循环10次,内层循环1000次,总指令数:

  • 外层循环的指令数:10 * 3 = 30
  • 内层循环的指令数:1000 * 3 * 10 = 30000

两种循环方式差了至少2970条指令

  • 当内外循环次数差距大的时候,外层放小循环,内层放大循环
  • 确保内层循环遍历的是内存中连续存放的数据,cache友好

#循环合并

1
2
3
4
5
for(...) { A; }
for(...) { B; }

// Modify
for(...) { A;B; }

一个循环至少3条指令,那么第一种情况:

  • 一个循环体:3 * n
  • 一共两个循环体:2 * 3 * n

第二种改进方法:

  • 3 * n

这个优化需权衡:

  • 如果 AB 都是很简单的操作,小指令,循环合并会带来更大的性能提升。
  • 操作独立且可并行的情况下:AB 之间没有依赖关系,能够同时执行。
  • 同时可能导致循环体超大,降低cache命中率等

#循环展开

通过减少循环控制开销(如比较和跳转),它通常在编译器无法自动进行优化时,手动展开循环能够显著提升性能。

1
2
3
for (int i = 0; i < n; i++) {
    arr[i] = arr[i] * 2;
}

展开后:

1
2
3
4
5
6
for (int i = 0; i < n; i += 4) {
    arr[i] = arr[i] * 2;
    arr[i + 1] = arr[i + 1] * 2;
    arr[i + 2] = arr[i + 2] * 2;
    arr[i + 3] = arr[i + 3] * 2;
}
  1. 减少控制指令:减少每次迭代中的比较和跳转开销,从而提升性能。
  2. 提高流水线效率:当多次操作并行执行时,CPU 更容易在流水线中安排指令,从而提高吞吐量。
  3. 更好的缓存局部性:当每次迭代处理更多数据时,CPU 更容易利用数据缓存来减少内存访问延迟。

展开因子是指每次迭代中展开的次数,通常选择为 2、4、8 等。

  • 因子过小优化效果可能不明显,没有显著减少循环控制指令
  • 过大可能导致循环体太大,降低指令cache命中

#数据结构

#数据对齐自然边界

若为跨CPU/模块的消息数据结构定义,需采用紧凑排列,同时尽量让被访问数据对齐到自然边界。

1
2
3
4
5
6
#pragma pack(1)      // 强制1字节对齐,紧凑排列
struct Demo{
    short num;       // 2字节 @偏移0
    int arr[20];     // 80字节 @偏移2(未4字节对齐!)
};                   // 总大小82字节
#pragma pack()
  • arr数组从偏移2开始,每个int元素都未4字节对齐
  • 在x86架构会有性能惩罚,在RISC架构可能直接崩溃

优化为:

1
2
3
4
5
struct Demo{
    short num;
    unsigned char rsvd[2];
    int arr[20];
};

#临近定义同时访问的数据结构

1
2
3
4
struct KVLists{
    int key[0x90];
    int value[0x90];
};

这样每次访问keyvalue时,间隙了0x90的长度,都会导致D-Cache缺失,需要从内存中重新装载,效率低下。

1
2
3
4
5
6
7
8
typedef struct KVPair{
    int key;
    int value;
}KVPair;

typedef struct KVList{
    KVPair pairs[0x90];
}KVList;

使得每次访问的K-V都是连续存放的,提高了cache命中率。

#避免对整个字符串清零(同为减少memset调用)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
struct Demo{
    char s1[0x90];
    char s2[0x90];
    int len;
};

memset(p_Demo, 0, sizeof(Demo));
// 改进为
p_Demo->s1[0] = '\0';
p_Demo->s2[0] = '\0';
p_Demo->len = 0;

#函数

#避免过多参数

不同的CPU传参方式有差异,在ARM64架构中,参数通过寄存器X0-X7传递,超过8个参数则会将超过的参数入栈

1
2
mov w0, 9
str w0, [sp]

在调用到参数的时候还需要从栈中取出:

1
ldr w0, [sp, 32]

#复杂类型避免值传递

值传递会有调用memset进行拷贝的指令:

1
bl memset

#合理使用inline或宏替代短函数

内联和宏都cache友好,函数则需要bl到另一个指令块

热点函数使用inline,兼具可调试,类型检测,安全性更高

#避免冗余合法性检查

信任边界定义:

  1. 进程与进程之间的消息通信
  2. 跨so的API接口
  3. 应用程序与OS内核

#内存

#CPU一致性与伪共享

多个CPU对某块内存同时读写,一个CPU写了Cache,则与内存中的数据不一致,需更新:

  1. 直写模式 Write through 写Cache时同时写内存
  2. 写回模式 Write back 写Cache时标记dirty,只有Cache需刷新了才写回内存

L3缓存和内存是多处理器共享的,多核多处理器的一致性方法:总线监听

写Cache时广播到总线,其他CPU嗅探到总线后,把自己的Cache副本标记为无效。

  • 由于这个机制,多个线程就无法读写相同的Cache line,从而导致访存效率降低,称为伪共享
  • 两个核操作两个不同的data,但在同一个Cache line中,即使data不同,但会导致另一个核的Cache失效

#位操作

位操作具有天然的并行性。CPU寄存器可以看作是32bits或64bits的集合,每次计算都可以看作是同时操作了这么多的位。

使用bitops.h库,会比自己写位操作好

#C++

#引用传参

不需要进行拷贝操作

#避免临时对象

构造和析构函数调用有开销

#unique_ptr替代shared_ptr

前者内存使用无任何开销,性能也与裸指针非常接近 后者

  1. 引用计数,控制块需要额外内存
  2. 控制块计数器为原子变量,增减比普通变量慢

#使用emplace函数

#容器遍历采用前置++--

普通循环变量前置后置加减没有区别

但迭代器有区别

1
2
Demo& operator++();     // 前置++,返回引用
Demo operator++(int);   // 后置++,返回临时对象,需拷贝

#STL容器需做预估与预留,避免自动扩张

#尽量用移动替代拷贝

#constexpr编译期计算

updatedupdated2025-07-212025-07-21