仓库源文

.. Kenneth Lee 版权所有 2019-2020

:Authors: Kenneth Lee :Version: 1.0

理解指令集


有人和我讨论设计指令集难不难。这个问题简单回答:设计“指令集”不难,设计“顶尖水平 的指令集”很难。

结论是这么个结论,但真能理解这个结论的外延和内涵不是那么容易的。这本质上是个构 架问题。

所谓指令集,就是CPU给软件提供的API,就好比软件模块提供给另一个软件模块的API,我 们先用软件类比一下,比如你做一个内存分配算法,这个是最简单的,我们提供这样一个 API:::

    address = memory_alloc(size)
    memory_free(address)

一个负责分配,一个负责释放,功能都有了。所以,你问我,设计一个“内存分配API”难不 难?我会告诉你,“不难”,一点都不难。

但内存分配API只做到这种程度是不够的,比如有人先分配了1M的内存,后面想变成2M。他 必须这样写程序:::

    addr = memory_alloc(1M);
    use(addr);
    new_addr=memory_alloc(2M);
    memory_copy(new_addr, addr, 1M);
    memory_free(addr);
    use(new_addr);

有人一个内存分配模块的实现者发现这里有机可乘了,想了另一个API,它就变成这样了: ::

    address = memory_alloc(size)
    address = memory_realloc(address, new_size)
    memory_free(address)

这样你的程序就可以变得很简单:::

    address = memory_alloc(1M);
    use(address);
    address = memory_realloc(address, 2M);
    use(address);

关键是,它可能可以提高效率,因为如果我的address后面还有可以用的空间,我这个是完 全不用做那些拷贝的。

这样的优化机会会不断发生。比如说,有人希望分配对齐的空间?:::

    address = memory_alloc_align(size, alignment);

有人希望分配cacheline对齐的空间?:::

    address = memory_alloc_align_to_cacheline(size);

内存反复分配和释放是会产生碎片的,产生碎片,就是虽然你还有内存,但大块的就分配 不到了。但有人说,我就是收破烂的,碎片我也要,这样你还可以这样:::

    fragment = memory_alloc_fragment(size);

有了这个,你前面那堆对齐的功能要不要也加进来?然后你要不要加多线程支持?要不要 加调试?要不要加自动回收?要不要加虚拟内存锁定?

……

这样下来,你就发现,你的API列表已经和原来完全不同了,你可以支持很多功能,这些功 能很多时候就是为了更高的性能,更好的功能,这个API手册的水平高不高?——当然是高的 ,但这种“高”不体现在这个API上了,而体现在你的那个实现上了。因为你能定义这样的 API,你的模块内部实现必然已经被这个API定义绑定了。这些API每个的增加,都是为了让 你模块内部的功能真正利用到极致。

换句话说,定义一个API不难,定义完了以后,这个API背后那个实现真得可以变得高效, 这才是问题。只能看个“样子”的人看看那个API,觉得“这没有什么了不起的嘛,我也能定 义”,这是因为你就看了个样子,根本不知道背后是什么。就好比你看别人拉小提琴,总结 一下:这不就是一推一拉吗?我也会——你当然会,但你会的是“一推一拉”,不是会“拉小提 琴”。

指令集是一样的问题,你现在看到的很多指令很多都是千百万次推演、实现、应用淘汰后 的结果,它代表的不是一个指令,它代表的是千百万次“失败的实现”后的精华。

更不要说,这个精华成型后,很多使用这个精华指令集的软件构成的利益链,为这个指令 集的背书。所以,实际它代表的不但是硬件的实现精华,也代表了软件的实现精华。

和软件一样,指令集不是一个静态的东西,它是要发展的,越往后就越难发展。这和软件 构架的问题一样,一开始都是容易的,但越往后就越难加东西,因为逻辑是会自相矛盾的 。放弃什么能力,接受什么能力,在不同的市场上分别保留哪套组合,这都是架构设计的 工作,难点不在表面的那些指令上,而在整个系统的竞争力上。

这个事实告诉了我们另外两个逻辑:

第一,指令集的演进越往后,就越难,不要用“我也有自己的指令集”来YY你达到了别人一 样的水平

第二,很多指令集,已经发展很慢了(因为不好加了),这反而制造了机会,就算我们停 在某个水平上,常常也不会落后太多。

其实,很多时候,现在很多的CPU问题,就算我们不加指令,一样可以解决,比如DMA引擎 ,比如PML(Page Management Log),甚至是原子锁,我们是可以用外部寄存器解决的。 所以,很多时候,不需要动不动就要指令集如何如何的,关键还是你要摸清楚业务的特性 。业务才是形式的动力,跟着业务走就是求道,跟着指令集这种形式走,不过是学个样子 ,求名而已。

补充1:有些人无法从软件API理解到CPU的接口,我再补充例子。首先我们要明白,今天的 数字电路设计,第一步根本就不是布置三极管,连线这类你认为的“电路设计”的方法,今 天的数字电路设计,第一步是写软件,决定硬件(芯片)里面的模块怎么布置,怎么设计 接口等等。他们常常其实就是在写C++的代码。所谓CPU,其实就是你的软件,把你的要求 写成一条条的命令——所谓指令——让CPU按你的要求执行,这和软件的API没有什么不同。

如果需要感性认识,我这里有一个纯粹用来说明“指令是什么”的CPU模拟器,你可以跑来看 看:

    in nek:一个非常简单的CPU模拟器

我们这些做硬件使能的,在芯片把功能设计好前,常常直接自己就模拟硬件的接口来“喂” 我们的软件,这也没有任何困难(下面这个例子是模拟设备的,其实模拟CPU的原理完全一 样):

    :doc:`在qemu中模拟设备`

比如,你要做一个加法,你可能下这样一条“指令”:::

    add input1_addr, input2_addr,output_addr

CPU就负责把第一个地址和第二个地址加起来,修改你给的第三个地址,把结果放在这里。 这个和软件的API没有任何区别。

同时,和软件一样,CPU作为一个模块设计的时候,也会有它自己的约束。比如,你这里有 三个地址,地址离CPU很远,为了计算效率,我们可以把数据拉近计算单元的要求单独定义 成独立的指令,上面那个过程就变成这样了:::

    load input1_addr, cpu_buffer1
    load input2_addr, cpu_buffer2
    add cpu_buffer1, cpu_buffer2, cpu_buffer3
    store output_addr, cpu_buffer3

这只是一种解决方案,实际上我们远远不止这一种解决方案。而且这种解决方案强依赖于 业务,比如对于神经网络计算,这种解决方案就很低效的,如果你有兴趣,也可以看看我 们都为此解决些什么具体问题:

    :doc:`流水线深度`

但硬件设计的约束,又确实和我们软件的约束会很不一样,比如我们软件是基本上没有物 理成本的,而芯片放一个加法器就是一个实实在在的加法器,放两个面积就会加倍,面积 大了(电路多了),Wafer成本,静态功耗,动态功耗都会增加。而且芯片有控制通道和计 算通道的布置问题,因为它的部署大概是这样一个模型:

每个模块都是一个电路,控制流和数据流是电路连线(或者异步接口,但最终本质还是连 线)。硬件其实很蠢(对比软件),计算单元都是很死板的,要用什么配置来调用某个计 算单元,都靠解码器通过控制信号去修改里面的门电路组织和行为,所以他们的考量和软 件就会很不同。

这些这流那流的,统统都是电路连线,如果多了,画都没法画——当然我也不知道他们怎么 画的——但至少说明,他们的约束和软件的约束是不同的——但约束还是约束,反应在接口定 义上,行为是一样的。

简单说吧,指令集的设计,和软件模块API设计一样,它是使用者和实现者在不同的应用场 景中冲刷出来的“样子”,它是个形式,本质是场景的需要,你眼睛只盯着这个接口本身讨 论它表现的形式,你根本抓不住规律的。