
您的计算机不是PDP-11的快速版本
哈Ha!
我的名字叫Anton Dovgal,我是Badoo的C(不仅是)开发人员。
我碰到了剑桥大学研究员大卫·奇兹内尔(David Chiznell)的一篇文章,他在其中对公认的C语言是低级语言的观点提出了质疑,他的论点对我来说似乎很有趣。
鉴于最近发现的漏洞,
Meltdown和Spectre应该花点时间找出发生这些漏洞的原因。 这两个漏洞均利用处理器对指令的推测执行,并允许攻击者通过第三方渠道接收结果。 添加了处理器中的漏洞以及其他几个漏洞,以便C程序员继续相信他们使用低级语言进行编程,尽管数十年来情况并非如此。
处理器制造商并不孤单。 C / C ++编译器开发人员也做出了贡献。
什么是低级语言?
美国计算机科学家和第一位图灵奖获得者艾伦·佩利斯(Alan Perlis)给出了以下定义:
“一种编程语言是低级的,如果写在它上面的程序需要注意那些非必需的语言。”
尽管此定义是针对C的,但它并不能帮助人们理解用低级语言所看到的内容。 各种属性使人们认为语言偏低。 想象一下一种规模的编程语言,该语言的一端是汇编器,而另一端是企业计算机的接口。 低级语言更接近铁,而高级语言则更接近人们的思维方式。
为了“更接近于硬件”,该语言必须提供与目标平台的抽象相匹配的抽象。 容易证明C是PDP-11中的低级语言。 在PDP-11寻址模式上,程序的顺序执行,平坦的地址空间,甚至前置和后置运算符,都完全适合。
快速的PDP-11仿真器
Spectre和Meltdown漏洞的主要原因是处理器的创建者不仅制作了快速处理器,而且还制作了具有PDP-11接口的快速处理器。 这很重要,因为它允许C程序员继续相信他们的语言与硬件接近。
C代码提供了一个主要是顺序的抽象自动机(直到C11,如果排除了非标准扩展名,它是完全顺序的)。 创建新线程是对库函数的调用,该函数非常昂贵。 因此,希望继续执行C代码的处理器依赖于指令级并行性(ILP)。 他们分析相邻的操作并并行执行独立的操作。 这极大地增加了处理器的复杂性,并导致功耗增加,但允许程序员编写大多数顺序代码。 相反,图形处理器(GPU)以另一种方式实现高性能:它们需要编写并行程序。
命令级别的高并发性是Spectre和Meltdown的直接原因。 现代的Intel处理器可同时执行多达180条指令(与顺序抽象C机器不同,后者期望上一条指令在下一条指令开始之前执行)。 C语言的一种典型启发式方法表明,每七个指令平均有一个分支。 如果要保持指令流水线完整,则需要猜测接下来的25个分支。 反过来,这又增加了复杂性-处理器首先计算出错误猜测的分支,然后抛出计算结果,这对能耗产生了负面影响。 抛出的数据具有可见的间接结果,该结果已在Spectre和Meltdown攻击中使用。
在现代处理器中,重命名寄存器会消耗大量能量和芯片面积。 它无法关闭或降低能量消耗,这在晶体管低的
黑暗硅时代是不方便的,但是所涉及的晶体管是宝贵的资源。 该设备在GPU中不存在,在GPU中,并发是通过使用线程而不是尝试并行执行最初的顺序代码来实现的。 如果指令没有需要重建的依赖项,则也无需重命名寄存器。
考虑一下C设计的另一个基本部分:平面存储器。 它已经存在了几十年了。 现代处理器通常在寄存器和主存储器之间具有三级缓存,从而减少了访问后者所需的时间。
缓存对程序员而言是隐藏的,因此对于C语言来说是不可访问的。缓存的有效使用是加快现代处理器上代码执行速度的方法之一,但是对抽象机而言,缓存是完全隐藏的,程序员被迫依赖于缓存实现细节的知识(例如,两个对齐的64位值可以出现在缓存的一行中)以编写有效的代码。
C优化
归因于低级语言的常见特征之一是速度。 特别是,它们应该易于转换为快速代码,而无需复杂的编译器。 C的支持者在谈论其他语言时通常会忽略足够聪明的编译器可以使一种语言快速发展的论点。
不幸的是,使用简单的翻译无法从C获得快速代码。
处理器设计师为创建可以快速执行C代码的芯片做出了巨大的努力。 但是,只有借助编译器执行的极其复杂的优化,才能达到程序员希望看到的性能水平。
Clang编译器(包括LLVM的相应部分)大约有200万行代码。 为了分析和转换代码(这是加快C速度所必需的),大约需要200,000行代码(不包括注释和空白行)。
例如,要在C中处理大量数据,您需要编写一个循环来顺序处理每个元素。 为了在现代处理器上最佳执行此循环,编译器必须确定该循环的迭代彼此独立。 在这种情况下,strict关键字可以提供帮助-它确保对一个指针的写入不会干扰从另一个指针的读取。 与C语言中的Fortran语言相比,C语言中的信息受到更多限制,这是C语言无法将其推出高性能计算的主要原因。
在编译器确定迭代彼此独立之后,下一步是尝试对结果进行矢量化处理,因为对于矢量化代码,现代处理器的吞吐量比标量代码高四到八倍。 用于此类处理器的低级语言将具有其自己的任意长度的向量类型。 这种类型存在于中间LLVM表示中,因为将带有向量的大型操作拆分为几个较小的操作总是比构造较大的向量操作更容易。
在这一点上,优化器必须应对C内存规则,C确保具有相同前缀的结构可以互换使用,并提供对语言中结构的偏移字段字段的访问。 这意味着编译器无法更改结构中字段的顺序或添加对齐方式以改善矢量化(例如,将结构从数组转换为结构数组,反之亦然)。 在低级语言中这通常不是问题,在低级语言中可以控制结构中字段的位置,但是这会使加速C.的任务更加困难。
C还需要在结构的末尾对齐,因为它确保数组中没有对齐。 对齐是C规范中相当复杂的一部分,它与语言的其他部分交互很差。 例如,您应该能够使用无类型比较方法(即memcmp()函数)比较两个结构,因此结构的副本也必须对齐。 在某些情况下,复制路线需要花费大量时间。
考虑一下C编译器产生的两个基本优化:
SROA (聚合的标量替换,聚合的标量替换)和
循环打开 。
SROA试图用单独的变量替换固定大小的结构和数组。 如果很明显未使用其结果,则编译器可以彼此独立地处理对它们的访问,并忽略该操作。 在某些情况下,此优化的间接效果是消除对齐。
第二个优化是打开循环,将带有条件的循环转换为两个分支中具有不同循环的条件。 与断言程序员知道知道将使用低级语言执行的内容相反,这改变了执行顺序。 这也给C如何处理未定义的变量和未定义的行为带来了严重的问题。
在C语言中,未初始化的变量具有未定义的值,该值随每次调用而不同。 这很重要,因为它允许您实现内存页面的延迟回收。 例如,在FreeBSD中,malloc()实现告诉系统不再使用页面,并且系统使用页面中的第一个条目来证明情况并非如此。 呼吁新分配的内存可以获取旧值,然后操作系统可以重新使用内存页面,然后在下次您写入该页面的另一个位置时,将其替换为填充有零的页面。 对页面上同一位置的第二次调用将获得零值。
如果条件使用未定义的值,则结果也未定义-可能发生任何事情。 想象一下一个循环执行优化,其中一个循环执行了0次。 原来,整个循环都是死代码。 在开放版本中,现在存在带有可能未初始化的变量的条件。
结果,死代码可以转换为未定义的行为。 这只是许多优化中的一种,当更彻底地探索C的语义时,结果证明是不可靠的。
最后,您可以使C代码快速运行,但要花费数千人年的时间来创建足够智能的编译器。 但这只有在违反某些语言规则的情况下才有可能。 编译器创建者使C程序员可以想象他们编写的代码“接近于硬件”,但是他们必须生成行为不同的机器代码,以便程序员继续相信他们使用快速语言编写。
了解C
底层语言的基本属性之一是,程序员可以轻松地理解抽象语言机器如何转移到物理机器。 在PDP-11上绝对是这种情况,其中C表达式被翻译成一条或两条指令。 同样,编译器将变量放入堆栈插槽中,并将简单类型转换为PDP-11可以理解的形式。
从那时起,C的实现变得更加复杂-维持了这样一种错觉,即C可以轻松移植到硬件平台并快速运行。 2015年,对C程序员,编译器作者和标准化委员会成员进行的一项调查显示,
对C的理解存在
问题。 例如,此语言允许实现将对齐方式添加到结构(而不是数组),以确保针对目标平台正确对齐所有字段。 如果用零填充此结构,然后为某些字段指定一个值,则对齐位中是否会有零? 根据调查,有36%的人确定会,而29%的人不知道答案。 根据编译器和优化级别的不同,这可能是正确的(也可能不是)。
这是一个非常简单的示例,但是许多程序员要么给出错误的答案,要么根本无法回答。
如果添加指针,C的语义将变得更加混乱。
BCPL模型非常简单:所有含义都是单词。 每个字要么是数据,要么是内存中的地址。 内存是按地址索引的单元的平面阵列。
模型C允许用于不同平台的实现,包括分段架构,其中的指针可以由分段ID和偏移量组成,以及带有垃圾回收器的虚拟机。 C规范限制了允许的指针操作,以避免此类系统出现问题。 对
缺陷报告260的答复提到了指针的来源:
“实现可以遵循一组位的来源,并以与包含特定位的方式不同的方式处理包含未定义值的位。 “即使它们的位值相同,它们也可以根据其来源来不同地处理指针。”
不幸的是,C11规范中缺少“起源”一词,因此编译器自己决定含义。 例如,GCC和Clang在转换为整数并返回的指针是否保留其原点方面有所不同。 进行比较时,即使指向同一地址,指向malloc()结果的两个指针也始终会给出负结果。
这些误解并非纯粹是学术上的。 例如,已经观察到漏洞,这是由于有符号整数(C中的未定义行为)溢出或
在检查指针是否为NULL之前取消对指针的引用而导致的 ,尽管实际上编译器被告知指针不能为NULL。
如果存在此类问题,则很难期望程序员充分理解C程序如何转换为适当的体系结构。
推出不适用于C的处理器
提议的补丁可防止Spectre和Meltdown造成严重的性能下降,使过去十年中所有微体系结构的成就无效。 也许是时候停止思考如何使C代码更快,而改为考虑为速度而设计的处理器上的新编程模型了。
有许多体系结构示例未专注于传统C代码,并且可以从中获得启发。 例如,面向多线程的处理器(例如Sun / Oracle UltraSPARC Tx)不需要太多的缓存来保持其执行器繁忙。 研究处理器已经将此概念扩展到了非常多的硬件计划线程。 关键思想是,有了足够的线程,处理器可以暂停正在等待数据的那些线程,并使用来自其他线程的指令填充执行器。 问题是C程序通常只有很少的线程。
ARM的SVE(标量向量扩展,标量向量扩展)是Berkeley的另一项类似工作,它介绍了程序与硬件之间的改进接口。 常规向量化块使用固定大小的向量实现操作,并期望编译器将算法调整为指定大小。 相反,SVE接口提示程序员独立描述并行度,并期望硬件将其调整为可用的执行器。 在C中使用此代码很困难,因为自动矢量化器需要根据代码中的循环计算并行度。
高速缓存很大,但这不是其复杂性的唯一原因。
高速缓存一致性支持协议是现代处理器中最复杂的组件之一。 大多数困难来自必须维护一种可以共享和可变数据的语言。 作为相反的例子,我们可以使用抽象的Erlang风格的机器,其中每个对象都是本地的或不可变的。 这种系统的高速缓存一致性协议只有两种情况:可变数据和共享数据。 必须显式禁用已传输到另一个处理器的程序流的缓存,但这是一个相对罕见的操作。
不可变的对象可以进一步简化缓存,还可以使某些操作更便宜。 在Sun Labs的Maxwell项目中,注意到缓存中的对象和最近创建的对象几乎总是相同的。 如果对象在从高速缓存中排除之前就已死亡,则无法将它们写入主内存,从而节省了能耗。 Maxwell项目提出了一个垃圾收集器,该垃圾收集器可在缓存中工作,并允许您快速回收内存。 由于堆和可变堆栈上的对象是不可变的,因此垃圾收集器变成了一个非常简单的状态机,可以在硬件中轻松实现,并允许您有效地使用相对较小的缓存。
专门为速度而设计,而不是为了速度和C支持之间的权衡而设计的处理器可能应该支持大量线程,具有大矢量化块和更简单的内存模型。 在这样的处理器上执行C代码将很困难,因此,鉴于世界上大量的C代码,在商业上不太可能取得成功。
在软件开发领域,有一个神话认为并行编程很困难。 艾伦·凯(Alan Kay)听到这一消息会感到非常惊讶:他教孩子们使用演员模型,并用他们在200多个流中编写程序。 Erlang程序员也不知道这一点,他们经常编写带有数千个并行组件的程序。 正确地说,使用像C这样的抽象机器的语言进行并行编程是困难的。而且,如果您注意并行硬件的优势(从多核处理器到多核GPU),那么这就是说C不适合现代硬件的另一种方式。提供。