现代 DDR3 SDRAM. 资料来源: BY-SA/4.0 by Kjerish
最近,在访问山景城的计算机历史博物馆的时,我深深地被过去的一些磁芯内存所吸引。
资料来源: BY-SA/3.0 by Konstantin Lanzet
我发现我完全不知道这些东西曾经是如何运作的。我想知道环是否旋转(它们没有),以及为什么每个环都有三根电线穿过(我仍然不明白它们是如何工作的)。更重要的是,我意识到我对现代计算机内存——动态RAM的工作方式知之甚少!
资料来源: Ulrich Drepper's series about memory
我对动态RAM工作原理的结果之一特别感兴趣。如你所见,每一位数据都是通过RAM芯片内的一个小电容充电(或低电平)存储的。但随着时间的推移,这些电容逐渐失去电荷。为避免丢失存储的数据,必须定期刷新以将电荷(如果存在)恢复到其原始级别。此刷新过程包括读取每个位的值,然后将其写回。在此“刷新”期间,内存被占用,无法执行加载或存储位等正常操作。
这困扰了我很长一段时间,我想知道......我们有没有捕捉到软件中刷新带来的延迟?
动态RAM刷新入门
每个DIMM模块由“单元(cells)”和“行(rows)”,“列(columns)”,“面(sides)”和/或“模组(ranks)”组成。犹他大学的这个演讲诠释了命名法。您可以使用 decode-dimms
命令检查计算机中的内存配置。举例如下:
$ decode-dimms
Size 4096 MB
Banks x Rows x Columns x Bits 8 x 15 x 10 x 64
Ranks 2
如今我们不需要进入整个DDR DIMM布局,我们只需要知道单个存储单元(存储一位信息)。具体来说,我们只关心储存单元如何执行刷新过程。
我们来看两份文档:
一个来自Micron的1千兆位单元的精彩文档:TN-46-09设计用于1Gb DDR SDRAM
存储在动态存储器中的每个位都必须经过刷新,通常为每64ms一次(称为静态刷新)。这是一项相当耗时的操作。为避免每64ms出现一次大的停顿,这一过程被分为8192个较小的刷新操作。在每个操作中,计算机的存储器控制器向DRAM芯片发送刷新命令。在接收到指令后,芯片将刷新其单元总数的1/8192。计算一下: 64ms / 8192 = 7812.5 ns或7.81μs。
这意味着:
每7812.5 ns有一次刷新命令,称为tREFI。
芯片执行刷新和恢复需要一些时间,因此它可以再次执行正常的读写操作。这一次称为tRFC,为75ns或120ns(参考提到的Micron数据表)。
当存储器很热(> 85C)时,存储器单元电荷保持时间下降,静态刷新时间减半到32ms,tREFI下降到3906.25 ns。
典型的内存芯片用于刷新的时间占其运行时间的很大一部分——介于0.4%到5%之间。此外,存储器芯片功耗是典型计算机功耗中的一大部分,并且大部分功率都用于执行刷新。
在刷新动作的持续时间内,整个存储器芯片被封锁。这意味着每过7812ns,内存中的每个位都被封锁超过75ns。让我们来测量一下吧。
准备实验
for (i = 0; i < ...; i++) {
// Perform memory load. Any load instruction will do
*(volatile int *) one_global_var;
// Flush CPU cache. This is relatively slow
_mm_clflush(one_global_var);
// mfence is needed, otherwise sometimes the loop
// takes very short time (25ns instead of like 160). I
// blame reordering.
asm volatile("mfence");
// Measure and record time
clock_gettime(CLOCK_MONOTONIC, &ts);
}
要以纳秒级间隔测量操作,我们必须编写一个紧密循环(也许用C语言编写)。它看起来是这样的:
代码非常简单直接:执行内存读取, CPU刷新缓存数据,测量时间。
(注:在第二个实验中,我尝试使用MOVNTDQA来执行数据加载,但这需要特殊的不可缓存的内存页面,这种页面需要root访问权限。)
# timestamp, loop duration
3101895733, 134
3101895865, 132
3101896002, 137
3101896134, 132
3101896268, 134
3101896403, 135
3101896762, 359
3101896901, 139
3101897038, 137
在我的计算机上,它生成的数据是这样的:
通常我每循环得到140ns,循环持续时间周期性地跳到360ns。有时我会得到超过3200ns的奇数读数。
不幸的是,数据结果非常杂乱,很难看出是否存在由刷新周期导致的明显延迟。
快速傅里叶变换
从某种程度上来说是可以计算到的。由于我们想要找到一个固定时间间隔事件,我们可以将数据输入FFT(快速傅立叶变换)算法,该算法能够揭示频率域的结果。
我并不是第一个想到这一点的人——以Rohamham成名的Mark Seaborn 在2015年就采用了这项技术。即使在看过了Mark的代码之后,利用快速傅里叶变换计算也比我预想的更难。但最后我完成了这一工作。
首先,我们需要准备数据。快速傅里叶计算要求对输入数据进行恒定的采样间隔采样。我们还需过滤数据以减少噪音。通过反复试验,我发现数据被预处理后的计算效果最好:
忽略循环迭代的小值(小于平均值* 1.8),并用“0”读数替换。我们不希望将噪音输入算法。
所有剩余的读数都替换为“1”,因为我们不需要考虑由某些噪声引起的延迟幅度。
我设定的采样间隔为100ns,但实际设置为任意达到奈奎斯特值(双倍预期频率)的数字也能正常计算。
在数据输入FFT之前,我们需要使用固定的时序对数据进行采样。所有合理的采样方法都可以正常计算,我最终选择了基本的线性插值。
UNIT=100ns
A = [(timestamp, loop_duration),...]
p = 1
for curr_ts in frange(fist_ts, last_ts, UNIT):
while not(A[p-1].timestamp <= curr_ts < A[p].timestamp):
p += 1
v1 = 1 if avg*1.8 <= A[p-1].duration <= avg*4 else 0
v2 = 1 if avg*1.8 <= A[p].duration <= avg*4 else 0
v = estimate_linear(v1, v2, A[p-1].timestamp, curr_ts, A[p].timestamp)
B.append( v )
该算法大致是:
[0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 0,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ...]
结果会在我的数据中产生相当单调的矢量,如下所示:
C = numpy.fft.fft(B)
C = numpy.abs(C)
F = numpy.fft.fftfreq(len(B)) * (1000000000/UNIT)
矢量很长,通常约为20万个数据点。通过这样的数据准备,我们准备将其输入FFT!
很简单吧?结果将会产生两个向量:
C中包含复杂的频率分量。我们对复杂的数字不感兴趣,我们可以通过调用
abs()
函数将它们处理成单调的分量。F中包含各频率采样率(采样点数)对应的矢量C中位置的标签。我们需要将其标准化为Hz单位——通过将其乘以输入矢量采样频率。
结果可以绘制成下图:
由于我们将延迟时间归一化,因此Y轴是无单位的。尽管如此,它仍然清楚地显示了某些固定频率间隔的峰值。让我们放大来看:
127850.0
127900.0
127950.0
255700.0
255750.0
255800.0
255850.0
255900.0
255950.0
383600.0
383650.0
我们可以清楚地看到前三个峰值。经过一些无意义的算术,包括将读数至少是平均值10倍的滤除,我们可以提取出基础频率:
计算:1000000000(ns / s)/ 127900(Hz)= 7818.6 ns
太棒了!第一个频率尖峰确实是我们正在寻找的,并且确实与刷新时间相关。
256kHz,384kHz,512kHz等的其他尖峰是我们128kHz基频的倍数(称为谐波)。这些是预期之内对像方波这样的东西进行快速傅里叶变换的副产物。
~/2018-11-memory-refresh$ make
gcc -msse4.1 -ggdb -O3 -Wall -Wextra measure-dram.c -o measure-dram
./measure-dram | python3 ./analyze-dram.py
[*] Verifying ASLR: main=0x555555554890 stack=0x7fffffefe2ec
[ ] Fun fact. I did 40663553 clock_gettime()'s per second
[*] Measuring MOVQ + CLFLUSH time. Running 131072 iterations.
[*] Writing out data
[*] Input data: min=117 avg=176 med=167 max=8172 items=131072
[*] Cutoff range 212-inf
[ ] 127849 items below cutoff, 0 items above cutoff, 3223 items non-zero
[*] Running FFT
[*] Top frequency above 2kHz below 250kHz has magnitude of 7716
[+] Top frequency spikes above 2kHZ are at:
127906Hz 7716
255813Hz 7947
383720Hz 7460
511626Hz 7141
为了便于实验,我们准备了此工具的命令行版本。您可以自己运行代码。这是在我的服务器上运行的示例:
我必须承认代码并不完全稳定。如果出现问题,请考虑禁用Turbo Boost,或进行CPU频率调整和性能调整。
最后
这项工作有两个主要的要点。
我们已经看到低电平数据很难分析,而且看起来很杂乱。但是我们可以采用良好而久经考验的快速傅里叶变换,而不是试图用肉眼看出来。在准备数据时我们需要按照一些主观的想法来处理。
最重要的是,我们发现通常情况下,从简单的用户空间流程中测量细微的硬件行为是可行的。这种想法促成了原始的Rowhammer漏洞的发现,被用于Meltdown / Spectre攻击,并在最近的ECC击败Rowhammer再生中再次出现。
还有很多话题还可以聊。我们现在仅仅是了解到内存子系统内部工作的表面。我建议阅读以下内容来进一步了解:
最后,这里有一个关于旧磁芯存储器的好读物: