是否有任何现代CPU的缓存字节存储实际上比字存储慢?

时间:2019-01-16 12:54:46

标签: performance x86 arm cpu-architecture cpu-cache

这是common claim,将字节存储到缓存中可能会导致内部读-修改-写周期,或者与存储完整寄存器相比,会损害吞吐量或延迟。

但是我从未见过任何示例。没有x86 CPU是这样的,我认为所有高性能CPU都可以直接修改高速缓存行中的任何字节。如果某些微控制器或低端CPU完全具有缓存,是否有所不同?

我不是在计算可字寻址的机器,或者说Alpha是字节可寻址的,但是缺少字节加载/存储指令。我说的是ISA本机支持的最窄存储指令。)

在回答Can modern x86 hardware not store a single byte to memory?的研究中,我发现Alpha AXP省略字节存储的原因是假定它们将被实现为缓存中的真实字节存储,而不是包含字的RMW更新。 (因此,本来使L1d缓存的ECC保护更加昂贵,因为它需要字节粒度而不是32位)。

我假设提交给L1d缓存期间的word-RMW不被视为其他最近实现了字节存储的ISA的实现选项。

所有现代架构(早期的Alpha除外)都可以将字节真正的字节加载/存储到不可缓存的MMIO区域(不是RMW周期),这对于为具有相邻字节I / O寄存器的设备编写设备驱动程序是必需的。 (例如,使用外部启用/禁用信号来指定更宽的总线的哪些部分保存了真实数据,例如this ColdFire CPU/microcontroller上的2位TSIZ(传输大小),或PCI / PCIe单字节传输,或DDR屏蔽所选字节的SDRAM控制信号。)

也许不是在针对Alpha之类的SMP服务器/工作站的高端超标量流水线设计中,为微控制器设计考虑在字节存储的高速缓存中进行RMW循环吗?

我认为这种说法可能来自字寻址机器。或是来自需要在多个CPU上进行多次访问的不对齐的32位存储,人们不正确地将其归纳为字节存储。


请明确一点,我希望到同一地址的字节存储循环在每次迭代中的运行周期与字存储循环相同。因此,为了填充阵列,32位存储区的存储速度比8位存储区快4倍。 (如果32位存储饱和内存带宽,而8位存储没有饱和内存,则可能会更少。)但是,除非字节存储有额外的损失,否则您不会获得比4倍速差更多的 。 (或任何宽度的单词)。

我说的是asm。好的编译器会自动向量化C中的字节或int存储循环,并使用更宽的存储空间或目标ISA上最合适的存储空间(如果它们是连续的)。

(而且在存储缓冲区中进行存储合并还可能导致对连续字节存储指令的L1d高速缓存进行更广泛的提交,因此在进行微基准测试时要提防另一件事)

; x86-64 NASM syntax
mov   rdi, rsp
; RDI holds at a 32-bit aligned address
mov   ecx, 1000000000
.loop:                      ; do {
    mov   byte [rdi], al
    mov   byte [rdi+2], dl     ; store two bytes in the same dword
      ; no pointer increment, this is the same 32-bit dword every time
    dec   ecx
    jnz   .loop             ; }while(--ecx != 0}


    mov   eax,60
    xor   edi,edi
    syscall         ; x86-64 Linux sys_exit(0)

或者像这样在8kiB数组上循环,每8字节存储1个字节或1个字(对于8kiB的sizeof(unsigned int)= 4和CHAR_BIT = 8的C实现,但应编译为可比的函数可以在任何C实现中使用,如果sizeof(unsigned int)不是2的乘方,则只有很小的偏差。 ASM on Godbolt for a few different ISAs,没有展开,或者两个版本的展开量相同。

// volatile defeats auto-vectorization
void byte_stores(volatile unsigned char *arr) {
    for (int outer=0 ; outer<1000 ; outer++)
        for (int i=0 ; i< 1024 ; i++)      // loop over 4k * 2*sizeof(int) chars
            arr[i*2*sizeof(unsigned) + 1] = 123;    // touch one byte of every 2 words
}

// volatile to defeat auto-vectorization: x86 could use AVX2 vpmaskmovd
void word_stores(volatile unsigned int *arr) {
    for (int outer=0 ; outer<1000 ; outer++)
        for (int i=0 ; i<(1024 / sizeof(unsigned)) ; i++)  // same number of chars
            arr[i*2 + 0] = 123;       // touch every other int
}

根据需要调整大小,如果有人可以指向word_store()byte_store()快的系统,我真的很好奇。  (如果实际进行基准测试,请提防预热效应,例如动态时钟速度以及触发TLB未命中和缓存未命中的第一遍。)

或者,如果不存在用于古代平台的实际C编译器,或者生成的次优代码不会对商店的吞吐量造成瓶颈,那么任何手工制作的asm都会显示出效果。

任何其他证明字节存储减慢的方法都很好,我不坚持在数组上执行跨循环或在一个字内发送垃圾邮件。

我也可以使用有关CPU内部的详细文档,或者使用不同指令的CPU周期时序号。不过,我对基于该声明而无需进行测试的优化建议或指南持怀疑态度。

  • 在缓存字节存储区的任何仍与之相关的CPU或微控制器都会带来额外的损失吗?
  • 还有任何仍与之相关的CPU或微控制器,其中不可缓存字节存储会产生额外的惩罚?
  • 是否有不符合上述条件的历史CPU(带有或不带有回写或直写式缓存)?最近的例子是什么?

例如在ARM Cortex-A上是这种情况吗?还是Cortex-M?任何较旧的ARM微体系结构?是否有MIPS微控制器或早期的MIPS服务器/工作站CPU?还有其他随机RISC(例如PA-RISC)或CISC(例如VAX或486)吗? (CDC6600可通过字寻址。)

或构建涉及负载和存储的测试用例,例如显示字节存储中的字RMW与负载吞吐量竞争。

(我不感兴趣的是显示从字节存储到字加载的存储转发比word-> word慢,因为正常情况下,只有当最新存储中完全包含了加载时SF才能有效地工作触摸任何相关的字节。但是显示字节->字节转发的效率不及word-> word SF的效率会很有趣,也许字节不是从单词边界开始的。)


我没有提到字节加载是因为通常很简单:从缓存或RAM中访问一个完整的字,然后提取所需的字节。除了MMIO之外,实现细节是无法区分的CPU绝对不会读取包含的单词。)

在MIPS之类的加载/存储体系结构上,使用字节数据仅意味着您使用lblbu对其进行加载和归零或符号扩展,然后将其与{{1} }。 (如果您需要在寄存器中的各步之间将其截断为8位,则可能需要一条额外的指令,因此本地var通常应为寄存器大小。除非您希望编译器使用具有8位元素的SIMD自动向量化,否则通常为uint8_t当地人是好人……)但是无论如何,如果您做得正确并且编译器是好人,那么拥有字节数组就不需要花费任何额外的指令。

我注意到gcc在ARM,AArch64,x86和MIPS上具有sb。但是IDK我们可以投入多少库存。 x86-64系统V ABI将sizeof(uint_fast8_t) == 1定义为x86-64上的64位类型。如果他们要这样做(而不是x86-64的默认操作数大小为32位),uint_fast32_t也应该是64位类型。也许在用作数组索引时避免零扩展?如果将它作为函数arg传递到寄存器中,则由于无论如何都必须从内存中加载它,因此可以免费将其扩展为零。

2 个答案:

答案 0 :(得分:7)

我的猜测是错误的。现代x86微体系结构确实与某些(大多数?)其他ISA有所不同。

即使在高性能非x86 CPU上,缓存的狭窄存储区也可能会受到惩罚。不过,缓存占用空间的减少仍然可以使int8_t阵列值得使用。 (在某些ISA(例如MIPS)上,无需为寻址模式扩展索引会有所帮助。)

在实际提交给L1d之前,在字节存储指令之间的存储缓冲器中合并/合并到同一字也可以减少或消除代价。 (x86有时不能做很多事情,因为它强大的内存模型要求所有存储都按程序顺序提交。)


ARM's documentation for Cortex-A15 MPCore(从2012年开始)说,它在L1d中使用32位ECC粒度,实际上确实对狭窄的存储区进行了字RMW更新数据。

  

L1数据高速缓存在标签和数据阵列中均支持可选的单比特纠正和双比特检测错误纠正逻辑。标签阵列的ECC粒度是单个缓存行的标签,数据阵列的ECC粒度是32位字。

     

由于数据阵列中的ECC粒度,对阵列的写操作无法更新4字节对齐存储位置的一部分,因为没有足够的信息来计算新的ECC值。对于任何不写入一个或多个对齐的4字节内存区域的存储指令,都是这种情况。 在这种情况下,L1数据存储系统读取缓存中的现有数据,合并修改后的字节,然后根据合并后的值计算ECC。满足对齐的4字节ECC粒度,并避免了读取-修改-写入要求。

(当他们说“ L1内存系统”时,如果您有尚未提交给L1d的连续字节存储,我想他们的意思是存储缓冲区。)

请注意,RMW是原子的,并且仅涉及修改的专有缓存行。这是一个不会影响内存模型的实现细节。因此,我对Can modern x86 hardware not store a single byte to memory?的结论仍然(可能)正确地表明x86可以,其他提供字节存储指令的ISA也可以。 / p>


Cortex-A15 MPCore是3位乱序执行CPU,因此它不是最低功耗/简单的ARM设计,但他们选择在OoO exec上花费晶体管,而不是有效的字节存储。

大概不需要支持高效的未对齐存储(x86软件更可能采用/利用该存储),因为L1d的ECC可靠性更高而没有过多开销,因此拥有较慢的字节存储被认为是值得的。

Cortex-A15可能不是唯一的,也不是最新的以这种方式工作的ARM内核。


其他示例(由@HadiBrais在评论中找到):

  1. Alpha 21264 (请参阅this文档第8章的表8-1)为其L1d缓存具有8字节ECC粒度。如果较窄的存储区(包括32位)未首先合并到存储缓冲区中,则它们提交到L1d时将导致RMW。该文档详细说明了L1d每个时钟可以做什么。并特别说明存储缓冲区确实可以合并存储。

  2. PowerPC RS64-II和RS64-III (请参阅this文档中有关错误的部分)。根据{{​​3}},对于每32位数据,RS / 6000处理器的L1具有7位ECC。

Alpha从头开始就积极地使用64位,因此8字节粒度是有意义的,特别是如果RMW成本大部分可以被存储缓冲区隐藏/吸收的话。 (例如,该CPU上大多数代码的正常瓶颈可能在其他地方;其多端口缓存通常每个时钟可以处理2次操作。)

POWER / PowerPC64源自32位PowerPC,并且可能关心使用32位整数和指针运行32位代码。 (因此,更有可能对无法合并的数据结构进行非连续的32位存储。)因此,在那里的32位ECC粒度非常有意义。

答案 1 :(得分:5)

cortex-m7 trm,手册的“缓存ram”部分。

  

在无错误的系统中,主要的性能影响是成本   数据端非完全存储的读取-修改-写入方案。如果   存储缓冲区插槽至少不包含完整的32位字,   必须读取该字才能计算校验位。这个可以   发生此错误是因为软件仅使用字节或字节写入内存区域   半字存储说明。然后可以将数据写入RAM。   这种额外的读取可能会对性能产生负面影响,因为   这样可以防止将该插槽用于其他写入操作。

  

内存系统掩码的缓冲和出色功能   额外阅读的一部分,对于大多数代码而言可以忽略不计。   但是,ARM建议您使用尽可能少的可缓存STRB和STRH   说明,以减少对性能的影响。

我有cortex-m7s,但迄今为止尚未进行测试以证明这一点。

“读取字”的含义是对数据缓存中SRAM中一个存储位置的读取。这不是高级系统内存。

缓存的内容是由SRAM块构成的,并且这些块是围绕SRAM块的,而SRAM块则是使缓存成为事实的快速SRAM,比系统内存快,可以将答案快速返回给处理器,等等。 (RMW)不是高级写入策略。他们的意思是,如果有命中,并且写策略要求将写操作保存在高速缓存中,则需要将字节或半字写入这些SRAM之一。如本文档所示,带有ECC的数据高速缓存数据SRAM的宽度为32 + 7位。 32位数据7位ECC校验位。您必须将所有39位保持在一起才能使ECC正常工作。根据定义,您不能仅修改某些位,因为这将导致ECC故障。

每当需要更改存储在数据高速缓存数据SRAM中的32位字中的8位,16位或32位中的任何位数时,都必须重新计算7个校验位并将所有39位立即写入。对于8或16位的STRB或STRH写入,需要读取32个数据位,修改后的8或16位在该字中的其余数据位不变的情况下,计算7个ECC校验位,将39个位写入sram

理想情况下/校验位的计算最好在设置写操作的同一时钟周期内进行,但读和写操作不在同一时钟周期内,因此应至少花费两个单独的周期来写入到达的数据在一个时钟周期内缓存。有一些技巧可以延迟写入,这有时也会造成伤害,但通常会将其移至一个本来不会使用的循环,并在需要时将其释放。但这不会是与读取相同的时钟周期。

他们说,如果您保持正确的嘴巴,并设法使足够多的较小存储足够快地命中高速缓存,它们将使处理器停滞,直到可以追赶。

文档还描述了不带ECC SRAM的32位宽,这意味着当您编译不带ECC支持的内核时也是如此。我没有访问此内存接口或文档的信号,所以我不能肯定地说,但是如果将其实现为没有字节通道控件的32位宽接口,那么您将遇到相同的问题,它只能写入整个32位项目而不是小数,因此要更改8或16位,必须在高速缓存的肠道内更改为RMW。

为什么不使用更窄的内存的简短答案是芯片的大小,使用ECC的大小加倍,因为即使宽度变小,也可以使用的校验位数受到限制(每8位为7位比每32位节省7位要多得多)。内存越窄,您将有更多的信号要路由,无法密集地打包内存。一间公寓与一堆单独的房子来容纳相同数量的人。通往前门而不是走廊的道路和人行道。

使用这样的单核处理器,尤其是esp,除非您有意尝试(我会这样做),否则您不太可能会无意间遇到这种情况,为什么还要提高产品成本:它可能不会成功?

请注意,即使使用多核处理器,您也会看到这样构建的内存。

编辑。

好的,可以参加考试了。

0800007c <lwtest>:
 800007c:   b430        push    {r4, r5}
 800007e:   6814        ldr r4, [r2, #0]

08000080 <lwloop>:
 8000080:   6803        ldr r3, [r0, #0]
 8000082:   6803        ldr r3, [r0, #0]
 8000084:   6803        ldr r3, [r0, #0]
 8000086:   6803        ldr r3, [r0, #0]
 8000088:   6803        ldr r3, [r0, #0]
 800008a:   6803        ldr r3, [r0, #0]
 800008c:   6803        ldr r3, [r0, #0]
 800008e:   6803        ldr r3, [r0, #0]
 8000090:   6803        ldr r3, [r0, #0]
 8000092:   6803        ldr r3, [r0, #0]
 8000094:   6803        ldr r3, [r0, #0]
 8000096:   6803        ldr r3, [r0, #0]
 8000098:   6803        ldr r3, [r0, #0]
 800009a:   6803        ldr r3, [r0, #0]
 800009c:   6803        ldr r3, [r0, #0]
 800009e:   6803        ldr r3, [r0, #0]
 80000a0:   3901        subs    r1, #1
 80000a2:   d1ed        bne.n   8000080 <lwloop>
 80000a4:   6815        ldr r5, [r2, #0]
 80000a6:   1b60        subs    r0, r4, r5
 80000a8:   bc30        pop {r4, r5}
 80000aa:   4770        bx  lr

每个都有一个加载字(ldr),加载字节(ldrb),存储字(str)和存储字节(strb)版本,每个版本在至少16个字节边界上对齐,直到循环地址的顶部

启用了icache和dcache

    ra=lwtest(0x20002000,0x1000,STK_CVR);  hexstring(ra%0x00FFFFFF);
    ra=lwtest(0x20002000,0x1000,STK_CVR);  hexstring(ra%0x00FFFFFF);
    ra=lbtest(0x20002000,0x1000,STK_CVR);  hexstring(ra%0x00FFFFFF);
    ra=lbtest(0x20002000,0x1000,STK_CVR);  hexstring(ra%0x00FFFFFF);
    ra=swtest(0x20002000,0x1000,STK_CVR);  hexstring(ra%0x00FFFFFF);
    ra=swtest(0x20002000,0x1000,STK_CVR);  hexstring(ra%0x00FFFFFF);
    ra=sbtest(0x20002000,0x1000,STK_CVR);  hexstring(ra%0x00FFFFFF);
    ra=sbtest(0x20002000,0x1000,STK_CVR);  hexstring(ra%0x00FFFFFF);


0001000B                                                                        
00010007                                                                        
0001000B                                                                        
00010007                                                                        
0001000C                                                                        
00010007                                                                        
0002FFFD                                                                        
0002FFFD  

负载按预期彼此相等,但是存储,当您像这样将它们堆在一起时,字节写入的长度是字写入的3倍。

但是如果您不那么努力访问缓存

0800019c <nbtest>:
 800019c:   b430        push    {r4, r5}
 800019e:   6814        ldr r4, [r2, #0]

080001a0 <nbloop>:
 80001a0:   7003        strb    r3, [r0, #0]
 80001a2:   46c0        nop         ; (mov r8, r8)
 80001a4:   46c0        nop         ; (mov r8, r8)
 80001a6:   46c0        nop         ; (mov r8, r8)
 80001a8:   7003        strb    r3, [r0, #0]
 80001aa:   46c0        nop         ; (mov r8, r8)
 80001ac:   46c0        nop         ; (mov r8, r8)
 80001ae:   46c0        nop         ; (mov r8, r8)
 80001b0:   7003        strb    r3, [r0, #0]
 80001b2:   46c0        nop         ; (mov r8, r8)
 80001b4:   46c0        nop         ; (mov r8, r8)
 80001b6:   46c0        nop         ; (mov r8, r8)
 80001b8:   7003        strb    r3, [r0, #0]
 80001ba:   46c0        nop         ; (mov r8, r8)
 80001bc:   46c0        nop         ; (mov r8, r8)
 80001be:   46c0        nop         ; (mov r8, r8)
 80001c0:   3901        subs    r1, #1
 80001c2:   d1ed        bne.n   80001a0 <nbloop>
 80001c4:   6815        ldr r5, [r2, #0]
 80001c6:   1b60        subs    r0, r4, r5
 80001c8:   bc30        pop {r4, r5}
 80001ca:   4770        bx  lr

然后单词和字节花费相同的时间

    ra=nwtest(0x20002000,0x1000,STK_CVR);  hexstring(ra%0x00FFFFFF);
    ra=nwtest(0x20002000,0x1000,STK_CVR);  hexstring(ra%0x00FFFFFF);
    ra=nbtest(0x20002000,0x1000,STK_CVR);  hexstring(ra%0x00FFFFFF);
    ra=nbtest(0x20002000,0x1000,STK_CVR);  hexstring(ra%0x00FFFFFF);

0000C00B                                                                        
0000C007                                                                        
0000C00B                                                                        
0000C007

做字节仍然需要4倍的时间,而所有其他因素保持不变,但这是使字节花费4倍以上的时间的挑战。

因此,正如我在此问题之前所描述的那样,您将看到sram是缓存中的最佳宽度以及其他位置,字节写入将遭受read-modify-write。现在,是否可见是其他开销或优化是另一回事了。 ARM明确指出它可能是可见的,并且我觉得我已经演示了这一点。无论如何,这都不会对ARM的设计产生负面影响,实际上,反过来,RISC通常将开销移至指令/执行端,这样做确实需要更多指令。设计中的效率使这种情况可见。整本书中都有关于如何使x86更快运行,不对此进行8位操作,或者不建议使用其他指令等的全书。这意味着您应该能够编写一个基准来证明这些性能优势。就像这个一样,即使在将字符串中的每个字节移动到内存中时都应该将其隐藏,也需要编写这样的代码,并且如果要执行这样的操作,您可能会考虑刻录组合字节的指令在写之前先输入一个词,可能更快或更慢...取决于。

如果我有半字(strh),那么也就不足为奇了,因为ram是32位宽(加上任何ecc位,如果有的话),它也遭受相同的读取-修改-写入

0001000C   str                                                                      
00010007   str                                                                      
0002FFFD   strh                                                                     
0002FFFD   strh                                                                     
0002FFFD   strb                                                                     
0002FFFD   strb

负载所花的时间与读取整个sram宽度并放在总线上的时间相同,处理器从中提取出感兴趣的字节通道,因此这样做不会花费时间/时钟。 / p>