【问题标题】:how does the processor read memory?处理器如何读取内存?
【发布时间】:2020-05-24 17:57:14
【问题描述】:

我正在尝试重新实现 malloc,我需要了解对齐的目的。据我了解,如果内存对齐,代码将执行得更快,因为处理器不必采取额外的步骤来恢复被切割的内存位。我想我理解 64 位处理器读取 64 位乘 64 位内存。现在,让我们假设我有一个按顺序排列的结构(没有填充):char、short、char 和 int。为什么短线会错位?我们拥有块中的所有数据!为什么它必须在一个是 2 的倍数的地址上。对于整数和其他类型的相同问题?

我还有第二个问题:对于我之前提到的结构,处理器如何知道它何时读取它的 64 位,前 8 位对应于一个字符,然后接下来的 16 位对应于一个短等等...... ?

【问题讨论】:

  • 两个不同的问题。如果你想看看malloc() 是如何工作的,它不一定与内存对齐有关。你可以在这里查看malloc() 的实现:jemalloc.net
  • malloc 不知道您如何使用它返回的内存,因此需要在您的机器上使用最严格的数据对齐方式。此外,未对齐的访问可能不仅速度很慢,还可能导致运行时异常,具体取决于您所在的机器。
  • 您之前问题的答案有什么问题?
  • 这能回答你的问题吗? Purpose of memory alignment
  • 这与malloc有什么关系? malloc 你最终会得到一个大小,只需分配那个大小,程序员想要用它做什么不是你的问题。要么让所有分配从一个对齐的地址开始,比如 32 位或 64 位,要么不要。

标签: c cpu cpu-architecture memory-alignment low-level


【解决方案1】:

效果甚至可以包括正确性,而不仅仅是性能:如果您有一个不满足 alignof(short)short 对象,C 未定义行为 (UB) 会导致可能的段错误或其他不当行为。 (在加载/存储指令默认需要对齐的 ISA 上会出现错误,例如 SPARC 和 MIPS64r6 之前的 MIPS)

如果_Atomic int 没有alignof(_Atomic int),则会破坏原子操作。

(通常alignof(T) = sizeof(T) 在任何给定的 ABI 中达到一定大小,通常是寄存器宽度或更宽)。


malloc 应该返回内存为alignof(max_align_t),因为您没有任何关于如何使用分配的类型信息。

对于小于sizeof(max_align_t) 的分配,您可以根据需要返回仅自然对齐的内存(例如,4 字节分配按 4 字节对齐),因为您知道存储不能可用于任何具有更高对齐要求的事物。

alignas (16) int32_t foo 的动态分配等价物这样过度对齐的东西需要使用像 C11 aligned_alloc 这样的特殊分配器。如果您正在实现自己的分配器库,您可能希望支持aligned_realloc 和aligned_calloc,填补ISO C 无缘无故留下的那些空白。

并确保您不要在分配大小不是对齐的倍数时实现aligned_alloc 失败的脑死亡 ISO C++17 要求。没有人想要一个分配器拒绝从 16 字节边界开始分配 101 个浮点数,或者为了更好的透明巨页而更大。 aligned_alloc function requirementsHow to solve the 32-byte-alignment issue for AVX load/store operations?


我想我理解 64 位处理器读取 64 位 x 64 位内存

不。数据总线宽度和突发大小,以及加载/存储执行单元的最大宽度或实际使用的宽度,不必与整数寄存器的宽度相同,或者 CPU 定义其位数。 (在现代高性能 CPU 中通常不是。例如,32 位 P5 Pentium 具有 64 位总线;现代 32 位 ARM 具有执行原子 64 位访问的加载/存储对指令。)

处理器将整个高速缓存行从 DRAM / L3 / L2 高速缓存读取到 L1d 高速缓存中;现代 x86 上 64 字节;在其他一些系统上为 32 字节。

当读取单个对象或数组元素时,它们会从 L1d 缓存中读取元素宽度。例如uint16_t 数组可能仅受益于 2 字节加载/存储的 2 字节边界对齐。

或者,如果编译器使用 SIMD 对循环进行向量化,则可以一次读取 16 或 32 个 字节uint16_t 数组,即 8 或 16 个元素的 SIMD 向量。 (甚至 64 与 AVX512)。将数组与预期的向量宽度对齐会很有帮助;未对齐的 SIMD 加载/存储在现代 x86 上运行得很快,当它们不跨越缓存线边界时。


缓存行拆分,尤其是页面拆分是现代 x86 因未对齐而减慢速度的地方;在高速缓存行中未对齐通常不是因为它们将晶体管用于快速未对齐的加载/存储。其他一些 ISA 会因任何未对齐而减慢速度,有些甚至会出现故障,即使在高速缓存行内也是如此。解决方法是一样的:给类型自然对齐:alignof(T) = sizeof(T)。

在您的结构示例中,即使 short 未对齐,现代 x86 CPU 也不会受到惩罚。 alignof(int) = 4 在任何普通 ABI 中,所以整个结构都有 alignof(struct) = 4,所以 char;short;char 块从 4 字节边界开始。因此,short 包含在单个 4 字节 dword 中,不跨越任何更宽的边界。 AMD 和英特尔都以完全的效率处理这个问题。 (并且 x86 ISA 保证在与 P5 Pentium 或更高版本兼容的 CPU 上对它的访问是原子的,甚至是非缓存的:Why is integer assignment on a naturally aligned variable atomic on x86?

某些非 x86 CPU 会因未对齐的短路而受到惩罚,或者必须使用其他指令。 (由于您知道相对于对齐的 32 位块的对齐方式,因此对于加载,您可能会执行 32 位加载和移位。)

所以是的,访问包含short 的单个单词没有问题,但是问题在于加载端口硬件将short 提取并零扩展(或符号扩展)到一个完整的寄存器中. 这就是 x86 使用晶体管来实现这一速度的地方。 (@Eric's answer 在此问题的先前版本中更详细地介绍了所需的转换。)

将未对齐的存储提交回缓存也很重要。例如,L1d 缓存可能在 32 位或 64 位块(我将其称为“缓存字”)中具有 ECC(针对位翻转的纠错)。因此,仅写入缓存字的一部分是一个问题,以及将其转移到您要访问的缓存字内的任意字节边界。 (在存储缓冲区中合并相邻的窄存储可以产生一个全宽提交,从而避免 RMW 循环来更新一个单词的一部分,在以这种方式处理窄存储的缓存中)。请注意,我现在说的是“单词”,因为我说的是更加面向单词的硬件,而不是像现代 x86 那样围绕未对齐的加载/存储进行设计。 Are there any modern CPUs where a cached byte store is actually slower than a word store?(存储单个字节只比未对齐的short简单一点)

(如果short 跨越两个缓存字,它当然需要分离 RMW 周期,每个字节一个。)

当然,short 未对齐,原因很简单,alignof(short) = 2 违反了此 ABI 规则(假设 ABI 确实具有该规则)。因此,如果您将指向它的指针传递给其他函数,您可能会遇到麻烦。尤其是在负载未对齐时出现故障的 CPU 上,而不是在运行时结果未对齐时由硬件处理这种情况。然后你可以得到像Why does unaligned access to mmap'ed memory sometimes segfault on AMD64? 这样的情况,其中 GCC 自动矢量化通过执行一些 2 字节元素标量的倍数预计会达到 16 字节边界,因此违反 ABI 会导致 x86 上的段错误(通常可以容忍错位.)


有关内存访问的完整详细信息,从 DRAM RAS / CAS 延迟到缓存带宽和对齐,请参阅What Every Programmer Should Know About Memory? 它几乎仍然相关/适用

Purpose of memory alignment 也有一个很好的答案。 SO 的 标签中还有很多其他好的答案。

如需更详细地了解(某种程度上)现代英特尔加载/存储执行单元,请参阅:https://electronics.stackexchange.com/questions/329789/how-can-cache-be-that-fast/329955#329955


处理器如何知道当它读取它的 64 位时,前 8 位对应于一个字符,然后接下来的 16 位对应于一个短等等......?

它没有,除了它正在运行以这种方式处理数据的指令。

在 asm / 机器码中,一切都只是字节。每条指令指定准确地处理哪些数据。由编译器(或人类程序员)在原始字节数组(主存储器)之上实现具有类型的变量和 C 程序的逻辑。

我的意思是,在 asm 中,您可以运行任何您想要的加载或存储指令,并由您在正确的地址上使用正确的指令。您可以将与两个相邻的int 变量重叠的 4 个字节加载到浮点寄存器中,然后在其上运行 addss(单精度 FP 添加),CPU 不会抱怨.但您可能不想这样做,因为让 CPU 将这 4 个字节解释为 IEEE754 binary32 浮点数不太可能有意义。

【讨论】:

  • 我认为您误解了 OP 的第二个问题。一切都只是字节并不能解释它们中的两个如何在某个地址成为(高级语言)短(在结构内)。
  • @old_timer:也许这个短语没有我希望传达的概念那么有意义:你可以运行任何你想要的加载或存储指令,这取决于你使用正确地址上的正确地址。您可以将两个int 变量之间的重叠加载到浮点寄存器中并在其上运行addss(单精度加法),CPU 不会抱怨。但你可能不想这样做。
  • 是的,明白了。
  • @old_timer:更新了我的答案,指出编译器必须在你在 asm.xml 中获得的平面内存数组之上实现变量/对象。感谢您的反馈,它可能不像我希望的那样清楚。
  • 基于这个问题和其他问题,OP 对事物的工作方式有一个基本的误解,特别是存在不同的架构和解决方案。更少的概括性。而且我仍然看不出这与实现新的 malloc 有什么关系。然而,对于特定架构,有大量关于可用指令主题和可用寻址模式的文档来涵盖所有这些问题。
【解决方案2】:

现代处理器和内存旨在尽可能优化内存访问。当前访问内存的一种方法是不是逐字节而是通过更大块的地址来寻址它,例如由一个 8 字节块组成。这样,您不需要地址的低 3 位。要访问块内的某个字节,进程需要在对齐的地址处获取块,然后移位和屏蔽该字节。所以,它变慢了。

当结构中的字段未对齐时,可能会减慢对它们的访问速度。因此,最好将它们对齐。

但是对齐要求是基于底层平台的。对于支持字访问(32 位)的系统,4 字节对齐是可以的,否则可以使用 8 字节或其他。编译器(和 libc)知道需求。

因此,在您的示例 char、short、char 中,如果不填充,short 将从奇数字节位置开始。要访问它,系统可能需要读取结构的 64 位字,然后将其右移 1 个字节,然后屏蔽 2 个字节,以便为您提供该字节。

【讨论】:

    【解决方案3】:

    据我了解,如果内存对齐,代码将执行得更快,因为处理器不必采取额外的步骤来恢复被切割的内存位。

    这不一定是执行的事情,x86 具有可变长度的指令,从单个 8 位指令开始,最多可达几个字节,这都是关于未对齐的。但他们已采取措施在很大程度上消除这种情况。

    如果我的处理器边缘有 64 位总线,这并不意味着芯片边缘,而是内核边缘。另一面是一个内存控制器,它知道总线协议,是地址开始被解码的第一个地方,事务开始将其他总线拆分到它们的目的地。

    它非常特定于架构和总线设计,随着时间的推移,您可以拥有具有不同总线的架构或不同版本,例如,您可以获得具有 64 总线或 32 位总线的臂。但是假设我们有一个非典型的情况,即总线为 64 位宽,并且该总线上的所有事务都在 64 位边界上对齐。

    如果我要对 0x1000 进行 64 位写入,这将是一个单一的总线事务,现在这是某种写入地址总线,带有一些 id x 和长度为 0 (n-1) 然后另一边确认我看到你想用 id x 进行写入,我准备好接收你的数据。然后处理器使用 id x 的数据总线发送数据,每 64 位一个时钟,这是一个 64 位,因此该总线上有一个时钟。可能会返回一个 ack,也可能不会。

    但是,如果我想对 0x1004 进行 64 位写入,会发生什么变成两个事务,一个完整的 64 位地址/数据事务位于地址 0x1000,只有四个字节通道启用通道 4-7(表示地址 0x1004-0x1007)。然后在 0x1008 完成一个完整的事务,启用 4 字节通道,通道 0-3。因此,总线上的实际数据移动从一个时钟变为两个时钟,但也有两倍的握手开销才能到达这些数据周期。在那辆公共汽车上,您可能会感觉到整体系统设计如何,或者可能需要做很多事情才能感觉到,但这是非常明显的。但效率低下是存在的,不管是否埋没在噪音中。

    我想我理解 64 位处理器读取 64 位 x 64 位内存。

    根本不是一个好的假设。如今,32 位 ARM 具有 64 位总线,例如 ARMv6 和 ARMv7 都带有或可以。

    现在,假设我有一个按顺序排列的结构(没有填充):char、short、char 和 int。为什么短线会错位?我们拥有块中的所有数据!为什么它必须在一个是 2 的倍数的地址上。整数和其他类型的问题相同?

    unsigned char a   0x1000
    unsigned short b  0x1001
    unsigned char c   0x1003
    unsigned int d    0x1004
    

    您通常会在代码 something.a something.b something.c something.d 中使用结构项。当您访问 something.b 时,它是针对总线的 16 位事务。在 64 位系统中,您是正确的,如果按照我已经解决的方式对齐,那么当您执行 x = something.b 时正在读取整个结构,但是处理器将丢弃除字节通道 1 和 2 之外的所有通道(丢弃 0 和3-7),那么如果您访问 something.c,它将在 0x1000 处进行另一次总线事务并丢弃除通道 3 之外的所有内容。

    当您使用 64 位总线写入 something.b 时,仅启用字节通道 1 和 2。现在更痛苦的地方是,如果有一个缓存,它很可能也由一个 64 位 ram 构造来与这个总线配合,不是必须的,但让我们假设它有。您想通过缓存写入 something.b,0x1000 处的写入事务,字节通道 1 和 2 启用 0、3-7 禁用。缓存最终会得到这个事务,它在内部必须执行读取修改写入,因为它不是一个完整的 64 位宽事务(启用所有通道),因此从性能角度来看,您也会受到读取修改写入的影响(上面未对齐的 64 位写入也是如此)。

    short 是未对齐的,因为在打包时它的地址 lsbit 被设置,要对齐 8 位中的 16 位项目,字节世界需要为零,因为 32 位项目要对齐其低两位地址是零,64位,三个零等等。

    根据系统的不同,您最终可能会使用 32 位或 16 位总线(这些天不是为了内存),因此您最终可以进行多次传输。

    您的高效处理器(如 MIPS 和 ARM)采用了对齐指令的方法,并强制对齐事务,即使在 something.b 情况下也不会对 32 位或 64 位总线造成任何损失。该方法是性能优于内存消耗,因此指令在某种程度上浪费了它们的消耗,以提高它们的获取和执行效率。数据总线同样简单得多。当构造高级概念(如 C 中的结构)时,在填充以对齐结构中的每个项目以获得性能时会浪费内存。

    unsigned char a   0x1000
    unsigned short b  0x1002
    unsigned char c   0x1004
    unsigned int d    0x1008
    

    举个例子

    我还有第二个问题:对于我之前提到的结构,处理器如何知道它何时读取它的 64 位,前 8 位对应于一个字符,然后接下来的 16 位对应于一个短等等...... ?

    unsigned char c   0x1003
    

    编译器在地址 0x1003 处生成单字节大小的读取,这将转换为具有该地址的特定指令,并且处理器生成总线事务来执行此操作,然后处理器总线的另一端执行其工作,依此类推下线。

    编译器通常不会将该结构的打包版本转换为提供所有项目的单个 64 位事务,而是为每个项目刻录一个 64 位总线事务。

    有可能取决于指令集、预取器、缓存等,而不是在高层使用结构,而是创建一个 64 位整数并在代码中完成工作,那么您可能会或可能会没有获得性能。在大多数使用缓存等运行的架构上,预计这不会更好地执行,但是当您进入嵌入式系统时,您可能在 ram 上有一些等待状态,或者在闪存或任何代码存储上有一些等待状态您可以找到时间,而不是更少的指令和更多的数据事务,您想要更多的指令和更少的数据事务。代码是线性的 代码段,如读取、掩码和移位、掩码和移位等。指令存储可能具有用于线性事务的突发模式,但数据事务需要尽可能多的时钟。

    中间立场是将所有内容都设为 32 位变量或 64 位,然后全部对齐并以使用更多内存为代价执行相对较好。

    因为人们不懂对齐,被 x86 编程宠坏了,选择跨编译域使用结构(这是个坏主意),ARM 和其他人正在容忍未对齐的访问,你可以非常感受到性能的影响那些平台,因为如果一切都对齐,它们会非常高效,但是当你做一些不对齐的事情时,它只会产生更多的总线事务,从而使一切花费更长的时间。因此,较旧的臂默认会出现故障,arm7 可以禁用故障但会围绕字旋转数据(在一个字中交换 16 位值的好技巧)而不是溢出到下一个字,后来的架构默认不对齐错误或大多数人将它们设置为不对齐错误,并且他们按照人们希望/期望的方式读取/写入未对齐的传输。

    对于您的计算机中的每个 x86 芯片,您在同一台计算机或挂在该计算机上的外围设备(鼠标、键盘、显示器等)中都有多个(如果不是少数)非 x86 处理器。其中很多是 8 位 8051s 和 z80s,但也有很多是基于 arm 的。因此,不仅所有手机和平板电脑的主处理器正在进行大量非 x86 开发。其他人希望低成本和低功耗,以便在总线性能方面提高编码效率,从而降低时钟速度,同时在整体上平衡代码/数据使用,以降低闪存/内存的成本。

    在 x86 平台上强制执行这些对齐问题非常困难,要克服其架构问题需要大量开销。但是您可以在更高效的平台上看到这一点。就像火车对跑车,有人从火车上掉下来或跳上火车,有如此大的动力,以至于一点都没有注意到,但是改变跑车上的质量,你会感觉到它。所以尝试在 x86 上执行此操作,如果您甚至可以弄清楚如何执行此操作,您将不得不更加努力地工作。但在其他平台上更容易看到效果。除非你找到一个 8086 芯片并且我怀疑你能感觉到那里的差异,否则必须拿出我的手册来确认。

    如果您有幸能够访问芯片源/模拟,那么您可以看到到处都在发生这种事情,并且可以真正开始手动调整您的程序(针对该平台)。同样,您可以看到各种形式的缓存、写缓冲、指令预取等对整体性能有何作用,有时会创建并行时间段,在这些时间段内其他效率不高的事务可以隐藏,或者故意创建备用周期,因此需要额外时间的事务可以有一个时间片。

    【讨论】:

    • 我认为 OP 是在谈论 data 内存,而不是指令内存。请注意,他们正在谈论实现 malloc。 (你的答案的开头是谈论可变长度的 x86 指令和代码对齐。还没有看过其余的。)
    • 基本上每次看到这个我都会给出相同的答案。 99% 是关于数据的,但也试图涵盖存在不同总线架构和解决方案的概念,但它们最终都有固定宽度的总线。不能在逐个事务的基础上添加和删除芯片部分或边缘内的金属连接,您可以选择有时不使用它们,但通常情况并非如此,但这取决于......操作人员想要一个具体的答案,但它这个问题太笼统了。
    猜你喜欢
    • 1970-01-01
    • 2020-05-28
    • 1970-01-01
    • 2018-07-14
    • 1970-01-01
    • 1970-01-01
    • 2014-07-19
    • 1970-01-01
    • 2020-05-02
    相关资源
    最近更新 更多