毛银杰:恒生电子股份有限公司
摘 要:本文简单地阐述了访存延迟的形成原因,列出各种存储类型的典型延迟值,并对CPU的缓存机制做了概要性的介绍;通过一个实例,形象地展示内存访问的具体延迟以加深读者的印象。最后,对开发微秒级的应用提出一些建议性的意见,以降低应用的延迟。
关键词:访存延迟;高速缓存;缓存命中;预读;工作集
对于一个毫秒级延迟的应用而言,程序员应该更多的考虑应用的整体逻辑架构的调整,减少进程间的交互次数,减少或者优化数据库的访问;调整系统的物理部署,尽量将不同应用部署在同一主机或者增加网络带宽以求减少网络的延迟因素,如有可能改变系统间的应用层交互协议以求加快协议报文的处理等。通常来说,上述的几种调整方案具有立竿见影的效果,一般只需耗费程序员几天的功夫,且调整是可控的,但带来的收益是巨大的,可以达到减少数百微秒甚至数毫秒访存延迟的效果。本文探讨的内容并不适合于毫秒级应用的性能调优。
对于微秒级延迟的应用(例如典型的20us左右延迟的应用)来说,其逻辑架构和物理部署一般都已经达到或者接近最优化,难以在这方面再取得突破。此时,程序员应该更多地审视自己的代码,以求发现瓶颈之所在,尽可能地再次降低几个微秒甚至十几微秒的延迟,以达到个位数的微秒级延迟的目标。考虑到我们的前提是逻辑架构和物理部署已经最优化,因此这里可以忽略所有I/O相关的因素。考虑如下事实:
根据以上两点,我们基本可以确定:在一个微秒级的应用中,访存延迟将成为整个应用延迟的主要瓶颈;解决了访存延迟就等于解决了应用延迟。
本文从内存访问入手分析纳秒级的延迟(多个纳秒级延迟累积到微秒级延迟)。
绝大多数的程序员都知道访存(内存访问)是一个比较慢的操作(相对于CPU运算速度来说),但是,究竟慢到什么程度?为什么会慢?如何尽可能地降低访存延迟?可能大多数的程序员都没有一个定量的概念,哪怕是对于访存延迟究竟达到一个怎样的数量级(是几纳秒?几十纳秒?几百纳秒?)都没有一个直观的认识。究其原因,是因为访存延迟的不直观,我们一般无法直接观察到访存延迟,由于CPU高速缓存的存在,CPU高效的缓存机制和预读机制将大量的访存成本隐藏了,导致在一般情况下我们都可以享受到低延迟的访存操作,从而忽视了访存的巨大成本。
看不到的东西总是虚无缥缈的,难以被认识的,为了让我们加深访存对微秒级应用的重大影响的认识,我们首先必须观察到它们,因此本文的主要工作就是把访存延迟“揪”出来示众。
在揪出访存延迟之前,我们先来科普一下访存延迟的来龙去脉,再大致了解一下CPU的缓存机制。从理论上来分析,访存为什么会慢呢?当前我们使用的大容量内存均是DRAM内存,而DRAM是使用电容器来保持状态的,内存的访问牵扯到电容器的充放电,所以DRAM 的延迟是由其物理属性所决定的,除非在原理和技术上有革命性的突破,否则访存延迟不可减少。
这里我们简单介绍一下一次内存访问在电气层上会发生哪些动作(一些主要动作),以及这些动作的时序:
至此,才真正进入数据传输。因此一次完整的访存,其延迟为:$∆t=tRAS+tRP+tRCD+tCL
一般DDR内存条标识上述参数为:w-z-y-z,以此来代表上述的延迟参数,其顺序为:tCL,tRCD,tRP, tRAS
现在我们来考察一个具体的DDR3内存条:
DR3典型的延迟参数为:5-5-5-15,根据上述技术参数,我们可以大致估算访存延迟为: ∆t=5+5+5+15=30个总线周期,按照DDR3-1066的总线主频533MHz计算,则其延迟为:∆t=30*1.876=56ns 记住,这还只是理论上的最小值。还有一些其他的延迟因素我们这里没有考虑,所以真正的完整延迟要大于56ns。综上理论分析,我们可以得出结论:一次访存延迟,是100ns数量级的。(对详细的访存过程和其精确延迟感兴趣的同学可以参阅DDR SPEC文档)
但是,实际的体验似乎告诉我们,访存延迟绝对没有这么大,哪儿出错了呢?这里我们不得不讨论下CPU的高速缓存机制,以下是CPU的高速缓存与主存关系的示意图(以及各存储类型的大致的延迟值,仅是数量级的,不精确的)。
寄存器速度最快;一级缓存次之,但也几乎可以和CPU保持同步;二级缓存和三级缓存速度依次降低;而我们今天讨论的主角-主存,其速度则更次之。为了探讨访存延迟,我们必须设法使CPU的高速缓存逐级失效,以观察L1,L2,L3和主存各自的延迟情况,为我们对内存使用的优化指出方向。这里我们对CPU缓存机制仅做简单探讨,我们只需记住如下几点:
L1有一个缓存线的概念,以缓存线为单位,做整体的缓存操作。而一般L1缓存线的大小为64个字节(不同平台有所差异,本文测试环境为64字节)当我们访问的数据不在L1中时,CPU会自动从L2、L3和主存逐级查找并加载到L1,而其加载是一次性加载整条缓存线(即我们访问4个字节和我们完整访问64个字节,其开销几乎是一样的)。
CPU存在硬件预读机制,如果我们在做连续的内存访问时,CPU的硬件预读机制将可以隐藏大多数的访存成本。
当L1缓存用完后,如果还有新数据的访问,CPU会根据最近最少使用原则将L1中的无效数据逐出L1,加载新的数据进L1。
有了上面的知识,我们很容易设计一个测试案例,使访存延迟暴露在光天化日之下,以下是笔者设计的数据结构示意图。
测试环境指标如下表所示:
待测试内存的初始化准备:首先分配1G个整数(在本测试环境中,整数占4个字节,这样总共占据4G内存,内存足够),然后对该1G个整数逐个赋值,这样可以确保分配的4G内存被真正分配物理内存,而非仅仅虚拟内存(避免缺页操作)。
在开始访问这些数据之前,为确保上述的1G整数均已被逐出缓存,程序又分配了26M的内存,并逐个赋值。到此为止,我们应该可以确认,我们需要访问的内存已经被逐出了L1,L2,L3,他们仅存在于主存中(实际上由于缓存的关联性,应该没有100%全部逐出,但是,这对我们获得大致的延迟时间影响不大)。
接下来,顺序遍历访问该1G个整数。其要点是引入一个步长的概念,不同的测试场景,输入不同步长。当步长为1时,就是连续的顺序遍历,而步长为2时,就是间隔遍历。这样,我们可以通过控制不同的步长,来测试L1命中,L2命中,L3命中,均不命中等各种场景。无论多少步长,我们均循环遍历到数组末尾(不同的步长,其循环次数是不一致的)然后统计遍历前和遍历后的CPU周期数之差,再除以循环次数,以获得平均延迟。
我们先做大致的猜想:
当步长为1时,访问一个整数的开销和访问16个整数的开销是几乎一致的,这样在连续多次的内存访问中,访存延迟开销将被摊薄到所有的16次操作中,而且L1可以容纳8192个整数,由此推断步长为1的延迟数据很小。随着步长的增加,随着L1,L2,L3的逐级失效,其延迟会逐渐增加,一直达到一个真正的访存延迟。
以下是笔者的测试结果:
备注:
经过上述的分析,我们的微秒级应用,可以从以下几方面来考虑降低访存延迟:
尽可能降低工作集内存大小:当某个业务逻辑处理时,需要遍历访问的数据集合越小越好,哪怕只是完整装载进L3也是好的,当然,最好是能完整装载进L1。示例:在证券业务中,我们在处理某个客户的订单信息时,订单信息按机构全局存放在一个连续的内存空间和按客户连续存放,其处理的延迟将是一个数量级甚至两个数量级的差异。(因为对于一个机构而言,其订单信息是连续分布在数以几G的内存空间中,即其工作集是几个G,而当订单信息按客户存放,那么其工作集可能只是几十KB,完全可以完整装在进L1)
在一个数据结构中,有业务相关性的字段紧密定义,以充分利用缓存线的优势。
在一个数据结构中,最先需要被访问的字段放置在第一位
如果可能,关键数据结构尽量定义成按缓存线对齐
针对业务特色组织数据结构
经常成对访问的数据紧密布局
数字列表项目常用数据和不常用数据分离布局
只读数据和读写数据分离布局
以下测试代码大致逻辑:
int main(intiArgc, void** Argv)
{
int iStep=atoi( (char*)Argv[1]);
int iIntCounts=1024*1024*1024*1; // 1G个int,4G内存
int* lpiValue=(int*)malloc(sizeof(int)*iIntCounts);
register int i;
for(i=0; i<iIntCounts; i++) // 分配4G内存且使用他,使其加载到物理内存
{
lpiValue[i]=i;
}
// 分配26M内存并赋值,将前面的内存空间逐出缓存
int iTmpSize=26*1024*1024/sizeof(int);
int* lpiTemp=(int*)malloc(sizeof(int)*iTmpSize);
for(i=0; i<iTmpSize; i++)
{
lpiTemp[i]=i;
}
int iLoopCounts;
iLoopCounts=iIntCounts/iStep; // 尽可能多访问,减少噪声污染
iLoopCounts--;
int j=0;
__u64 tmbegin=get_rdtsc();
for(i=0; i<iLoopCounts; i++)
{
if(lpiValue[j]!=2000000000) // 永远不可能满足
{
j+=iStep;
continue;
}
break;
}
__u64 dwDiff=GetDiff(tmbegin);
int iDiff=dwDiff/iLoopCounts;
printf("iDiff:%d iLoopCounts:%d\n", iDiff, iLoopCounts);
return 0;
}
毛银杰,恒生电子研发中心资深技术专家、恒生电子技术专家俱乐部成员,长期从事高性能、大并发消息通信和应用系统的研究。