多处理器架构可以解决大型或小型的问题。例如,在一个网络处理的应用中,用多个处理器并行地处理各个数据包,以便赶在新的数据包到来前完成工作,你可能要用多个处理器去解决许许多多的这种小问题。这种架构也可以在一个 DSP 或加密农场(encryption farm)结构里看到,它们都是把许多小问题送给一个中心点进行处理。对于图像或信号处理这类大型问题,你需要把问题分割成可管理的碎片,然后用不同的处理器内核分别解决各个碎片的问题。注意有些架构同时使用了这两种技术:即它们把许多小问题也打成碎片,交给不同的处理器去处理。
把一个问题打成碎片的方法可以更加有效地利用资源,这是它的优点。你可以按任务的性能密集度来分派一个进程或任务由多少内核来运行。它的主要缺陷是必须把这些碎片再联结起来,这就要求比在单处理器的情况下做更多的处理,一般包括把中间结果传递给下一任务并解决接缝问题。如果你把一幅图像分成四个部分,在重新拼合时就必须得解决相互间接缝的问题。
软件自动做的分割经常是很随意的,因此它对系统具有一些限制性要求。当我们做手工分割时,是按照人类易于理解的逻辑边界,而不是按诸如执行的周期数这样的物理界限。于是就会出现这样的情况:花费很大努力创建了一个软件分割,但还要花更大的努力去调整分割界限。如果你发现了一个不平衡的任务,比如它需要相当于 2.5 个内核的处理性能,那么,可能很难把任务进行重新分割,把剩下空闲的半个内核再利用起来(见附文"量子数学:2.5+3.5=7")。你不得不去尝试其它的抽象方法,以试图获得比按任务边界的抽象方法分割更高的效率。对这些抽象方法的优化就成为非常具有挑战性的工作,因为你要去除许多会导致低效率的抽象方法。
里面是什么?谁在乎?
有些环境使用软件结构来抽象地对资源进行存取。例如,有些 NPU(网络处理器)的提取表(abstract table)是通过一个 API 进行搜索,这样,程序员就不需要知道硬件团队选择了什么协处理器。一旦你需要更改选用的协处理器,这样的代码将具有较好的可移植性。但是,即使是一个内容很少的 API 也要有成本。你不会总以相同的方式使用所有的协处理器。如果一个 API 成功地捕获了差异,你就能肯定它会让你付出代价。即使每次访问只增加两个多余的指令,但把这一资源在 200 行执行代码中使用 5 次就会增加 5% 的无效成本。
与之类似的另一种抽象类型是使用库函数。代码越通用,库可以服务的应用也越多。这种说法的代价是:加入了很多低效的函数,还要告诉它们每次使用时具体做些什么。如果是对已建立的协议或定义良好的算法,库的使用可以减少系统各部分间的差异,使代码更健壮。但是,除非你为选定的处理器进行了代码优化,而不是只改一些内部 I/O 函数就拿来用,否则代码的效率肯定不高。此外,还要考虑使用一个函数增加的成本。除了直接使用原来存储器位置的参数外,你需要把这些参数(或者指向这些参数的指针)传给函数,然后还要用额外的周期去获得结果。
对资源作抽象有意义吗?考虑到每一代的协处理器都会提供新的功能和指令,它们会改变你的使用方式。当前一代的协处理器支持宏指令,即可以用一个指令来执行多个指令。对宏指令进行优化的唯一方法是使用老式的 API 重新写代码。虽然老的 API 仍可以用,但是它会让你付出巨大的代价。考虑到所有任务竞争的相关性都将改变,你不管如何都需要重新优化你的系统。如果你想支持第二个协处理器作为备用,则值得使用 API,但你也要考虑 API 的使用密度以及使用它对效率的减少程度。
内核间通信
对资源的抽象隐藏了许多访问该资源的相关特性。例如,你可以把一个值保存在本地内核存储器、其它内核的存储器、其它处理器的存储器或者外部存储器中。对一个程序员来说,对这些情况的访问方式是等效的。但实际上是不一样的,因为每种访问方式都有不同的等待周期。
如果你只在抽象层上工作,则你只能解决逻辑层的问题,而物理层则留给工具去完成。在许多情况下,工具可以比一个人更详尽地管理资源,但前提是它的抽象表述不会损坏优化的结果。举例来说,假设你需要把为任务 A 的处理工作分配给 3 个内核。如果工具把任务 A 全推给某一处理器 4 个内核中的 3 个进行处理,则该任务密集应用时就可能出现内核资源过载。同时,当任务 A 把中间结果传给另一个处理器中的任务 B 时,需要通过存储器传递。与通过同一处理器中的寄存器直接传给另一个内核的方式相比,前种技术的效率要低。把任务 A 和 B 一起分配在三个分离的处理器上,你将增加任务传递(task handoff)的开销。
另外要提到的是:在抽象层上工作增加的复杂性也会带到剖析阶段。假设任务 B 只需要两个内核就可以完成。任务 A 的头两个实例(instantiation)使用内核寄存器将中间结果传递给同一处理器中的任务 B,任务 A 的第三个实例必须使用存储器把中间结果传给任务 B 两个实例中的一个。你的剖析工具必须能把任务 A 的三个实例看成独立的任务。如果工具把结果集中或平均化处理了,则你就无法查看低效出现在何处,即不能判断低效来自传递过程还是在某些实例内部。将进程打碎成分别运行在自己内核上的许多任务,这种方法会使分配问题变得极其复杂(见附文"跨越时间和空间的仿真")。
剖析工作是抽象方法确实能够提供帮助的一个领域。因为任务 A 支持两种传递方式,所以,能抽象地定义传递过程,以及按任务的配置将其编译成不同的版本,这样你就从手工管理进程的复杂工作中解脱出来。即使你的工具不直接支持这种抽象,你也可以按照任务的位置,间接地使用宏指令来创建可选的代码,然后按要求把代码编译成多个版本。
在各个内核和处理器之间改变任务的物理配置会形成不同的剖析结果(见附文"抓住配置")。如果是用设计工具(而不是程序员)来处理内核间的通信问题,则程序员将看不到真实的通信等待周期,于是程序员就不能对通信进行优化。因此,抽象的工具也需要它们自己的工具,即能使它们完成物理配置优化的工具。就是说,编译器不应再把代码看成一份指令的静态清单,而要把代码看成是有历史和性能概况的动态指令。编译器必须是随同调试器、仿真器等工具一起工作的一个伙伴工具。你不再使用那种只能在一个时间点上独立使用的工具,而是要把它们结合起来共同使用。
对内核间通信的剖析是很复杂的。对内核间通信消息的监控能力取决于处理器能提供多少可见度,以及这种监控对内核的深入程度。处理器间通信效率的计算则有另外一个不同的问题。当四个处理器在超过六个通道上进行网状通信时,需要数台逻辑分析仪同时观察所有的通信流。在开发期间你或许应考虑使用一片在线 FPGA,专用于通信流的分析。
将公共数据保存在本地不仅减少了等待周期,还能节约总线带宽。除非仿真器可以剖析编译器使用的变量,以达到理想化的内存映像,否则你应坚持用手工方式定义一种高效率的映像。考虑这样一种情况,有两个处理器 P1 和 P4,通过处理器 P2 和 P3 相互连接。如果分别运行于 P1 和 P4 上的任务要交换大量数据,你就可以用物理连接 P1 和 P4 的方法来减少等待周期。尽管剖析工具也可能帮助你发现这种优化配置的信息,但多数情况下它们在优化方面没有什么用处。编译器则不考虑内核之间的物理联系,对新的映像方法作快速测试(使用任务相关性的抽象映像作优化可以"显著地"消除低效率的组合,而无需对每种组合进行完全的仿真)也不是一个可选项。
当进行仿真、调试、剖析和获得新信息时,你需要能对系统进行重新定义、重新分割以及重新配置。目前的工具都拒绝做重新分割的工作,因为要把具体的优化结果再返回到抽象模型是很困难的。经优化的代码会使抽象的泡沫破灭,而且增加的具体细节越多,要维护抽象模型就越困难。某种程度上,是混合模型上的投资迫使你做出错误的决定。
过之不及
随着处理器或内核数量的增加,每个处理器单元都变得更象一个黑箱。把聚集的结果集中起来是件简单的工作,但能在单独的数据点水平对这些结果进行检查则是一扇可以通向更高效率的大门。例如,一种数据类型可以比另一种更容易使系统停转。与仅仅简单知道这种停止发生在何处相比,如果能明白它是由哪种数据类型引起的,你能获得更好的洞察能力。
请注意:如果你的任务执行是基于一个预定的算法,一个任务要与其它任务进行大量的配置争夺,那么聚集可以成为一个有用的手段。但要对范围广泛的配置作优化是很困难的。所以可以考虑限制一个内核簇可以处理的任务数。你可以减少组合的数量,着手识别出哪种配置方式最没有效率。通过不同的任务限制方法可将这些配置去除。剩下的配置就是最常出现的,这样,你对任务组合所做的优化也是最有效益的。
效率仿真是在精确性、可观察性和仿真速度三者之间的一种平衡。一个因素的增加意味着另一因素的减小。对于一个给定器件处理速度的多处理器系统,当捕捉到一个错误或者创建一个剖析时,要收集到全面的系统跟踪数据是不可能的,因为数据总是比你能收集到的要多。所以,尽管你可以用硬件方式更快地运行一个系统,但同时也失去了观察事件发生细节的能力。
对于单处理器系统而言,可以设定一个断点,并且抓取系统的一个快照。你甚至可以在不影响系统执行的情况下,以非显式方法完成这一工作。如果这一处理器正在高速运行,除非使其停止,否则你只能取得进行下一步的处理且快照发生改变之前的数据。
在多处理器架构中,你无法在同一周期内停止所有处理器的运行。这种情况下的断点叫链式断点,即每个处理器会把断点传给下一个处理器。因此,在链中停止较晚的处理器会使相关数据发生变化。此外,你也无法从断点继续执行,而必须对系统作复位。你还面临一个问题,那就是要将海量数据从如此多的处理器上载出来。有限的上载能力可能要占掉你每天大量的调试时间。
有些处理器供应商除编译器和仿真器外,还面向多处理器应用提供设计工具。例如,英特尔的架构开发工具(Architectural Development Tool)可以在开始建立模型和仿真以前,帮助你对内存带宽、流水线性能、内核效率以及开销等进行预算和估计。多数处理器会形成它们自己仿真环境的内核。如果在一个系统中使用不同的处理器分别作控制和数据(微处理器加 DSP,或者 NPU 加协处理器),则你要决定对模型做怎样的改变,才能同时对这些处理器进行仿真。
有些非处理器公司也提供多处理器设计工具。MLDesign 公司的 MLDesigner 使用复杂的或抽象的模型进行系统性能的建模。Mathworks 公司的 Matlab 和 Simulink 是 DSP 应用的公共平台。而 Axys 公司提供的 MaxSim 是一个建立多内核系统芯片模型并完成校验的工具集。
你很快就能在整个周末和仿真器一起运行编译器了。明年面世的工具承诺可以基于前次编译的剖析对代码进行反复的再编译,这样可以充分检验多种配置。例如,编译器可以确定关键代码路径,识别出最常用的变量和寄存器,并把从属的数据一直保持在内存中,这样只需一次内存查找就能获得数据,而不需要作二次查找。
要注意的是,无论你的剖析工具是怎样的包罗万象,结果的准确性和意义也只和你代码的完备性相一致。
抽象的实际成本
抽象的实际成本是多少?要确定这个问题,你要考虑到可能使用抽象的所有级别。再者,抽象并不是件坏事。为达到简化设计的目的而在其中增加一些无效部分也是值得的。但是,如果抽象对设计的简化有影响,你就要分析一下你的付出与所得是否相符。
需要理解的是,每种抽象方法的应用都会有相应的妥协。例如,一个设计环境对资源做抽象,它在物理上可以是软件或一个硬件加速引擎,这取决于你的应用。因此,最终的代码可能是一个指令的集合,如一个宏、一个单指令或者是一些参数的设置,如一个可配置的资源。
开发工具好象可以处理这些资源的物理定位,但实际上经常做不到。你可能仍然要反复定义线程边界、各个成分之间如何通信、如何共享资源等等。也就是说,你还是要管理这些实际的东西。开发环境只给出一个有限的能力,概略地告诉你如何定义这些特性,但并不直接帮助你决定定义的最佳方式。你做的抽象甚至有可能形成这样一种结果,就是不能获得所有好处,却要承担全部的损失。
随着工具的不断发展,它们也对精确地确定一个设计的真实情况提出了新挑战(见附文"更多的工具要求")。然而,每一代的工具都更紧密地与无效的限制捆绑在一起。也许有一天,你将不再关心无效的问题,因为你已经习惯接受最糟糕的无效情况。这都很难说。也许有一天,会出现终极的抽象工具,它可以让营销经理在 PowerPoint 里直接设计产品,根本不需要工程师了。
一旦你刨根问底,大多数供应商都会承认用 C 编程会使你的效率丧失某个百分比。但用 C 编程只是本文提到的大量抽象过程中的一种。如果累积的抽象成本只不过 10%,则做抽象是聪明的选择。但是,到底抽象成本是多少并不清楚,而且到今天为止,能准确地计算成本的工具还根本没有出现。
结论是:你可以在性能效率与抽象可能带来的设计简化之间作出平衡。产品能快速面世并投入应用,但你要不断提升你的设计,让它运行得更快,以便容纳抽象过程所带来的低效部分。
估计一下产品面世时间对你的价值如何。如果一个 6 处理器的系统中有 50% 的无效设计,则意味着只要你愿意付出劳动去做优化,就有希望把设计缩小为只使用 5 个甚至 4 个处理器。问题并不仅是抽象的折衷方案是否值得去做。你需要根据不同的设计去探寻抽象的实际成本。
量子数学:2.5+3.5=7
除了按功能作分割外,还可以按更小的粒子尺度进行分割。例如,你可以把 50 个周期当作一个性能量子,它就成为你计算时间预算的单位,每个任务都应当是 50 个周期的某个倍数,并且应当尽可能地接近一个或多个量子。这样,当你作内核配置时,就能把多个任务合并到一起,成为一个单个的新任务(metatask)。假设你已经对两个连续的新任务作了分割,而且需要为新任务 A 分配 2.5 个内核,为新任务 B 分配 3.5 个内核。使用传统的分割方法,除非大量采用手工调整的方法,否则你就要用 7 个内核。而用量子的方法,你可以用增大新任务 A 的大小并缩小新任务 B 的方法(或与之相反),或许只需要少量的手工工作就可以用 6 个内核实现这一配置。应记住边界是随意的,所以你应该把它们看作可选项,而不是约束。
如果性能是唯一对任务完成有限制作用的资源,你就可以选择多种量子。此外,还可以对决定如何分割任务的资源作剖析;当弄清楚某个新任务比另一个更具备内存密集特性时,你就能决定该把任务向何处调整。
值得一提的一个关键概念是:将新任务与大多数工具集共同使用需要一些技巧。你可以手工管理那些工具可以提供、但实际上却没有提供的所有抽象工作。你可以在汇编里编写面向对象的代码,但没有一个编译器会告诉你何处违反了规则。所以,你可以创建许多小任务,并自己提取出相互间的缝合点。如果你把两个任务放在同一个内核内,两者间就不需要过多的缝合点。你可以连续地使用中间结果,而无需把它们作传递。这种灵活性很有用,因为,也许直到调试时你才发现,对一个算法作重新分割能增加效率。
跨越时间和空间的仿真
仿真工具的一个重要缺点是它的确定性:即相同的测试数据肯定会得到相同的结果。然而,对于真实世界的多内核和多处理器系统,当两个任务同时访问同一资源时,结果有时是任务 A 抢先,有时则是任务 B 胜出,这会影响到两个任务的等待周期。这种现象就是人所共知的空间的变异性:同一个初始状态却有不同的结果。问题是,每当两个任务争夺同一资源时,一个确定性的仿真器每次都会认定固定的一方获胜。
空间变异性非常重要,因为任何数据都将产生一个范围内的结果。单次仿真产生的结果会落在这个范围之内。然而,如果你每次仿真都得到相同的结果,就无法确定这个范围的广度,无法知道这次结果落在整个范围里的什么位置,或者不知道你所看到结果的出现概率。如果你依据这个单一结果作最糟情况的计算,则会低估或高估系统需求,而且还不知道错在哪里。
多数的仿真环境使用静态分析技术,它假定一个样本空间里有无数行代码。而在DSP应用中,多数的处理都发生在有限的代码集内。对网络处理而言,代码长度为100至1000行。从统计学角度来说,错误计算在一个周期的延迟只占100万行代码的0%。但对于一个NPU的200行代码,这个比例就上升为0.5%。
如果在仿真中,任务 A 总能获得某个资源的竞争胜利。任务 B 就会表现出比实际执行时更多的等待周期,而任务 A 则相应减少。换句话说,如果在真实世界里任务 A 可以赢得 50% 的竞争,则 A 永远胜利就表现为一种极端情况。结果是,你可能会给任务 B 分配过多的延迟周期,而给任务 A 的却不足。有人说任务 A/B 的问题可以忽略,既不需计量也无需担心。但是,没有经过实际的计量你无法确定它是否是可以忽略的。
你拥有越多的数据点,就对估计结果的范围以及某个特定结果出现的概率越有信心。因为用同样的数据进行多个会话不会比只进行一个会话产生更多的结果,所以你要么需要多个数据集,或者需要更长的仿真时间。
注意任务A稳赢也可能是真实世界实际情况的反映。比如内核间的走线会有不同的长度,任务 A 可能在物理上距某个资源较近,因此总能赢得胜利。然而,几乎没有工具具备描述物理内核关系的方法,诸如某个内核比其它内核距离内存更近之类。与一个资源等距离的各个内核会交替获得竞争的胜利。此外,一个任务可能会在多个内核中有实例,每个实例在真实世界中访问资源的能力都各不相同。所以,你必须能够从一个任务的每个实例中收集仿真的结果。不能只执行一个实例,然后就假定它可以代表所有的情况。
抓紧配置
如果流量的形态随每天不同的时间而不断变化,那么基于流量形态对系统的优化就很难处理。例如,数据流可能在白天超出 VOIP(voice-over-IP)流,而在夜间则相反。一个动态的系统不会要求你同时为两种极端情况作内核配置,而是让你重新分配内核,可以使用内核的一个共享的子集,当 VOIP 负荷大时为其服务,否则为数据服务。注意你是要为两个时段的配置作系统优化。
随着一个应用的成熟,优化也会出现问题。例如,你可能会预先考虑对一种新协议的使用作出限制,拨出一定量的内核资源去专门处理它的负载。然而,一旦新协议可以使用后,对其需求会快速上升,而旧协议则出现下降。你能有一种实现方法,在现场调整内核配置来延长产品的寿命吗?
一种处理 QOS(quality of service)的方法是给专门的内核以某种优先权。这样,当低优先权的内核任务满了的情况下,一个低优先权请求也不会占用为高优先权请求保留的位置。你还可以用某些内核专门处理某种协议的方法来保证该协议的最低带宽,而不是让所有内核都能处理各种的协议。挑战性的工作在于对这样一个系统作剖析,并且确定专用内核在整体吞吐量中的开销。
如果你的代码是要交使用费的,则你指定给某一过程的内核和线程的数目将直接影响到整个系统的成本。尽管可能不用为代码问题在优化系统时节约许多周期,但你仍能节省不少的费用,因为每秒钟运行代码的次数越多,需要付费的实例就越少。
在一个内核上运行单个任务可以显著地减少设计的复杂性。例如,一个内核只有有限的本地(意为:高速)内存可供使用。运行多个线程的一个内核必须对本地内存作出安排,以使各个任务之间没有交叉和碰撞。因此,每个可以运行在该处理器上的潜在任务都只能用有限数量的本地内存。限制某个内核上可运行的任务,可以为这些任务腾出更多的本地内存。
多线程系统通常限制了并行运行的线程数,这一限制是基于一个硬系统的数量,比如可用于零内容切换(zero-context switching)的寄存器库的数量。由于是动态的数据,所以每个线程的处理负荷各不相同。只关注优先权的计划表会把待处理的任务放到下一个可用的位置上,而不管内核负荷情况如何。例如,把一个内存密集型任务分配给一个已经有很多内存密集型任务在运行的处理器,这将很可能导致该处理器停转。处理器将不再执行任何代码,因为所有的线程都阻塞了。计划必须考虑到每个任务的特有情况,对各种资源上的负荷进行估计,把不同的任务作分组以减少整体的内部竞争。
对于实时数据,在管理高优先权流量的等待周期时,计划的控制能力是很重要的。当一个高优先权的任务来到时,它能否取得优先地位?换句话说,你是否可以在处理该数据的线程优先权中反映出数据的优先权,或者当一个进程启动时优先权是否就被锁定了?再举一个更详细的例子:如果任务管理器确定一个资源正持续处于高度竞争状态,例如正有一个内存申请,或者搜索队列已满,此时,你是否可以为避免处理器死锁而不让高优先权的任务加入竞争
,而且能够识别出不会产生瓶颈的任务,并给它们授以高优先权?
更多的工具要求
计算最差情况的执行路径是很复杂的,它不仅取决于对你所考虑因素(如周期、资源使用及其它因素)的某个限定,还依赖于关键路径范围的频率。过度估计了这个最差状态,就会在设计中对资源作过多的配置,或者花费时间去作不必要的系统优化。唯一的问题是大多数的仿真和剖析工具都缺少寻找关键路径的直接方法。
沿一条线性的执行路径对周期作计数不能表示出全貌。因为它并不包括到处都有的竞争失败的周期,但它们是永远存在的,因为在每次运行时等待周期都有所变化。在多数情况下,你可以获得任务执行的次数,但你还必须把它们累加起来,才能算出处理一个数据集要多长时间。做到这一点的前提是,你可以把每个任务的剖析分解成每个数据集的倍数,并可以将这一倍数与任务相关联,这一工作用手工来做将是一件极其冗长乏味的事情。
供应商可能会在你发表最终产品前对工具作更新。但一个改进后的编译器可能创建出完全不同的代码,这会对原来确定如何优化代码的剖析方案产生严重的影响。如果你别无选择(很可能如此),只能使用这些工具的话,就得走回头路,对系统进行重新剖析。为减少这一工作量,应该要确保剖析过程的文档化,这样就不必再次进行计算。此外,要留出一些余量以防获得较慢的代码,即一个工具按照代码尺寸和周期对一个操作的优化是以牺牲其它操作为代价的。你还应保留大部分剖析工作,直到最后的工作完成。考虑下列情况:新的编译器能把一个任务的周期使用率从 95% 降到 75%,这样,你的余量就从 5% 一下提高到了 25%。
如果由操作系统来管理计划表,你会遇到另一种层次的抽象和低效问题。要确认把操作系统中不必要的功能删掉,只保留必需的部分,以尽可能减少低效率的开销。一个虽好但不很必要的操作系统调用可能会带来多余的库和对所有函数的开销,这比起只在有限地方使用调用的方法成本要高得多。
你的参考平台是将要使用的多内核器件的"小兄弟"版吗(比如,只有六个内核中的一个)?如果是这样的话,你只可以对任务的理想执行状况作剖析。在没有一个完整系统的情况下,你不会遇到资源竞争的问题,而这种竞争是多内核系统中产生大多数奇怪结果的原由。内核不是一个孤岛。
在一个设计的图形界面和 C 源码之间的切换非常有用。多数情况下,由图形界面产生 C 代码。但如果不能方便地把 C 代码里的变动反馈到图形界面模型中,则你的抽象工作就要付出双倍的劳动。
Consystant 和 Teja 的工具可以使用一个图形界面对程序进行建模。这些工具把模型转换成 C 语言,然后由编译器再转换成汇编语言,这二个抽象过程中都可能产生低效的部分。
当你把自己的代码加到框架环境中时,你需要在它外面包一个外壳,用以向框架环境描述这些代码,并接通所有的连接,这也增加了另一层次的抽象和低效部分。
尽管较多的数据点可以增强你对仿真结果的信赖程度,但这时的仿真速度却减慢了。某供应商的产品在对 7000 万门设计作门级仿真时,速度是每秒 2 到 5 个周期;而使用抽象模型作功能仿真时,速度是每秒 1000 个周期。以这个速度,要仿真一个 100MHz 的器件的一秒操作就要花超过 27 小时的时间,这还只是对单个处理器的情况。再考虑到抽象的竞争与实际的竞争还不完全相仿,则功能仿真的准确性也值得怀疑。
精确的剖析是一个困难的工作,因为你必须在完整的系统上进行试错。如果只在系统的某个子集上工作,你获得的只是那个子集的信息,并不是你所需要的尺度或者全部工作集的相互关联信息。
文章评论(0条评论)
登录后参与讨论