编译器优化之内存对齐
前言
在工作中,做性能优化,无意间看到反汇编中有nop指令,大致能猜测是内存对齐相关优化,但不清楚相关优化选项,编来了兴趣,对编译器的内存对齐优化进行一次系统的学习和总结
由于我编写代码的运行在ARMV8指令集架构服务器上,编译器是gcc,因此后续的总结主要基于ARM指令集。
这次总结相关实例代码主要通过Compiler Explorer网站提供的在线编译进行编译对比
自从有了AI后,学习总结变得容易了很多,这次学习和总结也主要基于百度文心一言的对话进行理解
为什么需要内存对齐?
我们从CPU体系结构中知道,数据/指令加载都依赖缓存,而缓存是按照CacheLine粒度进行数据保存的,我所运行的环境CacheLine大小为64字节(byte);而CPU流水线的fetch阶段也是有带宽的,我的环境是fetch带宽为8条指令32字节(Arm架构指令固定占4字节);这两个都对我们代码的运行效率至关重要;
想想如果我们有一次循环刚好处理64字节数据,而我们数据的起始地址刚好在64字节开头,我们就只需要使用一条cacheline,数据加载效率就跟高,运行效率当然就更好;
同样,如果我们代码中函数按照64字节对齐,保障每次函数跳转时,都能将接下来的16条指令同时加载到ICache的一条CacheLine中,那么接下来函数的执行在指令测就不会有太多阻塞。
Gcc相关内存对齐优化:
这里直接引用文心一言,我觉得回答的十分全面:
- 属性设置方式:
__attribute__ ((aligned(n))):这个属性用于指定结构体、类、联合或者一个类型的变量(对象)在分配地址空间时的对齐方式。它确保所作用的对象在编译过程中按n字节对齐。这里的n必须是2的幂次方。如果类型中的成员的自然边界对齐值大于n,则按照机器字长(32位或者64位)对齐。
__attribute__((packed)):这个属性用于取消结构在编译过程中的优化对齐,使得结构按照最小的对齐方式(通常是1字节)进行排列。这有助于减少结构体的总大小,但可能会降低访问速度,因为CPU可能需要进行额外的内存访问来获取对齐的数据。
- 修改对齐系数:
使用pragma宏指令(如#pragma pack(n))可以在编译时修改对齐系数。这会影响后续的结构体或联合体的对齐方式,直到遇到另一个#pragma pack()指令或文件结束。与__attribute__((packed))类似,降低对齐系数可以减少结构体的大小,但可能会降低访问速度。
- 结构体成员排序:
将结构体中占用空间较大的成员放在前面,有助于提高内存访问的局部性。这有助于减少CPU缓存未命中的次数,从而提高程序的性能。
将访问频率较高的成员放在一起,也可以提高内存访问的局部性。
- 使用静态断言:
在C++中,可以使用static_assert来确保结构体的对齐满足特定要求。这有助于在编译时捕获潜在的对齐问题。
- 编译器选项:
GCC编译器提供了一些选项来影响内存对齐的行为。例如,-falign-functions选项可以用于指定函数的对齐方式;-falign-loops选项可以用于指定循环的对齐方式;-falign-jumps选项可以用于指定跳转指令的对齐方式。这些选项可以根据需要进行调整以优化程序的性能。
- 平台特定的优化:
不同的CPU架构和操作系统可能对内存对齐有不同的要求。GCC编译器支持针对特定平台进行优化。例如,使用-mtune=cpu_type选项可以为特定的CPU类型进行优化;使用-march=architecture选项可以指定目标架构的指令集。这些选项有助于确保程序在目标平台上的最佳性能。
我这里主要对编译器选项、属性设置方式、修改对齐系数三个编译器相关优化进行详细说明
-falign-functions选项
-falign-functions用来指定函数起始地址对齐格式,如-falign-functions=64,指函数地址以64字节对齐,这样能确保每次函数跳转时,cache和fetch都能够用到最大带宽,举例:
#include <stdio.h>
void my_function1() {
printf("Hello, function1!\n");
}
void my_function2() {
printf("Hello, function2!\n");
}
汇编对比:
在汇编代码中,用nop占位进行地址对齐
-falign-jumps选项
-falign-jumps可以用于指定跳转指令的对齐方式;这样方便在指令跳转时,能保证fetch和cache到的指令能连续运行,提升运行效率;举例:
int square(int num) {
if(num < 10){
return num * 10;
} else if (num < 100){
return num * 100;
}
return num * 200;
}
-falign-loops选项
-falign-loops用于控制循环代码的对齐方式。目的是通过提高循环代码在内存中的对齐度来优化性能,特别是在处理缓存(cache)时。
当处理器执行循环代码时,它通常会从内存中读取循环体中的指令到缓存中。如果循环的起始地址没有与缓存行的起始地址对齐,那么处理器可能需要读取多个缓存行来获取完整的循环体,这会导致额外的内存访问和可能的缓存未命中(cache miss)。缓存未命中会显著增加处理器的等待时间,因为从主内存中读取数据比从缓存中读取要慢得多。举例:
#include <stdint.h>
int test_loop(uint8_t n, int a)
{
for(uint8_t i = 0; i < n; ++i) {
a = a + i;
}
return a;
}
__attribute__ ((aligned(n)))设置
__attribute__ ((aligned(n))) 是 GCC 编译器提供的一个特性,用于指定变量或结构体在内存中的对齐方式。这个属性可以确保变量或结构体按照指定的字节数 n 对齐。__attribute__ ((aligned(n))) 可以用于变量或结构体声明;
举例1:变量设置地址对齐
运行结果如下:添加__attribute__ ((aligned(64)))的输出的地址按照64字节对齐,而没有设置对齐属性的,输出地址未对齐
E:\zjtfile\C++\TestDemo\TestDemo>gcc -o2 main.cpp -o main_64
E:\zjtfile\C++\TestDemo\TestDemo>main_64
Address of array: 000000000061FE00
E:\zjtfile\C++\TestDemo\TestDemo>gcc -o2 main.cpp -o main
E:\zjtfile\C++\TestDemo\TestDemo>main
Address of array: 000000000061FE14
举例2:结构体实例对齐
运行结果如下:
E:\zjtfile\C++\TestDemo\TestDemo>gcc -o2 main.cpp -o main_64
E:\zjtfile\C++\TestDemo\TestDemo>main_64
Address of s: 000000000061FE00
E:\zjtfile\C++\TestDemo\TestDemo>gcc -o2 main.cpp -o main
E:\zjtfile\C++\TestDemo\TestDemo>main
Address of s: 000000000061FE10
__attribute__((packed)) 的用法
__attribute__((packed))
用于指定结构体或联合体(union)的成员在内存中以最小的对齐方式进行布局,即紧凑排列,中间没有填充(padding)字节;__attribute__((packed))
只能用于结构体或联合体的定义;举例:
#include <stdio.h>
// 默认情况下,编译器可能会在 char 和 int 之间插入填充字节
struct DefaultStruct {
char c;
int i;
};
// 使用 __attribute__((packed)) 后,char 和 int 之间不会有填充字节
struct PackedStruct {
char c;
int i;
} __attribute__((packed));
int main()
{
printf("Size of DefaultStruct: %zu\n", sizeof(struct DefaultStruct));
// 在 32 位系统上,输出可能是 8(char 1 字节 + 3 字节填充 + int 4 字节)
// 在 64 位系统上,输出可能是 12(char 1 字节 + 7 字节填充 + int 4 字节)
printf("Size of PackedStruct: %zu\n", sizeof(struct PackedStruct));
// 输出始终是 5(char 1 字节 + int 4 字节,没有填充)
return 0;
}
运行结果如下:
E:\zjtfile\C++\TestDemo\TestDemo>gcc -o2 main.cpp -o main
E:\zjtfile\C++\TestDemo\TestDemo>main
Size of DefaultStruct: 8
Size of PackedStruct: 5
#pragma pack(n)属性
#pragma pack(n) 是一个预处理指令,用于控制结构体(struct)或联合体(union)中成员的对齐方式。作用是指定数据成员在结构体中的对齐方式,其中 n 是对齐的字节数。当 n 等于 1 时,数据成员会按照紧凑的方式进行排列,不会插入填充字节。当 n 大于 1 时,编译器会尝试将数据成员对齐到 n 字节的边界。
#include <stdio.h>
#pragma pack(push, 1) // 设置对齐为 1 字节,并保存当前对齐设置
struct PackedStruct {
char c; // 1 字节
int i; // 4 字节(在 32 位系统上)
};
#pragma pack(pop) // 恢复之前的对齐设置
struct DefaultStruct {
char c; // 1 字节
int i; // 4 字节(在 32 位系统上),但可能前面有填充字节
};
int main() {
printf("Size of PackedStruct: %zu\n", sizeof(struct PackedStruct));
// 输出:5(char 1 字节 + int 4 字节,没有填充)
printf("Size of DefaultStruct: %zu\n", sizeof(struct DefaultStruct));
// 输出可能是 8(char 1 字节 + 3 字节填充 + int 4 字节),具体取决于编译器和平台
return 0;
}
运行结果如下:
E:\zjtfile\C++\TestDemo\TestDemo>main
Size of PackedStruct: 5
Size of DefaultStruct: 8