原文来自知乎 计算机体系结构:VLIW
本文主要介绍计算机体系结构中的VLIW,以供读者能够理解该技术的定义、原理、应用。
🎬个人简介:一个全栈工程师的升级之路!
📋个人专栏:计算机杂记
🎀CSDN主页 发狂的小花
🌄人生秘诀:学习的本质就是极致重复!
目录
1、引言
2、VLIW设计哲学
3、干脏活累活的VLIW编译器
3.1、循环操作优化
3.2、循环之外
4、总结
1、引言
在开始之前,我们思考一下,怎么才能够让一个程序执行得更快?在计算机领域当中有一个非常经典的公式来计算一个程序的执行时间。
根据上面的式子可以看出来,有三个因素影响了程序的执行时间。
- 该程序总共需要执行的指令个数;
- 每条指令所需要的周期数(简称CPI);
- 每个周期所对应的时间;
本篇文章围绕CPI展开,CPI全称Clock Per Instruction,即每个时钟周期能够执行的指令个数,它反映了处理器并行处理指令个数的能力。有时候我们也用其倒数形式,即IPC(Instruction Per Clock)。
对于非流水线的多周期处理器,我们需要多个Cycle才能执行完一条指令,对于普通的流水线处理器,我们一个周期最多也就执行一条指令,IPC最大也就只能为1了,还有没有办法让IPC进一步提高呢?
实际上在程序的指令流中,很多指令相互之间是独立的,只要处理器有足够的硬件资源,理论上它们就可以被同时执行,即实现指令级别的并行(ILP,Instruction Level Parallesim),最典型的ILP有以下几种方式:
- 流水线(有时候提ILP不会提流水线,因为处理器设计默认采用流水线);
- 超标量(Superscalar);
- 超长指令字(Very Long/Large Instruction Word,VLIW);
超标量处理器设计是个非常大的话题,也是目前高性能CPU的主流技术。本篇文章不讨论超标量处理器,而是围绕VLIW展开,去思考为何VLIW曾一时风光无限,又为何在通用处理器市场败下阵来,又是怎么在DSP/NPU等领域重新大放异彩的,带着这些问题,我们正式进入VLIW的学习。
VLIW于1983年由美国计算机科学家Josh Fisher提出,并发表于体系结构顶会ISCA上,根据VLIW的名字我们就可以知道,其指令一定很长。实际也确实如此。
VLIW是将多条互相独立的指令,通过软件(编译器)的方式打包(Pack)在一起,我们将打包好的多条指令,称为instruction bundle。取指模块根据打包好的指令,送入各自独立的功能部件,并行执行,如下图所示,取指模块从指令存储器取出了Instruction Bundle,共包含四条指令,然后同时发送给后级模块,从而实现了指令级并行。
此外我们可以看出来其一共有两个浮点部件、三个整数部件、两个存储部件、一个转移部件,因此对应的指令Bundle长度为32*8=256。
根据上面的例子我们不难发现,指令一旦送出以后,各个功能部件是互相独立的,各个指令也是天然并行的。即VLIW不需要通过硬件检查指令与指令之间的依赖关系,而是由软件(编译器)去静态的调度互相独立的指令,其优点就是硬件设计将对简单(为什么?),但对于编译器开发工作者而言,任务非常艰巨(为什么?),也导致了一系列的软件兼容问题(为什么?)。
2、VLIW设计哲学
对于传统的VLIW而言,其一般具有以下的特性:
- 一次取多个指令(是指令,而不是指令bundle,记得区分这两个东西),具备多个功能部件(MIMD架构);
- 对于同一个bundle里面的指令而言,基于lock step的方式执行(后面解释什么是lock step);
- 同一个bundle里面的指令静态对齐,送给功能单元;
有了以上的概念,我们看一个典型五级流水线与VLIW相结合的例子,如下图所示。Bundle包含两条指令,因此理想情况下可以实现IPC=2。此外我们还可以看出来,VLIW通常具有集中的存储器结构,以及独立的功能单元。
所谓的Lock-step,即要么全都带走,要么一无所有。只要VLIW中任意一个指令stall住了,为了维持并排走的原则,所有与之并行的指令都需要停住。(有点像两人三足的感觉)
对于VLIW机器而言,是这么处理依赖关系的:
- 由编译器处理所有依赖相关的stalls;
- 硬件不做任何的依赖检查(无条件信任软件);
- 如果是可变延时操作又该怎么处理呢?Memory堵住了怎么办呢?
既然VLIW是由软件即编译器处理所有和依赖相关的问题,那能够获得的信息就比较少,实际上是缺乏运行时(硬件)信息的,因为一旦发出去,就没有回头路了,由此会有一系列的问题。
假设我们取出了某条指令,比如add $t1,$s1,$s2,剩下的指令都需要用到$t1当做源操作数,如果硬件不做任何检查,那岂不是一定拿到旧的数据?整个就出错了?
又比如对于同一个程序而言,假设我们更换了不同的处理器,对于不同的硬件而言,功能部件也有可能不同,依赖关系自然也不同,如果还用之前那一套编译器,那结果岂不是有可能对不上?没错,这些都是VLIW潜在的问题。
到目前为止,即使我们对VLIW编译器的细节不清楚,但我们应该已经领略了VLIW的设计哲学了,让我们暂时总结一下VLIW的优点和缺点:
首先谈一下优点,其实优点无非就是硬件设计简单,我们具体展开讲讲:
1、无需动态调度硬件->因此硬件设计起来相对简单。之所以不用动态调度硬件,实际上是我们相信编译器,所发的指令都是没有依赖关系的,因此硬件各自执行各自的,不会有任何问题。如果是超标量处理器,是需要硬件动态的检查各个指令之间是否存在依赖关系,如果有的话,可能需要阻塞,可能需要旁路,可能需要重命名等等,这些在VLIW中都不用。(静态就是不涉及运行时,动态与之相反,可以简单的理解,硬件跑起来以后提供的信息就是动态信息)
2、同一个Bundle内的指令天然对齐,因此取指令以后直接往功能单元发即可,简化了硬件设计;
然后我们再看一下VLIW的缺点:
1、编译器需要在每个周期找到N条互相独立的指令,组成相应的bundle往功能单元发。如果找不到呢?那也只能硬着头皮发,总比不发好,但这样的话,必然某个槽或某几个槽(slot,比如上面的例子中,一个bundle中就有8个slot)需要插入NOP指令。这样就减少了并行性,并且导致代码size变大(总归是要执行完所有指令的,插入无意义的指令越多,自然总的size就越大)。
2、当执行宽度(N,slot个数)、指令延迟、功能单元发生改变的时候,都需要重新编译(而超标量处理器不需要),因此软件兼容性非常差,这也是VLIW在通用CPU领域走向失败的根本原因。
3、由于Lockstep执行的原因,导致某个指令阻塞住了,整个bundle都会阻塞住,即使它们彼此之间互相独立的。就好像两人三足中队友摔倒了,即使你是好好的,也需要等待你的队友。
VLIW就是这么一套设计哲学,将原本硬件应该干的事情,全部交给编译器去做,这也就导致了编译器开发极为困难,因为要通过有限的代码信息,去找到互相独立的指令,即充分挖掘并行性,但很多时候纯靠静态信息,并行性是很难挖掘的,而编译器为了避免出错,都是按照最保守的方式,假设最坏的情况去做,即对似是而非的独立,默认不独立,这样就会导致大量的NOPs指令。
但由于VLIW对硬件设计非常友好,因此当并行性非常容易挖掘的时候(这种情况编译器开发就没那么困难了),比如DSP、GPU、NPU等,很多指令天然就是彼此独立的,不存在依赖关系的,这种场景VLIW就具有非常大的优势。
3、干脏活累活的VLIW编译器
首先我们看一下VLIW编译器都干了啥:
- 通过静态调度,尽可能的并行化(填满Bundle中的slot);
- 保证同一个Bundle内的所有指令是互相独立的(必须要保证,因为硬件是不会去做检查的);
- 通过静态调度,尽可能的避免数据冒险;(因为Lockstep的存在,一个堵住了,整个就堵住了);
3.1、循环操作优化
当我们谈并行,谈VLIW,就一定要谈循环级别的并行,因为很多时候循环是天然并行的。并且对于编译器而言是很好发现的(Loop-Level Parallelism is normally analyzed at the source level or close to it)。
我们假设编译器无法发现循环的并行性,如下图所示,可以看到编译以后的汇编代码,对于VLIW机器而言,可以挖掘的并行性很少,并且由于fadd需要用到fld以后的f1做运算,因此编译器为了避免出现数据冒险,会晚几个周期再送fadd指令到功能组件(再强调一次,依赖关系由编译器保证,这里对应图中的fld->fadd的红线),整体的性能非常的差。跑一个浮点加法需要8个周期(即使我们认为浮点加是单周期指令)。
实际上写过C/C++的朋友,应该都知道这种循环实际上是可以展开的(Loop Unrolling),就比如下面的例子,编译器就可以发现这一点,我们假设按照4的粒度进行展开,这样就可以在一次循环中执行四次迭代了。(当然如果N不是4的整数倍,需要把剩下的几次额外处理一下)
编译器完成该优化以后,我们再看展开以后对应的汇编代码,以及VLIW机器的流水线占用情况。可以看到流水线排布比之前满了一些,整体的FLOPS也有了显著的提高。
可不可以进一步提高FLOP?实际上是可以的,因为我们实际上可以发现,两次循环之间是没有依赖关系的,即上述的例子中,我们原本是0,1,2,3为一组,然后4,5,6,7。组与组之间是没有重叠的,但实际上它们就是独立的,因此可以进一步,将它们Overlap起来,这称之为Software Pipelineing,软件流水线(其实跟硬件流水线非常的像,本质上没太大区别)。如下图所示:
可能上面的这个VLIW指令排布还不够Makesense,我们再看下面这幅图。这幅图相比大家能理解单纯的Loop Unrolled和Software Pipelined的区别了。核心在于Overlapping,让独立的东西尽可能重叠起来。
3.2、循环之外
上面讲的是针对循环并行性的优化,如果没有循环呢?
Josh Fisher在提出VLIW的时候,同时也提出了一种名为Trace Scheduling的技术,该方法的关键思想是将基本块组合起来,使它们形成一个单入口多出口的较大块,可能作为直线代码执行。如下图所示,此外我们做如下的定义:
所谓的basic block其实就是一段连续的代码,其中没有分支或者跳转指令,具有单一的入口和出口;
由于控制密集型指令的存在(比如分支指令),basic block的size不可能太大;
对于单个basic block内部而言,很难挖掘出ILP(单个Basic block内部,认为是顺序执行的,指令与指令之间依赖关系较大);
Trace内部的指令都是单周期指令,通过重新安排基本块的顺序,使得Trace中的指令尽可能地利用处理器的功能单元,同时避免指令之间的冲突和依赖关系,进而提高程序的执行效率(如下图的灰色路径就是一条Trace,实际上Trace对应的是分支或跳转指令执行最频繁的一条路径)。
我们来看一个实例,左边是所有的基本块,以及某个基本块到另外一个基本块对应的概率,其实就是个决策树。右边是我们根据概率最大选出来的trace,是不是非常的清晰?
但是,直接用Trace是有问题的,跟踪调度的主要缺点之一是跟踪中间的进入和退出会导致严重的复杂性(实际上trace也没有中间进入或退出,但实际是的指令是可能有的),需要编译器生成和跟踪补偿代码,并且通常使得评估此类代码的成本变得困难。 超级块的形成过程与用于跟踪的过程类似,但它是扩展基本块的一种形式,仅限于单个入口点,但允许多个出口。
我们直接采取尾部复制的方式,就可以实现单入口多出口的superblock,大家看下图,我们只是在尾部加了F的复制,我们可以看到原本C到F以及D到F的入口没了,全部转移了。这就是我们所需要的超级块。
我们看一下具体的例子,左边是原始代码。右边是经过Superblock变换以后的代码,通过该机制实现了单入口多出口。下面是Superblock变换以后的代码进一步优化的结果,通过更大的代码块,有机会找到更多的并行执行机会,编译器会相对激进的在superblock内部进行code reorder以及代码优化。
总而言之,超级块是一种复杂的编译器优化技术,需要综合考虑指令之间的依赖关系、处理器的特性和约束条件等因素。它可以在静态编译阶段对程序进行优化,以提高程序的执行效率和性能。通过合并基本块并利用指令级并行性,超级块可以减少指令之间的冲突和依赖关系,从而提高程序的吞吐量和执行效率。
这里也只是说了个superblock是什么,到底如何根据superblock进行优化,优化策略细节就是非常大的话题了,涉及到编译器的很多知识,这里就不讲了(实际上我也不会)。
4、总结
看完本篇文章,大家应该也清楚VLIW为什么会在通用处理器领域失败了,其实无非就是软硬件没有充分解耦,对编译器开发要求过高,软件兼容性太差。并且在通用CPU领域,纯靠静态信息可挖掘的并行性不够,会插入很多NOPS指令,实际的性能打不过超标量+乱序处理器。
但VLIW的硬件设计简单,对于使用场景具有大量并行性的处理器,如DSP、GPU、部分NPU等,VLIW都在大放异彩,感兴趣的可以搜相关论文看,本篇文章就讲到这里,希望对大家有所帮助,如果有错误欢迎指正。
🌈我的分享也就到此结束啦🌈
如果我的分享也能对你有帮助,那就太好了!
若有不足,还请大家多多指正,我们一起学习交流!
📢未来的富豪们:点赞👍→收藏⭐→关注🔍,如果能评论下就太惊喜了!
感谢大家的观看和支持!最后,☺祝愿大家每天有钱赚!!!欢迎关注、关注!