现代处理器上的并行内存访问

14

我有一颗最近的12核英特尔CPU(Haswell架构),它有4个内存通道。这台机器可以同时执行多少DRAM内存访问?

例如,如果我有一个程序使用12个线程,在一个紧密循环中读取随机内存地址的单个字节,而地址范围太大无法放入缓存中。我预计所有12个线程将几乎全部时间都在等待内存获取。

这些线程必须轮流使用DRAM总线吗?

注意:假设我使用1GB VM页面大小,因此没有TLB缓存未命中。


1
这更多关于计算机工程而非计算机科学。 - Yuval Filmus
你的程序还应该是一个并行程序,这样它才能充分利用CPU的容量。这意味着使用类似MPI或OpenMP的并行编程来编写程序。 - Juniar
考虑到这个问题仅涉及特定处理器型号的工作原理,而不是如何使处理器工作,因此这并不涉及科学问题。因此,我将把这个问题迁移到一个工程网站上。 - Gilles 'SO- stop being evil'
@Giles - 为了避免任何潜在的反弹......我一开始没有在Stackoverflow上提出问题,因为它确实满足“告诉我你试图解决什么具体问题”的要求。Meta stackexchange告诉我在CS网站上提问计算机架构问题(https://meta.stackexchange.com/questions/193961/where-i-should-ask-computer-architecture-questions)。只要其他人也同意,我很乐意让它留在这里。我也看到它并不是CS stackexchange网站的理想选择。 - Andrew Bainbridge
糟糕,我是指“不符合”。 - Andrew Bainbridge
1个回答

18

英特尔的数据手册几乎回答了这个问题。

我的第一个线索来自于英特尔论坛上的一个问题: https://communities.intel.com/thread/110798

Jaehyuk.Lee在2017年2月1日09:27提出了与我几乎相同的问题:

第二个问题是关于IMC上的同时请求及其对新型CPU模型(如Skylake和Kaby Lake)的支持。根据 http://www.intel.com/Assets/PDF/datasheet/323341.pdf 上面的链接,“内存控制器可以处理最多32个同时请求(读和写操作)”,我想知道Skylake和Kabylake CPU支持多少个同时请求。我已经查看了英特尔CPU数据手册的第6和第7代,但没有找到任何信息。

该链接已失效。但他的“32”数字听起来很有道理。

一位英特尔员工回复了他,并引用了第1卷:针对S平台的第6代英特尔®处理器系列中的内容:

内存控制器具有先进的命令调度程序,它同时检查所有挂起的请求以确定下一个发出的最有效请求。从所有挂起的请求中选出最有效的请求,并即时发出到系统内存,以充分利用命令重叠。因此,不必让所有内存访问请求通过仲裁机制单独进行,强制请求一次执行一次,而是可以在不干扰当前请求的情况下开始执行,从而实现并发请求的发出。这样可以实现优化带宽和减少延迟,同时保持适当的命令间隔,以满足系统内存协议要求。
令人恼火的是,我的Xeon E5-2670 v3的数据手册没有相应的章节。
答案的另一个部分是E5-2670具有4个DDR通道。内存在256字节粒度上交错以优化带宽。换句话说,如果您从地址0读取1024字节块,则前256字节从DIMM 0获取。字节256到511来自DIMM 1等等。
将两者结合起来,我怀疑内存控制器可以并行执行4个读取操作,并且足够聪明,如果有4个或更多线程正在等待映射到4个不同DIMMs的读取,则会并行执行它们。它具有足够的硬件来保持其调度表中约32个读取/写入。
我可以想到另一种实现并行的可能方式。每个DDR通道都有自己的数据和地址总线。当内存控制器请求读取时,它使用地址线+一些控制线来请求读取,然后等待响应。对于随机读取,通常有两个等待时间 - RAS到CAS延迟和CAS延迟 - 每个约15个周期。在这些等待期间,您可以想象内存控制器从不同的DIMM(*)开始另一个读取。我不知道是否已经这样做了。
* 实际上,根据此Anandtech文章,DRAM硬件中的并行性不仅仅是每个通道具有多个DIMM。每个DIMM可能具有多个rank,每个rank具有许多bank。我认为您可以切换到DIMM内的任何其他rank和bank以执行另一个并行访问。
编辑
我测量过我的计算机至少可以同时执行6个随机访问,尽管只有4个内存通道。因此,单个内存通道可以执行2个或更多个随机访问,也许可以使用我在上面段落中描述的方案。
要获得此信息,我使用tinymembench测量了我的机器上DRAM访问的延迟。结果为60 ns。然后,我编写了一个小型C程序,从1 GB的随机数表中执行32位读取,并使用结果递增校验和。伪代码:
uint32_t checksum = 0;
for (int i = 0; i < 256 * 1024 * 1024; i++) {
    unsigned offset = rand32() & (TABLE_SIZE - 1);
    checksum += table_of_random_numbers[offset];
}

每次循环平均耗时为10纳秒。这是因为我的CPU中的乱序执行和预测执行功能能够将该循环并行化6次。即10纳秒=60纳秒/6。

如果我用以下代码替换:

unsigned offset = rand32() & (TABLE_SIZE - 1);
for (int i = 0; i < 256 * 1024 * 1024; i++) {
    offset = table_of_random_numbers[offset];
    offset &= (TABLE_SIZE - 1);
}

然后每次迭代需要60纳秒,因为循环无法并行化。它无法并行化,因为每个访问的地址都取决于前一个读取的结果。我还检查了编译器生成的汇编代码,以确保它没有进行并行化。
编辑2:我决定测试在多个进程中运行多个测试时会发生什么。我使用了上面包含校验和的程序片段(即似乎每个访问的延迟为10纳秒的那个)。通过同时运行6个实例,我得到了平均表观延迟为13.9纳秒,这意味着大约有26个访问必须同时发生。(60纳秒/13.9纳秒)* 6 = 25.9。
6个实例是最优的。更多的实例会导致总吞吐量下降。
编辑3 - 回答Peter Cordes RNG问题
我尝试了两种不同的随机数生成器。
uint32_t g_seed = 12345;
uint32_t fastrand() {
    g_seed = 214013 * g_seed + 2531011;
    return g_seed;
}

并且

// *Really* minimal PCG32 code / (c) 2014 M.E. O'Neill / pcg-random.org
// Licensed under Apache License 2.0 (NO WARRANTY, etc. see website)
typedef struct { uint64_t state;  uint64_t inc; } pcg32_random_t;

uint32_t pcg32_random_r(pcg32_random_t* rng)
{
    uint64_t oldstate = rng->state;
    // Advance internal state
    rng->state = oldstate * 6364136223846793005ULL + (rng->inc|1);
    // Calculate output function (XSH RR), uses old state for max ILP
    uint32_t xorshifted = ((oldstate >> 18u) ^ oldstate) >> 27u;
    uint32_t rot = oldstate >> 59u;
    return (xorshifted >> rot) | (xorshifted << ((-rot) & 31));
}

他们的表现差不多。我记不清确切的数字了。我看到的单线程峰值性能是使用更简单的RNG,它给出了平均延迟为8.5纳秒的摊销,意味着可以并行读取7个数。定时循环的汇编代码如下:

// Pseudo random number is in edx
// table is in rdi
// loop counter is in rdx
// checksum is in rax
.L8:
        imull   $214013, %edx, %edx
        addl    $2531011, %edx
        movl    %edx, %esi
        movl    %edx, g_seed(%rip)
        andl    $1073741823, %esi
        movzbl  (%rdi,%rsi), %esi
        addq    %rsi, %rax
        subq    $1, %rcx
        jne     .L8
        ret

我不理解"g_seed(%rip)",它是一个内存访问吗?编译器为什么要这样做?
编辑4-从随机数生成器中删除全局变量
我按照Peter的建议从随机数生成器中删除了全局变量。生成的代码确实更简洁。我还切换到了Intel语法进行反汇编(感谢提示)。
// Pseudo random number is in edx
// table is in rdi
// loop counter is in rdx
// checksum is in rax
.L8:
        imul    edx, edx, 214013
        add     edx, 2531011
        mov     esi, edx
        and     esi, 1073741823
        movzx   esi, BYTE PTR [rdi+rsi]
        add     rax, rsi
        sub     rcx, 1
        jne     .L8
        ret

性能没有发生变化,无论是单进程还是多进程情况。


1
有趣的结果表明,单个核心平均只能处理约6个in-flight cache-miss-loads。有依赖和独立加载两种数据点很好。我想知道这是来自L2<->L3之间有限的并发性,还是其他原因?我也想知道你的rand32()函数有多慢,以及它是否访问内存(这将命中缓存,但CPU必须与cache-miss负载进行推测性重排序)。此外,如果速度太慢,ROB大小限制可能会防止OOO执行查看那么大的指令窗口。 - Peter Cordes
1
你出于某种原因正在使用AT&T语法(我喜欢使用gcc -masm = intelobjdump -drwC -Mintel),所以是的,g_seed(%rip)是一个静态内存位置,使用RIP相对寻址(对于x86-64来说很正常)。请使用static g_seed = ...(最好作为函数范围变量,但文件范围也可以,只要它是static而不是全局的),这样编译器就可以将其优化为寄存器,而不是让它保留给外部函数查看。 - Peter Cordes
全局变量对性能和人类可读性都不利。最好通过引用传递。是的,你的fastrand()线性同余生成器很棒。它甚至比xorshift更简单,但仍然足够随机。更好的是,它不会重复相同的地址,直到看到每个其他地址,因为周期与范围相同。在这种情况下,这是一个特点。 - Peter Cordes
1
等一下,我以为你正在测试只读负载。但是 addb $1, (%rdi,%rax) 正在写入表格,就像 table_of_random_numbers[offset]++ 一样。这可能解释了每个核心少于10个未完成的负载,因为某些 LFB 忙于存储,当需要驱逐脏缓存行时。还要注意,char* 可以与任何东西别名,因此如果编译器不确定表格存储是否别名 g_seed,它将不得不在循环内部存储/重新加载它。这是将其作为本地变量并通过引用传递给 PRNG 的另一个原因,而不是使用全局变量(甚至是 static)。 - Peter Cordes
1
是的,正如@PeterCordes上面暗示的那样,集中于内存控制器并行性可能并不能给你完整的画面。到达内存的路径相当长,实际的DRAM访问只占用了很短的墙钟时间。从核心到控制器再返回L1需要大部分时间。 "32个同时请求"更多地是为了使内存控制器对待处理的请求有更多的可见性,以便它可以很好地安排它们(尝试将读取组合到同一页以避免页面打开延迟)。 - BeeOnRope
显示剩余11条评论

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接