为什么 29% 的 x86 都是我的错

标题:Why 29% of x86 is my fault

日期:2019/11/16

作者:Tom Forsyth

链接:https://vimeo.com/450406346

注意:此为 AI 翻译生成 的中文转录稿,详细说明请参阅仓库中的 README 文件。

备注:找资料(vpternlogd)时看到的。


好的,大家好。我是 Tom Forsyth。我一生中扮演过很多角色。我当过游戏程序员、图形程序员、指令集设计师,还在 Oculus 做了大量的 VR 工作。实际上,我刚刚意识到,我参与的第一款游戏发布至今已经 20 年了。所以,这挺酷的。

我突然意识到,哦,是的,1999 年是 20 年前了。天哪,我老了。不管怎样,这是我关于指令集生命周期的演讲,因为我非常幸运地参与了一件事物的开端,它最终演变成了 AVX-512,也就是所有最新英特尔 CPU 中那个花哨的新指令集。机缘巧合的是,我现在又回到了英特尔做这个演讲,尽管我中途去了 Oculus 搞了一阵子 VR。

这次演讲的副标题是“为什么 29% 的 x86 都是我的错”。我知道你们都热爱 x86,我很抱歉。这个统计数据有点可疑。我只是翻了翻指令手册,数了数有多少指令是以 V 开头的,我想那应该就是我的错。当然,这不只是我一个人的功劳。在英特尔,有成百上千的人参与了指令集的设计。而我说的“错”(fault),指的是如果 CR4.OSFXSR 没有被正确设置时会发生的 UD fault(无效指令异常)。这是个 CPU 领域的笑话。抱歉。

好了,先说几点注意事项:我知道你们想让我谈谈 Larrabee,但我不会谈 Larrabee,因为我不被允许谈论它,也因为我没有三天的时间。所以,我只会谈论指令集,很少涉及那个设备本身。这些内容都来自我的记忆。不幸的是,我所有的旧邮件在我跳槽到 Oculus 又回来后都被清空了。所以我没有任何资料可查。这完全是凭记忆。所以内容会很随机。我曾考虑过列出帮助过我的人,但实在太多了,无法一一提名。而且我大幅精简了这份幻灯片,稍后我会把完整版放到网上。这篇演讲也完全不是英特尔的官方指导或任何官方信息,对吧?所以别对我或我的老板大喊大叫。

好了,当谈论指令集时,讨论其底层的硬件是很有用的,也就是这些不同层级的硬件。这些是我们在英特尔的叫法,我想其他人可能有不同的称呼。最顶层是用户级架构 (user level architecture)。这是你们作为程序员看到的那一面,或者至少当你在进行汇编编程或调试程序时,你看到的就是这个层面,对吧?你能看到有多少寄存器,它们有多大,叫什么名字,指令集长什么样,指令集的编码方式是怎样的。这些对我们程序员来说都是可见的,特别是如果你是写编译器的。

在它下面是操作系统级架构 (OS level architecture),这部分通常对我们普通程序员是隐藏的,除非你是操作系统程序员。它包括像监督者状态、虚拟机管理程序状态、虚拟内存的实际工作原理等。我们大多数人并不关心这些,但它能正常工作本身就非常酷,甚至有点吓人。这部分还包括像超线程这样的东西——我有多少物理核心与多少虚拟线程?还有一些奇怪的东西,比如非一致性内存架构 (NUMA) 是如何工作的。这些仍然是架构状态,对程序员可见,但他们是操作系统程序员,不是游戏程序员。

所以以上这些都是公开的东西,英特尔很难去改变它们,因为这意味着其他所有人都需要跟着改变。

再往下是微架构 (microarchitecture),我们通常写作 UARC,因为这本应是一个希腊字母 mu (μ),但你懒得在键盘上找那个符号,所以我们就直接写 UARC。但它的意思是微架构。这部分东西,硬件制造商可以更容易地进行修改,而不会吓到所有程序员。因为它涉及到诸如你的缓存究竟有多大?它具体是如何工作的?分支预测如何工作?我的芯片里有多少流水线阶段?有多少种什么类型的算术逻辑单元 (ALU)?我们可以调整这些东西,而不太会吓到程序员。虽然会对性能有影响,但你的代码仍然能运行,你不需要修改它。大多数优化文档谈论的都是微架构,我们当然也会发布关于这方面的文档。

在微架构之下是物理设计 (physical design)。这部分通常是保密的。这里通常是魔法发生的地方,也是我们不希望任何人知道的。从你(程序员)的视角,一路从顶层看下来,是很难发现任何关于物理设计的信息的。但这部分极其重要。它决定了芯片上单元之间的导线有多长,对吧?我稍后会举几个例子,说明这一点是如何影响指令集的。

好了,假设你想创造一些新的指令,就像你平时做的那样。这件事总是在一个历史背景下进行的,对吧?你总是在为特定的芯片、特定的设计流程创造指令。而这些设计约束,比如这些信号能跑多快?你有多少芯片面积?会产生多少功耗?这些都驱动着基于该设计的微架构。然后微架构会说:“嘿,我们有了这个新功能。比如,我们刚弄了些新的缓存玩意儿,你需要一些指令来驱动这些缓存,比如预取、失效之类的。” 在我这次演讲的案例中,当然就是更宽的 SIMD,对吧?我们从 SSE 的 128 位,扩展到了这个 512 位宽的 SIMD。我们需要一些新指令。

但指令集始终是在你正在构建的物理机器的背景下产生的。绝不是“哦,我想要这条指令,所以我就把它造出来”,然后魔法就发生了。对吧?你能构建什么样的指令,以及它们能在哪里真正起作用,这些都受到了非常、非常严格的限制。而且很多时候,底层的物理设计——我指的是“嗯,这根线有多长?哦,看,这两个单元在芯片上离得太远了,这根线变长了”——这种问题实际上会一直渗透到最高层,导致“你不能拥有那条指令”,或者“这条指令比那条指令更好”。而这正是你作为程序员会感到困惑的地方:“你们为什么选了那条指令?为什么不是这条?” “嗯,因为我们造不出这条。这条更好。” “为什么?” “我不能告诉你。这是秘密。”

然后,当然了,你用这个绝妙的芯片做出了你绝妙的指令集。芯片出货了,一切都很棒。然后我们开始制造下一代芯片。下一代芯片不一样了。结果发现,你那个绝妙的指令实现起来简直是个噩梦。现在那帮人恨死你了。恭喜,你留下了历史包袱。你已经进入了指令集的“大象墓地”。

x86 在这类事情上是臭名昭著的。我们可以花好几年时间嘲笑 x86 里的各种疯狂设计。但其他架构也有。比如 MIPS 的分支延迟槽 (branch delay slot),在当时是个绝妙的主意,现在则是个糟糕透顶的主意。还有寄存器栈(x87 FPU),嗯,我不太确定这在当时算不算个好主意,但那时候人们喜欢它。Arm 的条件执行 (predication) 和免费的移位器 (free shifter),这在 Arm 1 时代是天才设计,在 Arm 2 还行,到了 Arm 3 就很烦人了。而如今 Arm 的工程师们会说:“是啊……” Arm 现在的历史包袱问题和 x86 一样多,甚至可能更多。像 Thumb 指令集这样的东西简直让他们抓狂。这很有趣。

所以,在你作为程序员跑过来问“他们当时到底在想什么?”之前,请记住:我们当时有个好主意,而且在那个时候是合情合理的。只是那是 10 年前了,现在不合理了。而且通常,它现在之所以不是个好主意的原因,和它当初之所以是个好主意的原因,这两者你作为程序员都是看不到的,因为它们都深藏在微架构和物理设计的内部,而那些都是秘密。

好了,历史。我告诉过你们历史很重要,所以这里有一些历史。大约在 2004 年,Rad Game Tools 公司的 Mike Abrash 和 Mike Sutton 做了一个名为 Pixomatic 的软件光栅化器。在当时这东西非常棒,因为很多笔记本电脑根本就没有任何显卡,所以它至少填补了这个空白。那是一个 DX7 风格的光栅化器,所以 MMX 对整数运算来说足够了。但他们想做 Pixomatic 2,那将是 DX9 级别的。所以你需要在 x86 中加入一条浮点乘加指令 (floating point multiply accumulate),而当时并不存在这条指令。于是他们就在想:“我们在英特尔认识谁呢?”

巧的是,当时我们在 GDC(游戏开发者大会)的 Rad 展台,对面的展台就是英特尔。Dean McCree 正在那里做演讲。于是我就想:“哦,我们直接走过去跟他聊聊吧。” 他说:“你们问得真巧。我认识 Doug Carmean 和 Eric Sprungel,他们大约两年前就有了这个想法,想要构建一个由大量非常简单的核心组成的阵列。” 也就是非常简单、非常节能的核心,然后尽可能多地把它们塞进一个大芯片里。

当时的问题是,英特尔正在出货双核和四核的系统。而程序员们都在抱怨:“你们在干什么?给我四个核心,结果三个都在闲置。我根本不知道怎么处理这些东西。” 对吧?多线程、GPGPU,在当时几乎闻所未闻。而那些了解的人也不喜欢它。他们说:“我只想让我的单线程跑得更快。” 但是,你知道,未来是不可避免的,不幸的是。那么,Doug 和 Eric 从哪里找到那些“易于并行”(embarrassingly parallel)的工作负载,来喂饱这个将拥有成百上千个线程的芯片呢?

就在这时,Dean 走过来说:“嘿,我们这儿有几个人,他们有一个图形管线,一个软件管线。图形管线是可以并行的。我们为什么不用这个作为示例工作负载呢?” 太棒了。很好。我们为我们疯狂的芯片找到了可以喂的东西。

于是他们招募了 Rad 来开始做这个项目。Mike Abrash 和 Mike Sutton 负责固定功能部分的工作。他们让我为 SSE 编写一个 DirectX 着色器编译器,因为我更懂 DirectX 着色器语言。所以我开始为 SSE 写这个编译器。是啊,SSE 太烂了。仅仅给 SSE 加上一条乘加指令,虽然有帮助,但还不够。128 位的宽度,你每个核心能获得的性能并不够。我们有时称之为“填缝料太多,瓷砖太少”——意思是开销太大,有效计算部分太少。所以你想要更大的“瓷砖”,旁边更少的“填缝料”。

我们知道我们要做得更宽,尝试了各种方法,试图把它塞进现有的 SSE 模型里,但行不通。好吧,我们得创造一个全新的指令集了。我们其实不想这么做,这是个大工程,但看来我们非做不可了。

请记住,这仍然是一个 x86 核心。英特尔已经有 GPU 了,我们不需要再造一个 GPU。我们正在构建的是 x86 核心,只是图形处理看起来是个不错的工作负载。所以它仍然能运行 Linux,有虚拟内存,有超级精细的模式等等,我们不能过多地重新发明轮子。

那么,我们如何发明一个 ISA(指令集架构)呢?我的方法是,我想这也是每个人都应该采用的方法,就是从一个编译器开始。如果你还不知道你要适配的指令集是什么,这听起来有点傻,但这就是重点。你从一个能够适配多种不同指令集的编译器入手。你把一堆工作负载(在我的案例中,是从当时主流游戏中收集的大量像素和顶点着色器,大概有 150 个)扔到编译器前端。

你从 SSE 开始。你知道这不够好,但你还是把那些东西编译了。你找出瓶颈在哪里,它擅长什么,不擅长什么。然后你开始思考:“我能给这个功能集增加点什么?我能给这个指令集增加点什么来让它变得更好?” 然后你尝试那个想法。你能教会编译器去适配这个新东西吗?如果答案是肯定的,很好。那性能提升了多少?如果你无法让编译器适配你的指令,那你的指令就毫无用处。手写汇编是愚蠢的,纯属浪费大家的时间。你知道,如果那是你使用这条指令的唯一方式,那它很可能不是一条好指令。

这个方法对于迭代速度来说简直太棒了。我有时甚至可以一天增加一条指令。一个新功能的验证周期大概是一周,就能得出结论:“嗯,这是个好主意。” “哦,这是个绝妙的主意。” “这是个糟糕透顶的主意。” 所以我们通过这个系统测试了大量、大量的奇思妙想。当然,大部分都被扔掉了。但你必须经历那些奇怪的想法,才能找到好的想法。

我们还面临一个问题,就是我们总得从一个核心开始。在我们对核心进行大手术,把这个巨大的 512 位的东西嫁接上去之前,我们需要一个基础核心。基本上有两个选择。一个是最初的奔腾(Pentium),1991 年左右的那个 33 兆赫兹的奔腾,到那时已经 15 岁了。听起来像个蠢主意,但它是英特尔最后一个顺序执行(in-order)的核心。好吧,我们得把它扩展到 64 位,但它存在,而且我们了解它,知道它能工作。我们还有个叫 Ed Grahowski 的人,当时处于半退休状态,但他写了奔腾的大部分代码,比如 RTL(寄存器传输级语言)。那时候的团队规模小一些。所以他能告诉我们关于这个核心的一切,而且他非常热情,觉得“太酷了,居然有人在用我 15 年前的核心”。

但另一方面,它毕竟是 15 年前的东西了,对吧?另一个选择是 Bonnell,这是英特尔当时正在制造的一款新的低功耗顺序执行核心。它后来以 Atom 1 的名字出货。那就是 Bonnell。但当时它还没完成。所以他们正在疯狂地开发它。如果我们当时闯进去说:“嘿,这个半成品核心不错,能借我们用用吗?” 那会干扰他们,也会干扰我们。我们真的很想用那个漂亮的新核心,而不是这个尘封已久的老古董。但这太冒险了,对吧?我们可能会把两个项目都搞砸。事后看来,不毁掉他们那个项目的决定是绝对正确的。

所以我们用了奔腾,15 年后的奔腾 1。这还挺酷的。我不知道有多少人,举手示意一下,谁在 1991 年还活着?如果你曾经在上面写过代码,你会认出它。它里面有两条流水线:胖管道 (fat pipe) 和瘦管道 (thin pipe)。胖管道,也就是 U-pipe,可以执行任何指令。而瘦管道 V-pipe 只能执行一部分指令。

这套机制实际上效果非常好。我们把那个大的乘加单元嫁接到了胖管道上。然后我们基本没怎么改动瘦管道,只是给它加上了向量存储功能。所以编译器的策略很简单:让胖管道一直运行乘加指令,这是我们绝大多数的指令,也就是数学运算。我们还有一个 load-op(加载并操作)指令,所以我们也可以在那个流水线里加载一些数据。所以是加载并操作。然后 V-pipe 用来做“生命支持”,处理非数学运算,比如循环计数器、分支、调用、返回和向量存储。

所以,尽管我们是从奔腾那里继承的这套机制,但它实际上效果非常好。我们真的把它利用得很好,它看起来有点奇怪,对吧?这两条不对称的流水线,实际上效果出奇地好。这是一个微架构驱动指令集设计的例子。因为为了让它高效,我们需要 load-op。load-op 是 x86 有而几乎所有其他 ISA 都没有的奇怪东西。但在我们这个案例里,它非常酷。其他人可能视其为累赘,但在我们这里,它是一种优势。我们还需要在 V-pipe 上增加向量存储。所以,胖管道和瘦管道这个微架构上的东西,渗透到了指令集层面。这挺酷的。

当然,在我们承诺这么做之前,我们必须确保编译器能处理这种流水线的奇怪不对称性。结果证明,是的,它可以。有很多其他奇怪的不对称流水线的例子,就是因为有人忘了问编译器团队他们是否能驱动它,结果芯片就成了灾难。但在我们这个案例里,我们确保了这一点,而且它确实奏效了。

好了,大约在 2005 年,我们正式从一个研究项目升级为一个产品项目,这意味着员工数量膨胀了大约 10 倍。我们得到了更多的资金,并且需要一个正式的代号。英特尔的代号是基于地名的。在众多选择中,我们选了 Larrabee,这是一座山,也是一个州立公园的名字,尽管奇怪的是,公园和山根本不在一块儿。不管怎样,那就是 Larrabee 山。其他的 Larrabee 还有《糊涂侦探》(Get Smart) 里最蠢的特工,以及 Larry the Hot Ebby Loon(一个卡通形象)。“Is it Larry B? There in my pocket.”

所以那些就是我们的吉祥物。总之,是的,我们“引以为豪地借鉴”(steal with pride)。这是我们的座右铭。我们从奔腾那里“偷”了核心。我们从奔腾 4 那里“偷”了 L2 缓存。我们从英特尔 GPU 的 Gen 项目那里“偷”了纹理采样器。没错,谢谢,那个很棒。而连接这一切的环形总线 (ring bus),是从一个研究项目中“偷”来的,我记得那个项目叫 Tanglewood 之类的。总之,把它们都堆在一起,然后用锤子敲打,直到它们能工作为止。这过程并不长,只花了三年。

然后,是的,SIGGRAPH 2008,那是我们盛大公开的日子,我们宣布:“嘿,我们正在做这个愚蠢的想法。” 一半的人觉得我们疯了,一半的人觉得这是个绝妙的主意,还有一些人觉得两者兼而有之。

现在谈谈指令集的一些具体特性。

条件执行 (Predication) 是一个关键的东西,很明显,在宽 SIMD 架构中你需要它。所有的 GPU 都有这个功能。但在当时,CPU 架构做这个还很奇怪,我们花了很多功夫在内部进行推销,才说服他们这是正确的做法。但你就是需要它。如果你要同时处理 16 或 32 个通道,你就必须有这个功能。

好吧,但你怎么实现它呢?具体用什么指令呢?大多数 GPU 的做法是隐式的。它有一个掩码寄存器,但你不需要在指令中提及它,它就是“哦,那个掩码寄存器”。它会通过分支指令(其实不是真正的分支)被神奇地管理。隐式寄存器在 x86 中是出了名的麻烦。历史上有很多问题。我们不想用它们,因为,你知道,方向标志位、舍入模式设置,都糟透了。所以我们希望它是显式的。也就是你明确地说:“使用这个寄存器作为条件执行的掩码。”

最显而易见的选择是使用标准的 x86 寄存器组,RAX、RBX 等等。那样会最优雅。问题在于,这边是我那个我们没怎么改动的、小巧的标量核心(源自奔腾),而那边是那个硕大无比的 SIMD 单元。它们之间有相当大的距离。所以设计人员说:“伙计们,如果你们想在这两者之间传输数据,会很麻烦,对吧?那会需要多个时钟周期,会有一条流水线,会有延迟。请不要这么做。”

“但这方案太优雅了。” “请不要这么做。” 好吧。于是,我们在 SIMD 那边创建了一套全新的、小巧的 16 位寄存器组。并且还为它们设计了一套自己的、小规模的与、或等指令,基本上是我们为 RAX 已经有的那些指令的翻版。好吧。听设计人员的。这是正确的答案。听设计人员的。那个优雅的方案会很慢,而且是个噩梦。不要那样做。当设计人员告诉你某件事很难办时,你要听他们的。即使你必须创造一堆基本上是重复的指令,也要去做。

另一个特性是寄存器乱序 (Swizzle)。当你在写 SSE 代码时,你会注意到大约一半的指令都是乱序操作,对吧?对通道进行乱序或重排 (shuffle)。真的,50% 的该死指令都是这个。你总是在重排数据,然后你加点东西,再重排一次,乘一下,再重排一次。所以我想:“好吧,我们为什么不把它们做成内联的呢?” 比如,这是一条向量加法指令,我们可以免费获得对其中一个操作数的重排功能,而不是用两条独立的指令。这主意不错。酷。这会很棒。这样当人们把他们的 SSE 代码转换到这个新 ISA 时,指令数量会减半,因为它们都会被压缩到一起。这是个好主意。

不,事实并非如此。根本没人把他们的 SSE 代码像这样转换过来。他们会说:“这个指令集不错。” 然后把他们的 SSE 代码扔掉。对,扔进篝火里烧了。那玩意儿太糟糕了,干掉它。然后他们用这个漂亮的新指令集重写了代码。所以根本没人用过这个功能。不幸的是,我们发现这一点时已经太晚了,芯片都造出来了。所以这是个坏主意。而且把这个乱序功能内联进去,设计人员又会说:“看吧,早就告诉过你们了。不该这么干。从来不听我们的。” 这无疑是整个芯片上最大的失误。幸运的是,我们在 KNL (Knights Landing) 中修复了它,但没错,这是我搞砸的地方。

内存乱序 (Memory Swizzles)。对程序员来说,内存乱序看起来和寄存器乱序一模一样。甚至语法都一样。当你从内存加载时,可以在加载途中对它们进行乱序。如果你做一次存储,也可以在存储途中进行乱序。但在硬件上,这完全是另一套硬件。这是因为内存单元本身已经有了所有这些大型的乱序硬件。因为当你想存储或加载一个 64 位的数字(比如 RAX)到一个 512 位的缓存行时,你就需要所有这些重排的东西,对吧?因为你必须把它放到缓存行的正确位置,或者从缓存行的正确位置提取出来。所有这些东西在我们有 SIMD 单元之前就已经存在了。所以,没问题。

更重要的是,我们用它来实现 gather-scatter(聚合-分散),这对于宽 SIMD 单元来说是基础功能。我们用它来实现压缩存储。我们用它做很多事情。直到今天我们还在用,对吧?AVX-512 仍然有这个能力。所以这是个非常好的主意。再次强调,从软件和语法的角度看,你把这两类指令——寄存器乱序和内存乱序——放在一起,它们看起来是一样的。但在硬件上,它们截然不同。一个是绝妙的主意,另一个是糟糕透顶的主意。你作为程序员可能会想:“为什么他们移除了这个?” 因为它是个坏主意。“为什么你们保留了这个?” 因为它是个好主意。“它们为什么不同?” 原因就在这里。

嗯,这个无聊。乘加指令 (Multiply-add)。你加入 FMA(融合乘加)是很酷,作为指令集设计师,你就是会去做。但我们这个有点特别。我们经常需要对一大堆数字进行缩放和偏移。比如,我们想乘以 2 再加 1,或者乘以 0.5 再减 0.5。这是非常常见的操作。问题是,缩放因子 A 和偏移量 B 都在内存里。而奔腾每个时钟周期只能从内存加载一样东西。所以,我们有两个东西,那就需要两个时钟周期。嗯,这太糟了。

但是,如果我们能把这两个值并排放在内存里,比如两个 32 位的值,只要确保你把它们紧挨着放在内存里,它就可以做一次 64 位的加载。奔腾会说:“哦,这只是一次加载。我能做到。” 然后在内部,你把这个 64 位的值广播出去。但是,这只需要一个时钟周期就完成了。太棒了。一个非常巧妙的小技巧。大约有 10% 的指令都用到了这个技巧。对于这么一个小技巧来说,这是巨大的节省。

浮点修正 (Floating Point Fixup) 是个奇怪的东西。好了,假设我们要做一次倒数、指数或者平方根运算,对吧?这些运算序列需要多个时钟周期。在几乎所有架构中都是如此,当然,如果你想获得高精度的话。

通常的做法是使用微码。你发一条指令,比如倒数指令,然后微码开始工作,在内部,会有一系列指令被执行,但作为程序员你是看不到这些的。问题在于,奔腾的微码烂透了。它非常非常慢,大概只有正常速度的四分之一。所以你永远不想用它。因此,我们必须把它展开成实际的指令,让编译器来发出这四条指令,而且必须遵循非常特定的顺序。其中一些指令非常特殊,你永远不会在别的地方用到它们。

问题是,倒数运算有一些特殊值,对吧?0 的倒数是无穷大。无穷大的倒数是 0。而且是正 0 还是负 0?所有这些烦人的东西。如果你把一个 0 输入到这个指令序列中,它得不到正确的答案,它会得到一个 NaN(非数值)。哦,或者一个无穷大,而不是 NaN。嗯,没办法。

那我们如何修正结果呢?技巧就是,你有一条名为 fixup 的指令……朋友们,这是创新。创新。我们花了好几周才想出这个名字。总之,fixup 指令,你把指令序列的输出结果喂给它,比如问:“这是正确的结果吗?” 然后你还要把输入也喂给它。它内部有一个表。它的工作方式是,对于每个输入,它会对输入进行分类。“你给我的是哪种输入?是负无穷大?是正 0?” 这里有七种可能性。所以每个通道有七种可能的输入。这是一个 21 位的表——一个有 7 个条目,每个条目 3 位的表。你对输入进行分类,得到一个 0 到 6 的数字。你用这个数字去查表,得到一个 3 位的结果。这 3 位告诉你该执行以下八种操作中的哪一种:要么,无变化,你得到了正确的结果,干得好;要么,你必须把它改成这几个选项中的一个。

就这样,它修正了结果。这看起来像个补丁,但好处是,这是一条通用指令。你可以对任何函数使用它。所以如果你在写一些奇怪的数学函数,而你不想处理那些烦人的边界情况,你可以用你想要的任何函数来处理所有常规情况。然后就在最后一步,你说:“哦,我需要……哦,你给我传了个无穷大。好吧。那我们就让输出也是无穷大。” 所以你可以用它来修正你自己的函数。就这样,它成了一个通用的功能。尽管我们最初要解决的问题根本上是“哦,奔腾太烂了”。

然后是三元逻辑指令 (ternary logic instruction)。这是因为我们需要那些与 (and)、或 (or)、异或 (xor) 等等所有这些无聊的指令。它们很简单,但你就是需要把它们都文档化和测试。我们不只想要两个输入的,我们还想要三个输入的。三个输入之间的逻辑运算,种类非常多。我们当时就在看,我们到底需要多少种?答案是,相当多。然后你就会发现,指令手册的一半都被这些无聊的逻辑指令填满了。

然后我想起了一个在 FPGA 编程时用过的小技巧。你不用改变连线,你只需要说:“你能把它塞进一个查找表吗?”

哦,查找表。我在哪里见过这个?fixup 指令!我把查找表放进了指令里,不是吗?嘿,这是个好主意。所以我们这么做了。三个输入,同时也是输出。然后你的逻辑表就在这里,在指令里,它只是一个 8 位的立即数。你从这三个输入中各取一位,组成一个 3 位的数,也就是 0 到 7 之间的一个数字。我用这个数字在这个表里查找,这个表就会告诉我结果是什么,是 0 还是 1。这样就涵盖了所有可能的一输入、二输入或三输入逻辑运算。所有这些,用一条指令就搞定了。

验证部门现在对我非常满意。200 条指令变成 1 条。搞定。

注意这件事的奇特之处。ternlog (三元逻辑) 是怎么来的?对吧?它源于“微码太烂了”。如果奔腾有一个像样的微码定序器,我们还会想出 ternlog 吗?我不确定。是那个限制迫使我们走到了这一步。这是一个值得思考的奇怪事情。

想想看,为什么 ternlog 以前不存在?没什么特别的原因。所以,那个诱因就是我们用的芯片的微码太烂了。

很酷。

我是说,我能这么说,因为是我发明的它。我真的有那个……好吧,我有那个小牌匾,所以我不是在说,“谁知道那家伙在想什么”。我知道我当时在想什么。我当时在努力解决这个问题。

总之,我们最终出货了。我们有了芯片。太棒了。Knights Ferry。Knights Ferry 就是 Larrabee 1。终于在 2010 年出货了。32 个核心,8 个纹理单元,HDMI 输出。它长这个样子,代号叫 Aubrey Isle。它是一块显卡,你把它插到你的电脑里。你把现有的显卡拔出来,把这个插进去,把显示器插在后面,启动 Windows。Windows 会说:“哦,我有了块新显卡。酷。” 然后把 DX 指令扔给它。这东西渲染它们,然后从显示器输出。酷的是,它运行的是 Linux。它是一块(或者说一套)运行 Linux 的 x86 芯片,在假装自己是一块显卡。它真的是在模拟一块显卡。所以,酷。

嗯,好吧,那个很酷。但第一个版本不行。它没法工作。计划好扔掉第一个版本,因为你反正都会这么做。对吧?第一个版本是坏的。iCache(指令缓存)坏了,x87 有 bug,有几条指令不工作。然后我们用一个快速的 B-stepping 修复了这些,至少它能运行了。但内存接口很糟糕,而且非常慢。所以我们又做了一个 D-stepping,这听起来像是“哦,只是一个步进”,但我想它花了六个月。所以那是一次相当大的芯片重新设计,但内存终于能工作了,这很好。

不幸的是,这个产品的目标客户是物理学家。物理学家们说:“嗯,32 位浮点数不错。但我们不用那个。我们要 64 位浮点数。” 我当时就想:“真的吗?” 他们说:“是的,别的都不关心。” 哦,那正是我们为了把 32 个核心塞进这个芯片而扔掉的东西。如果我们需要那个 64 位浮点性能,我们的核心数得减半。所以这很糟糕。但它是一个很好的开发平台。如果你们中有人玩过 Aubrey Isle,我知道它分发给了一些人,你们拿到的就是这个。但你们不是物理学家,所以它对你们来说用得很好。

好了,下一个是 Knights Corner。目标很简单:把所有东西都做得更好,特别是把 64 位浮点性能提高四倍,好让物理学家们开心。物理学家们非常开心。他们用它建造了一些非常酷的超级计算机,包括一台在两年半的时间里都是世界第一快的。所以那很酷。我的东西用在了世界第一的超级计算机里。那感觉还挺不错的。

这就是 Knights Corner,这次有 62 个核心。他们给了我们双倍的核心数,而且核心也更快了,但我们仍然只有 8 个纹理单元,而且没有视频输出,所以如果你想看到图像,你得把它们传回集成显卡,然后从那里输出。但这个你真的可以买到,它叫 Xeon Phi。今天你还能买到,在 eBay 上有。

在那之后是 Knights Landing。Knights Landing 的目标是说:“嗯,PCIe 卡不错,但我想成为一个 CPU,一个真正的 CPU,在主板上,作为你唯一的 CPU。” 它做到了。为了做到这一点,他们把奔腾核心扔了,换上了最新的 Atom 核心。这听起来像个简单的工作,但如果你注意到日期,这花了四年时间。

但这些是更好的核心。我们这次得到了 76 个,没有纹理单元,没有视频输出,这不再是显卡了,这是一个严肃的 CPU,而且它是装在主板上的,不是一个像显卡那样的东西。

之后还有一个叫 Knights Mill 的,但那个非常专用。我还有多少时间?可以再讲五分钟吗?可以。

我们跳过 VConflict 吧。它很酷,但是,是的。VConflict 的巧妙之处在于它是一个全对全(all-to-all)的比较。你给它一个数据向量,然后它检查内部的冲突。比如,1 是一个冲突,所以你不能执行那个。所以,当你作为一个架构师看这个指令时,你会想:“啊,这是一个全对全的比较,对吧?N 平方复杂度,哦天哪,我们讨厌这个。” 所以我们在做 Knights Corner 的时候考虑过它,但最终结论是:“是的,太贵了,做不了。” 于是我们用 gather-scatter 蹩脚地模拟了它。

然后一个设计人员,就是那个真正把单元放到芯片上、进行布局的人,他看了看这个说:“嗯,这是个难题,但这是个我已经解决过的问题。” 他说:“看,这和芯片上的这个部分是一样的。所以如果你对这个部分稍作调整,你就能得到你想要的东西。而且你已经为它付过钱了。”

这是天才。这是一条极其强大的指令,从架构上看非常吓人,对吧?它是个 N 平方算法,我们讨厌这种东西。而他说:“是的,但我们芯片上已经有这个了。我们因为别的什么原因已经必须解决这个问题了。” 太棒了。所以 vConflict 的添加成本极低。这一切都因为 Dennis 真的说了句:“嗯,不管怎样,让我看看。” 这又是一个例子,从物理设计层面一直渗透到顶层,一个你通常认为会非常困难的东西,我们得到了这条指令,而且基本上是免费的。酷。

最终,我们终于有了 AVX-512。我们和大核心团队开了无数次会,历时整整十年,他们终于同意了,我们找到了一个双方都能实现、都能接受的指令集。大核心在实现条件执行时遇到了巨大的困难。它和他们的乱序执行方式真的不太合得来。这就是为什么,我不知道你们是否注意到,在 AVX-512 中,有两种条件执行:清零式 (zeroing predication) 和合并式 (merging predication)。他们真的不想做合并式条件执行。我们花了大量来自软件方的反馈,去说服他们:“不,不,求求你们,请做吧。” 但是,我们还是做到了。

于是,我们有了 Skylake Xeon,终于在 2017 年出货了。这是第一个大核心实现的 AVX-512。距离我们发明它仅仅过去了 13 年。这些开发流程真的很慢。不幸的是,它只是一款服务器芯片,但,好吧。然后终于,去年我们有了 Cannon Lake,它无处不在。它在笔记本里,在台式机里,它就是 CPU。而且它支持 AVX-512,现在所有人都可以玩了。

离奇的是,你知道,在这期间,我跑去做 VR 了。所以,这就是这个过程需要多长时间,对吧?我在一个曾经不存在,然后我们让它存在了的领域里,有了一段完整的职业生涯。然后当我回来的时候,我们说:“是的,我的东西出货了。”

所以,经验教训:如果你有一天要为世界上最流行的芯片设计指令集,你知道,就像你平时做的那样。

  1. 依赖编译器。 先写编译器。在编译器里测试你的东西。如果编译器无法驱动你那个疯狂的新硬件想法,那它就只是一个疯狂的新硬件想法。不要去做。

  2. 每一个层级都会塑造硬件。 我们谈到了物理设计如何以两种方式塑造了我们的 ISA,对吧?内联乱序,看起来是个好主意,设计人员说:“别这么干。” 我们本该听的。但 VConflict,比你想象的要便宜。确保你问过设计人员,他们有时能从帽子里变出兔子来。

  3. 微架构,奔腾的细节,真的很好地塑造了指令集。我也谈到了慢速的微码如何催生了那些好用的通用指令,fixupternlog。在某种程度上,如果没有这个慢速的微码,我们可能根本不会考虑它们。

  4. 当然,用户层面,也就是你看到的指令,显然也会驱动事情的发展,对吧?FMA,很明显,为什么要有乘法和加法两条指令?把它们融合在一起,指令更少。非常直观。三元逻辑的效率,对吧?为什么我代码里有这么多 move 指令?干掉它们。这些是程序员也能看到的明显问题。

  5. 还有条件执行,对吧?条件执行是个大问题,硬件方不想做它,而所有程序员都在说:“你们必须做。没有其他方法可以实现高效的 SIMD。”

所有这些层面共同施加影响。真的,作为一名程序员,这很困难,因为这是你唯一能看到的层面。

没有提问环节了,因为我们没时间了。谢谢大家。谢谢。