现代 x86 硬件不能将单个字节存储到内存中吗?

Can modern x86 hardware not store a single byte to memory?

谈到用于并发的 C++ 内存模型,Stroustrup 的 C++ 编程语言, 第 4 版,第 1 节。 41.2.1,说:

... (like most modern hardware) the machine could not load or store anything smaller than a word.

但是,我的 x86 处理器已经使用了几年,可以而且确实可以存储小于一个字的对象。例如:

#include <iostream>
int main()
{
    char a =  5;
    char b = 25;
    a = b;
    std::cout << int(a) << "\n";
    return 0;
}

没有优化,GCC 将其编译为:

        [...]
        movb    , -1(%rbp)   # a =  5, one byte
        movb    , -2(%rbp)  # b = 25, one byte
        movzbl  -2(%rbp), %eax # load b, one byte, not extending the sign
        movb    %al, -1(%rbp)  # a =  b, one byte
        [...]

评论是我写的,汇编是 GCC 写的。当然,它运行良好。

显然,当 Stroustrup 解释硬件可以加载和存储小于一个字的任何内容时,我不明白他在说什么。据我所知,我的程序什么都不做 加载和存储小于一个字的对象。

C++ 对零成本、硬件友好抽象的彻底关注使 C++ 有别于其他更容易掌握的编程语言。因此,如果 Stroustrup 对总线上的信号有一个有趣的心理模型,或者有其他类似的东西,那么我想了解 Stroustrup 的模型。

Stroustrup 在说什么?

带上下文的较长引用

以下是 Stroustrup 在更完整的上下文中的引述:

Consider what might happen if a linker allocated [variables of char type like] c and b in the same word in memory and (like most modern hardware) the machine could not load or store anything smaller than a word.... Without a well-defined and reasonable memory model, thread 1 might read the word containing b and c, change c, and write the word back into memory. At the same time, thread 2 could do the same with b. Then, whichever thread managed to read the word first and whichever thread managed to write its result back into memory last would determine the result....

补充说明

我不相信 Stroustrup 是在谈论缓存行。据我所知,即使他是,缓存一致性协议也会透明地处理该问题,除非在硬件期间 I/O.

我已经检查了处理器的硬件数据表。在电气方面,我的处理器(Intel Ivy Bridge)似乎通过某种 16 位多路复用方案寻址 DDR3L 内存,所以我不知道那是什么。不过,我不清楚这与 Stroustrup 的观点有多大关系。

Stroustrup 是一个聪明人,也是一位杰出的科学家,所以我不怀疑他在做一些明智的事情。我很困惑。

另见 this question. 我的问题在几个方面类似于链接的问题,链接问题的答案在这里也很有帮助。但是,我的问题还涉及 hardware/bus 模型,该模型促使 C++ 成为现在的样子,并导致 Stroustrup 写他写的东西。我不只是寻求 C++ 标准正式保证的答案,而且还想了解为什么 C++ 标准会保证它。潜在的想法是什么?这也是我问题的一部分。

不仅 x86 CPUs 能够读取和写入单个字节,所有现代通用 CPUs 都能够做到这一点。更重要的是,大多数现代 CPUs(包括 x86、ARM、MIPS、PowerPC 和 SPARC)都能够原子地读取和写入单个字节。

我不确定 Stroustrup 指的是什么。曾经有一些不支持 8 位字节寻址的字寻址机器,例如 Cray,正如 Peter Cordes 提到的早期 Alpha CPUs 不支持字节加载和存储,但今天只有 CPU 不能进行字节加载和存储的是特定应用程序中使用的某些 DSP。即使我们假设他的意思是大多数现代 CPUs 没有原子字节加载和存储,但对于大多数 CPUs 来说并非如此。

但是,简单的原子加载和存储在多线程编程中用处不大。您通常还需要顺序保证和一种使读-修改-写操作原子化的方法。另一个考虑是,虽然 CPU a 可能有字节加载和存储指令,但编译器不需要使用它们。例如,编译器仍然可以生成 Stroustrup 描述的代码,使用单个字加载指令加载 bc 作为优化。

因此,虽然您确实需要一个定义良好的内存模型,但前提是编译器被迫生成您期望的代码,问题不在于现代 CPUs 无法加载或存储任何小于一个字的东西。

不确定 Stroustrup "WORD" 的意思。 可能是机器内存的最小容量?

无论如何,并非所有机器都是使用 8 位 (BYTE) 分辨率创建的。 事实上,我推荐 Eric S. Raymond 撰写的这篇很棒的文章,其中描述了计算机的一些历史: http://www.catb.org/esr/faqs/things-every-hacker-once-knew/

"... It used also to be generally known that 36-bit architectures explained some unfortunate features of the C language. The original Unix machine, the PDP-7, featured 18-bit words corresponding to half-words on larger 36-bit computers. These were more naturally represented as six octal (3-bit) digits."

作者似乎担心线程 1 和线程 2 会陷入读取-修改-写入的情况(不是在软件中,软件执行两个字节大小的独立指令,在某处逻辑有做一个读-修改-写)而不是理想的读修改写读修改写,变成读读修改修改写写或其他一些时间,这样既读修改前的版本又读最后一个写的版本。读取读取修改修改写入写入,或读取修改读取修改写入写入或读取修改读取写入修改写入。

问题是从 0x1122 开始,一个线程想要将其设为 0x33XX,而另一个线程想要将其设为 0xXX44,但是对于例如读取读取修改修改写入写入,您最终得到的是 0x1144 或 0x3322,但不是 0x3344

一个理智的 (system/logic) 设计不会有这个问题,当然不是像这样的通用处理器,我曾研究过像这样的时序问题的设计,但这不是我们正在谈论的大约在这里,完全不同的系统设计用于不同的目的。在理智的设计中,读取-修改-写入不会跨越足够长的距离,而 x86 是理智的设计。

读-修改-写将在第一个涉及的 SRAM 附近发生(理想情况下是 L1,当 运行以典型方式使用能够 运行ning C++ 编译的多操作系统的 x86 时-线程程序)并在几个时钟周期内发生,因为 ram 处于理想的总线速度。正如 Peter 指出的那样,这被认为是在缓存中经历这种情况的整个缓存行,而不是处理器核心和缓存之间的读取-修改-写入。

"at the same time" 的概念即使在多核系统中也不一定是同时的,最终你会被序列化,因为性能不是基于它们从头到尾的并行,而是基于关于保持总线加载。

引用是说分配给内存中同一个词的变量,所以这是同一个程序。两个独立的程序不会像那样共享一个地址 space。所以

欢迎您尝试这个,制作一个多线程程序,一个写地址 0xnnn00000,另一个写地址 0xnnnn00001,每个写一个,然后读一个或更好的几个相同值的写而不是一个读,检查读取的是他们写入的字节,然后用不同的值重复。暂时让那个运行,hours/days/weeks/months。查看您是否使系统出错...使用程序集来执行实际的写入指令,以确保它按照您的要求进行操作(不是 C++ 或任何执行或声称不会将这些项目放在同一个词中的编译器)。可以增加延迟以允许更多的缓存逐出,但这会降低 "at the same time" 冲突的几率。

你的例子只要你确保你没有坐在像 0xNNNNFFFFF 和 0xNNNN00000 这样的边界(缓存或其他)的两侧,隔离对像 0xNNNN00000 和 0xNNNN00001 这样的地址的两个字节写入有背靠背的指令看看你是否得到了读读修改修改写写。围绕它进行测试,这两个值在每个循环中都是不同的,您可以根据需要在任何延迟后读回整个单词并检查这两个值。重复 days/weeks/months/years 以查看它是否失败。阅读你的处理器执行和微码功能,看看它用这个指令序列做了什么,并根据需要创建一个不同的指令序列,试图在处理器核心的远端的少数几个时钟周期内启动事务。

编辑

引号的问题在于,这都是关于语言和使用的。 "like most modern hardware" 把整个 topic/text 放在一个敏感的位置,它太模糊了,一方可以争辩说我所要做的就是找到一个真实的案例,使其余的都真实,同样一方可能会争辩说,如果我发现一个案例,那么其他所有案例都不是真的。使用像这样的词有点像一个可能的免狱卡。

现实情况是,我们的数据中有很大一部分存储在 8 位宽内存的 DRAM 中,只是我们不会以 8 位宽访问它们,通常我们一次访问其中的 8 个,64 位宽.在某些情况下 weeks/months/years/decades 这个说法是不正确的。

大引号说 "at the same time" 然后说 read ... first, write ... last, 好吧 first 和 last 同时在一起没有意义,是并行还是串行?上下文作为一个整体关注上面的读取读取修改修改写入写入变体,其中您最后一次写入并且取决于该读取何时确定是否发生两种修改。不是同时 "like most modern hardware" 没有意义的事情实际上是在单独的 cores/modules 中并行开始的,如果它们在内存中瞄准相同的 flip-flop/transistor 最终会被序列化,一个终究要等对方先走。基于物理学,我认为这在即将到来的 weeks/months/years.

中是不正确的

这是正确的。 x86_64 CPU,就像原始 x86 CPU 一样,无法从 rsp 读取或写入小于(在本例中为 64 位)字的任何内容。到记忆中。它通常不会读取或写入少于整个缓存行,尽管有一些方法可以绕过缓存,尤其是在写入时(见下文)。

不过,在这种情况下,Stroustrup 指的是潜在的数据竞争(在可观察的水平上缺乏原子性)。由于您提到的缓存一致性协议,此正确性问题与 x86_64 无关。换句话说,是的,CPU 仅限于整字传输,但是 这是透明处理的,作为程序员的您通常不必担心。事实上,从 C++11 开始,C++ 语言保证不同内存位置上的并发操作具有明确定义的行为,即您所期望的行为。即使硬件不能保证这一点,实现也必须通过生成可能更复杂的代码来找到一种方法。

也就是说,将整个单词甚至高速缓存行总是涉及机器级别这一事实牢记在脑后仍然是一个好主意,原因有两个。

  • 首先,这仅适用于编写设备驱动程序或设计设备的人员,内存映射 I/O 可能对其访问方式敏感。例如,考虑一个在物理地址 space 中公开 64 位只写命令寄存器的设备。那么可能需要:
    • 禁用缓存。读取缓存行、更改单个字并写回缓存行是无效的。此外,即使它是有效的,仍然存在命令丢失的巨大风险,因为 CPU 缓存没有足够快地写回。最起码,页面需要配置为"write-through",即写入立即生效。因此,x86_64 页面 table 条目包含控制 CPU 缓存行为的标志 此页面 .
    • 确保始终在汇编级别写入整个单词。例如。考虑这样一种情况,您将值 1 写入寄存器,然后写入 2。编译器,尤其是针对 space 进行优化时,可能决定只覆盖最低有效字节,因为其他字节已经被假定为零(也就是说,对于普通 RAM),或者它可能会删除第一次写入,因为这个值似乎无论如何都会立即被覆盖。然而,这两种情况都不应该发生在这里。在 C/C++ 中,volatile 关键字对于防止此类不合适table 优化至关重要。
  • 其次,这与几乎所有编写多线程程序的开发人员都相关,缓存一致性协议虽然巧妙地避免了灾难,但如果 "abused" 可能会产生巨大的性能成本。

这是一个非常糟糕的数据结构的示例——有点做作。假设您有 16 个线程从一个文件中解析一些文本。每个线程都有一个从 0 到 15 的 id

// shared state
char c[16];
FILE *file[16];

void threadFunc(int id)
{
    while ((c[id] = getc(file[id])) != EOF)
    {
        // ...
    }
}

这是安全的,因为每个线程都在不同的内存位置上运行。然而,这些内存位置通常驻留在同一个高速缓存行上,或者最多分为两个高速缓存行。然后使用缓存一致性协议正确同步对 c[id] 的访问。这就是问题所在,因为这会迫使每个 other 线程在对 c[id] 执行任何操作之前等待,直到缓存行变得独占可用,除非它已经是 运行核心就是"owns"缓存行。假设有几个,例如16、核心,缓存一致性通常会一直将缓存行从一个核心转移到另一个核心。由于显而易见的原因,这种效应被称为 "cache line ping-pong"。它造成了可怕的性能瓶颈。这是 错误共享 的一个非常糟糕的情况的结果,即线程共享物理缓存行而实际上没有访问相同的逻辑内存位置。

与此相反,特别是如果采取额外步骤确保 file 数组驻留在其自己的缓存行上,使用它将完全无害(在 x86_64 上)性能角度,因为大多数时候只读取指针。在这种情况下,多个核心可以 "share" 缓存行为只读。只有当任何核心尝试写入缓存行时,它才必须告诉其他核心它将"seize"缓存行进行独占访问。

(这已经大大简化了,因为有不同级别的 CPU 缓存,并且多个内核可能共享相同的 L2 或 L3 缓存,但它应该让您对问题有一个基本的了解。)

Stroustrup 不是说没有机器可以执行小于其本机字大小的加载和存储,他是说机器不能

虽然乍一看这似乎令人惊讶,但并不深奥。
首先,我们将忽略缓存层次结构,稍后我们会考虑到这一点。
假设 CPU 和内存之间没有缓存。

内存的大问题是密度,试图将更多的位放入最小的区域。
为了方便实现,从电气设计的角度来说,尽量把总线露出来(这样有利于一些电信号的复用,具体细节我没看)。
因此,在需要大内存的架构(如 x86)或简单的低成本设计是有利的(例如涉及 RISC 机器的地方),内存总线大于最小的可寻址单元(通常是字节)。

根据项目的预算和遗留问题,内存可以单独或与一些边带信号一起暴露更宽的总线到select一个特定的单元。
这实际上意味着什么?
如果您看一下 datasheet of a DDR3 DIMM,您会看到 read/write 数据有 64 个 DQ0–DQ63 引脚。
这是数据总线,64位宽,一次8字节。
这个 8 字节的东西在 x86 架构中有很好的基础,以至于英特尔在其优化手册的 WC 部分提到了它,它说数据是从 64 字节 填充传输的缓冲区(记住:我们现在忽略缓存,但这类似于缓存行被写回的方式)以 8 字节的突发(希望是连续的)。

这是否意味着x86只能写QWORDS(64位)?
不,相同的数据表显示每个 DIMM 都有 DM0–DM7、DQ0–DQ7DQS0–DQS7 信号来屏蔽、引导和选通每个64 位数据总线中的 8 个字节。

所以 x86 可以本地和原子地读写字节。
然而,现在很容易看出,并非每个架构都是如此。
例如,VGA 视频内存是 DWORD(32 位)可寻址的,使其适合 8086 的字节可寻址世界导致了混乱的位平面。

在通用的特定用途架构中,如 DSP,在硬件级别不能有字节可寻址内存。

有一个转折:我们刚刚谈到了内存数据总线,这是可能的最低层。
某些 CPU 可以包含在字可寻址内存之上构建字节可寻址内存的指令。
这是什么意思?
加载一个词的一小部分很容易:只需丢弃其余字节即可!
不幸的是,我不记得架构的名称(如果它根本存在的话!)处理器通过读取包含它的对齐字并在将结果保存到寄存器之前旋转结果来模拟加载未对齐字节。

对于商店,事情就更复杂了:如果我们不能简单地写下刚刚更新的单词部分,我们还需要写下未更改的剩余部分。
CPU 或程序员必须读取旧内容,更新它并写回。
这是一个Read-Modify-Write操作,是讨论原子性的核心概念。

考虑:

/* Assume unsigned char is 1 byte and a word is 4 bytes */
unsigned char foo[4] = {};

/* Thread 0                         Thread 1                 */
foo[0] = 1;                        foo[1] = 2;

是否存在数据竞争?
这在 x86 上是安全的,因为它们可以写入字节,但如果架构不能呢?
两个线程都必须读取 整个 foo 数组,修改它并写回它。
伪 C 中,这将是

/* Assume unsigned char is 1 byte and a word is 4 bytes */
unsigned char foo[4] = {};

/* Thread 0                        Thread 1                 */

/* What a CPU would do (IS)        What a CPU would do (IS) */
int tmp0 = *((int*)foo)            int tmp1 = *((int*)foo)

/* Assume little endian            Assume little endian     */
tmp0 = (tmp0 & ~0xff) | 1;         tmp1 = (tmp1 & ~0xff00) | 0x200;

/* Store it back                   Store it back            */
*((int*)foo) = tmp0;               *((int*)foo) = tmp1;

我们现在可以明白 Stroustrup 所说的:两个商店 *((int*)foo) = tmpX 相互阻碍,要了解这一点,请考虑以下可能的执行顺序:

int tmp0 = *((int*)foo)                   /* T0  */ 
tmp0 = (tmp0 & ~0xff) | 1;                /* T1  */        
int tmp1 = *((int*)foo)                   /* T1  */
tmp1 = (tmp1 & ~0xff00) | 0x200;          /* T1  */
*((int*)foo) = tmp1;                      /* T0  */
*((int*)foo) = tmp0;                      /* T0, Whooopsy  */

如果 C++ 没有 内存模型,这些麻烦将成为特定于实现的细节,使 C++ 在多线程环境中成为无用的编程语言。

考虑到玩具示例中描述的情况有多普遍,Stroustrup 强调了明确定义的内存模型的重要性。
形式化内存模型是一项艰巨的工作,这是一个令人筋疲力尽、容易出错且抽象的过程,所以我也从 Stroustrup 的话中看到了一点 骄傲

我还没有复习 C++ 内存模型,但更新了不同的数组元素
这是一个非常有力的保证。

我们省略了缓存,但这并没有真正改变任何东西,至少对于 x86 情况而言。
x86 通过缓存写入内存,缓存以 64 字节.
行为单位逐出 在内部,每个核心都可以原子地更新任何位置的一行,除非 load/store 跨越行边界(例如,通过在它的末尾附近写入)。
这可以通过自然对齐数据来避免(你能证明吗?)。

在 multi-code/socket 环境中,缓存一致性协议确保一次只允许 CPU 自由写入缓存的内存行(CPU 具有它处于独占或修改状态)。
基本上,MESI 协议族使用类似于锁定的概念来发现 DBMS。
出于写作目的,这具有 "assigning" 不同内存区域到不同 CPUs 的效果。
所以其实不影响上面的讨论。

TL:DR:在每个具有字节存储指令(包括 x86)的现代 ISA 上,它们都是原子的,不会干扰周围的字节。(我是不知道任何旧的 ISA,其中字节存储指令也可以“发明写入”到相邻字节。)

实际的实现机制 () 有时是一个内部 RMW 循环来修改缓存行中的整个单词,但这是在核心内部“无形地”完成的,同时它拥有缓存行的独占所有权所以这只是一个性能问题,而不是正确性。 (并且在存储缓冲区中合并有时可以将字节存储指令转换为高效的全字提交到 L1d 缓存。)



关于 Stroustrup 的措辞

我认为这不是一个非常准确、清晰或有用的陈述。更准确地说,现代 CPUs 无法加载或存储任何小于缓存行的内容。 (尽管对于不可缓存的内存区域,例如 MMIO,情况并非如此。)

如果只做一个假设的例子来谈论内存模型可能会更好,而不是暗示真实的硬件是这样的。但如果我们尝试,我们也许可以找到一种不那么明显或完全错误的解释,这可能是 Stroustrup 在写这篇介绍记忆模型主题时的想法。 (抱歉,这个答案太长了;我在猜测他可能的意思和相关主题的同时写了很多...)

或者这可能是高级语言设计者不是硬件专家的另一个例子,或者至少偶尔会做出错误陈述。


我认为 Stroustrup 是在谈论 CPUs 如何在内部 工作以实现字节存储指令。他建议 CPU 没有定义明确且合理的内存模型 可能会在缓存行中实现包含字的非原子 RMW 的字节存储,或者在没有缓存的 CPU 内存中。

对于高性能 x86 CPUs,即使这种关于内部(外部不可见)行为的较弱声明也不成立。现代英特尔 CPU 对字节存储,甚至不跨越高速缓存行边界的未对齐字或矢量存储没有吞吐量损失。 AMD类似。

如果字节或未对齐的存储必须执行 RMW 循环作为存储提交到 L1D 缓存,它会以我们可以用性能计数器测量的方式干扰存储 and/or 加载 instruction/uop 吞吐量. (在一个精心设计的实验中,避免了在提交到 L1d 缓存之前在存储缓冲区中存储合并的可能性隐藏成本,因为存储执行单元只能 运行 每个时钟在当前 CPUs.)


但是,非 x86 ISA 的一些高性能设计确实使用原子 RMW 周期在内部将存储提交到 L1d 缓存。 缓存行一直处于 MESI Exclusive/Modified 状态,因此不会引入任何正确性问题,只会对性能造成很小的影响。这与做一些可能会踩到其他 CPU 商店的事情有很大不同。 (下面关于 没有发生的论点仍然适用,但我的更新可能遗漏了一些仍然认为原子缓存 RMW 不太可能的东西。)

(在许多非 x86 ISA 上,完全不支持未对齐的存储,或者比在 x86 软件中更少使用。弱排序的 ISA 允许在存储缓冲区中进行更多合并,因此没有那么多字节存储指令实际上导致单字节提交到 L1d。如果没有这些花哨(耗电)缓存访问硬件的动机,分散字节存储的词 RMW 在某些设计中是可以接受的权衡。)


Alpha AXP,1992 年的高性能 RISC 设计,著名(在现代非 DSP ISA 中独一无二)省略字节 load/store instructions until Alpha 21164A (EV56) in 1996. Apparently they didn't consider word-RMW a viable option for implementing byte stores, because one of the cited advantages for implementing only 32-bit and 64-bit aligned stores was more efficient ECC for the L1D cache. "Traditional SECDED ECC would require 7 extra bits over 32-bit granules (22% overhead) versus 4 extra bits over 8-bit granules (50% overhead)."(@Paul A. Clayton 关于字与字节寻址的回答还有一些其他有趣的计算机架构内容。)如果字节存储是使用 word-RMW 实现的,您仍然可能会出错detection/correction 具有字粒度。

当前的 Intel CPU 出于这个原因只在 L1D 中使用奇偶校验(而不是 ECC)。 (至少一些较旧的 Xeons 可以 运行 在 ECC 模式下使用 L1d 的一半容量而不是正常的 32KiB,因为 discussed on RWT. It's not clear if anything's changed, e.g. in terms of Intel now using ECC for L1d). See also 关于硬件(不)消除“静默存储”:之前检查缓存的旧内容为避免在行匹配时将行标记为脏而进行的写入将需要 RMW 而不仅仅是存储,这是一个主要障碍。

事实证明,一些高性能流水线设计确实使用原子字 RMW 来提交到 L1d,尽管它会拖延内存流水线,但是(正如我在下面讨论的那样)很多 不太可能对 RAM 执行外部可见的 RMW。

Word-RMW 对于 MMIO byte stores either, so unless you have an architecture that doesn't need sub-word stores for IO, you'd need some kind of special handling for IO (like Alpha's sparse I/O space 不是一个有用的选项,其中单词 load/stores 被映射到字节 load/stores 因此它可以使用商品 PCI 卡而不需要特殊的硬件没有字节 IO 寄存器)。

一样,DDR3 内存控制器可以通过设置屏蔽突发其他字节的控制信号来进行字节存储。将此信息获取到内存控制器(对于未缓存存储)的相同机制也可以将该信息与加载或存储一起传递到 MMIO space。所以有真正做的硬件机制 即使在面向突发的内存系统上也是一个字节存储,现代 CPUs 很可能会使用它而不是实现 RMW,因为它可能更简单并且 更好MMIO 正确性。

显示了 ColdFire 微控制器如何通过外部信号线发送传输大小(byte/word/longword/16-byte 行), 让它执行字节 loads/stores 即使 32 位宽的内存连接到其 32 位数据总线。对于大多数内存总线设置来说,这样的事情大概是典型的(但我不知道)。 ColdFire 示例很复杂,因为还可以配置为使用 16 位或 8 位内存,需要额外的周期来进行更广泛的传输。但没关系,重要的一点是它有外部传输大小的信号,告诉内存硬件它实际写入的是哪个字节。


Stroustrup 的

"The C++ memory model guarantees that two threads of execution can update and access separate memory locations without interfering with each other. This is exactly what we would naively expect. It is the compiler’s job to protect us from the sometimes very strange and subtle behaviors of modern hardware. How a compiler and hardware combination achieves that is up to the compiler. ..."

显然他认为真正的现代硬件可能无法提供“安全”字节 load/store。设计硬件内存模型的人同意 C/C++ 人的观点,并意识到如果字节存储指令可以踩到相邻字节,则字节存储指令对程序员/编译器来说不是很有用。

除了早期的 Alpha AXP 之外的所有现代(非 DSP)架构都有字节存储和加载指令,而且 AFAIK 这些都是在架构上定义为不影响相邻字节。但是它们完成在硬件中,软件不需要关心正确性。即使是 MIPS(1983 年)的第一个版本也有字节和半字 loads/stores,它是一个非常面向字的 ISA。

然而,他实际上并没有声称大多数现代硬件需要任何特殊的编译器支持来实现 C++ 内存模型的这一部分,只是 some 可能。也许他真的只是在第 2 段中谈论可字寻址的 DSP(其中 C 和 C++ 实现通常使用 16 或 32 位 char,这正是 Stroustrup 所谈论的那种编译器解决方法。)


大多数“现代”CPU(包括所有 x86)都有 L1D 缓存。他们将获取整个缓存行(通常为 64 字节)并在每个缓存行的基础上跟踪脏/不脏。 所以如果两个相邻的字节都在同一缓存行中,那么两个相邻的字节几乎与两个相邻的字完全相同。写入一个字节或一个字将导致获取整行,并最终回写整行。请参阅 Ulrich Drepper 的 What Every Programmer Should Know About Memory. You're correct that MESI(或类似 MESIF/MOESI 的派生词)确保这不是问题。 (但同样,这是因为硬件实现了合理的内存模型。)

当行处于修改状态(MESI)时,存储只能提交到 L1D 缓存。因此,即使内部硬件实现对于字节来说很慢,并且需要额外的时间将字节合并到缓存行中的包含字中,只要它不这样做,它实际上就是一个 atomic 读取修改写入' 允许行在读取和写入之间失效并重新获取。 (). See 提出相同的观点(但也适用于内存控制器中的 RMW)。

这比例如来自寄存器的原子 xchgadd 也需要 ALU 和寄存器访问,因为所有涉及的硬件都在同一个流水线阶段,它可以简单地停止一两个额外的周期。这显然不利于性能,并且需要额外的硬件才能让流水线阶段发出信号表明它正在停滞。这不一定与 Stroustrup 的第一个声明相冲突,因为他在谈论一个没有内存模型的假设 ISA,但这仍然是一个延伸。

在单核微控制器上,用于缓存字节存储的内部字 RMW 更合理,因为不会有来自其他内核的无效请求,它们必须在原子期间延迟响应RMW 缓存字更新。但这对 I/O 无法缓存的区域没有帮助。我说微控制器是因为其他单核 CPU 设计通常支持某种多插槽 SMP。


许多 RISC ISA 不支持单个指令的未对齐字 loads/stores,但这是一个单独的问题(困难在于处理负载跨越两个缓存行甚至页面的情况,这可以'发生在字节或对齐的半字上)。不过,越来越多的 ISA 在最近的版本中添加了对未对齐 load/store 的保证支持。 (例如 MIPS32/64 Release 6 2014 年,我认为是 AArch64 和最近的 32 位 ARM)。


The 4th edition of Stroustrup's book was published in 2013 when Alpha had been dead for years. The first edition was published in 1985, when RISC was the new big idea (e.g. Stanford MIPS in 1983, according to Wikipedia's timeline of computing HW,但当时的“现代”CPUs 是字节存储的字节寻址。 Cyber​​ CDC 6600 可以通过单词寻址,可能仍然存在,但不能称为现代。

即使像 MIPS and SPARC 这样非常面向字的 RISC 机器也有字节存储和字节加载(带符号或零扩展)指令。它们不支持未对齐的字加载,简化缓存(或内存访问,如果没有缓存)和加载端口,但你可以用一条指令加载任何单个字节,更重要的是 store 没有对周围字节进行任何架构可见的非原子重写的字节。 (虽然缓存存储可以

我想如果目标是没有字节存储的 Alpha ISA 版本,Alpha 上的 C++11(它向该语言引入了线程感知内存模型)将需要使用 32 位 char。或者当它不能证明没有其他线程可以有一个指针让它们写入相邻字节时,它必须使用带有 LL/SC 的软件 atomic-RMW。


IDK 如何 慢字节 load/store 指令在任何 CPUs 中,它们在硬件中实现但并不便宜作为单词 loads/stores。只要您使用 movzx/movsx 来避免部分注册错误依赖或合并停顿,字节加载在 x86 上就很便宜。 On AMD pre-Ryzen, movsx/movzx needs an extra ALU uop, but otherwise zero/sign extension is handled right in the load port on Intel and AMD CPUs.) x86 的主要缺点是您需要单独的加载指令,而不是使用内存操作数作为 ALU 指令的源(如果您将零扩展字节添加到 32 位整数),节省前端 uop 吞吐量带宽和代码大小。或者,如果您只是向字节寄存器添加一个字节,那么在 x86 上基本上没有任何缺点。无论如何,RISC 加载-存储 ISA 总是需要单独的加载和存储指令。 x86 字节存储并不比 32 位存储贵。

作为一个性能问题,对于具有慢字节存储的硬件的良好 C++ 实现可能会将每个 char 放在它自己的字中并尽可能使用字 loads/stores(例如对于结构外部的全局变量,以及对于堆栈上的本地人)。 IDK 如果 MIPS / ARM / 任何具有慢字节 load/store 的实际实现,但如果是这样,gcc 可能有 -mtune= 选项来控制它。

That doesn't help for char[],或者在您不知道 char * 指向何处时取消引用它。 (这包括您将用于 MMIO 的 volatile char*。)因此让编译器+链接器将 char 变量放在单独的单词中并不是一个完整的解决方案,如果真正的字节存储很慢,这只是一个性能黑客.


PS:关于 Alpha 的更多信息:

Alpha 很有趣,原因有很多:少数干净的 64 位 ISA 之一,而不是对现有 32 位 ISA 的扩展。以及最近的全新 ISA 之一,Itanium 是几年后的另一个 ISA,它尝试了一些简洁的 CPU 架构想法。

From the Linux Alpha HOWTO.

When the Alpha architecture was introduced, it was unique amongst RISC architectures for eschewing 8-bit and 16-bit loads and stores. It supported 32-bit and 64-bit loads and stores (longword and quadword, in Digital's nomenclature). The co-architects (Dick Sites, Rich Witek) justified this decision by citing the advantages:

  1. Byte support in the cache and memory sub-system tends to slow down accesses for 32-bit and 64-bit quantities.
  2. Byte support makes it hard to build high-speed error-correction circuitry into the cache/memory sub-system.

Alpha compensates by providing powerful instructions for manipulating bytes and byte groups within 64-bit registers. Standard benchmarks for string operations (e.g., some of the Byte benchmarks) show that Alpha performs very well on byte manipulation.