内存复制

    我们的程序中经常要使用内存复制操作,此时一般调用memcpy函数来完成。一般来说,在x86平台上,编译器实现memcpy是用的rep movsd指令。这在8086/8088等老的CPU上是非常高效的内存复制操作。但随着体系机构的发展,特别是缓存的发展,这个指令所执行的操作已经与现有的内存体系机构有很大的差异,从而导致其性能很差,对内存带宽的利用率还不到50%。为此,我们专门设计了这个测试,用于反映各种内存复制操作的速度。在这里首先要声明,测试中使用的内存地址都是对齐到页边界的,访问方式为顺序访问,数据大小为2倍L2大小。

    第一种要测试的复制操作当然是使用rep movsd的操作方式了。这对应于使用memcpy的情况。我们把这种方法叫做MOVSD。

    在MOVSD上做的最基本的优化是使用MMX指令加上循环展开。其循环核心语句如下:

Cross Block
movq mm0, [eax+ecx]
movq mm1, [eax+ecx+8]
movq [edx+ecx], mm0
movq [edx+ecx+8], mm1

movq mm2, [eax+ecx+16]
movq mm3, [eax+ecx+24]
movq [edx+ecx+16], mm2
movq [edx+ecx+24], mm3

movq mm4, [eax+ecx+32]
movq mm5, [eax+ecx+40]
movq [edx+ecx+32], mm4
movq [edx+ecx+40], mm5

movq mm6, [eax+ecx+48]
movq mm7, [eax+ecx+56]
movq [edx+ecx+48], mm6
movq [edx+ecx+56], mm7
movq mm0, [eax+ecx]
movq mm1, [eax+ecx+8]
movq mm2, [eax+ecx+16]
movq mm3, [eax+ecx+24]
movq mm4, [eax+ecx+32]
movq mm5, [eax+ecx+40]
movq mm6, [eax+ecx+48]
movq mm7, [eax+ecx+56]

movq [edx+ecx], mm0
movq [edx+ecx+8], mm1
movq [edx+ecx+16], mm2
movq [edx+ecx+24], mm3
movq [edx+ecx+32], mm4
movq [edx+ecx+40], mm5
movq [edx+ecx+48], mm6
movq [edx+ecx+56], mm7

   上面的语句序列使用了MMX的64位读和写指令,并进行了8次循环展开。对左面的语句序列,读和写是交替出现的,我们叫做Cross;对右面的语句序列,首先执行了大块的读,然后执行大块的写,我们叫做Block。

    下面的优化要用到扩展的MMX指令(SSE指令的整数部分)。以前的文章中介绍过“Write-Allocate”写策略,可以知道在采用这种写策略的缓存的CPU上,其写策略将极大地影响写的速度。在扩展MMX指令中,就有专门旁路这种写策略的写指令movntq。这个指令的作用,说得简单点就是把采用“Write-Allocate”的缓存“临时转变”成采用“Write-Back(Through on Miss)”。当然其原理和操作要复杂得多了,这里就不仔细介绍,有兴趣可以参考Intel和AMD的相关文章。这个指令的使用也很简单,就是用这个指令替换上面的movq指令就可以了。下面是相应的核心循环语句序列:

NTQI NTQ
movq mm0, [eax+ecx]
movntq [edx+ecx], mm0
movq mm1, [eax+ecx+8]
movntq [edx+ecx+8], mm1
movq mm2, [eax+ecx+16]
movntq [edx+ecx+16], mm2
movq mm3, [eax+ecx+24]
movntq [edx+ecx+24], mm3
movq mm4, [eax+ecx+32]
movntq [edx+ecx+32], mm4
movq mm5, [eax+ecx+40]
movntq [edx+ecx+40], mm5
movq mm6, [eax+ecx+48]
movntq [edx+ecx+48], mm6
movq mm7, [eax+ecx+56]
movntq [edx+ecx+56], mm7
movq mm0, [eax+ecx]
movq mm1, [eax+ecx+8]
movq mm2, [eax+ecx+16]
movq mm3, [eax+ecx+24]
movq mm4, [eax+ecx+32]
movq mm5, [eax+ecx+40]
movq mm6, [eax+ecx+48]
movq mm7, [eax+ecx+56]

movntq [edx+ecx], mm0
movntq [edx+ecx+8], mm1
movntq [edx+ecx+16], mm2
movntq [edx+ecx+24], mm3
movntq [edx+ecx+32], mm4
movntq [edx+ecx+40], mm5
movntq [edx+ecx+48], mm6
movntq [edx+ecx+56], mm7

    在这里,把读和写交替出现的叫做NTQI(Interlaced),把读和写分块出现的叫做NTQ。

    解决了写策略的问题,下面要处理的就是读未命中了。读未命中对速度的影响是很大的,而在内存复制操作中,上面的代码将在每个块产生一个读未命中。在扩展MMX指令中,还有数据预取(prefetchXXX)指令,用于解决这个问题。为简单起见,这里就不再测试读写交替的方式了。我们把这种方法命名为NTQpf:

NTQpf

prefetchnta [eax+ecx+32]
prefetchnta [eax+ecx+64]

movq mm0, [eax+ecx]
movq mm1, [eax+ecx+8]
movq mm2, [eax+ecx+16]
movq mm3, [eax+ecx+24]
movq mm4, [eax+ecx+32]
movq mm5, [eax+ecx+40]
movq mm6, [eax+ecx+48]
movq mm7, [eax+ecx+56]

movntq [edx+ecx], mm0
movntq [edx+ecx+8], mm1
movntq [edx+ecx+16], mm2
movntq [edx+ecx+24], mm3
movntq [edx+ecx+32], mm4
movntq [edx+ecx+40], mm5
movntq [edx+ecx+48], mm6
movntq [edx+ecx+56], mm7

    预取的间隔对性能的影响也是很大的,不过为了简单,我们只测试了预取下一次循环的数据。不对数据预取作更完整的测试还有一个原因是这个方法还不是最快的。还有一种叫做“块预取”的方法可以比使用预取指令快。

    块预取的原理很简单,就是先把一大块的数据读到L1中,然后再用movntq一次传递到内存中。这样的好处是,对CPU来说,内存的读和写都将有一个非常长的操作周期,这样就可以优化外部总线上的数据传输。对块预取我们也准备了读写操作交替和不交替2种测试:

NTQBlk NTQBlkI
CPNBPreRead:
sub ecx, 2*CACHE_BLOCK
sub ebx, 2*CACHE_BLOCK
mov esi, [eax+ecx+CACHE_BLOCK]
mov esi, [eax+ecx]
jnz CPNBPreRead

mov ebx, OP_BLOCK
mov esi, 64
CPNBWrite:
sub ebx, esi
movq mm0, [eax+ecx]
movq mm1, [eax+ecx+8]
movq mm2, [eax+ecx+16]
movq mm3, [eax+ecx+24]
movq mm4, [eax+ecx+32]
movq mm5, [eax+ecx+40]
movq mm6, [eax+ecx+48]
movq mm7, [eax+ecx+56]

movntq [edx+ecx], mm0
movntq [edx+ecx+8], mm1
movntq [edx+ecx+16], mm2
movntq [edx+ecx+24], mm3
movntq [edx+ecx+32], mm4
movntq [edx+ecx+40], mm5
movntq [edx+ecx+48], mm6
movntq [edx+ecx+56], mm7

lea ecx, [ecx+esi]
jnz CPNBWrite
CPNBIPreRead:
sub ecx, 2*CACHE_BLOCK
sub ebx, 2*CACHE_BLOCK
mov esi, [eax+ecx+CACHE_BLOCK]
mov esi, [eax+ecx]
jnz CPNBIPreRead

mov ebx, OP_BLOCK
mov esi, 64

CPNBIWrite:
sub ebx, esi
movq mm0, [eax+ecx]
movq mm1, [eax+ecx+8]
movntq [edx+ecx], mm0
movntq [edx+ecx+8], mm1

movq mm2, [eax+ecx+16]
movq mm3, [eax+ecx+24]
movntq [edx+ecx+16], mm2
movntq [edx+ecx+24], mm3

movq mm4, [eax+ecx+32]
movq mm5, [eax+ecx+40]
movntq [edx+ecx+32], mm4
movntq [edx+ecx+40], mm5

movq mm6, [eax+ecx+48]
movq mm7, [eax+ecx+56]
movntq [edx+ecx+48], mm6
movntq [edx+ecx+56], mm7

lea ecx, [ecx+esi]
jnz CPNBIWrite

    注意核心循环中包含2个内层循环,第一个循环是预取数据到L1的,第二个循环是向内存写数据的。在预取循环中,并不是使用的prefetchnta指令,而是使用的mov指令,这是为了强迫CPU把数据读到L1。另外根据缓存工作原理,每个缓存块只要一个任意大小的读操作就可以了。在程序中,缓存块的大小定义成宏,可以根据不同的CPU修改成不同的值。在这里的测试中,缓存块的大小定义为32字节,预取块大小定义为4096字节。这些参数对PIII是比较适合的,对其它CPU可能需要修改。

    初看起来,这个方案要多执行很多指令(预取循环),应该是速度最慢的,但是测试结果却表明这个方案是最快的。下面是测试结果:

MemCopy_Tb01.gif (11093 bytes)

    表中第一列是函数的起始地址,数据的单位是MB/s。可以看出,最快的是块预取操作,而对块预取,读写是否交替影响不大。传统的MOVSD方案的速度仅是块预取的50%不到,可见其速度之慢。在测试中普通MMX优化方案的速度还没有MOVSD快,不知道是什么原因,应该是不太正常的。另外,movntq指令和prefetchntq指令的效果也是很显著的。在使用movntq而不使用块预取的方案中,读写交替的影响也是很大的,说明在这里循环展开也发挥了一定的作用。

    另外,上面的测试是基于主内存的,对显示内存,情况肯定有所差异。下面是显示内存的测试结果:

MemCopy_Tb02.gif (13861 bytes)

    这里的测试,数据源是在主内存中的,而数据的目的地址是在显示内存中的。这对应于视频解码这样的应用。可以看出,此时块预取反而成了最慢的方案了,使用预取指令的方案也是很慢的,而其它方案的速度基本上没有差异。出现这个结果的原因,应该是CPU中的写合并缓冲区发挥了作用。至于进一步的解释,请参阅Intel和AMD关于写合并缓冲区的介绍。


Leading Cloud Surveillance, Recording and Storage service; IP camera live viewing

Leading Enterprise Cloud IT Service; cloud file server, FTP Hosting, Online Storage, Backup and Sharing

Powered by FirstCloudIT.com, a division of DriveHQ, the leading Cloud IT and Cloud Surveillance Service provider since 2003.