为什么C语言在2013年仍然很重要:一个简单的例子
附注:在最初的文章里,我没说明进行模2^64的计算——我当然明白那些不是“正确的”斐波那契数列,其实我不是想分析大数,我只是想探寻编译器产生的代码和计算机体系结构而已。
最近,我一直在开发Dynvm——一个通用的动态语言运行时。就像其他任何好的语言运行时项目一样,开发是由基准测试程序驱动的。因此,我一直在用基准测试程序测试各种由不同语言编写的算法,以此对其典型的运行速度有一个感觉上的认识。一个经典的测试就是迭代计算斐波那契数列。为简单起见,我以2^64为模,用两种语言编写实现了该算法。
用python语言实现如下:
def fib(n):
SZ = 2**64
i = 0
a, b = 1, 0
while i < n:
t = b
b = (b+a) % SZ
a = t
i = i + 1
return b
用C语言实现如下:
#include <stdio.h>
#include <stdlib.h>
typedef unsigned long ulong;
int main(int argc, char *argv[])
{
ulong n = atoi(argv[1]);
ulong a = 1;
ulong b = 0;
ulong t;
for(ulong i = 0; i < n; i++) {
t = b;
b = a+b;
a = t;
}
printf("%lu\n", b);
return 0;
}
用其他语言实现的代码示例在github上。
Dynvm包含一个基准测试程序框架,该框架可以允许在不同语言之间对比运行速度。在一台Intel i7-3840QM(调频到1.2 GHz)机器上,当n=1,000,000时对比结果如下:
=======================================================
Language Time (in sec)
=======================================================
Java 0.133
C Language 0.006
CPython 0.534
Javascript V8 0.284
很明显,C语言是这里的老大,但是java的结果有点误导性,因为大部分的时间是由JIT编译器启动(~120ms)占用的。当n=100,000,000时,结果变得更明朗:
=======================================================
Language Time (in sec)
=======================================================
Java 0.300
C Language 0.172
CPython 47.909
Javascript V8 24.179
在这里,我们探究下为什么C语言在2013年仍然很重要,以及为什么编程世界不会完全“跳槽”到Python或者V8/Node。有时你需要原始性能,但是动态语言仍然艰难地在这方面挣扎,即使对以上很简单的例子而言。我个人相信这种情况会被克服,通过几个项目我们能在这方面看到很大的希望:JVM,V8,PyPy,LuaJIT等等,但是在2013年我们还没有到达“目的地”。
然而,我们无法回避这样的问题:为什么差距如此之大?在C语言和Python之间有278.5x的性能差距!最不可思议的地方是,从语法角度讲,以上例子中的C语言和Python内部循环基本上一模一样。
为了找到问题的答案,我搬出了反汇编器,发现了以下现象:
(译者注:1、不同的编译器版本及不同的优化选项(-Ox)会产生不同的汇编代码。2、反汇编方法:gcc -g -O2 test.c -o test;objdumb -d test>test.txt 读者可以自己尝试变换优化选项对比反汇编结果)
0000000000400480 <main>:
247 400480: 48 83 ec 08 sub $0x8,%rsp
248 400484: 48 8b 7e 08 mov 0x8(%rsi),%rdi
249 400488: ba 0a 00 00 00 mov $0xa,%edx
250 40048d: 31 f6 xor %esi,%esi
251 40048f: e8 cc ff ff ff callq 400460 <strtol@plt>
252 400494: 48 98 cltq
253 400496: 31 d2 xor %edx,%edx
254 400498: 48 85 c0 test %rax,%rax
255 40049b: 74 26 je 4004c3 <main+0x43>
256 40049d: 31 c9 xor %ecx,%ecx
257 40049f: 31 f6 xor %esi,%esi
258 4004a1: bf 01 00 00 00 mov $0x1,%edi
259 4004a6: eb 0e jmp 4004b6 <main+0x36>
260 4004a8: 0f 1f 84 00 00 00 00 nopl 0x0(%rax,%rax,1)
261 4004af: 00
262 4004b0: 48 89 f7 mov %rsi,%rdi
263 4004b3: 48 89 d6 mov %rdx,%rsi
264 4004b6: 48 83 c1 01 add $0x1,%rcx
265 4004ba: 48 8d 14 3e lea (%rsi,%rdi,1),%rdx
266 4004be: 48 39 c8 cmp %rcx,%rax
267 4004c1: 77 ed ja 4004b0 <main+0x30>
268 4004c3: be ac 06 40 00 mov $0x4006ac,%esi
269 4004c8: bf 01 00 00 00 mov $0x1,%edi
270 4004cd: 31 c0 xor %eax,%eax
271 4004cf: e8 9c ff ff ff callq 400470 <__printf_chk@plt>
272 4004d4: 31 c0 xor %eax,%eax
273 4004d6: 48 83 c4 08 add $0x8,%rsp
274 4004da: c3 retq
275 4004db: 90 nop
最主要的部分是计算下一个斐波那契数值的内部循环:
262 4004b0: 48 89 f7 mov %rsi,%rdi
263 4004b3: 48 89 d6 mov %rdx,%rsi
264 4004b6: 48 83 c1 01 add $0x1,%rcx
265 4004ba: 48 8d 14 3e lea (%rsi,%rdi,1),%rdx
266 4004be: 48 39 c8 cmp %rcx,%rax
267 4004c1: 77 ed ja 4004b0 <main+0x30>
变量在寄存器中分配情况如下:
a: %rsi
b: %rdx
t: %rdi
i: %rcx
n: %rax
262和263行实现了变量交换,264行增加循环计数值,虽然看起来比较奇怪,265行实现了b=a+t。然后做一个简单地比较,最后一个跳转指令跳到循环开始出继续执行。
手动反编译以上代码,代码看起来是这样的:
loop: t = a
a = b
i = i+1
b = a+t
if(n > i) goto loop
整个内部循环仅用六条X86-64汇编指令就实现了(很可能内部微指令个数也差不多。译者注:Intel X86-64处理器会把指令进一步翻译成微指令,所以CPU执行的实际指令数要比汇编指令多)。CPython解释模块对每一条高层的指令字节码至少需要六条甚至更多的指令来解释,相比而言,C语言完胜。除此之外,还有一些其他更微妙的地方。
拉低现代处理器执行速度的一个主要原因是对于主存的访问。这个方面的影响十分可怕,在微处理器设计时,无数个工程时(engineering hours)都花费在找到有效地技术来“掩藏”访存延时。通用的策略包括:缓存、推测预取、load-store依赖性预测、乱序执行等等。这些方法确实在使机器更快方面起了很大作用,但是不可能完全不产生访存操作。
在上面的汇编代码中,内存从没被访问过,实际上变量完全存储在CPU寄存器中。现在考虑CPython:所有东西都是堆上的对象,而且所有方法都是动态的。动态特性太普遍了,以至于我们没有办法知道,a+b执行integer_add(a, b)、string_concat(a, b)、还是用户自己定义的函数。这也就意味着很多时间花在了在运行时找出到底调用了哪个函数。动态JIT运行时会尝试在运行时获取这个信息,并动态产生x86代码,但是这并不总是非常直接的(我期待V8运行时会表现的更好,但奇怪的是它的速度只是Python的0.5倍)。因为CPython是一个纯粹的翻译器,在每个循环迭代时,很多时间花在了解决动态特性上,这就需要很多不必要的访存操作。
除了以上内存在搞鬼,还有其他因素。现代超标量乱序处理器核一次性可以取好几条指令到处理器中,并且“在最方便时”执行这些指令,也就是说:鉴于结果仍然是正确的,指令执行顺序可以任意。这些处理器也可以在同一个时钟周期并行执行多条指令,只要这些指令是不相关的。Intel Sandy Bridge CPU可以同时将168条指令重排序,并可以在一个周期中发射(即开始执行指令)至多6条指令,同时结束(即指令完成执行)至多4条指令!粗略地以上面斐波那契举例,Intel这个处理器可以大约把28(译者注:28*6=168)个内部循环重排序,并且几乎可以在每一个时钟周期完成一个循环!这听起来很霸气,但是像其他事一样,细节总是非常复杂的。
我们假定8条指令是不相关的,这样处理器就可以取得足够的指令来利用指令重排序带来的好处。对于包含分支指令的指令流进行重排序是非常复杂的,也就是对if-else和循环(译者注:if-else需要判断后跳转,所以必然包含分支指令)产生的汇编代码。典型的方法就是对于分支指令进行预测。CPU会动态的利用以前分支执行结果来猜测将来要执行的分支指令的执行结果,并且取得那些它“认为”将要被执行的指令。然而,这个推测有可能是不正确的,如果确实不正确,CPU就会进入复位模式(译者注:这里的复位不是指处理器reset,而是CPU流水线的复位),即丢弃已经取得的指令并且重新开始取指。这种复位操作有可能对性能产生很大影响。因此,对于分支指令的正确预测是另一个需要花费很多工程时的领域。
现在,不是所有分支指令都是一样的,有些可以很完美的预测,但是另一些几乎不可能进行预测。前面例子中的循环中的分支指令——就像反汇编代码中267行——是最容易预测的其中一种,这个分支指令会连续向后跳转100,000,000次。
以下是一个非常难预测的分支指令实例:
void main(void)
{
for(int i = 0; i < 1000000; i++) {
int n = random();
if(n >= 0) {
printf("positive!\n");
} else {
printf("negative!\n");
}
}
}
如果random()是真正随机的(事实上在C语言中远非如此),那么对于if-else的预测还不如随便猜来的准确。幸运的是,大部分的分支指令没有这么顽皮,但是也有很少一部分和上面例子中的循环分支指令一样变态。
回到我们的例子上:C代码实现的斐波那契数列,只产生一个非常容易预测的分支指令。相反地,CPython代码就非常糟糕。首先,所有纯粹的翻译器有一个“分配”循环,就像下面的例子:
void exec_instruction(instruction_t *inst)
{
switch(inst->opcode) {
case ADD: // do add
case LOAD: // do load
case STORE: // do store
case GOTO: // do goto
}
}
编译器无论如何处理以上代码,至少有一个间接跳转指令是必须的,而这种间接跳转指令是比较难预测的。
接下来回忆一下,动态语言必须在运行时确定如“ADD指令的意思是什么”这样基本的问题,这当然会产生——你猜对了——更加变态的分支指令。
以上所有因素加起来,最后导致一个278.5倍的性能差距!现在,这当然是一个很简单的例子,但是其他的只会比这更变态。这个简单例子足以凸显低级静态语言(例如C语言)在现代软件中的重要地位。我当然不是2013年里C语言最大的粉丝,但是C语言仍然主导着低级控制领域及对性能要求高的应用程序领域。
英文网页地址:http://jabsoft.io/2013/08/29/why-c-still-matters-in-2013-a-simple-example/
为什么C语言在2013年仍然很重要:一个简单的例子的更多相关文章
- Go语言之从0到1实现一个简单的Redis连接池
Go语言之从0到1实现一个简单的Redis连接池 前言 最近学习了一些Go语言开发相关内容,但是苦于手头没有可以练手的项目,学的时候理解不清楚,学过容易忘. 结合之前组内分享时学到的Redis相关知识 ...
- C语言搬书学习第一记 —— 认识一个简单程序的细节
#include<stdio.h> /*告诉编译器把stdio.h 中的内容包含在当前程序中,stdio.h是C编译器软件包的标准部分,它提供键盘输入和 屏幕输入的支持studio.h文件 ...
- 一个简单的例子让你很轻松地明白JavaScript中apply、call、bind三者的用法及区别
JavaScript中apply.call.bind三者的用法及区别 引言 正文 一.apply.call.bind的共同用法 二. apply 三. call 四. bind 五.其他应用场景 六. ...
- 用一个简单的例子比较SVM,MARS以及BRUTO(R语言)
背景重述 本文是ESL: 12.3 支持向量机和核中表12.2的重现过程.具体问题如下: 在两个类别中产生100个观测值.第一类有4个标准正态独立特征\(X_1,X_2,X_3,X_4\).第二类也有 ...
- 用 C 语言编写一个简单的垃圾回收器
人们似乎觉得编写垃圾回收机制是非常难的,是一种仅仅有少数智者和Hans Boehm(et al)才干理解的高深魔法.我觉得编写垃圾回收最难的地方就是内存分配,这和阅读K&R所写的malloc例 ...
- Tinyhttpd - 超轻量型Http Server,使用C语言开发,全部代码只有502行(包括注释),附带一个简单的Client(Qt也有很多第三方HTTP类)
- 2. Tinyhttpd tinyhttpd是一个超轻量型Http Server,使用C语言开发,全部代码只有502行(包括注释),附带一个简单的Client,可以通过阅读这段代码理解一个 Htt ...
- 利用OD破解一个简单的C语言程序
最近在学习汇编(看的是王爽老师的<汇编语言(第三版)>),然后想尝试使用OD(Ollydbg)软件破解一个简单的C语言程序练练手. 环境: C语言编译环境:VC++6.0 系统:在Wind ...
- VS2017生成一个简单的DLL文件 和 LIB文件——C语言
下面我们将用两种不同的姿势来用VS2017生成dll文件(动态库文件)和lib文件(静态库文件),这里以C语言为例,用最简单的例子,来让读者了解如何生成dll文件(动态库文件) 生成动态库文件 姿势一 ...
- R语言建立回归分析,并利用VIF查看共线性问题的例子
R语言建立回归分析,并利用VIF查看共线性问题的例子 使用R对内置longley数据集进行回归分析,如果以GNP.deflator作为因变量y,问这个数据集是否存在多重共线性问题?应该选择哪些变量参与 ...
随机推荐
- 帝国cms本地搬家到服务器文章路径问题?
由于我的服务器不支持采集功能,我只能选择先在本地采集好文章发布于本地,再打算同步于服务器. 按照官方的做法, 1.先进后台备份了网站的所有数据,系统——备份与恢复数据——备份数据 2.将e\admin ...
- ISO/IEC14443和15693的对比有何具体区别
ISO14443 ISO14443A/B:超短距离智慧卡标准.这标准订出读取距离7-15厘米的短距离非接触智慧卡的功能及运作标准,使用的频率为13.56MHz. ISO14443定义了TYPE ...
- 去确认CP210x UART Bridge的USB的VID和PID
[背景] 之前买的USB口的HART猫: [记录]为USB接口的HART猫ExSaf ESH232U安装对应的USB转RS232驱动 其中内部是USB转RS232. 然后打算去看看之前的自己此处的某个 ...
- ubutun 下webalizer 分析Apache日志
http://www.webalizer.org/ 配置Webalizer 我们可以通过命令行配置Webalizer,也可以通过配置文件进行配置.下面将重点介绍使用配置文件进行配置,该方法使用形式比 ...
- Poj 1166 The Clocks(bfs)
题目链接:http://poj.org/problem?id=1166 思路分析:题目要求求出一个最短的操作序列来使所有的clock为0,所以使用bfs: <1>被搜索结点的父子关系的组织 ...
- vmware 几种联网的方式,怎样实现虚拟机上网
我的pc有一个IP地址是可以訪问网络的,那么如何让VM可以共享我的IP地址,也能上网呢.今天在摸索中实现了,详细的配置例如以下: 1,首先将VM的网卡net8启用: 2,然后将VM的网卡设置为VMne ...
- linux内核源码阅读之facebook硬盘加速利器flashcache
从来没有写过源码阅读,这种感觉越来越强烈,虽然劣于文笔,但还是下定决心认真写一回. 源代码下载请参见上一篇flashcache之我见 http://blog.csdn.net/liumangxiong ...
- ADO.NET基础笔记
ADO.NET 程序要和数据库交互要通过ADO.NET进行,通过ADO.Net就能在程序中执行SQL了. ADO.Net中提供了对各种不同的数据库的统一操作接口. 连接字符串: 程序通过连接字符串指定 ...
- Tortoisegit 记住用户名和密码
Tortoisegit 记住用户名和密码方法: [Windows系统] 当你配置好git后,在 C:\Documents and Settings\Administrator\ 目录下有一个 .gi ...
- 块元素block,内联元素inline; inline-block;
block:块元素的特征 div ol li 等: 1.只有高度不设置宽度的时候默认撑满一行: 2.默认块元素不在一行: 3.支持所以CSS命令: inline:内联元素的特征 span i stro ...