python的numpy向量化语句为什么会比for快?

我们先来看看,python之类语言的for循环,和其它语言相比,额外付出了什么。

我们知道,python是解释执行的。

举例来说,执行 x = 1234+5678 ,对编译型语言,是从内存读入两个short int到寄存器,然后读入加法指令,通知CPU内部的加法器动作,最后把加法器输出存储到x对应的内存单元(实质上,最后这个动作几乎总会被自动优化为“把加法器输出暂存到寄存器而不是内存单元,因为访问内存的时间消耗常常是访问寄存器的几十倍”)。一共2~4条指令(视不同CPU指令集而定)。

换了解释性语言,情况就大大不同了。

它得先把“x = 1234+5678”当成字符串,逐个字符比对以分析语法结构——不计空格这也是11个字符,至少要做11个循环;每个循环至少需要执行的指令有:取数据(如读'x'这个字符)、比较数据、根据比较结果跳转(可能还得跳转回来)、累加循环计数器、检查循环计数器是否到达终值、根据比较结果跳转。这就是至少6条指令,其中包含一次内存读取、至少两次分支指令(现代CPU有分支预测,若命中无额外消耗,否则……)。总计66条指令,比编译型语言慢至少17倍(假设每条指令执行时间相同。但事实上,访存/跳转类指令消耗的时间常常是加法指令的十倍甚至百倍)。

这还只是读入源码的消耗,尚未计入“语法分析”这个大头;加上后,起码指令数多数百倍(消耗时间嘛……我猜起码得多数千倍吧)。

不过,python比起其它解释性语言还是强很多的。因为它可以事先把文本代码编译成“字节码”(存储于扩展名为pyc的文件里),从而直接处理整型的“指令代码”,不再需要从头开始分析文本。

但是,从“字节码”翻译到实际CPU代码这步,仍然是省不下的。

这个消耗,可看作“利用虚拟机”执行异构CPU上的程序。有人证明过,哪怕优化到极致,这也需要10倍的性能消耗。

这个消耗也有办法缩减。这就是JIT技术。

JIT说白了,就是在第一遍执行一段代码前,先执行编译动作,然后执行编译后的代码。

如果代码中没有循环,那么这将白白付出很多额外的时间代价;但若有一定规模以上的循环,就可能节省一点时间。

这里面的佼佼者是Java。它甚至能根据上次运行结果实时profile,然后花大力气优化关键代码,从而得到比C更快的执行速度。

不过,理想很丰满,现实很骨感。虽然局部热点的确可能更快,但Java的整体效率仍然比C/C++差上很多——这个原因就比较复杂了。

和C/C++/Java那种投入海量资源经过千锤百炼的编译器不同,python的JIT甚至可称得上“蹩脚”。

加加减减,仅一个循环,慢上十几甚至几十倍还是很正常的。

以上讨论,仅仅考虑了for循环这个控制结构本身。事实上,“慢”往往是全方位的。

举例来说,要计算一组向量,首先就要存储它。

怎么存储呢?

对C/C++来说,就存在“数组”里;而它的数组,就是赤裸裸的一片连续内存区域;区域中每若干个字节就存储了一个数值数据。

这种结构CPU处理起来最为方便快捷,且cache友好(若cache不友好就可能慢数倍甚至数十倍)。

Java等其它语言就要稍逊一筹。因为它的“数组”是“真正的数组”;相对于“连续内存区域”,“真正的数组”就不得不在每次访问时检查数组下标有无越界。这个检查开销不大,但也不小……

当然,这也是有好处的。至少不用像C/C++那样,整天担心缓冲区溢出了。

而python之类……

为了迁就初学者,它去掉了“变量声明”以及“数据类型”——于是它的用户再也用不着、也没法写 int xxx了。随便什么数据,咱想存就存,乌拉!

但是,如果我告诉你,可变数据类型其实在C/C++里面是这样声明的呢:

typedef struct tagVARIANT {
  union {
    struct __tagVARIANT {
      VARTYPE vt;
      WORD    wReserved1;
      WORD    wReserved2;
      WORD    wReserved3;
      union {
        LONGLONG            llVal;
        LONG                lVal;
        BYTE                bVal;
        SHORT               iVal;
        FLOAT               fltVal;
        DOUBLE              dblVal;
        VARIANT_BOOL        boolVal;
        _VARIANT_BOOL       bool;
        SCODE               scode;
        CY                  cyVal;
        DATE                date;
        BSTR                bstrVal;
        IUnknown            *punkVal;
        IDispatch           *pdispVal;
        SAFEARRAY           *parray;
        BYTE                *pbVal;
        SHORT               *piVal;
        LONG                *plVal;
        LONGLONG            *pllVal;
        FLOAT               *pfltVal;
        DOUBLE              *pdblVal;
        VARIANT_BOOL        *pboolVal;
        _VARIANT_BOOL       *pbool;
        SCODE               *pscode;
        CY                  *pcyVal;
        DATE                *pdate;
        BSTR                *pbstrVal;
        IUnknown            **ppunkVal;
        IDispatch           **ppdispVal;
        SAFEARRAY           **pparray;
        VARIANT             *pvarVal;
        PVOID               byref;
        CHAR                cVal;
        USHORT              uiVal;
        ULONG               ulVal;
        ULONGLONG           ullVal;
        INT                 intVal;
        UINT                uintVal;
        DECIMAL             *pdecVal;
        CHAR                *pcVal;
        USHORT              *puiVal;
        ULONG               *pulVal;
        ULONGLONG           *pullVal;
        INT                 *pintVal;
        UINT                *puintVal;
        struct __tagBRECORD {
          PVOID       pvRecord;
          IRecordInfo *pRecInfo;
        } __VARIANT_NAME_4;
      } __VARIANT_NAME_3;
    } __VARIANT_NAME_2;
    DECIMAL             decVal;
  } __VARIANT_NAME_1;
} VARIANT, *LPVARIANT, VARIANTARG, *LPVARIANTARG;

简单说,这玩意儿的思路就是“利用一个tag指示数据类型,真正的数据存储在下面的union里;访问时,依据tag指示转换/返回合适类型”。

很显然,对C/C++/Java程序员来说,这玩意儿无论时间还是空间上,都是个灾难。

并且,它也极度的cache不友好——本来可以连续存储的,现在……变成了个结构体;而且一旦存了某些类型的数据,就不得不通过指针跳转到另一块区域才能访问(如果原地存储,浪费的空间就太恐怖了)。

所以你看,咱要基于这种结构谈效率,是不是有点……

哪怕仅仅了解到这个程度也已经很是触目惊心了:解释执行+字节码优化慢上至少10倍到几十上百倍,“初学者友好”的基础数据又慢上几倍到几十倍,透过容器访问(而非性能更好的、固定大小数组乃至不检查下标假装自己是数组的“内存区域”)再慢上几倍到几十倍……哪怕咱暂时不考虑其它机制带来的开销,仅把这几样往一块一凑(在某些特定的情况下,这些不同的“慢”点还可能相互影响、起到“迟缓度倍增放大”的效果)……

除此之外,还有python内部如何管理/索引/访问脚本中的全局/局部变量的问题(一般会用dict)、用户数据和物理机存储器严重不匹配引起的缓存未命中问题、python内部状态机/执行现场管理等等方面管理的问题——对编译型语言,这些统统不存在,CPU/内存自己就把自己照顾的很好了;但对解释性语言,这些都会成为“迟缓度倍增”的元凶。

这些东西的相互影响极为复杂微妙,几乎没人能彻底搞明白它。

你看,明白了前因后果,咱是不是只能说“python的优化实在不错,才仅仅慢了20万倍而已”呢?(笑~

当然,如果不做这类较为复杂的处理,仅仅是一些流程性的东西的话,这类语言的处理速度还是够用的——至少与之交互的人感受不到丝毫延迟。

甚至,哪怕需要复杂的处理,这类语言也可以向其它语言求救啊。就好像有个numpy,谁敢说python做不了向量运算呢?

——当然,和行家说话时,你得明白,这是找C之类语言搬救兵了。睁眼说瞎话把它当成python语言自己的能力是有点丢人的。不过如果只混python的圈子的话,这倒也不耽误什么。

————————————————————————————

如果要揭短,专业程序员还会把无数据类型导致接口模糊所以无法写较为复杂的程序之类弊端给你列出一火车的。但这些就是没必要的题外话了。

毕竟,python只是个胶水语言,初学者友好并且应付常见的简单应用场景绰绰有余,这已经足够了。

就好像把office做的傻瓜化,本就是专业程序员的工作一样——用户觉得好用、乐意掏钱就行了,何必关心“做出一套office需要砸进去的钱足够盖N座迪拜塔”呢。

当然,如果想进一步发展的话,请记住“在合适的地方用合适的工具”这句话——然后想办法搞明白每种工具的局限性吧。

毕竟,哪怕是C/C++,在做矩阵之类运算时,也还会求助于SIMD的MMX指令、超线程/多核心CPU乃至GPU,以便为自己“增补”上并行处理能力呢。

你可能感兴趣的