内存访问模式很重要

内存访问模式对软件性能至关重要。线性、伪随机和大步走等模式影响高速缓存子系统的效率。理解Cache Oblivious Algorithms可以帮助优化算法,减少缓存丢失,提升并行执行效果。在多核和大内存时代,考虑缓存友好性成为设计算法时的必要考量。
摘要由CSDN通过智能技术生成
在高性能计算中,通常会说高速缓存未命中的代价是算法的最大性能损失。 多年来,处理器速度的提高大大超过了延迟到主内存的速度。 通过更宽的多通道总线,到主内存的带宽已大大增加,但是延迟并未显着减少。 为了掩盖这种延迟,我们的处理器采用了具有许多层的,越来越复杂的缓存子系统。
1994年的论文“触及内存壁:显而易见的含义”描述了这个问题,并继续指出,由于强制性丢失缓存,缓存最终并没有帮助。 我的目的是表明,通过使用显示缓存层次结构注意事项的访问模式,这个结论是不可避免的。
让我们从一些示例开始将问题放在上下文中。 我们的硬件尝试通过多种技术来隐藏主内存延迟。 基本上,对内存访问模式有三大押注:
  1. 时间的:最近可能需要再次访问最近访问的内存。
  2. 空间:可能很快就会需要相邻的内存。
  3. 大步走:内存访问可能遵循可预测的模式。
为了说明这三个赌注,让我们编写一些代码并衡量结果。
  1. 以线性方式遍历内存是完全可预测的。
  2. 伪随机在限制区域内四处走走,然后继续前进。 这个限制区域就是通常所说的内存的操作系统页面
  3. 伪随机地在大堆区域中走动。
以下代码应与-Xmx4g JVM选项一起运行。
public class TestMemoryAccessPatterns
{
    private static final int LONG_SIZE = 8;
    private static final int PAGE_SIZE = 2 * 1024 * 1024;
    private static final int ONE_GIG = 1024 * 1024 * 1024;
    private static final long TWO_GIG = 2L * ONE_GIG;
 
    private static final int ARRAY_SIZE = (int)(TWO_GIG / LONG_SIZE);
    private static final int WORDS_PER_PAGE = PAGE_SIZE / LONG_SIZE;
 
    private static final int ARRAY_MASK = ARRAY_SIZE - 1;
    private static final int PAGE_MASK = WORDS_PER_PAGE - 1;
 
    private static final int PRIME_INC = 514229;
 
    private static final long[] memory = new long[ARRAY_SIZE];
 
    static
    {
        for (int i = 0; i < ARRAY_SIZE; i++)
        {
            memory[i] = 777;
        }
    }
 
    public enum StrideType
    {
        LINEAR_WALK
        {
            public int next(final int pageOffset, final int wordOffset, final int pos)
            {
                return (pos + 1) & ARRAY_MASK;
            }
        },
 
        RANDOM_PAGE_WALK
        {
            public int next(final int pageOffset, final int wordOffset, final int pos)
            {
                return pageOffset + ((pos + PRIME_INC) & PAGE_MASK);
            }
        },
 
        RANDOM_HEAP_WALK
        {
            public int next(final int pageOffset, final int wordOffset, final int pos)
            {
                return (pos + PRIME_INC) & ARRAY_MASK;
            }
        };
 
        public abstract int next(int pageOffset, int wordOffset, int pos);
    }
 
    public static void main(final String[] args)
    {
        final StrideType strideType;
        switch (Integer.parseInt(args[0]))
        {
            case 1:
                strideType = StrideType.LINEAR_WALK;
                break;
 
            case 2:
                strideType = StrideType.RANDOM_PAGE_WALK;
                break;
 
            case 3:
                strideType = StrideType.RANDOM_HEAP_WALK;
                break;
 
            default:
                throw new IllegalArgumentException("Unknown StrideType");
        }
 
        for (int i = 0; i < 5; i++)
        {
            perfTest(i, strideType);
        }
    }
 
    private static void perfTest(final int runNumber, final StrideType strideType)
    {
        final long start = System.nanoTime();
 
        int pos = -1;
        long result = 0;
        for (int pageOffset = 0; pageOffset < ARRAY_SIZE; pageOffset += WORDS_PER_PAGE)
        {
            for (int wordOffset = pageOffset, limit = pageOffset + WORDS_PER_PAGE;
                 wordOffset < limit;
                 wordOffset++)
            {
                pos = strideType.next(pageOffset, wordOffset, pos);
                result += memory[pos];
            }
        }
 
        final long duration = System.nanoTime() - start;
        final double nsOp = duration / (double)ARRAY_SIZE;
 
        if (208574349312L != result)
        {
            throw new IllegalStateException();
        }
 
        System.out.format("%d - %.2fns %s\n",
                          Integer.valueOf(runNumber),
                          Double.valueOf(nsOp),
                          strideType);
    }
}

结果

Intel U4100 @ 1.3GHz, 4GB RAM DDR2 800MHz, 
Windows 7 64-bit, Java 1.7.0_05
===========================================
0 - 2.38ns LINEAR_WALK
1 - 2.41ns LINEAR_WALK
2 - 2.35ns LINEAR_WALK
3 - 2.36ns LINEAR_WALK
4 - 2.39ns LINEAR_WALK

0 - 12.45ns RANDOM_PAGE_WALK
1 - 12.27ns RANDOM_PAGE_WALK
2 - 12.17ns RANDOM_PAGE_WALK
3 - 12.22ns RANDOM_PAGE_WALK
4 - 12.18ns RANDOM_PAGE_WALK

0 - 152.86ns RANDOM_HEAP_WALK
1 - 151.80ns RANDOM_HEAP_WALK
2 - 151.72ns RANDOM_HEAP_WALK
3 - 151.91ns RANDOM_HEAP_WALK
4 - 151.36ns RANDOM_HEAP_WALK

Intel i7-860 @ 2.8GHz, 8GB RAM DDR3 1333MHz, 
Windows 7 64-bit, Java 1.7.0_05
=============================================
0 - 1.06ns LINEAR_WALK
1 - 1.05ns LINEAR_WALK
2 - 0.98ns LINEAR_WALK
3 - 1.00ns LINEAR_WALK
4 - 1.00ns LINEAR_WALK

0 - 3.80ns RANDOM_PAGE_WALK
1 - 3.85ns RANDOM_PAGE_WALK
2 - 3.79ns RANDOM_PAGE_WALK
3 - 3.65ns RANDOM_PAGE_WALK
4 - 3.64ns RANDOM_PAGE_WALK

0 - 30.04ns RANDOM_HEAP_WALK
1 - 29.05ns RANDOM_HEAP_WALK
2 - 29.14ns RANDOM_HEAP_WALK
3 - 28.88ns RANDOM_HEAP_WALK
4 - 29.57ns RANDOM_HEAP_WALK

Intel i7-2760QM @ 2.40GHz, 8GB RAM DDR3 1600MHz, 
Linux 3.4.6 kernel 64-bit, Java 1.7.0_05
=================================================
0 - 0.91ns LINEAR_WALK
1 - 0.92ns LINEAR_WALK
2 - 0.88ns LINEAR_WALK
3 - 0.89ns LINEAR_WALK
4 - 0.89ns LINEAR_WALK

0 - 3.29ns RANDOM_PAGE_WALK
1 - 3.35ns RANDOM_PAGE_WALK
2 - 3.33ns RANDOM_PAGE_WALK
3 - 3.31ns RANDOM_PAGE_WALK
4 - 3.30ns RANDOM_PAGE_WALK

0 - 9.58ns RANDOM_HEAP_WALK
1 - 9.20ns RANDOM_HEAP_WALK
2 - 9.44ns RANDOM_HEAP_WALK
3 - 9.46ns RANDOM_HEAP_WALK
4 - 9.47ns RANDOM_HEAP_WALK

分析

我在3种不同的CPU架构上运行了该代码,这些代码说明了英特尔在代代化方面的进步。 从结果可以明显看出,基于上述针对相对较小堆的3个下注,每一代在隐藏到主内存的延迟方面都变得越来越好。 这是因为各种缓存的大小和复杂性一直在提高。 但是,随着内存大小的增加,它们的作用减弱。 例如,如果将阵列大小增加一倍以达到4GB,则i7-860进行随机堆遍历的平均延迟从约30ns增加到约55ns。
对于线性遍历情况,似乎不存在内存延迟。 但是,当我们以越来越多的随机模式在内存中四处走动时,等待时间开始变得非常明显。
随机堆遍历产生了有趣的结果。 这是我们最坏的情况,考虑到这些系统的硬件规格,我们可能会根据内存控制器和内存模块的等待时间分别为上述测试选择150ns,65ns和75ns。 对于Nehalem(i7-860),我可以使用4GB阵列进一步破坏高速缓存子系统,从而使每次迭代平均约55ns。 i7-2760QM具有更大的负载缓冲区,TLB缓存,并且Linux运行着透明的大页面,所有这些页面都在进一步隐藏延迟。 通过跨步使用不同的质数,结果会因处理器类型而异,例如对于Nehalem尝试PRIME_INC = 39916801。 我想用Sandy Bridge在更大的堆上进行测试。
主要优势是对存储器的访问模式越可预测,那么缓存子系统隐藏主存储器延迟的性能就越好。 让我们更详细地看一下这些缓存子系统,以尝试了解所观察到的结果。
硬件组件
我们有很多层缓存以及预取器,以考虑如何隐藏延迟。 在本节中,我将尝试介绍用于隐藏我们的硬件和系统软件朋友所采用的延迟的主要组件。 我们将调查这些延迟隐藏的组件和使用Linux PERF谷歌轻量级性能计数器的实用程序从我们的CPU,告诉我们执行我们的计划,这些组件是如何有效的检索性能计数器。 性能计数器是特定于CPU的,我在这里使用的是Sandy Bridge的特定。
资料快取
处理器通常具有2或3层数据缓存。 随着迁移的增加,每一层都随着延迟的增加而逐渐变大。 最新的Intel处理器具有3层(L1D,L2和L3)。 大小分别为32KB,256KB和4-30MB; 对于3.0GHz CPU,延迟分别为〜1ns,〜4ns和〜15ns。
数据缓存实际上是硬件哈希表,每个哈希值具有固定数量的插槽。 这些插槽称为“方式”。 8路关联高速缓存将具有8个插槽,以保存散列到相同高速缓存位置的地址的值。 在这些插槽中,数据缓存不存储字,而是存储多个字的缓存行。 对于Intel处理器,这些高速缓存行通常为64字节,即在64位计算机上为8个字。 这在空间上押注了可能很快需要相邻存储器的情况,如果我们想到一个对象的数组或字段,通常就是这种情况。
数据缓存通常以LRU方式逐出。 高速缓存通过使用回写算法来工作,而存储仅在驱逐修改后的高速缓存行时才需要传播到主内存中。 这引起了有趣的现象,即负载可能导致对外部高速缓存层以及最终对主存储器的写回。
perf stat -e L1-dcache-loads,L1-dcache-load-misses java -Xmx4g TestMemoryAccessPatterns $

 Performance counter stats for 'java -Xmx4g TestMemoryAccessPatterns 1':
     1,496,626,053 L1-dcache-loads                                            
       274,255,164 L1-dcache-misses
         #   18.32% of all L1-dcache hits

 Performance counter stats for 'java -Xmx4g TestMemoryAccessPatterns 2':
     1,537,057,965 L1-dcache-loads                                            
     1,570,105,933 L1-dcache-misses
         #  102.15% of all L1-dcache hits 

 Performance counter stats for 'java -Xmx4g TestMemoryAccessPatterns 3':
     4,321,888,497 L1-dcache-loads                                           
     1,780,223,433 L1-dcache-misses
         #   41.19% of all L1-dcache hits  

likwid-perfctr -C 2 -g L2CACHE java -Xmx4g TestMemoryAccessPatterns $

java -Xmx4g TestMemoryAccessPatterns 1
+-----------------------+-------------+
|         Event         |   core 2    |
+-----------------------+-------------+
|   INSTR_RETIRED_ANY   | 5.94918e+09 |
| CPU_CLK_UNHALTED_CORE | 5.15969e+09 |
| L2_TRANS_ALL_REQUESTS | 1.07252e+09 |
|     L2_RQSTS_MISS     | 3.25413e+08 |
+-----------------------+-------------+
+-----------------+-----------+
|     Metric      |  core 2   |
+-----------------+-----------+
|   Runtime [s]   |  2.15481  |
|       CPI       | 0.867293  |
| L2 request rate |  0.18028  |
|  L2 miss rate   | 0.0546988 |
|  L2 miss ratio  | 0.303409  |
+-----------------+-----------+

java -Xmx4g TestMemoryAccessPatterns 2
+-----------------------+-------------+
|         Event         |   core 2    |
+-----------------------+-------------+
|   INSTR_RETIRED_ANY   | 1.48772e+10 |
| CPU_CLK_UNHALTED_CORE | 1.64712e+10 |
| L2_TRANS_ALL_REQUESTS | 3.41061e+09 |
|     L2_RQSTS_MISS     | 1.5547e+09  |
+-----------------------+-------------+
+-----------------+----------+
|     Metric      |  core 2  |
+-----------------+----------+
|   Runtime [s]   | 6.87876  |
|       CPI       | 1.10714  |
| L2 request rate | 0.22925  |
|  L2 miss rate   | 0.104502 |
|  L2 miss ratio  | 0.455843 |
+-----------------+----------+

java -Xmx4g TestMemoryAccessPatterns 3
+-----------------------+-------------+
|         Event         |   core 2    |
+-----------------------+-------------+
|   INSTR_RETIRED_ANY   | 6.49533e+09 |
| CPU_CLK_UNHALTED_CORE | 4.18416e+10 |
| L2_TRANS_ALL_REQUESTS | 4.67488e+09 |
|     L2_RQSTS_MISS     | 1.43442e+09 |
+-----------------------+-------------+
+-----------------+----------+
|     Metric      |  core 2  |
+-----------------+----------+
|   Runtime [s]   |  17.474  |
|       CPI       |  6.4418  |
| L2 request rate | 0.71973  |
|  L2 miss rate   | 0.220838 |
|  L2 miss ratio  | 0.306835 |
+-----------------+----------+
注意:随着访问模式变得更加随机,组合的L1D和L2的缓存丢失率显着增加。
翻译后备缓冲区(TLB)
我们的程序处理需要转换为物理内存地址的虚拟内存地址。 虚拟内存系统通过映射页面来做到这一点。 我们需要知道给定页面的偏移量以及任何内存操作的大小。 通常,页面大小为4KB,然后逐渐增加到2MB或更大。 Linux在2.6.38内核中引入了Transparent Huge Pages ,为我们提供了2MB的页面。 虚拟内存页到物理页的转换由页表维护。 这种转换可能导致对页表的多次访问,这是巨大的性能损失。 为了加快查找速度,处理器在每个缓存级别都有一个称为TLB缓存的小型硬件缓存。 由于页表可能不在附近的数据高速缓存中,因此未命中TLB高速缓存可能会造成巨大的代价。 通过移至更大的页面,TLB高速缓存可以为相同数量的条目覆盖更大的地址范围。
perf stat -e dTLB-loads,dTLB-load-misses java -Xmx4g TestMemoryAccessPatterns $
 
 Performance counter stats for 'java -Xmx4g TestMemoryAccessPatterns 1':
     1,496,128,634 dTLB-loads
           310,901 dTLB-misses
              #    0.02% of all dTLB cache hits 

 Performance counter stats for 'java -Xmx4g TestMemoryAccessPatterns 2':
     1,551,585,263 dTLB-loads
           340,230 dTLB-misses
              #    0.02% of all dTLB cache hits

 Performance counter stats for 'java -Xmx4g TestMemoryAccessPatterns 3':
     4,031,344,537 dTLB-loads
     1,345,807,418 dTLB-misses
              #   33.38% of all dTLB cache hits
注意:当使用大页面时,当随机遍历整个堆时,我们只会招致重大的TLB丢失。
硬件预取器
硬件将尝试预测我们程序将进行的下一次内存访问,并推测性地将该内存加载到填充缓冲区中。 通过为空间投注预先加载相邻的缓存行,或通过识别基于规则的跨步访问模式(通常跨步长度通常小于2KB),可以在最简单的级别上完成此操作。 下面的测试正在测量从硬件预取中命中填充缓冲区的负载数量。
likwid-perfctr -C 2 -t intel -g LOAD_HIT_PRE_HW_PF:PMC0 java -Xmx4g TestMemoryAccessPatterns $

java -Xmx4g TestMemoryAccessPatterns 1
+--------------------+-------------+
|       Event        |   core 2    |
+--------------------+-------------+
| LOAD_HIT_PRE_HW_PF | 1.31613e+09 |
+--------------------+-------------+

java -Xmx4g TestMemoryAccessPatterns 2
+--------------------+--------+
|       Event        | core 2 |
+--------------------+--------+
| LOAD_HIT_PRE_HW_PF | 368930 |
+--------------------+--------+

java -Xmx4g TestMemoryAccessPatterns 3
+--------------------+--------+
|       Event        | core 2 |
+--------------------+--------+
| LOAD_HIT_PRE_HW_PF | 324373 |
+--------------------+--------+
注意:在线性行走中,通过预取器,成功实现成功的负载命中率。
内存控制器和行缓冲区
除了上一级缓存(LLC)之外,还有存储控制器,用于管理对SDRAM库的访问。 内存分为行和列。 要访问地址,首先必须选择行地址(RAS),然后在该行中选择列地址(CAS)以获取单词。 该行通常为页面大小,并加载到行缓冲区中。 即使在此阶段,硬件仍在帮助隐藏延迟。 内存访问请求队列被维护和重新排序,以便在可能的情况下可以从同一行中提取多个字。
非统一内存访问(NUMA)
系统现在在CPU插槽上具有内存控制器。 与现有的前端总线(FSB)和外部北桥存储控制器相比,这种向插座式存储控制器的转移使等待时间减少了约50ns。 具有多个插槽的系统使用内存互连,即Intel的QPI ,当一个CPU想要访问由另一个CPU插槽管理的内存时使用。 这些互连的存在引起服务器内存访问的不统一性质。 在2插槽系统中,内存可能是本地内存,也可能是1跳。 在8插槽系统上,内存最多可以跳3个跳,每个跳在每个方向上都会增加20ns的延迟。
这对算法意味着什么?
L1D缓存命中与导致主内存访问的完全未命中之间的差为2个数量级; 即<1ns与65-100ns。 如果算法随机地绕过我们不断增加的地址空间,那么我们不太可能受益于隐藏这种延迟的硬件支持。
在设计算法和数据结构时,我们能做些什么? 是的,我们可以做很多事情。 如果我们对位于同一位置的数据执行大量工作,并且以可预测的方式跨越内存,那么我们的算法可能会快很多倍。 例如,与其像JDK中那样使用存储桶和链式哈希表,不如使用带有线性探测的开放地址的哈希表。 与其在每个节点中使用链表或带有单个项目的树,不如在每个节点中存储许多项目的数组。

正在研究与高速缓存子系统协调工作的算法方法。 我发现令人着迷的一个方面是Cache Oblivious Algorithms 。 这个名称有点误导,但是这里有一些很棒的概念,可以用来提高软件性能和更好地并行执行。 本文很好地说明了可以获得的性能优势。

结论

为了获得出色的性能,同情缓存子系统非常重要。 我们已经在本文中看到了通过以与这些缓存(而不是针对这些缓存)一起工作的模式访问内存可以实现的目标。 现在,在设计算法和数据结构时,考虑缓存丢失更为重要,可能比计算算法中的步骤还要重要。 在学习计算机科学时,这不是我们在算法理论中所学的。 在过去的十年中,技术发生了一些根本性的变化。 对我来说,最重要的两个是多核以及具有64位地址空间的大内存系统的兴起。

可以肯定的是,如果我们希望软件能够更快地执行并更好地扩展,我们需要更好地利用CPU中的许多内核,并注意内存访问模式。
参考:机械访问”博客上的JCG合作伙伴 Martin Thompson提供了内存访问模式很重要

翻译自: https://www.javacodegeeks.com/2012/08/memory-access-patterns-are-important.html

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值