持续更新中。
Memory Access
Address Alignment
在内存中存取一个变量最高效的方式是将其放在一个可以被它的长度整除的地址上。
(void *)&variable % sizeof(variable) == 0
所谓的按某个长度对齐就是这个意思。GCC编译器会自动帮我们处理这些事情。比较特殊的方式是将一个大型的结构体,或者静态数组按64byte的方式对齐:
int BigArray[1024] __attribute__((aligned(64)));
这主要是考虑到CPU的Cache Line长度多为64byte,变量按64对齐可以使其正好开始于一个Cache Line,减少Cache Miss/False Sharing以及利用CPU的高级指令集并行计算。
Note: _ attribute _((aligned(x)))有时只对全局变量有效,而对局部变量无效。
Huge Page
大页技术是当前流行的一种性能优化技术。在Linux系统中有一套复杂的进程虚拟地址和内存物理地址的转换机制,复杂的细节我们不去关心,只需要知道Linux是通过页(Page)这一机制(Look-up table)来确立两者的对应关系的。简单的类比就是在一本2000页的书中找到某一个章节,远比在一本2页的书中复杂。考虑到传统页面4KB的大小和大页2GB的小大之差,这个类比还不是那么恰当。
在CPU中,需要以缓存的形式存储一些转换关系,这种缓存成为TLB Cache。使用大页可以减少TLB Cache Miss。
Virtual addr maps to physical addr
Note: Huge Page可以在绝大部分情况之下提高性能,但并不是所有情况下都可以起到提升性能的效果。对于内存,需要综合考虑各种因素,提高性能的基本策略还是以空间换时间。详细的分析文章请Click
NUMA
严格来说NUMA并不是一种性能优化技术,而是一种内存架构。
NUMA Architecture
每一个CPU Core都与它本地连接的内存直接相连,独享总线,具有最快的读写速度。如果去远程(remote)内存去读写的话,则需要跨CPU Core执行。在DPDK中,有一整套精巧且高效的内存分配和管理机制,结合大页和NUMA等机制,基本原则是,将一个CPU Core需要处理的数据都放在离它最近的内存上。
相关的实现可以参考DPDK中memseg
memzone
等内存机制相关代码的实现,这里可以有专门文章介绍。
Polling Mode Drive(PMD)
是DPDK实现的优化Linux网络接收发送性能的模块,官方有详细的介绍资料。Click
Memory Pool
对于需要频繁填充释放的内存空间可以采用内存池的方式预先动态分配一整块内存区域,然后统一进行管理,从而省去频繁的动态分配和释放过程,既提高了性能,同时也减少了内存碎片的产生。
内存池多以队列的形式组织空闲或占用内存。在DPDK中,还考虑了地址对齐,以及CPU core local cache等因素,以提升性能。
这里提到的内存对齐不同于前面仅仅将变量放在一个合适的地址“数目”上,而是综合考虑了内存通道(channel)和rank,将变量(比如一个三层网络的Pkt),平均分布于不同的channel之上(多依靠padding),可以减少channel拥塞,显著提升性能。如图:
Two Channels and Quad-ranked DIMM Example
对于供多个线程同时使用的内存池,为了减少对内存池的读写冲突,可以考虑Local Cache的机制。即内存池为每一个线程/CPU Core维护一个Local Cache,本地的CPU Core对其操作是没有竞争的。每个CPU Core都是以bulk的形式从内存池中请求数据写入Local Cache或者将Local Cache的数据写入内存池。这样便大幅减少了读写冲突。
Local cache per core
Linker Considerations
可以理解的一个简单事实是,如果经常使用的函数被储存在指令内存的同一区域,甚至存储顺序和调用顺序一致,那么程序整体执行的效率将会有所提升。
一个简单的方法就是尽量使用static
函数:
static void f(void)
将同一模块中的函数在链接阶段放在一起。但很多时候,处于程序模块化编程考虑,模块之间互相调用的函数和方法并没有被显式地置于同一处指令内存,此时可以在关键函数集中采用:
_attaribute_(section(X))
将函数显式地置于Read only(program memory)Section X,方便一起调用。同时也可以map文件的形式安排指定。
Note: 同样的原则也适用于变量。
CPU
Advanced Instruction Set
使用先进的CPU指令集,带来的主要好处是可以并行完成向量化的操作,也就是所谓的SIMD(Single-Instruction-Multiple-Data)操作。
当需要对大型数据集执行相同的操作的时候,向量操作可以带来明显的性能提升。例如图像处理、大型矩阵计算、网络数据包还有内存复制操作等。
Note: 对于数据间有互相依赖和操作上有继承的运算,比如排序,并不适合向量操作。
先进的指令集一般包括SSE
SSE2
AVX
AVX512
YMM
ZMM
。这些指令集对数据储存的地址都有比较严格的要求,比如256bit-YMM
要求数据按32对齐,512-bit ZMM
要求数据按64对齐。
对于向量操作,一般希望符合如下条件:
- 小型的数据类型:
char
short
int
float
- 对大型数据集执行类似的操作
- 数据对齐
- 数据集长度可以被向量长度整除
Note: 可以将关键函数写为针对不同数据集的不同版本,视运行环境编译运行。
Compiler
Branch Predication
CPU是以流水线的方式执行程序指令。所谓流水线,可以简单理解为在执行一个指令的同时,读取下一条指令。对于程序中大量出现的
if else
while
for
? :
等含有条件判断的情景,CPU需要能够正确提取下一条指令以便流水线可以流畅执行下去。一旦提取的是错误分支的指令,虽然不影响程序运行的结果,但整条流水线都会被清空,再重新读入正确分支的指令,对程序运行效率影响颇大。
CPU一般都有硬件分支预测器,但我们也可以用
likely()/unlikely()
等方式显示指定,另外在设计程序的时候也以使分支判断具有一定的规律性为好,比如一组经过排序的输入数据。
Branchless Code
为了最大限度减小Branch mispredication对性能带来的影响,可以将一些常见的分支判断转换为Branchless的形式。比如返回两个数中较大的值,一般可以写做:
int max = (x > y) ? x : y;
这里其实隐含了一个条件判断。如果用branchless的形式,同样的功能可以写做为:
int max = x ^ ((x ^ y) & -(x < y));
当有大量调用,同时输入无甚规律性的时候可以考虑采用Branchless code。一个比较全面的技巧合计在:Click。
loop-unrolling
Loop-unrolling的一大好处就是可以减少循环分支预测的次数。对于简单的循环,CPU其实可以很好的完成分支预测的工作,但对于嵌套的循环,或者循环内部会改变循环次数的循环,分支预测就变得困难。loop-unrolling的特点可以用如下的例子说明:
int i;
for (i = 0; i < 20; i++) {
if (i % 2 == 0) {
FuncA(i);
} else {
FuncB(i);
}
FuncC(i);
}
上面这个执行了20次的循环可以用loop-unrolling展开:
int i;
for (i = 0; i < 20; i += 2) {
FuncA(i);
FuncC(i);
FuncB(i + 1);
FuncC(i + 1);
}
Cons:
循环只执行10次,减少了一半
可以更准确得被CPU的branch predictor预测
没有了循环体内的
if
分支Pros
如果循环计数器是奇数,则需要特别的处理。
Note: Loop-unrolling也需要考虑适用场合。主要适用于循环体的分支是主要的性能热点的时候。
Anti-aliasing
当有多个指针指向同一处物理内存(变量)的时候,称为pointer aliasing。作为编译器,并不能确认两个相同类型的指针是否指向同一处地址,即对其他指针的操作,是否会影响另外的指针所指向的内存。这就要求每次碰到这两个指针其中的任何一个的时候,都需要重新从内存中读取当前值。示例如下:
void Func1 (int a[], int *p) {
int i;
for (i = 0; i < 100; i++) {
a[i] = *p + 2;
}
}
void Func2() {
int list[100];
Func1(list, &list[8]);
}
在Func1
中,有必要每次都重新读入*p
,并且重新计算*p + 2
,因为在Func2
的调用中,与list[8]
发生aliasing。对编译器来讲,它需要考虑这种“理论上的可能”,从而付出大量的重复劳动。
当程序可以确认两个指针不会发生aliasing的时候,可以用关键字__restrict__
给编译器以明确的指示。
Prefetch
使用prefetch指令可以帮助我们提前预存一个将要使用的变量至CPU缓存:
_mm_prefetch
但在实际使用过程中要特别小心,现代CPU都有自己的硬件prefetch机制,如果不是经过测试,确认性能有所提高,尽量不要轻易使用该指令。这里有一篇资料对此有详细解释:Click
Note:需要确认CPU支持SSE指令集
Multi-threads
Lock-less
一般将GCC提供的一些原子操作视为“Lock-less code”。这些操作包括一些原子自增,CAS等操作。
__sync_fetch_and_add(type* ptr, type value)
__sync_compare_and_swap(type *ptr, type oldval, type newval)
etc...
这些操作虽然表面上没有了锁的痕迹,但实际上其汇编指令还是存在一个#lock锁总线的操作。所以也不必对其性能抱太高期望。对于所有关于锁的操作,需要强调的是,锁本身并不影响性能,只有对锁的争抢才影响性能。
Local Cache
如同之前介绍的那样,还有一种策略是将任务尽量划分为不相互依赖的各部分,分别交给不同的CPU Core去处理,仅仅在结果汇总的时候有少量的锁操作。在DPDK中大量应用了这种思想。
Core Affinity
将一个任务指定交给某个CPU Core处理,可以减少上下文切换和context switch的次数,以及提高缓存命中率。在Linux程序中可以通过
int sched_setaffinity(pid_t pid, size_t cpusetsize, const cpu_set_t *mask);
来设定线程的CPU亲和性。
False Sharing
False Sharing也是在多线程操作中需要避免的缓存失效的问题。如果两个变量分别被两个线程操作,但它们出现在同一条Cache Line中,则两个线程之间还是会互相影响。任何一个线程对该Cache Line的写操作,都会失整条Cache Line在另外一个线程处失效。如下图:
对此最简单的办法,是可以添加Cache Padding将两个变量分隔在不同的Cache Line之中,或者以Cache Line Size对齐的方式分配内存。