优化汇编例程(11)

11. 优化内存访问

从1级缓存读大约要3个时钟周期。从2级缓存读需要数十个时钟周期。从主存读需要数百时钟周期。如果跨了DRAM页边界,访问时间甚至更长,如果内存区已经被交换到硬盘,时间极长。这里,我不能给出确切的访问时间,因为它依赖硬件配置,且由于技术的快速发展,数值持续地变化。

不过,从这三个值,显而易见,代码与数据的缓存对性能极端重要。如果代码有许多缓存不命中,每个缓存不命中的代价超过一百个时钟周期,那么这会是性能一个非常严重的瓶颈。

关于如何组织数据,最优使用缓存的更多建议,在手册1《优化C++软件》中给出。处理器特定的细节在手册3《Intel,AMD与VIA CPU微架构》以及Intel与AMD的软件优化手册里给出。

11.1. 缓存如何工作

缓存是比主存更靠近微处理器的一种临时存储。经常使用或者预期很快会使用的数据与代码被保存在缓存里,使得对它的访问更快。不同的微处理器有1、2或3级缓存。1级缓存更靠近微处理器,在几个时钟周期就可访问。更大的2级缓存在相同的芯片上或至少在相同的封装里。

例如,在P4处理器上的1级数据缓存可以包含8 kb数据。它被组织位128行,每行64字节。缓存是4路组相联的。这意味着来自一个特定内存地址的数据不能任意分配到一个缓存行,而只能是4个可能行中的一个。在这个例子里,行长度是26 = 64。因此,每行必须对齐到可被64整除的地址。内存地址的最低6位,即比特0 ~ 5,用于在缓存行的64个字节内定位一个字节。因为每组包含4行,有128 / 4 = 32 = 25不同组。因此,内存地址接下来的5个比特,即比特6 ~ 10,将在这32个组中选择。剩下的比特可以是任意值。这个数学练习的结论是,如果两个内存地址的比特6 ~ 10相等,那么它们将被缓存到同一组的缓存行中。竞争同一组缓存行的64字节内存块隔开211 = 2048字节。同一时间,可以缓存不超过4个这样的地址。

让我通过下面的代码片段展示之,其中EDI保存一个可被64整除的地址:

; Example 11.1. Level-1 cache contention

again: mov eax, [edi]

mov ebx, [edi + 0804h]

mov ecx, [edi + 1000h]

mov edx, [edi + 5008h]

mov esi, [edi + 583ch]

sub ebp, 1

jnz again

这里使用的5个地址都有相同的组值,因为地址间的差值,在截去低6位后,都是2048 = 800H的倍数。这个循环性能很差,因为在读ESI时,没有空闲的缓存行具有恰当的组值,因此处理器采用4个可能缓存行中最近最少使用——即用于EAX的那个——填入来自[EDI+5800H]到[EDI+583FH]的数据,读ESI。接着,在读EAX时,发现保存EAX值的缓存行已经被丢弃,因此处理器采用最近最少使用、保存了EBX值的行,以此类推。除了缓存不命中,我们一无所获,但如果第5行改为MOV ESI, [EDI+5840H],那么我们跨过一个64字节边界,因此没有与前4行相同的组值,对这5个地址每个分配一缓存行,将没有问题。

不同微处理器上的缓存大小、缓存行大小及组相联,在手册3《Intel,AMD与VIA CPU微架构》中描述。1级缓存行竞争的性能损失,在较旧的微处理器上相当可观,但在较新的处理器上,如P4,仅损失几个时钟周期,因为数据很可能从2级缓存预取,通过一条全速的256位数据总线,2级缓存的访问相当快。在P4中,2级缓存效率的提高,补偿了较小的1级数据缓存。

缓存行总是对齐到被缓存行大小整除的物理地址(上例中是64)。在读64整除地址处一个字节时,后63字节也被缓存,可以几乎没有额外代价地读写。通过把使用地方接近的数据项安排到对齐的64字节内存中,可以利用之。

1级代码缓存的工作方式与数据缓存相同,除了在具有追踪缓存的处理器上(参见下面)。2级缓存通常在代码与数据间共享。

11.2. ​​​​​​​追踪缓存

Intel P4与P4E处理器具有追踪缓存而不是代码缓存。追踪缓存保存被翻译位微操作(μop)的代码,而普通的代码缓存保存没有翻译的原始代码。追踪缓存消除了指令解码的瓶颈,并尝试以代码执行的次序,而不是出现在内存中的次序,保存代码。追踪缓存的主要缺点是,在追踪缓存中代码占据的空间要多于代码缓存。较新的Intel处理器没有追踪缓存。

11.3. ​​​​​​​μop缓存

Intel Sandy Bridge与更新的处理器在解码器前面有传统的代码缓存,在解码器后有一个μop缓存。μop缓存是一个大优势,因为在Intel处理器上,指令解码通常是一个瓶颈。μop缓存的容量远小于1级代码缓存的容量。μop缓存是一个如此关键的资源,程序员应该节省地使用它,确保代码的关键部分能放入μop缓存。节省μop缓存(原文为追踪缓存)使用的一个方式是避免循环展开。

大多数AMD处理器在代码缓存中标记了指令边界。这缓解了指令长度解码的关键瓶颈,因此可视为μop缓存的一个替代。我不知道为什么这个技术没有被Intel处理器使用。

11.4. ​​​​​​​数据对齐

根据这个方案,RAM中所有数据应该对齐到2指数倍的地址:

操作数大小

对齐

1 (byte)

1

2 (word)

2

4 (dword)

4

6 (fword)

8

8 (qword)

8

10 (tbyte)

16

16 (oword, xmmword)

16

32 (ymmword)

32

64 (zmmword)

64

表11.1. 首选的数据对齐

下面的例子展示了静态数据的对齐。

; Example 11.2, alignment of static data

.data

A DQ ?, ?                                                  ; A is aligned by 16

B DB 32 DUP (?)

C DD ?

D DW ?

ALIGN 16                                                  ; E must be aligned by 16

E DQ ?, ?

.code

movdqa xmm0, [A]

movdqa [E], xmm0

在上面的例子中,A,B与C都在16整除地址开始。D在4整除地址开始,这足够了,因为它仅需要对齐2。在E前面必须插入一个对齐指示,因为D后面的地址不是MOVDQA指令要求的16整除。另外,E可以放在A或B后面来对齐。

在访问跨缓存行边界的非对齐数据时,大多数微处理器会有几个时钟周期的性能损失。在非对齐数据跨8字节边界时,AMD K8与更早的处理器也有性能损失,而某些早期Intel处理器(P1,PMMX)对跨4字节边界的非对齐数据也有性能损失。在写入一个非对齐操作数后,立即读这个操作数,大多数处理器有性能损失。

大多数读写16字节内存操作数XMM指令,要求该操作数对齐到16。在较旧的处理器上,接受非对齐16字节操作数的指令会相当低效。不过,这个限制被AVX指令大大缓解。AVX指令不要求内存操作数对齐,除了明确要求对齐的指令。支持AVX指令集的处理器通常能非常高效地处理非对齐内存操作数。

 对齐保存在栈上的数据,可以通过向下取整栈指针值来完成。当然,在返回前要恢复栈指针的旧值。带有局部对齐数据的函数看起来像这样:

; Example 11.3a, Explicit alignment of stack (32-bit Windows)

_FuncWithAlign PROC NEAR

       push ebp                                                  ; Prolog code

       mov ebp, esp                                           ; Save value of stack pointer

       sub esp, LocalSpace                                ; Allocate space for local data

       and esp, 0FFFFFFF0H                              ; (= -16) Align ESP by 16

       mov eax, [ebp+8]                                    ; Function parameter = array

       movdqu xmm0, [eax]                             ; Load from unaligned array

       movdqa [esp], xmm0                             ; Store in aligned space

       call SomeOtherFunction                        ; Call some other function

       ...

       mov esp, ebp                                            ; Epilog code. Restore esp

       pop ebp                                                     ; Restore ebp

       ret

_FuncWithAlign ENDP

这个函数使用EBP对函数参数取址,ESP对局部对齐数据取址。通过与-16的AND操作,ESP被取整到最接近的16倍数。通过将栈指针与负值的AND操作,可以将栈对齐到2的任意指数。

所有64位操作数,及某些32位操作系统(Mac OS,Linux中可选)在所有call指令处,保持栈对齐到16。这消除了AND指令与栈框指针的需要。通过在每个函数中恰当地对齐栈指针,将这个对齐从一条CALL指令传播到下一条,是必须的:

; Example 11.3b, Propagate stack alignment (32-bit Linux)

FuncWithAlign PROC NEAR

       sub esp, 28                                                          ; Allocate space for local data

       mov eax, [esp+32]                                              ; Function parameter = array

       movdqu xmm0, [eax]                                         ; Load from unaligned array

       movdqa [esp], xmm0                                         ; Store in aligned space

       call SomeOtherFunction                                    ; This call must be aligned

       ...

       ret

FuncWithAlign ENDP

在例子11.3b中,我们依赖在FuncWithAlign调用前,栈指针对齐到16的这个事实。指令CALL FuncWithAlign(这里没有显示)必须将返回地址压栈,因而从栈指针减去4。在它再次对齐到16前,必须从栈指针再减去12。对需要16字节的局部变量来说,12是不够的,因此必须减去28来保持栈指针对齐到16。用于返回地址的4 + 28 = 32,它是16的倍数。记住在计算中包括任何PUSH指令。例如,如果在函数prolog中有一条PUSH指令,那么我们将从ESP减去24来保持它对齐到16。例子11.3b出于两个原因,需要对齐栈。MOVDQA指令需要一个对齐的操作数,需要对齐CALL SomeOtherFunction,向SomeOtherFunction传播正确的栈对齐。

在64位模式中,原则是相同的:

; Example 11.3c, Propagate stack alignment (64-bit Linux)

FuncWithAlign PROC

       sub rsp, 24                                                  ; Allocate space for local data

       mov rax, rdi                                                ; Function parameter rdi = array

       movdqu xmm0, rax]                                 ; Load from unaligned array

       movdqa [rsp], mm0                                  ; Store in aligned space

       call SomeOtherFunction                          ; This call must be aligned

       ...

       ret

FuncWithAlign ENDP

这里,返回地址需要8字节,我们从RSP减去24,这样减去的总数是8 + 24 = 32,它是16的倍数。在64位模式中,每条PUSH指令从RSP减去8。

在混用C++与汇编语言时,对齐问题也是重要的。考虑这个C++结构体:

// Example 11.4a, C++ structure

struct abcd {

       unsigned char a;                                        // takes 1 byte storage

       int b;                                                            // 4 bytes storage

       short int c;                                                  // 2 bytes storage

       double d;                                                    // 8 bytes storage

} x;

大多数编译器(但不是所有)将在a与b之间插入3个空字节,在c与d之间插入6个空字节,以为每个成员提供自然对齐。可以将这个结构体定义改为:

// Example 11.4b, C++ structure

struct abcd {

       double d;                                                  // 8 bytes storage

       int b;                                                          // 4 bytes storage

       short int c;                                                // 2 bytes storage

       unsigned char a;                                      // 1 byte storage

       char unused[1];                                       // fill up to 16 bytes

} x;

这有几个好处:在有及没有自动对齐的编译器上,实现都是相同的,这个结构体很容易被翻译为汇编,所有成员都正确对齐,未使用字节更少。在末尾额外的未使用字节,确保在一个结构体数组中,所有元素都正确对齐。

如何高效地移动非对齐块,参考第165页。

11.5. ​​​​​​​代码对齐

大多数微处理器以对齐的16字节或32字节块获取代码。如果一个重要的子例程入口或跳转标签恰好在一个16字节块末尾附近,那么微处理器在获取该代码块时,将仅获得几个有用的字节。在可以解码标签后的第一条指令前,它可能还必须获取下16字节。这可以通过将重要子例程入口以及循环入口对齐到16来避免。对齐到8将确保至少8字节代码随着第一条指令的获取读入,如果指令都是小的,这可能足够了。如果子例程是一个关键热点的部分,且前面的代码不太可能在同一个上下文里执行,可以把子例程入口对齐到缓存行大小(通常64字节)。代码对齐的一个坏处是,浪费了对齐代码入口前面的空闲缓存空间。

在大多数情形里,代码对齐的效果是微不足道的。因此,我的建议是,仅在最关键的地方对齐代码,像关键的子例程以及关键的最内层循环里。

对齐一个子例程入口,只需在子例程入口前,放置所需数量的NOP,使得这个地址如期望那样被8、16、32或64整除。要这样,汇编器使用ALIGN指示。插入的NOP不会降低性能,因为它们不会被执行。

对齐一个循环入口问题更多,因为前面的代码也被执行。把一个循环入口对齐到16,可能需要最多15个NOP。这些NOP将在进入该循环前执行,将消耗处理器时间。使用更长、不做事情的指令比使用大量单字节NOP更高效。现在最好的汇编器,在一条ALIGN nn语句前,会使用像MOV EAX, EAX及LEA EBX, [EBX + 00000000H]这样的指令来填充空间。LEA指令特别灵活。通过各种添加SIB字节、段前缀以及1或4字节的零偏移,给出长度在2到8字节的指令是可能的,如LEA EBX, [EBX]。在32位模式中,不要使用2字节偏移,因为这会减慢解码。也不要使用多个前缀,因为在较旧的Intel处理器上,这会减慢解码。

使用伪NOP,如MOV RAX, RAX与LEA RBX, [RBX+0]作为填充,有对寄存器有假依赖,且使用执行资源的缺点。使用可以调节到期望长度的多字节NOP指令更好。多字节NOP指令在所有支持条件移动指令的处理器上都可用,即Intel PPro,P2,AMD Athlon,K7及更新的处理器。

对齐循环入口的另一个方式是,以比所需更长的方式编码前面的指令。在大多数情形里,这将不会增加执行时间,但可能增加指令获取时间。如何将指令编码为更长版本,参考第74页。

对齐最内层循环的最高效方式是移动前面的子例程入口。下面的例子展示了如何做到这:

; Example 11.5, Aligning loop entry

ALIGN 16

X1 = 9                                                                             ; Replace value with whatever X2 is.

DB (-X1 AND 0FH) DUP (90H)                                    ; Insert calculated number of NOP's.

INNERFUNCTION PROC NEAR                                   ; This address will be adjusted

       mov eax,[esp+4]

       mov ecx,10000

INNERLOOP:                                                                 ; Loop entry will be aligned by 16

X2 = INNERLOOP - INNERFUNCTION                        ; This value is needed above

.ERRNZ X1 NE X2                                                          ; Make error message if X1 != X2

       ; ...

       sub ecx, 1

       jnz INNERLOOP

       ret

INNERFUNCTION ENDP

这个代码看起来很笨拙,因为大多数汇编器不能解析这里对两个标签间差值的前向引用。X2是INNERFUNCTION到INNERLOOP的距离。通过查看汇编序列。X1必须被手动调整到与X2相同的值。如果X1与X2不同,.ERRNZ行将从汇编器产生一个错误消息。为了将INNERLOOP对齐到16,在INNERFUNCTION之前插入的字节数是((-X1) modulo16)。这里,通过与15的AND操作,计算modulo 16。DB行计算这个值,并插入操作码90H的合适数量的NOP。

这里,通过不对齐INNERFUNCTION来对齐INNERLOOP。不对齐INNERFUNCTION的代价,与对齐INNERLOOP的收益相比,微不足道,因为到后者要跳转多达10000次。

11.6. ​​​​​​​组织数据改善缓存

如果关键数据被包含在内存一小块连续区域时,数据缓存工作得最好。放置关键数据最好的地方是栈。由子例程分配的栈空间,在子例程返回时释放。相同的栈空间被下一个调用的子例程重用。重用相同的内存区域提供了最优的缓存。因此,在可能时,变量应该保存在栈上,而不是在数据段。

浮点常量通常保存在数据段。这是一个问题,因为将不同子例程使用的常量放在一起是困难的。另一个做法是将常量保存在代码中。在64位模式里,通过整数寄存器读入一个双精度常量,避免使用数据段是可能的。例子:

; Example 11.6a. Loading double constant from data segment

.data

C1 DQ SomeConstant

.code

movsd xmm0, C1

这可以改为:

; Example 11.6b. Loading double constant from register (64-bit mode)

.code

mov rax, SomeConstant

movq xmm0, rax                  ; Some assemblers use 'movd' for this instruction

无需从内存读入数据,产生常量的各种方法,参考第132页。如果预期数据缓存不命中,这是有利的,但如果数据缓存是高效的,就不是了。通常,常量表保存在数据段中。从数据段拷贝这样一个表到最内层循环外的栈,如果这可以改善循环内缓存,可能是有利的。

静态变量是从一次函数调用保留到下一次的变量。通常,这样的变量保存在数据段中。将函数与其数据封装在一个类中可能是一个更好的做法。这个类可以在代码的C++部分中声明,而成员函数以汇编编写。

对数据缓存而言太大的数据结构,为了优化预取与缓存,最好通过一个线性、前向的方式访问。如果步长是2的大指数,非连续的访问会导致缓存行竞争。手册1《优化C++软件》包含了如何避免步长是2大指数的访问的例子。

11.7. ​​​​​​​组织代码改善缓存

如果代码的关键部分包含在不超过代码缓存的连续内存区域内,代码缓存工作得最好。避免关键子例程随机散布。很少访问的代码,比如错误处理例程,应该与关键热点代码分开。

将代码段分为用于代码不同部分的不同段,可能有帮助。例如,可以制作一个热代码段用于最经常执行的代码,一个冷代码段用于非速度关键的代码。

另外,可以控制模块链接的次序,使得使用程序相同部分的模块链接到彼此接近的地址上。

函数库的动态链接(DLL或共享对象)使得代码缓存效率较低。通常,动态链接库在取整的内存地址载入。如果多个DLL间的距离可被2的大指数整除,这会导致缓存竞争。

11.8. ​​​​​​​缓存控制指令

当在一个回写缓存里出现缓存不命中时,内存写比读代价要高得多。在缓存不命中时,必须从内存读一整个缓存行、修改及回写。这可以通过使用非临时写指令MOVNTI,MOVNTQ,MOVNTDQ,MOVNTPD,MOVNTPS来避免。这些指令应该用在写一个不太可能缓存的内存位置,且在这个冒充缓存行被逐出前,读的可能性不大时。作为一个经验法则,建议仅在写一个超过最上层缓存大小一半的内存块时,使用非临时写。

使用PREFETCH指令的显式数据预取,有时可以改进缓存性能,但在大多数情形里,自动预取就足够了。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
混合编程是指在一个程序中同时使用汇编语言和C语言编写代码。这样做的好处是可以充分发挥汇编语言的高效性能,同时又可以利用C语言的易读性和可移植性。 下面是一个简单的程,展示了如何在C语言中调用汇编语言函数: ```c #include <stdio.h> extern int asm_add(int a, int b); int main() { int a = 10, b = 20, sum; sum = asm_add(a, b); printf("sum = %d\n", sum); return 0; } ``` 上面的代码中,`asm_add`是一个汇编语言函数,在C语言中通过`extern`关键字声明。在`main`函数中,我们调用了`asm_add`函数,并将结果打印出来。 下面是`asm_add`函数的汇编语言实现: ```asm section .text global asm_add asm_add: mov eax, edi add eax, esi ret ``` 上面的代码中,`asm_add`函数接收两个参数,分别存放在寄存器`edi`和`esi`中。函数实现将这两个参数相加,并将结果返回。 对于上面的程,我们需要将C语言和汇编语言代码分别保存为`.c`和`.asm`文件,并使用汇编器和编译器编译链接: ```bash nasm -f elf64 -o asm_add.o asm_add.asm gcc -c main.c gcc -o main main.o asm_add.o ``` 上述命令中,`-f elf64`参数指定了汇编语言程序的目标平台为x86-64,`-c`参数表示只编译不链接,最后一个命令将编译好的目标文件链接成可执行文件。 以上就是一个简单的混合编程程,通过在C语言中调用汇编语言函数,实现了高效的计算。实际应用中,混合编程可以用于底层的系统编程和优化性能要求较高的算法实现。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值