最近我观察到一些内存密集型工作负载的性能问题,但无法解释。为了找出问题所在,我开始运行几个微基准测试,以确定常见的性能参数,如缓存线大小和 L1/L2/L3 缓存大小(我已经知道它们,我只是想看看我的测量是否反映了实际值)。
对于缓存行测试,我的代码大致如下(Linux C,但类似于 Windows 等):
char *array = malloc (ARRAY_SIZE);
int count = ARRAY_SIZE / STEP;
clock_gettime(CLOCK_REALTIME, &start_time);
for (int i = 0; i < ARRAY_SIZE; i += STEP) {
array[i]++;
}
clock_gettime(CLOCK_REALTIME, &end_time);
// calculate time per element here:
[..]
将
STEP
从1变化到128,结果显示从STEP=64
开始,每个元素的时间不再进一步增加,即每次迭代需要获取一个新的高速缓存行来支配运行时间。将
ARRAY_SIZE
从1K变化到16384K,保持STEP=64
,我能够创建一个漂亮的图表,展示出大致对应于L1、L2和L3延迟的步进模式。然而,为了得到可靠的数据,对于非常小的数组大小,甚至需要重复循环100,000次以上。在我的IvyBridge笔记本上,我可以清楚地看到L1在64K结束,L2在256K结束,甚至L3在6M结束。现在是我的真正问题:在NUMA系统中,任何单个核心都会获取远程主内存以及并非像本地缓存和内存一样接近的共享缓存。我希望看到延迟/性能的差异,从而确定我可以分配多少内存,同时仍然留在我的快速缓存/内存部分。
为此,我将我的测试细化为以1/10 MB块遍历内存,分别测量延迟,然后收集最快的块,大致如下:
for (int chunk_start = 0; chunk_start < ARRAY_SIZE; chunk_start += CHUNK_SIZE) {
int chunk_end = MIN (ARRAY_SIZE, chunk_start + CHUNK_SIZE);
int chunk_els = CHUNK_SIZE / STEP;
for (int i = chunk_start; i < chunk_end; i+= STEP) {
array[i]++;
}
// calculate time per element
[..]
一旦我开始将
ARRAY_SIZE
增加到大于L3大小的某个值,我得到的数字变得异常不可靠,即使重复次数很多也无法平坦化。对于这种情况,我无法获得可用于性能评估的模式,更无法确定NUMA条带的起始、结束或位置。
然后,我发现硬件预取已经足够聪明,可以识别我的简单访问模式,并在我访问它们之前将所需的行提前取入缓存中。将一个随机数添加到数组索引中会增加每个元素的时间,但在其他方面似乎没有太大的帮助,可能是因为我在每次迭代中有一个rand()
调用。预计算一些随机值并将它们存储在数组中对我来说似乎不是一个好主意,因为这个数组同样会被存储在一个热缓存中并且会影响我的测量结果。将STEP
增加到4097或8193也没有太大帮助,预取器必须比我更聪明。
我的方法是否合理/可行,或者我错过了更大的画面?是否可能像这样观察NUMA延迟?如果是,我做错了什么?
我禁用了地址空间随机化,以确保排除奇怪的缓存别名效应。在测量之前,是否还有其他操作系统方面的调整需要进行?