20

djygrdzh · 2020年05月06日

ARM攒机指南-基础篇(一)

在开篇里,我们对芯片PPA有了初步的认识。下面,让我们从访存这个简单的问题开始展开介绍芯片基础概念。
作者:djygrdzh
来源:https://zhuanlan.zhihu.com/p/32365902

CPU是怎样访问内存的?简单的答案是,CPU执行一条访存指令,把读写请求发往内存管理单元。内存管理单元进行虚实转换,把命令发往总线。总线把命令传递给内存控制器,内存控制器再次翻译地址,对相应内存颗粒进行存取。之后,读取的数据或者写入确认按照原路返回。再复杂些,当中插入多级缓存,在每一层缓存都未命中的情况下,访问才会最终达到内存颗粒。

知道了完整的路径,就可以开始研究每一步中的硬件到底是怎么样的,读写指令到底是怎样在其中传输的。首先要说下处理器。处理器的基本结构并不复杂,一般分为取指令,译码,发射,执行,写回五个步骤。而这里说的访存,指的是访问数据,不是指令抓取。访问数据的指令在前三步没有什么特殊,在第四步,它会被发送到存取单元,等待完成。当指令在存取单元里的时候,产生了一些有趣的问题。

第一个问题,对于读指令,当处理器在等待数据从缓存或者内存返回的时候,它到底是什么状态?是等在那不动呢,还是继续执行别的指令?一般来说,如果是乱序执行的处理器,那么可以执行后面的指令,如果是顺序执行,那么会进入停顿状态,直到读取的数据返回。当然,这也不是绝对的。在举反例之前,我们先要弄清什么是乱序执行。乱序执行是指,对于一串给定的指令,为了提高效率,处理器会找出非真正数据依赖的指令,让他们并行执行。但是,指令执行结果在写回到寄存器的时候,必须是顺序的。也就是说,哪怕是先被执行的指令,它的运算结果也是按照指令次序写回到最终的寄存器的。这个和很多程序员理解的乱序执行是有区别的。有些人在调试软件问题的时候,会觉得使用了一个乱序的处理器,那么可能会使得后面的代码先被执行,从而让调试无法进行。这搞混了两个个概念,就是访存次序和指令完成次序。对于普通的运算指令,他们仅仅在处理器内部执行,所以程序员看到的是写回或者完成次序。而对于访存指令,指令会产生读请求,并发送到处理器外部,看到的次序是访存次序。对于乱序处理器,可能同时存在多个读写请求,而其次序,如果不存在相关性,可以是打乱的,不按原指令顺序的。但是与这些读写指令无相关性的的运算指令,还是按照乱序执行,顺序提交的。

对于顺序执行的处理器,同样是两条读指令,一般必须等到前一条指令完成,才能执行第二条,所以在处理器外部看到的是按次序的访问。不过也有例外,比如读写同时存在的时候,由于读和写指令实际上走的是两条路径,所以可能会看到同时存在。还有,哪怕是两条读指令,也有可能同时存在两个外部请求。比如Cortex-A7,对于连续的读指令,在前一条读未命中一级缓存,到下一级缓存或者内存抓取数据的时候,第二条读指令可以被执行。所以说,乱序和顺序并不直接影响指令执行次序。而乱序需要额外的缓冲和逻辑块(称为重排序缓冲,
re-order buffer)来计算和存储指令间的相关性以及执行状态,顺序处理器没有重排序缓冲,或者非常简单。这些额外的面积可不小,可以占到处理器核心的40%。它们带来更高的并行度,性能提升却未必有40%。因为我们写的单线程程序,由于存在很多数据相关,造成指令的并行是有限的,再大的重排序缓冲也解决不了真正的数据相关。所以对于功耗和成本敏感的处理器还是使用顺序执行。

还有一点需要注意,顺序执行的处理器,在指令抓取,解码和发射阶段,两条或者多条指令,是可以同时进行的。比如,无依赖关系的读指令和运算指令,可以被同时发射到不同的执行单元,同时开始执行。并且,在有些ARM处理器上,比如Cortex-A53,向量或者加解密指令是可以乱序完成的,这类运算的结果之间并没有数据依赖性。这点请千万注意。

再来看看写指令。写和读有个很大的不同,就是写指令不必等待数据写到缓存或者内存,就可以完成了。写出去的数据会到一个叫做store buffer的缓冲,它位于一级缓存之前,只要它没满,处理器就可以直接往下走,不必停止并等待。

对于同时存在的多个请求,有一个名词来定义它,叫做outstanding transaction,简称OT。它和延迟一起,构成了我们对访存性能的描述。延迟这个概念,在不同领域有不同的定义。在网络上,网络延迟表示单个数据包从本地出发,经过交换和路由,到达对端,然后返回,当中所花的总时间。在处理器上,我们也可以说读写的延迟是指令发出,经过缓存,总线,内存控制器,内存颗粒,然后原路返回所花费的时间。但是,更多的时候,我们说的访存延迟是大量读写指令被执行后,统计出来的平均访问时间。这里面的区别是,当OT=1的时候,总延时是简单累加。当OT>1,由于同时存在两个访存并行,总时间通常少于累加时间,并且可以少很多。这时候得到的平均延迟,也被称作访存延迟,并且用得更普遍。再精确一些,由于多级流水线的存在,假设流水线每一个阶段都是一个时钟周期,那访问一级缓存的平均延迟其实就是一个周期.而对于后面的二级,三级缓存和内存,就读指令来说,延迟就是从指令被发射(注意,不是从取指)到最终数据返回的时间,因为处理器在执行阶段等待,流水线起不了作用。如果OT=2, 那么时间可能缩短将近一半。OT>1的好处在这里就体现出来了。当然,这也是有代价的,存储未完成的读请求的状态需要额外的缓冲,而处理器可能也需要支持乱序执行,造成面积和功耗进一步上升。对于写指令,只要store buffer没满,还是一个时钟周期。当然,如果流水线上某个节拍大于一个时钟周期,那平均的延时就会取决于这个最慢的时间。在读取二级,三级缓存和内存的时候,我们可以把等待返回看作一个节拍,那么就能很自然的理解此时的延迟了。由此,我们可以得到每一级缓存的延迟和访存延迟。


上图画了ARM某处理器读写指令经过的单元,简单流程如下:

当写指令从存取单元LSU出发,它首先经过一个小的store queue,然后进入store buffer。之后,写指令就可以完成了,处理器不必等待。Store buffer通常由几个8-16字节的槽位组成,它会对自己收到的每项数据进行地址检查,如果可以合并就合并,然后发送请求到右边的一级缓存,要求分配一行缓存,来存放数据,直到收到响应,这称作写分配write allocate。当然,等待的过程可以继续合并同缓存行数据。如果数据是Non-Cacheable的,那么它会计算一个等待时间,然后把数据合并,发送到总线接口单元BIU里面的写缓冲Write buffer。 而写缓冲在把数据发到二级缓存之前,会经过监听控制单元,把四个核的缓存做一致性检测。

当读指令从存取单元LSU出发,无论是否Cacheable的,都会经过一级缓存。如果命中,那么直接返回数据,读指令完成。如果未命中,那么Non-Cacheable的请求直接被送到Read Buffer。如果是Cacheable的,那么一级缓存需要分配一个缓存行,并且把原来的数据写出到替换缓冲eviction buffer,同时发起一个缓存行填充,发送到Linefill Buffer。Eviction buffer会把它的写出请求送到BIU里面的Write buffer,和Store Buffer送过来的数据一起,发到下一级接口。然后这些请求又经过监听控制单元做一致性检测后,发到二级缓存。当然有可能读取的数据存在于别的处理器一级缓存,那么就直接从那里抓取。

过程并不复杂,但程序员关心的是这个过程的瓶颈在哪,对读写性能影响如何。我们已经解释过,对于写,由于它可以立刻完成,所以它的瓶颈并不来自于存取单元;对于读,由于处理器会等待,所以我们需要找到读取路径每一步能发出多少OT,每个OT的数据长度是多少。

拿Cortex-A7来举例,它有2x32字节linefill buffer,支持有条件的miss-under-miss(相邻读指令必须在3时钟周期内),也就是OT最多等于2,而它的数据缓存行长度是64字节,所以每个OT都是半个缓存行长度。对于Cacheable的读来说,我还关心两个数据,就是eviction buffer和Write buffer,它们总是伴随着line fill。在A7中,存在一个64字节的eviction buffer和一个Write buffer。有了这些条件,那么我就可以说,对于连续的读指令,我能做到的OT就是2,而linefill的速度和eviction, write buffer的速度一致,因为2x32=64字节。

那这个结论是不是正确?写个小程序测试下就知道。我们可以关掉二级缓存,保留一级缓存,然后用以下指令去读取一个较大的内存区域。所有的地址都是缓存行对齐。不对齐,甚至越过缓存行边界,会把一个操作变成两个,肯定会慢。伪代码如下:

loop

load R0, addr+0

load R0, addr+4

load R0, addr+8

load R0, addr+12

addr=addr+16

这里通过读取指令不断地去读数据。通过处理器自带的性能计数器看了下一级缓存的未命中率,6%多一点。这恰恰是4/64字节的比率。说明对于一个新的缓存行,第一个四字节总是未命中,而后面15个四字节总是命中。当然,具体的延迟和带宽还和总线,内存控制器有关,这里只能通过命中率简单验证下。

对于有的处理器,是严格顺序执行的,没有A7那样的miss-under-miss机制,所以OT=1。我在Cortex-R5上做同样的实验,它的缓存行长度是32字节,2xLinefill buffer是32字节。测试得到的命中率是12%多点。也完全符合估算。

但是为什么R5要设计两个32字节长度的Linefill buffer?既然它的OT=1,多出来的一个岂不是没用?实际上它是可以被用到的,而方法就是使用预取指令PLD。预取指令的特点就是,它被执行后,处理器同样不必等待,而这个读请求会被同样发送到一级缓存。等到下次有读指令来真正读取同样的缓存行,那么就可能发现数据已经在那了。它的地址必须是缓存行对齐。这样,读也可像写那样把第二个Linefill buffer给用上了。

我们把它用到前面的例子里:

loop

PLD addr+32

load R0,

addr+0;...;load R0, addr+28;

load R0,

addr+32;...;load R0, addr+60;

addr=addr+64

PLD预先读取第二行读指令的地址。测试发现,此时的未命中率还是6%。这也符合估算,因为第二排的读指令总是命中,第一排的未命中率4/32,平均下就是6%。而测试带宽提升了80%多。单单看OT=2,它应该提升100%,但实际不可能那么理想化,80%也可以理解。

还有一种机制使得OT可以更大,那就是缓存的硬件预取。当程序访问连续的或者有规律的地址时,缓存会自动检测出这种规律,并且预先去把数据取来。这种方法同样不占用处理器时间,但是也会占用linefill buffer,eviction buffer和write buffer。所以,如果这个规律找的不好,那么反而会降低效率。

读看完了,那写呢?Cacheable的写,如果未命中缓存,就会引发write allocate,继而造成Linefill和eviction,也就是读操作。这点可能很多程序员没想到。当存在连续地址的写时,就会伴随着一连串的缓存行读操作。有些时候,这些读是没有意义的。比如在memset函数中,可以直接把数据写到下一级缓存或者内存,不需要额外的读。于是,大部分的ARM处理器都实现了一个机制,当探测到连续地址的写,就不让store buffer把数据发往一级缓存,而是直接到write buffer。并且,这个时候,更容易合并,形成突发写,提高效率。在Cortex-A7上它被称作Read allocate模式,意思是取消了write allocate。而在有的处理器上被称作streaming模式。很多跑分测试都会触发这个模式,因此能在跑分上更有优势。

但是,进入了streaming模式并不意味着内存控制器收到的地址都是连续的。想象一下,我们在测memcpy的时候,首先要从源地址读数据,发出去的是连续地址,并且是基于缓存行的。过了一段时间后,缓存都被用完,那么eviction出现了,并且它是伪随机的,写出去的地址并无规律。这就打断了原本的连续的读地址。再看写,在把数据写到目的地址时,如果连续的写地址被发现,那么它就不会触发额外的linefill和eviction。这是好事。可是,直接写到下一级缓存或者内存的数据,很有可能并不是完整的缓存发突发写,应为store buffer也是在不断和write buffer交互的,而write buffer还要同时接受eviction buffer的请求。其结果就是写被分成几个小段。这些小块的写地址,eviction的写地址,混合着读地址,让总线和内存控制器增加了负担。它们必须采用合适的算法和参数,才能合并这些数据,更快的写到内存颗粒。

然而事情还没有完。我们刚才提到,streaming模式是被触发的,同样的,它也可以退出。退出条件一般是发现存在非缓存行突发的写。这个可能受write buffer的响应时间影响。退出后,write allocate就又恢复了,从而读写地址更加不连续,内存控制器更加难以优化,延时进一步增加,反馈到处理器,就更难保持在streaming模式。

再进一步,streaming模式其实存在一个问题,那就是它把数据写到了下一级缓存或者内存,万一这个数据马上就会被使用呢?那岂不是还得去抓取?针对这个问题,在ARMv8指令集中,又引入了新的一条缓存操作指令DCZVA,可以把整行缓存设成0,并且不引发write allocate。为什么?因为整行数据都被要改了,而不是某个字段被改,那就没有必要去把原来的值读出来,所以只需要allocate,不需要读取,但它还是会引发eviction。类似的,我们也可以在使用某块缓存前把它们整体清除并无效化,clean&invalidate,这样就不会有eviction。不过如果测试数据块足够大,这样只是相当于提前做了eviction,并不能消除,让写集中在某段,使之后的读更连续。

以上都是针对一级缓存。二级缓存的控制力度就小些,代码上无法影响,只能通过设置寄存器,打开二级缓存预取或者设置预取偏移。我在ARM的二级缓存控制器PL301上看到的,如果偏移设置的好,抓到的数据正好被用上,可以在代码和一级缓存优化完成的基础上,读带宽再提升150%。在新的处理器上,同时可以有多路的预取,探测多组访存模板,进一步提高效率。并且,每一级缓存后面挂的OT数目肯定大于上一级,它包含了各类读写和缓存操作,利用好这些OT,就能提高性能。

对于Non-Cacheable的写,它会被store buffer直接送到write buffer进行合并,然后到下一级缓存。对于Non-Cacheable的读,我们说过它会先到缓存看看是不是命中,未命中的话直接到read buffer,合并后发往下一级缓存。它通常不占用linefill buffer,因为它通常是4到8字节,不需要使用缓存行大小的缓冲。

我们有时候也可以利用Non-Cacheable的读通道,和Cacheable的读操作并行,提高效率。它的原理就是同时利用linefill buffer和read buffer。此时必须保证处理器有足够的OT,不停顿。

总而言之,访存的软件优化的原则就是,保持对齐,找出更多可利用的OT,访存和预取混用,保持更连续的访问地址,缩短每一环节的延迟。

最后解释一下缓存延迟的产生原因。程序员可能不知道的是,不同大小的缓存,他们能达到的时钟频率是不一样的。ARM的一级缓存,16纳米工艺下,大小在32-64K字节,可以跑在2Ghz左右,和处理器同频。处理器频率再快,那么访问缓存就需要2-3个处理器周期了。但由于访问一级缓存的时间一般不会超过3个始终周期,每增加一个周期,性能就会有明显的下降。而二级缓存更慢,256K字节的,能有800Mhz就很好了。这是由于缓存越大,需要查找的目录index越大,扇出fanout和电容越大,自然就越慢。但由于访问二级缓存本身的延迟就有10个时钟周期左右,多一个周期影响没有那么明显。还有,通常处理器宣传时候所说的访问缓存延迟,存在一个前提,就是使用虚拟地址索引VIPT。这样就不需要查找一级tlb表,直接得到索引地址。如果使用物理地址索引PIPT,在查找一级tlb进行虚实转换时,需要额外时间不说,如果产生未命中,那就要到二级甚至软件页表去找。那显然太慢了。那为什么不全使用VIPT呢?因为VIPT会产生一个问题,多个虚地址会映射到一个实地址,从而使得缓存多个表项对应一个实地址。存在写操作时,多条表项就会引起一致性错误。而指令缓存通常由于是只读的,不存在这个问题。所以指令缓存大多使用VIPT。随着处理器频率越来越高,数据缓存也只能使用VIPT。为了解决前面提到的问题,ARM在新的处理器里面加了额外的逻辑来检测重复的表项。

下图是真正系统里的访存延迟:

上图的配置中,DDR4跑在3.2Gbps,总线800Mhz,内存控制器800Mhz,处理器2.25Ghz。关掉缓存,用读指令测试。延迟包括出和进两个方向,69.8纳秒,这是在总是命中一个内存物理页的情况下的最优结果,随机的地址访问需要把17.5纳秒再乘以2到3。在内存上花的时间是控制器+物理层+接口,总共38.9纳秒。百分比55%。如果是访问随机地址,那么会超过70纳秒,占70%。在总线和异步桥上花的时间是20纳秒,8个总线时钟周期,28%。处理器11.1纳秒,占16%,20个处理器时钟周期。

所以,即使是在3.2Gbps的DDR4上,大部分时间还都是在内存,显然优化可以从它上面入手。在处理器中的时间只有一小部分。但从另外一个方面,处理器控制着linefill,eviction的次数,地址的连续性,以及预取的效率,虽然它自己所占时间最少,但也是优化的重点。

在ARM的路线图上,还出现了一项并不算新的技术,称作stashing。它来自于网络处理器,原理是外设控制器(PCIe,网卡)向处理器发送请求,把某个数据放到缓存,过程和监听snooping很类似。在某些领域,这项技术能够引起质的变化。举个例子,intel至强处理器,配合它的网络转发库DPDK,可以做到平均80个处理器周期接受从PCIe网卡来的包,解析包头后送还回去。80周期是个什么概念?看过了上面的访存延迟图后你应该有所了解,处理器访问下内存都需要200-300周期。而这个数据从PCIe口DMA到内存,然后处理器抓取它进行处理后,又经过DMA从PCIe口出去,整个过程肯定大于访存时间。80周期的平均时间说明它肯定被提前送到了缓存。 但传进来的数据很多,只有PCIe或者网卡控制器才知道哪个是包头,才能精确的推送数据,不然缓存会被无用的数据淹没。这个过程做好了,可以让软件处理以太网或者存储单元的速度超过硬件加速器。事实上,在Freescale的网络处理器上,有了硬件加速器的帮助,处理包的平均延迟还是需要200处理器周期,已经慢于至强了。其原因是访问硬件加速器本身需要设置4-8次的寄存器,而访问一次寄存器的延迟是几十纳秒,反而成为了瓶颈。

如果上面一段看完你没什么感觉,那我可以换个说法:对于没有完整支持stashing的ARM SoC,哪怕处理器跑在10Ghz,网络加速器性能强的翻天,基于DPDK的简单包转发(快于Linux内核网络协议栈转发几十倍)还是只能到至强的30%,而包转发是网络处理器的最重要的指标之一,也是服务器跑网络转发软件的指标之一,更可以用在存储领域,加速SPDK之类的存储应用。

还有,在ARM新的面向网络和服务器的核心上,会出现一核两线程的设计。处理包的任务天然适合多线程,而一核两线程可以更有效的利用硬件资源,再加上stashing,如虎添翼。

弄清了访存的路径,可能就会想到一个问题:处理器发出去的读写请求到底是个什么东西?要想搞清楚它,就需要引入总线。下文我拿ARM的AXI/ACE总线协议以及由它衍生的总线结构来展开讨论。这两个协议广泛用于主流的手机芯片上,是第四代AMBA(Advanced Microcontroller Bus Architecture)标准。

简单的总线就是一些地址线和数据线,再加一个仲裁器,就可以把处理器发过来的读写请求送到内存或者外设,再返回数据。在这个过程中,我们需要一个主设备,一个从设备,所有的传输都是主设备发起,从设备回应。让我们把处理器和它包含的缓存看作一个主设备,把内存控制器看作从设备。处理器发起访问请求,如果是读,那么总线把这个请求(包括地址)送到内存控制器,然后等待回应。过了一段时间,内存控制器把内存颗粒里面读出的数据交给总线,总线又把数据交给处理器。如果数据无误(ECC或者奇偶校验不出错),那么这个读操作就完成了。如果是写,处理器把写请求(包括地址)和数据交给总线,总线传递给内存控制器,内存控制器写完后,给出一个确认。这个确认经由总线又回到了处理器,写操作完成。

以上过程有几个重点。第一,处理器中的单个读指令,被分为了请求(地址),完成(数据)阶段。写指令也被分为了请求(地址,数据),完成(写入确认)阶段。第二,作为从设备,内存控制器永远都无法主动发起读写操作。如果一定要和处理器通讯,比如发生了读写错误,那就得使用中断,然后让处理器来发起读写内存控制器状态的请求。第三,未完成的读写指令就变成了OT,总线可以支持多个OT。然而,总线支持多OT并不表示处理器能发送这么多请求出来,尤其是读。所以瓶颈可能还是在处理器。

我遇到过几次这样的情况,在跑某个驱动的时候,突然系统挂死。但是别的设备中断还能响应,或者报个异常后系统又继续跑了。如果我们把上文的内存控制器替换成设备控制器,那就不难理解这个现象了。假设处理器对设备发起读请求,而设备没有回应,那处理器就会停在那等待。我看到的处理器,包括PowerPC, ARM,都没有针对这类情况的超时机制。如果没有中断,那处理器无法自己切换到别的线程(Linux等操作系统的独占模式),就会一直等待下去,系统看上去就挂住了。有些设备控制器可以自动探测这类超时,并通过中断调用相应的异常或者中断处理。在中断处理程序中,可以报个错,修改返回地址,跳过刚才的指令往下走,系统就恢复了。也有些处理器在触发某类异常后能自动跳到下一行指令,避免挂死。但是如果没有异常或者中断发生,那就永远挂在那。

继续回到总线。在AXI/ACE总线协议中,读和写是分开的通道,因为他们之间并没有必然联系。更细一些,总线上规定了五个组,分别是读操作地址(主到从),读操作数据(从到主),写操作地址(主到从),写操作数据(主到从),写操作确认(从到主)。读和写两大类操作之间,并没有规定先后次序。而在每一类操作之内的组之间,是有先后次序的,比如地址是最先发出的,数据随后,可以有很多拍,形成突发操作。而确认是在写操作中,从设备收到数据之后给出的。对内存控制器,必须在数据最终写入到颗粒之后再给确认,而不是收到数据放到内部缓存中的时候。当然,这一点可以有例外,那就是提前应答early response。中间设备为了提高效,维护自己的一块缓冲,在收到数据后,直接向传递数据的主设备确认写入,使得上层设备释放资源。但是这样一来,由于数据并没有真正写入最终从设备,发出提前应答的中间设备必须自己维护好数据的一致性和完整性,稍不小心就会造成死锁。ARM的现有总线都不支持这个操作,都是不会告知主设备early response的,所有的内部缓冲,其实是一个FIFO,不对访问次序和应答做任何改动。
56.5.jpg
对于同一个通道,如果收到连续的指令,他们之间的次序是怎么样的呢?AXI/ACE协议规定,次序可以打乱。拿读来举例,前后两条读指令的数据返回是可以乱序的。这里包含了一个问题,总线怎么区分住前后两读条指令?很简单,在地址和数据组里加几根信号,作为标志符,来区分0-N号读请求和完成。每一对请求和完成使用相同的标志符。有了这个标志符,就不必等前一个请求完成后才开始第二个请求,而是让他们交替进行,这样就可以实现总线的OT,极大提高效率。当然,也需要提供相应的缓冲来存储这些请求的状态。并且最大的OT数取决于缓冲数和标志符中小的那个。原因很简单,万一缓冲或者标志符用完了,但是所有的读操作全都是请求,没有一个能完成怎么办?那只好让新的请求等着了。于是就有了AXI/ACE总线的一条规则,同一个读或者写通道中,相同标志符的请求必须按顺序完成。

有时候,处理器也会拿这个标志符作为它内部的读写请求标志符,比如Cortex-A7就是这么干的。这样并不好,因为这就等于给自己加了限制,最大发出的OT不得大于总线的每通道标志符数。当一个处理器组里有四个核的时候,很可能就不够用了,人为限制了OT数。

最后,读写通道之间是没有规定次序的,哪怕标志相同。

看到这里可能会产生一个问题,读写指令里面有一个默认原则,就是相同地址,或者地址有重叠的时候,访存必须是顺序的。还有,如果访问的内存类型是设备,那么必须保证访存次序和指令一致。这个怎么在总线上体现出来呢?总线会检查地址来保证次序,一般是内存访问前后乱序地址不能64字节内,设备访问前后乱序地址不能在4KB内。

在AXI/ACE中,读和写通道的比例是一比一。实际上,在日常程序中,读的概率比写要大。当然,写缓存实际上伴随着缓存行填充linefill(读),而读缓存会造成缓存行移除eviction(写),再加上合并和次序调整,所以并不一定就是读写指令的比例。我看到Freescale
PowerPC的总线CCB,读写通道的比率是二比一。我不知道为什么ARM并没有做类似的设计来提高效率,也许一比一也是基于手机典型应用统计所得出的最好比例。

至此,我们已经能够在脑海中想象一对读写通道中读写操作的传输情况了。那多个主从设备组合起来是怎么样的情况?是不是简单的叠加?这涉及到了总线设计最核心的问题,拓扑结构。

在ARM当前所有的总线产品里,根据拓扑的不同可以分为三类产品:NIC/CCI系列是交叉矩阵的(Crossbar),CCN/CMN系列是基于环状和网状总线的(Ring/Mesh),NoC系列是包转发总线(Router)。他们各有特点,适合不同场景。交叉矩阵连接的主从设备数量受到限制,但是效率最高,读写请求可以在1到2个周期内就直达从设备。如下图所示,这就是一个5x4的交叉矩阵:

根据我看到的数据,在28纳米制程上,5x4的配置下,这个总线的频率可以跑到300Mhz。如果进一步增加主从对数量,那么由于扇出增加,电容和走线增加,就必须通过插入更多的寄存器来增加频率。但这样一来,从主到从的延迟就会相应增加。哪怕就是保持5x3的配置,要想进一步提高到500Mhz,要么使用更好的工艺,16纳米我看到的是800Mhz;要么插入2-3级寄存器,这样,读写延时就会达到4-5个总线时钟周期,请求加完成来回总共需要10个。如果总线和处理器的倍频比率为1:2,那么仅仅是在总线上花费的时间,就需要至少20个处理器时钟周期。倍率为4,时间更长,40个时钟周期。要知道处理器访问二级缓存的延迟通常也不过10多个处理器周期。当然,可以通过增加OT数量减少平均延迟,可是由于处理器的OT数是有限制的,对于顺序处理器,可能也就是1-2个。所以,要达到更高的频率,支持更多的主从设备,就需要引入环状总线CCN系列,如下图:
56.6.jpg
CCN总线上的每一个节点,除了可以和相邻的两个节点通讯之外,还可以附加两个节点组件,比如处理器组,三级缓存,内存控制器等。在节点内部,还是交叉的,而在节点之间,是环状的。这样使得总线频率在某种程度上摆脱了连接设备数量的限制(当然,还是受布线等因素的影响),在16纳米下,可以达到1.2GHz以上。当然,代价就是节点间通讯更大的平均延迟。为了减少平均延迟,可以把经常互相访问的节点放在靠近的位置。

在有些系统里,要求连接更多的设备,并且,频率要求更高。此时环状总线也不够用了,这时需要网状总线CMN。ARM的网状总线,符合AMBA5.0的CHI接口,支持原子操作(直接在缓存运算,不用读取到处理器),stashing和直接访问(跳过中间的缓存,缩短路径)等特性,适用于服务器或者网络处理器。

但是有时候,系统需要连接的设备数据宽度,协议,电源,电压,频率,都不一样,这时就需要NoC出马了,如下图:
56.7.jpg

这个图中,刚才提到的交叉矩阵,可以作为整个网络的某部分。而连接整个系统的,是位于NoC内的节点。每个节点都是一个小型路由,它们之间传输的,是异步的包。这样,就不必维持路由和路由之间很大数量的连线,从而提高频率,也能支持更多的设备。当然,坏处就是更长的延迟。根据我看到的数据,在16纳米上,频率可以跑到1.5Ghz。并且它所连接每个子模块之间,频率和拓扑结构可以是不同的。可以把需要紧密联系的设备,比如CPU簇,GPU放在一个子网下减少通讯延迟。

在实际的ARM生态系统中,以上三种拓扑结构的使用情况是怎么样的呢?一般手机芯片上使用交叉矩阵,网络处理器和服务器上使用环状和网状拓扑,而NoC也被大量应用于手机芯片。最后一个的原因倒不是手机上需要连接的设备数太多,而是因为ARM的AXI总线NIC400对于交叉访问(interleaving)支持的非常有限。在手机里面,GPU和显示控制器对内存带宽要求是很高的。一个1080p的屏幕,每秒要刷新60次,2百万个像素,每个像素32比特颜色,再加上8层图层,就需要4GB/s的数据,双向就是8GB/s。而一个1.6GHz传输率的LPDDR4控制器,64位数据,也只能提供12.8GB/s的的理论带宽。理论带宽和实际带宽由于各种因素的影响,会有很大差别,复杂场景下能做到70%的利用率就不错了,那也就是9GB/s。那处理器怎么办?其他各类控制器怎么办?只能增加内存控制器的数量。但是,不能简单的增加数量。成本和功耗是一个原因,并且如果仅仅把不同的物理地址请求发送到不同的内存控制器上,很可能在某段时间内,所有的物理地址全都是对应于其中某一个,还是不能满足带宽要求。解决方法就是,对于任何地址,尽量平均的送到不同的内存控制器。并且这件事最好不是处理器来干,因为只有总线清楚有多少个内存控制器。最好处理器只管发请求,总线把所有请求平均分布。

有时候,传输块大于256字节,可以采用一个方法,把很长的传输拆开(Splitting),分送到不同的内存控制器。不幸的是,AXI总线天然就不支持一对多的访问。原因很简单,会产生死锁。想象一下,有两个主设备,两个从设备,通过交叉矩阵连接。M1发送两个读请求,标志符都是1,先后送到到S1和S2,并等待完成。然后M2也做同样的事情,标志符都是2,先后送到S2和S1。此时,假设S2发现它如果把返回的数据次序交换一下,会更有效率,于是它就这么做了。但是M1却不能接收S2的返回数据,因为根据同标志符必须顺序完成的原则,它必须先等S1的返回数据。而S1此时也没法送数据给M2,因为M2也在等待S2返回的数据,死锁就出现了。解决方法是,AXI的Master不要发出相同标志的操作。如果标志相同时,则必须等待上一次操作完成。或者,拆分和设置新标识符操作都由总线来维护,而主设备不关心,只管往外发。

在实际情况下,拆分主要用于显示,视频和DMA。ARM的CPU和GPU永远不会发出大于64字节的传输,不需要拆分。

现在的中低端手机很多都是8核,而根据ARM的设计,每个处理器组中最多有四个核。这就需要放两个处理器组在系统中,而他们之间的通讯,包括大小核的实现,就需要用到总线一致性。每个处理器组内部也需要一致性,原理和外部相同,我就不单独解释了。使用软件实可以现一致性,但是那样需要手动的把缓存内容刷到下一级缓存或者内存,对于一个64字节缓存行的64KB缓存来说,需要1000次刷新,每次就算是100纳秒,且OT=4的话,也需要25微秒。对处理器来说这是一个非常长的时间。ARM使用了一个协处理器来做这个事情,这是一个解决方案。为了用硬件解决,ARM引入了几个支持硬件一致性的总线,下图是第一代方案CCI400:


后文请看 ARM攒机指南-基础篇(二)

推荐阅读

授权转自知乎,欢迎关注ARM攒机指南专栏,后续还有AI等相关篇章。
推荐阅读
关注数
10701
内容数
12
Arm相关芯片文章,涵盖AI,5G,自动驾驶等,欢迎关注。
目录
极术微信服务号
关注极术微信号
实时接收点赞提醒和评论通知
安谋科技学堂公众号
关注安谋科技学堂
实时获取安谋科技及 Arm 教学资源
安谋科技招聘公众号
关注安谋科技招聘
实时获取安谋科技中国职位信息