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

时间:2013-09-10 01:59:50

标签: arrays assembly hardware ram random-access

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

我搜索了我的心脏寻找答案,并没有找到一个非常好的答案,所以我希望你们中的一个人可以与我分享你们的低级知识。

只是为了让你知道我希望得到的答案有多低,我会告诉你为什么我认为它需要不断的时间。

当我在程序中说array[4] = 12时,我实际上只是将存储器地址的位表示存储到寄存器中。硬件中的该物理寄存器将根据馈送它的位表示打开相应的电信号。那些电子信号将以某种方式神奇地(希望有人可以解释魔法)在物理/主存储器中访问正确的存储器地址。

我知道这很粗糙,但它只是让你知道我正在寻找什么样的答案。

(编者注:从OP后来的评论中,他理解地址计算需要花费一些时间,并且只是想知道之后会发生什么。)

3 个答案:

答案 0 :(得分:17)

因为软件喜欢O(1)“工作”内存,因此硬件的设计就是那样

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

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

现在,对于“常规”RAM,这个属性(至少是AFAIK)是真的 - 当处理器/主板/内存控制器要求RAM芯片获取一些数据时,它会在恒定时间内完成;细节与软件开发并不相关,内存芯片的内部结构在过去发生过很多次变化,并且将来会再次发生变化。如果您对当前RAM的详细信息感兴趣,可以查看有关DRAM的here

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

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

所以:

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


对于软件开发人员而言确实重要的一点是,虽然我们看到一个扁平的地址空间,但它通常映射到RAM上,但在现代机器上,访问任何元素都是相同的是错误的成本。实际上,由于处理器具有多个板载高速缓存(=更小但更快的片上存储器),因此访问位于同一区域中的元素可能比跳过地址空间更便宜方式保持最近使用的数据和内存在同一邻域;因此,如果你有良好的数据局部性,内存中的连续操作将不会持续命中ram(延迟比缓存长得多),最后你的代码运行得更快。

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

答案 1 :(得分:8)

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

答案 2 :(得分:2)

C和C ++中的数组具有随机访问权限,因为它们以有限的,可预测的顺序存储在RAM中 - 随机存取存储器。结果,需要简单的线性操作来确定给定记录的位置(a [i] = a + sizeof(a [0])* i)。该计算具有恒定的时间。从CPU的角度来看,不需要“搜索”或“倒回”操作,它只是告诉内存“在地址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时需要大约8100ns来访问所有8kb的小空间。但是当我们在两次之后立即再次调用它时,在~7400ns时需要大约600ns。

现在我们离开并做bigspace,它比当前的CPU缓存大,所以我们知道我们已经吹走了L1和L2缓存。

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

我们吹出缓存,现在我们引入了不同的行为。我们使用strided loop版本。这放大了“缓存未命中”效应并显着地破坏了时序,尽管“小空间”适合L2缓存,因此我们仍然看到第1遍和随后的2次传递之间的减少。