随机存取存储器如何工作?为啥是恒定时间随机访问?

Posted

技术标签:

【中文标题】随机存取存储器如何工作?为啥是恒定时间随机访问?【英文标题】:How does random access memory work? Why is it constant-time random-access?随机存取存储器如何工作?为什么是恒定时间随机访问? 【发布时间】:2013-09-13 14:46:32 【问题描述】:

或者换句话说,为什么访问数组中的任意元素需要恒定时间(而不是O(n) 或其他时间)?

我用谷歌搜索了这个问题的答案,但没有找到一个很好的答案,所以我希望你们中的一个可以与我分享你的低级知识。

只是为了让您了解我希望得到的答案有多低,我会告诉您为什么我认为这需要恒定的时间。

当我在程序中说array[4] = 12 时,我实际上只是将内存地址的位表示存储到寄存器中。硬件中的这个物理寄存器将根据我输入的位表示打开相应的电信号。然后,这些电信号将以某种神奇的方式(希望有人能解释其中的魔力)访问物理/主存储器中的正确内存地址。

我知道这很粗略,但这只是为了让您了解我正在寻找什么样的答案。

(编者注:从 OP 的后期 cmets 中,他了解到地址计算需要恒定的时间,只是想知道之后会发生什么。)

【问题讨论】:

您是在问编译器是如何工作的,还是电子设备是如何工作的? 您所描述的对于某些内存架构来说是相当准确的,但并非对所有架构都如此。例如,图灵机要求我们使用一个步骤来推进任意长的磁带,在磁带上写入符号(以及从中读取符号)。此外,某些语言不支持对数据进行类似数组的“随机访问”。 @Mike W 电子产品。将内存地址的位表示放在寄存器中如何导致能够从主内存中的该地址读取/写入? @KacyRaye 您可以将地址中的每一位视为选择剩余内存子系统的一半或另一半。有时它们是分组的,因此可能会解码四个位以启用内存子系统的十六个可能部分之一。由于内存硬件没有改变,因此到达特定内存单元所需的“切换”量是一个固定常数。 (这个过程称为“地址解码”。) 因为现代 RAM 内存本质上提供了对内存的随机访问? 【参考方案1】:

从数组的开头到任何给定元素的计算只需要两个操作,一个乘法(乘以 sizeof(element))和加法。这两个操作都是常数时间。通常使用当今的处理器,基本上可以立即完成,因为处理器针对这种访问进行了优化。

【讨论】:

我知道计算内存地址需要 2 次操作,但我的问题更针对的是,一旦将其存储在寄存器中,它如何在 O(1) 时间内访问该内存地址。 @KacyRaye 然后问 this 问题,而不是问为什么数组是 O(1),如果后者对你来说是显而易见的。 +1 对于这个答案,想自己写这个,直到看到一些 cmets 并在问题中提到 “神奇的电信号”【参考方案2】:

因为软件喜欢 O(1) 的“工作”内存,因此硬件被设计成这样

基本的一点是,程序的地址空间被认为是抽象地具有 O(1) 访问性能,即无论你想读取什么内存位置,它都应该花费一些恒定的时间(无论如何这与它与最后一次内存访问之间的距离)。因此,作为数组只不过是地址空间的连续块,它们应该继承此属性(访问数组的元素只需将索引添加到数组的起始地址,然后取消引用获得的指针)。

这个属性来自这样一个事实,一般来说,程序的地址空间与 PC 的物理 RAM 有一些对应关系,正如名称(随机存取存储器)部分暗示的那样, 本身应该具有这样的属性,即无论您要访问 RAM 中的任何位置,您都可以在恒定时间内到达它(例如,对于磁带驱动器,寻道时间取决于您的磁带的实际长度)必须移动才能到达那里)。

现在,对于“常规”RAM,此属性(至少 AFAIK)是正确的 - 当处理器/主板/内存控制器要求 RAM 芯片获取一些数据时,它会在恒定时间内这样做;细节与软件开发无关,内存芯片的内部结构过去曾多次更改,将来还会再次更改。如果您有兴趣了解当前 RAM 的详细信息,可以查看here about DRAM。

一般概念是 RAM 芯片不包含必须移动的磁带或必须定位的磁盘臂;当您在某个位置向他们询问一个字节时,对于您可能要求的任何位置,工作(主要是更改某些硬件多路复用器的设置,将输出连接到存储字节状态的单元)都是相同的;因此,您可以获得 O(1) 的性能

这背后有一些开销(逻辑地址必须由 MMU 映射到物理地址,各个主板部件必须相互通信以告​​诉 RAM 获取数据并将其带回处理器, ...),但硬件设计为在或多或少恒定的时间内这样做。

所以:

数组映射到地址空间,该地址空间映射到 RAM,它具有 O(1) 随机访问;作为所有映射(或多或少)O(1),数组保持了 RAM 的 O(1) 随机访问性能。


对软件开发人员来说很重要的一点是,尽管我们看到一个平坦的地址空间并且它通常映射到 RAM 上,但在现代机器上,访问任何元素都具有相同的地址是错误的成本。事实上,访问同一区域中的元素可能比在地址空间中跳转便宜方式,因为处理器具有多个板载缓存(=更小但更快的片上内存)将最近使用的数据和内存保持在同一区域;因此,如果您有良好的数据局部性,内存中的连续操作将不会一直撞击 ram(其延迟比缓存长得多),最终您的代码将运行得更快。

此外,在内存压力下,提供虚拟内存的操作系统可以决定将地址空间中很少使用的页面移动到磁盘,并在它们被访问时按需获取它们(响应页面错误);这样的操作是非常昂贵的,并且再次严重偏离了访问任何虚拟内存地址都是相同的想法。

【讨论】:

所以基本上我关心的是我真的不应该关心的东西,我应该接受这个小概念对我来说永远是魔法......哈哈。严肃地说,我想我明白了你提出的一个重要观点。即使我今天了解了随机访问是如何在硬件中实现的,明天它总是会发生变化。我想我可以对此感到高兴。感谢您的回答。 @KacyRaye:这样看:RAM 芯片不包含必须移动的磁带或必须定位的磁盘臂;当您在某个位置向他们询问一个字节时,对于您可能要求的任何位置,工作(主要是更改某些硬件多路复用器的设置,将输出连接到存储字节状态的单元)都是相同的;因此,您可以获得 O(1) 的性能。 @KacyRaye 内存芯片是一个单元格。每个单元格持有一位。提供给芯片的地址被分成两半用于行地址选择(RAS)和列地址选择(CAS),行和列唯一地选择一个要访问的单元。 @KacyRaye 这里有多种抽象在起作用。你的程序、编程语言、计算机硬件模型、芯片、电子设备、量子效应等等。我鼓励你学习所有你想要的所有东西,但你真的只需要了解最顶层的几个级别就可以成为一名优秀的程序员 自“Fast Burst”以来,RAM 已经十多年没有 O(1) 了。但您不会注意到这一点,因为该功能旨在与缓存行结合使用。【参考方案3】:

C 和 C++ 中的数组具有随机访问权限,因为它们以有限的、可预测的顺序存储在 RAM - 随机访问内存中。因此,需要一个简单的线性运算来确定给定记录的位置 (a[i] = a + sizeof(a[0]) * i)。这个计算有恒定的时间。从 CPU 的角度来看,不需要“seek”或“rewind”操作,它只是告诉内存“加载地址 X 的值”。

但是:在现代 CPU 上,获取数据需要恒定时间的想法不再适用。它需要constant amortized time,具体取决于给定的数据是否在缓存中。

仍然 - 一般原则是无论地址如何,从 RAM 中获取一组给定的 4 或 8 个字节的时间都是相同的。例如。如果您从头开始访问 RAM[0] 和 RAM[4294967292],CPU 将在相同的周期数内获得响应。

#include <iostream>
#include <cstring>
#include <chrono>

// 8Kb of space.
char smallSpace[8 * 1024];

// 64Mb of space (larger than cache)
char bigSpace[64 * 1024 * 1024];

void populateSpaces()

    memset(smallSpace, 0, sizeof(smallSpace));
    memset(bigSpace, 0, sizeof(bigSpace));
    std::cout << "Populated spaces" << std::endl;


unsigned int doWork(char* ptr, size_t size)

    unsigned int total = 0;
    const char* end = ptr + size;
    while (ptr < end) 
        total += *(ptr++);
    
    return total;


using namespace std;
using namespace chrono;

void doTiming(const char* label, char* ptr, size_t size)

    cout << label << ": ";
    const high_resolution_clock::time_point start = high_resolution_clock::now();
    auto result = doWork(ptr, size);
    const high_resolution_clock::time_point stop = high_resolution_clock::now();
    auto delta = duration_cast<nanoseconds>(stop - start).count();
    cout << "took " << delta << "ns (result is " << result << ")" << endl;


int main()

    cout << "Timer resultion is " << 
        duration_cast<nanoseconds>(high_resolution_clock::duration(1)).count()
        << "ns" << endl;

    populateSpaces();

    doTiming("first small", smallSpace, sizeof(smallSpace));
    doTiming("second small", smallSpace, sizeof(smallSpace));
    doTiming("third small", smallSpace, sizeof(smallSpace));
    doTiming("bigSpace", bigSpace, sizeof(bigSpace));
    doTiming("bigSpace redo", bigSpace, sizeof(bigSpace));
    doTiming("smallSpace again", smallSpace, sizeof(smallSpace));
    doTiming("smallSpace once more", smallSpace, sizeof(smallSpace));
    doTiming("smallSpace last", smallSpace, sizeof(smallSpace));

现场演示:http://ideone.com/9zOW5q

输出(来自ideone,可能不太理想)

Success  time: 0.33 memory: 68864 signal:0
Timer resultion is 1ns
Populated spaces
doWork/small: took 8384ns (result is 8192)
doWork/small: took 7702ns (result is 8192)
doWork/small: took 7686ns (result is 8192)
doWork/big: took 64921206ns (result is 67108864)
doWork/big: took 65120677ns (result is 67108864)
doWork/small: took 8237ns (result is 8192)
doWork/small: took 7678ns (result is 8192)
doWork/small: took 7677ns (result is 8192)
Populated spaces
strideWork/small: took 10112ns (result is 16384)
strideWork/small: took 9570ns (result is 16384)
strideWork/small: took 9559ns (result is 16384)
strideWork/big: took 65512138ns (result is 134217728)
strideWork/big: took 65005505ns (result is 134217728)

我们在这里看到的是缓存对内存访问性能的影响。我们第一次点击 smallSpace 时,访问所有 8kb 的小空间大约需要 8100ns。但是当我们在之后立即再次调用它,两次,它在 ~7400ns 处减少了 ~600ns。

现在我们开始做 bigspace,它比当前的 CPU 缓存更大,所以我们知道我们已经炸毁了 L1 和 L2 缓存。

回到小,我们确定现在没有缓存,我们再次看到第一次 ~8100ns,第二次看到 ~7400。

我们清空了缓存,现在我们引入了一种不同的行为。我们使用跨步循环版本。这会放大“缓存未命中”效应并显着影响时间,尽管“小空间”适合 L2 缓存,因此我们仍然看到第 1 遍和接下来的 2 遍之间有所减少。

【讨论】:

【参考方案4】:

当我在程序中说 array[4] = 12 时,我实际上只是在存储位 将内存地址表示到寄存器中。这种物理 硬件中的寄存器会开启相应的电器 根据我输入的位表示发出信号。那些电 然后信号会以某种方式神奇地(希望有人可以解释 魔术)访问物理/主内存中的正确内存地址。

我不太确定你在问什么,但我没有看到任何与硬件魔力真正发生的事情相关的答案。希望我理解的足够多,可以通过这个冗长的解释(这仍然是非常高的水平)。

array[4] = 12;

因此,从 cmets 看来,您必须获取数组的基地址,然后乘以数组元素的大小(或者如果可以进行优化,则移位)以获取地址(从您的程序视角)的内存位置。蝙蝠的权利,我们有一个问题。这些物品是否已经在登记册中,还是我们必须去拿它们?数组的基地址可能在寄存器中,也可能不在寄存器中,具体取决于这行代码周围的代码,特别是它之前的代码。该地址可能位于堆栈或其他位置,具体取决于您声明它的位置和方式。这可能与需要多长时间无关。优化编译器可能(经常)预先计算数组 [4] 的地址并将其放置在某个地方以便它可以进入寄存器并且乘法永远不会在运行时发生,因此计算绝对不正确与其他随机访问相比,随机访问的数组 [4] 的时间是固定的。根据处理器的不同,一些直接模式是一条指令,其他指令需要更多,这也与该地址是从 .text 还是堆栈等读取有关的一个因素,等等......为了避免鸡和蛋这个问题死,假设我们有计算出数组[4]的地址。

从程序员的角度来看,这是一个写操作。从一个简单的处理器开始,没有缓存,没有写缓冲区,没有 mmu 等。最终,简单的处理器会将地址放在处理器内核的边缘,带有写选通和数据,每个处理器总线都不同于其他处理器系列,但大致相同,地址和数据可以在同一周期或不同周期中出现。命令类型(读、写)可以同时发生,也可以不同。但命令出来了。处理器内核的边缘连接到解码该地址的内存控制器。结果是一个目的地,这是一个外围设备,如果是,是哪一个,在什么总线上,这个内存是什么,如果是,在什么内存总线上等等。假设 ram,假设这个简单的处理器有 sram 而不是 dram。在苹果与苹果的比较中,Sram 更贵、更快。 sram 有一个地址和写/读选通脉冲和其他控制。最终您将拥有事务类型、读/写、地址和数据。不管它的几何形状如何,sram 都会将各个位路由并存储在它们各自的晶体管对/晶体管组中。

一个写周期可能是一劳永逸的。完成交易所需的所有信息,这是一个写入,这是地址,这是数据,当场就知道。内存控制器可以选择告诉处理器写事务已完成,即使数据不在内存附近。该地址/数据对将花时间进入内存,处理器可以继续运行。一些系统虽然设计是这样的,但处理器写入事务会等待,直到有信号返回以指示写入已经到达内存。在火灾和忘记类型设置中,该地址/数据将在某个地方排队,然后进入内存。队列不能无限深,否则它将是 ram 本身,因此它是有限的,并且有可能并且很可能连续的许多写入可以比另一端写入 ram 更快地填充该队列。此时,当前和/或下一次写入必须等待队列表明还有空间。因此,在这种情况下,您的写入速度有多快,您的简单处理器是否受 I/O 限制都与先前的事务有关,这些事务可能是也可能不是该指令之前的写入指令。

现在添加一些复杂性。 ECC 或任何您想称呼它的名称(EDAC,是另一个名称)。 ECC 内存的工作方式是写入都是固定大小的,即使您的实现是四个 8 位宽的内存部分,每次写入为您提供 32 位数据,您必须固定 ECC 覆盖并且您必须同时写入数据位和 ecc 位(必须在整个宽度上计算 ecc)。因此,如果这是一个 8 位写入,例如写入 32 位 ECC 保护存储器,那么该写入周期需要一个读取周期。读取 32 位(检查读取时的 ecc)修改该 32 位模式中的新 8 位,计算新的 ecc 模式,写入 32 位加上 ecc 位。自然,写入周期的读取部分可能会以 ecc 错误结束,这只会让生活变得更加有趣。通常可以纠正单位错误(如果不能纠正,ECC/EDAC 有什么好处),多位错误则不能。硬件如何设计来处理这些故障会影响接下来发生的事情,读取故障可能只是回到导致写入事务故障的处理器,或者它可能作为中断返回,等等。但这里是另一个随机访问的地方与另一个不同,取决于正在访问的内存,并且读取-修改-写入的访问大小肯定比简单的写入花费更长的时间。

Dram 也可以属于这种固定宽度类别,即使没有 ECC。实际上,所有内存在某些时候都属于这一类。存储器阵列在硅上针对以位为单位的特定高度和宽度进行了优化。您不能违反该内存,它只能在该级别以该宽度的单位进行读取和写入。硅库将包括许多几何形状的 ram,设计人员将为他们的零件选择这些几何形状,零件将有固定的限制,通常您可以使用多个零件来获得该尺寸的整数倍宽度,有时设计会如果只有某些位发生变化,则允许您仅写入其中一个部分,或者某些设计会强制所有部分亮起。请注意您插入家用计算机或笔记本电脑的下一个 ddr 系列模块是如何插入的,第一波是电路板两侧的许多部件。然后,随着该技术变得越来越陈旧,越来越乏味,它可能会变为电路板两侧的部件更少,最终在该技术过时之前成为电路板一侧的部件更少,而我们已经进入下一个技术。

这个固定宽度的类别也带有对齐惩罚。不幸的是,大多数人都是在 x86 机器上学习的,这不会像许多其他平台一样限制您使用对齐的访问。如果允许,未对齐访问在 x86 或其他系统上存在一定的性能损失。当人们第一次学习有关对齐访问的程序员时,通常是当人们去 mips 或通常在某些电池供电设备上的手臂时。并且遗憾地发现它们是痛苦的而不是祝福(由于编程的简单性和由此带来的硬件优势)。简而言之,如果您的内存是 32 位宽并且只能访问、读取或写入,一次 32 位,这意味着它仅限于对齐访问。 32 位宽内存上的内存总线通常没有低地址位 a[1:0],因为它们没有用处。从程序员的角度来看,那些低位是零。如果我们的写入是针对这些 32 位存储器之一的 32 位,并且地址是 0x1002。然后有人必须读取地址 0x1000 处的内存并获取我们的两个字节并修改该 32 位值,然后将其写回。然后取地址 0x1004 的 32 位并修改两个字节并将其写回。单个写入的四个总线周期。如果我们向地址 0x1008 写入 32 位,尽管这将是一个简单的 32 位写入,没有读取。

SRAM 与 DRAM。 DRAM 非常慢,但超级便宜。每比特晶体管数量的一半到四分之一。 (4 用于 SRAM,例如 1 用于 DRAM)。只要电源打开,Sram 就会记住这一点。 DRAM 必须像可充电电池一样被刷新。即使电源停留在一个位上,也只会被记住很短的时间。因此,沿途的一些硬件(ddr 控制器等)必须定期执行总线周期,告诉 ram 记住一定的内存块。这些周期从您想要访问该内存的处理器中窃取时间。 dram 很慢,盒子上可能写着 2133Mhz(2.133ghz)。但它真的更像 133Mhz ram,对 0.133Ghz。第一个作弊是 ddr。通常,数字世界中的事情每个时钟周期发生一次。时钟进入断言状态,然后进入取消断言状态(1 和 0),一个周期就是一个时钟。 DDR意味着它可以在高半周期和低半周期都做一些事情。这样 2133Ghz 内存就真正使用了 1066mhz 时钟。然后出现类似并行的管道,您可以以如此高的速率以突发方式推送命令,但最终必须实际访问该 ram。整体 DRAM 是不确定的并且非常缓慢。另一方面,Sram 不需要刷新,只要电源打开就可以记住。可以快几倍(133mhz * N),以此类推。它可以是确定性的。

下一个障碍,缓存。缓存有好有坏。缓存通常由 sram 制成。希望您对缓存有所了解。如果处理器或上游某人已将事务标记为不可缓存,则它会通过未缓存的方式到达另一侧的内存总线。如果可缓存,则在表中查找地址的一部分,这将导致命中或未命中。这是一次写入,取决于缓存和/或事务设置,如果未命中,它可能会传递到另一端。如果命中,则数据将被写入缓存内存,这取决于缓存类型,它也可能传递到另一端,或者数据可能位于缓存中,等待其他一些数据块将其驱逐,然后它被写入另一端。缓存肯定会进行读取,有时会使写入不确定。顺序访问具有最大的好处,因为您的驱逐率较低,缓存行中的第一次访问相对于其他访问速度较慢,然后其余的访问速度较快。无论如何,这就是我们得到这个随机访问术语的地方。随机访问与旨在使顺序访问更快的方案背道而驰。

有时缓存的远端有一个写缓冲区。一个相对较小的队列/管道/缓冲区/fifo,包含一些写入事务。另一个火并忘记交易,这些好处。

多层缓存。 l1,l2,l3...L1 通常是最快的,无论是技术还是接近度,通常是最小的,从那里开始,它的速度和大小都会上升,其中一些与内存成本有关。我们正在执行写入操作,但是当您执行启用缓存的读取操作时,请了解如果 l1 未命中,则转到 l2,如果未命中,则转到 l3,如果未命中,则转到主存,然后 l3、l2 和l1 all 将存储一个副本。因此,所有 3 项均未命中当然是最痛苦的,并且比完全没有缓存的情况要慢,但是顺序读取将为您提供现在处于 l1 且超快的缓存项,以便缓存成为有用的顺序读取与直接从慢速 DRAM 读取那么多内存相比,通过缓存线总体上花费的时间应该更少。一个系统不必有 3 层缓存,它可以变化。同样,有些系统可以将指令提取与数据读取分开,并且可以有单独的缓存,它们不会相互驱逐,而有些缓存不是独立的,指令提取可以从数据读取中驱逐数据。

缓存有助于解决对齐问题。但是,跨缓存行的非对齐访问当然会受到更严重的惩罚。缓存倾向于使用称为缓存行的内存块进行操作。这些通常是另一侧内存大小的整数倍。例如 32 位内存,例如高速缓存行可能是 128 位或 256 位。因此,如果并且当高速缓存行在高速缓存中时,由于未对齐写入而导致的读取-修改-写入对更快的内存不利,仍然比对齐更痛苦,但没有那么痛苦。如果它是未对齐的读取,并且地址使得该数据的一部分位于高速缓存行边界的一侧,而另一侧位于另一侧,则必须读取两条高速缓存行。例如,读取 16 位可能会在最慢的内存上花费很多字节,显然比完全没有缓存时要慢几倍。取决于缓存和内存系统的一般设计方式,如果您跨缓存线边界进行写入,它可能会同样痛苦,或者可能没有那么多它可能有一部分写入缓存,而另一部分则消失在远端作为较小尺寸的写入。

下一层复杂性是 mmu。允许处理器和程序员产生平坦内存空间的错觉和/或控制缓存或不缓存的内容,和/或内存保护,和/或所有程序都在同一地址空间中运行的错觉(因此您的工具链始终可以编译/link 例如地址 0x8000)。 mmu 在处理器内核端占用一部分虚拟地址。在一个表或一系列表中查找,这些查找通常位于系统地址空间中,因此这些查找中的每一个都可能是上述所有内容中的一个或多个,因为每个都是系统内存上的内存周期。即使您尝试进行写入,这些查找也可能导致 ecc 错误。最终在一次或两次或三次或更多次读取之后,mmu 确定了 mmu 另一侧的地址是什么,以及属性(可缓存与否等),然后传递给下一件事(l1,等),以上所有内容均适用。一些 mmus 在其中有一些先前事务的缓存,请记住,因为程序是顺序的,所以用于提高内存性能错觉的技巧是基于顺序访问,而不是随机访问。所以一些查找可能会存储在 mmu 中,因此它不必立即进入主内存......

因此,在具有 mmus、缓存、dram 的现代计算机中,特别是顺序读取,而且写入可能比随机访问更快。差异可能是巨大的。顺序读取或写入中的第一个事务在那一刻是随机访问,因为它从未被看到过或有一段时间了。一旦序列继续,尽管优化按顺序排列,接下来的几个/一些明显更快。事务的大小和对齐方式在性能方面也起着重要作用。虽然有很多非确定性的事情发生,但作为具有这些知识的程序员,您可以修改您的程序以使其运行得更快,或者如果不幸或故意修改您的程序以使其运行得更慢。在这些系统之一上,顺序通常会更快。随机访问将是非常不确定的。数组[4]=12;其次是数组[37]=12;这两个高级操作在计算写入地址和实际写入本身时可能会花费显着不同的时间。但是例如discarded_variable=array[3];数组[3]=11;数组[4]=12;通常可以比 array[3]=11; 执行得更快。数组[4]=12;

【讨论】:

以上是关于随机存取存储器如何工作?为啥是恒定时间随机访问?的主要内容,如果未能解决你的问题,请参考以下文章

2017.0701.《计算机组成原理》-随机存取存储器

随机存取顺序存取随机存储和顺序存储

RAM computer

RAM computer

存储器

随机存取存储器SRAM