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

Are there any modern CPUs where a cached byte store is actually slower than a word store?

common claim 将字节存储到缓存中可能会导致内部读取-修改-写入循环,或者以其他方式损害吞吐量或延迟,而不是存储完整的寄存器。

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

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

在我回答 的研究中,我发现 Alpha AXP 省略字节存储的原因是假定它们将作为真正的字节存储实现到缓存中,而不是包含字的 RMW 更新。 (所以它会使 L1d 缓存的 ECC 保护更加昂贵,因为它需要字节粒度而不是 32 位)。

我假设在提交到 L1d 缓存期间,word-RMW 未被视为其他较新的实现字节存储的 ISA 的实现选项。

所有现代架构(早期 Alpha 除外)都可以对不可缓存的 MMIO 区域(不是 RMW 周期)执行真正的字节 loads/stores,这对于为具有相邻字节的设备编写设备驱动程序是必需的 I/O 注册。 (例如,使用外部 enable/disable 信号来指定更宽总线的哪些部分保存真实数据,例如 上的 2 位 TSIZ(传输大小),或 PCI / PCIe 单字节传输,或就像屏蔽选定字节的 DDR SDRAM 控制信号。)

也许在缓存中为字节存储做一个 RMW 循环是微控制器设计需要考虑的事情,即使它不是针对像 Alpha 这样的 SMP 服务器/工作站的高端超标量流水线设计?

我认为这种说法可能来自词寻址机器。或者来自未对齐的 32 位存储,需要对许多 CPU 进行多次访问,而人们错误地将其推广到字节存储。


为了清楚起见,我希望到相同地址的字节存储循环将 运行 在每次迭代的相同周期作为字存储循环。因此,对于填充数组,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 不同指令的循环时序号的详细文档也很好。不过,我对可能基于此声明而未经过测试的优化建议或指南持怀疑态度。

例如ARM Cortex-A 是这种情况吗?还是皮质-M?任何较旧的 ARM 微体系结构?任何 MIPS 微控制器或早期 MIPS server/workstation CPU?还有其他随机 RISC(如 PA-RISC)或 CISC(如 VAX 或 486)吗? (CDC6600 是可字寻址的。)

或者构造一个包含加载和存储的测试用例,例如显示来自字节存储的 word-RMW 与负载吞吐量竞争。

(我不想证明从字节存储到字加载的存储转发比字->字慢,因为正常情况下,只有当加载完全包含在最近的store to touch any of the relevant bytes. 但是一些显示 byte->byte 转发效率低于 word->word SF 的东西会很有趣,也许字节不从字边界开始。)


(我没有提到字节加载,因为这通常很容易:从缓存或 RAM 中访问一个完整的单词,然后提取您想要的字节。该实现细节与其他无法区分比 MMIO,其中 CPUs 绝对不读包含的词。)

在像 MIPS 这样的 load/store 体系结构上,处理字节数据意味着您使用 lblbu 加载它并对其进行归零或符号扩展,然后将其存储回sb。 (如果你需要 t运行cation 到寄存器中步骤之间的 8 位,那么你可能需要一个额外的指令,所以局部变量通常应该是寄存器大小的。除非你希望编译器使用 SIMD 自动矢量化 8-位元素,然后通常 uint8_t 本地人很好...)但是无论如何,如果你做对了并且你的编译器很好,那么拥有字节数组不应该花费任何额外的指令。

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

我猜错了。现代 x86 微体系结构在这方面确实与某些(大多数?)其他 ISA 不同。

即使在高性能非 x86 CPUs 上,缓存窄存储也会受到惩罚。缓存占用空间的减少仍然可以使 int8_t 值得使用的数组。 (在 MIPS 等一些 ISA 上,不需要为寻址模式缩放索引会有所帮助)。

在实际提交到 L1d 之前,在字节存储指令之间的存储缓冲区中合并/合并到同一个字也可以减少或消除惩罚。 (x86 有时无法做到这一点,因为其强大的内存模型要求所有存储按程序顺序提交。)


ARM's documentation for Cortex-A15 MPCore(从 ~2012 年开始)说它在 L1d 中使用 32 位 ECC 粒度,实际上确实为窄存储做了一个 word-RMW 来更新数据。

The L1 data cache supports optional single bit correct and double bit detect error correction logic in both the tag and data arrays. The ECC granularity for the tag array is the tag for a single cache line and the ECC granularity for the data array is a 32-bit word.

Because of the ECC granularity in the data array, a write to the array cannot update a portion of a 4-byte aligned memory location because there is not enough information to calculate the new ECC value. This is the case for any store instruction that does not write one or more aligned 4-byte regions of memory. In this case, the L1 data memory system reads the existing data in the cache, merges in the modified bytes, and calculates the ECC from the merged value. The L1 memory system attempts to merge multiple stores together to meet the aligned 4-byte ECC granularity and to avoid the read-modify-write requirement.

(当他们说 "the L1 memory system" 时,我认为他们指的是存储缓冲区,如果您有尚未提交给 L1d 的连续字节存储。)

注意RMW是原子的,只涉及被修改的独占缓存行。这是一个不影响内存模型的实现细节。 所以我对 的结论仍然(可能)正确,x86 可以,其他提供字节存储指令的 ISA 也可以。


Cortex-A15 MPCore 是 3 路乱序执行 CPU,所以它不是最小功耗/简单的 ARM 设计,但他们选择在 OoO exec 上使用晶体管但效率不高字节存储。

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

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


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

  1. Alpha 21264(参见 this 文档第 8 章的 Table 8-1)具有 8 字节的 ECC 粒度它的 L1d 缓存。较窄的存储(包括 32 位)在提交到 L1d 时会导致 RMW,如果它们没有首先合并到存储缓冲区中。该文档解释了 L1d 每个时钟可以做什么的完整细节。并特别记录存储缓冲区确实合并存储。

  2. PowerPC RS64-II 和 RS64-III(请参阅 this doc). According to this abstract 中的错误部分,RS/6000 处理器的 L1每 32 位数据有 7 位 ECC。

Alpha 从一开始就是 64 位的,所以 8 字节的粒度是有道理的,特别是如果 RMW 成本大部分可以被存储缓冲区隐藏/吸收。 (例如,对于 CPU 上的大多数代码,正常的瓶颈可能在别处;它的多端口缓存通常每个时钟可以处理 2 个操作。)

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

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

In an error-free system, the major performance impact is the cost of the read-modify-write scheme for non-full stores in the data side. If a store buffer slot does not contain at least a full 32-bit word, it must read the word to be able to compute the check bits. This can occur because software only writes to an area of memory with byte or halfword store instructions. The data can then be written in the RAM. This additional read can have a negative impact on performance because it prevents the slot from being used for another write.

.

The buffering and outstanding capabilities of the memory system mask part of the additional read, and it is negligible for most codes. However, ARM recommends that you use as few cacheable STRB and STRH instructions as possible to reduce the performance impact.

我有 cortex-m7s,但迄今为止还没有进行测试来证明这一点。

"read the word" 是什么意思,它是对作为数据高速缓存一部分的 SRAM 中的一个存储位置的读取。它不是高级系统内存。

缓存的核心由 SRAM 块构建并围绕这些块构建,这些块是使缓存成为现实的快速 SRAM,比系统内存更快,可以快速 return 响应处理器等。此读取-修改-写入 (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 .

校验位的计算 ideally/likely 在设置写入的同一时钟周期内,但读取和写入不在同一时钟周期内,因此至少需要两个独立的周期才能完成写入一个时钟周期内到达缓存的数据。有一些技巧可以延迟写入,这有时也会造成伤害,但通常会将其移至一个未使用的周期,如果您愿意,可以将其释放。但它不会与读取相同的时钟周期。

他们说,如果你管好自己的嘴,并设法让足够多的小存储以足够快的速度访问缓存,他们就会停止处理器,直到他们能赶上。

该文档还将没有 ECC 的 SRAM 描述为 32 位宽,这意味着在编译没有 ECC 支持的内核时也是如此。我无法访问此内存接口的信号或文档,所以我不能肯定地说,但如果它是作为一个没有字节通道控制的 32 位宽接口实现的,那么你有同样的问题,它只能写一个完整的 32 位项目到此 SRAM 而不是分数,因此要更改 8 或 16 位,您必须将 RMW 放在缓存的内部。

为什么不使用更窄的内存的简短答案是,芯片的大小,使用 ECC 时,大小会增加一倍,因为即使宽度变小,您也可以使用多少校验位(每 8 个 7 位)比每 32 位节省 7 位要多得多)。内存越窄,您也有更多的信号要路由,并且不能将内存打包得那么密集。一套公寓 vs 一堆单独的房子来容纳同样数量的人。通往前门的道路和人行道,而不是走廊。

尤其是像这样的单核处理器,除非你有意尝试(我会这样做),否则你不太可能不小心碰到这个,为什么要提高产品成本:它可能不会发生?

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

编辑。

好的,抽空去做个测试。

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 是缓存中的最佳宽度以及其他地方,字节写入将经历读取-修改-写入。现在是否对其他开销或优化可见是另一回事了。 ARM明明说了可能是可见的,我感觉我已经证明了这一点。这在任何方面都不是对 ARM 设计的负面影响,事实上,相反,RISC 通常会在 instruction/execution 端移动开销,它确实需要更多指令来完成相同的任务。设计中的效率允许这样的事情是可见的。整本书都是关于如何让你的 x86 运行得更快,不要为此或那个做 8 位操作,或者其他指令是首选,等等。这意味着你应该能够编写一个基准来证明这些性能命中。就像这个一样,即使在将字符串移动到内存时计算字符串中的每个字节,这也应该被隐藏,您需要编写这样的代码,如果您打算做这样的事情,您可能会考虑烧录结合字节的指令在写之前写成一个词,可能会或可能不会更快......取决于。

如果我有半字 (strh) 那么毫不奇怪,它也会遇到相同的读-修改-写,因为 ram 是 32 位宽(加上任何 ecc 位,如果有的话)

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

加载所花的时间与整个读取 sram 宽度并放在总线上所花费的时间相同,处理器从中提取感兴趣的字节通道,因此没有 time/clock 成本这样做。