第十三章:速度

2018-02-24 15:51 更新

Lisp 实际上是两种语言:一种能写出快速执行的程序,一种则能让你快速的写出程序。 在程序开发的早期阶段,你可以为了开发上的便捷舍弃程序的执行速度。一旦程序的结构开始固化,你就可以精炼其中的关键部分以使得它们执行的更快。

由于各个 Common Lisp 实现间的差异,很难针对优化给出通用的建议。在一个实现上使程序变快的修改也许在另一个实现上会使得程序变慢。这是难免的事儿。越强大的语言,离机器底层就越远,离机器底层越远,语言的不同实现沿着不同路径趋向它的可能性就越大。因此,即便有一些技巧几乎一定能够让程序运行的更快,本章的目的也只是建议而不是规定。

13.1 瓶颈规则 (The Bottleneck Rule)

不管是什么实现,关于优化都可以整理出三点规则:它应该关注瓶颈,它不应该开始的太早,它应该始于算法。

也许关于优化最重要的事情就是要意识到,程序中的大部分执行时间都是被少数瓶颈所消耗掉的。 正如高德纳所说,“在一个与 I/O 无关 (Non-I/O bound) 的程序中,大部分的运行时间集中在大概 3% 的源代码中。” λ 优化程序的这一部分将会使得它的运行速度明显的提升;相反,优化程序的其他部分则是在浪费时间。

因此,优化程序时关键的第一步就是找到瓶颈。许多 Lisp 实现都提供性能分析器 (profiler) 来监视程序的运行并报告每一部分所花费的时间量。 为了写出最为高效的代码,性能分析器非常重要,甚至是必不可少的。 如果你所使用的 Lisp 实现带有性能分析器,那么请在进行优化时使用它。另一方面,如果实现没有提供性能分析器的话,那么你就不得不通过猜测来寻找瓶颈,而且这种猜测往往都是错的!

瓶颈规则的一个推论是,不应该在程序的初期花费太多的精力在优化上。高德纳对此深信不疑:“过早的优化是一切 (至少是大多数) 问题的源头。” λ 在刚开始写程序的时候,通常很难看清真正的瓶颈在哪,如果这个时候进行优化,你很可能是在浪费时间。优化也会使程序的修改变得更加困难,边写程序边优化就像是在用风干非常快的颜料来画画一样。

在适当的时候做适当的事情,可以让你写出更优秀的程序。 Lisp 的一个优点就是能让你用两种不同的工作方式来进行开发:很快地写出运行较慢的代码,或者,放慢写程序的速度,精雕细琢,从而得出运行得较快的代码。

在程序开发的初期阶段,工作通常在第一种模式下进行,只有当性能成为问题的时候,才切换到第二种模式。 对于非常底层的语言,比如汇编,你必须优化程序的每一行。但这么做会浪费你大部分的精力,因为瓶颈仅仅是其中很小的那部分代码。一个更加抽象的语言能够让你把主要精力集中在瓶颈上, 达到事半功倍的效果。

当真正开始优化的时候,还必须从最顶端入手。 在使用各种低层次的编码技巧 (low-level coding tricks) 之前,请先确保你已经使用了最为高效的算法。 这么做的潜在好处相当大 ── 甚至可能大到你都不再需要玩那些奇淫技巧。 当然本规则还是要和前一个规则保持平衡。 有些时候,关于算法的决策必须尽早进行。

13.2 编译 (Compilation)

有五个参数可以控制代码的编译方式: speed (速度)代表编译器产生代码的速度; compilation-speed (编译速度)代表程序被编译的速度; safety (安全) 代表要对目标代码进行错误检查的数量; space (空间)代表目标代码的大小和内存需求量;最后, debug (调试)代表为了调试而保留的信息量。

交互与解释 (INTERACTIVE VS. INTERPRETED)

Lisp 是一种交互式语言 (Interactive Language),但是交互式的语言不必都是解释型的。早期的 Lisp 都通过解释器实现,因此认为 Lisp 的特质都依赖于它是被解释的想法就这么产生了。但这种想法是错误的:Common Lisp 既是编译型语言,又是解释型语言。

至少有两种 Common Lisp 实现甚至都不包含解释器。在这些实现中,输入到顶层的表达式在求值前会被编译。因此,把顶层叫做解释器的这种说法,不仅是落伍的,甚至还是错误的。

编译参数不是真正的变量。它们在声明中被分配从 0 (最不重要) 到 3 (最重要) 的权值。如果一个主要的瓶颈发生在某个函数的内层循环中,我们或许可以添加如下的声明:

(defun bottleneck (...)
  (do (...)
      (...)
    (do (...)
        (...)
      (declare (optimize (speed 3) (safety 0)))
      ...)))

一般情况下,应该在代码写完并且经过完善测试之后,才考虑加上那么一句声明。

要让代码在任何情况下都尽可能地快,可以使用如下声明:

(declaim (optimize (speed 3)
                   (compilation-speed 0)
                   (safety 0)
                   (debug 0)))

考虑到前面提到的瓶颈规则 [1] ,这种苛刻的做法可能并没有什么必要。

另一类特别重要的优化就是由 Lisp 编译器完成的尾递归优化。当 speed (速度)的权值最大时,所有支持尾递归优化的编译器都将保证对代码进行这种优化。

如果在一个调用返回时调用者中没有残余的计算,该调用就被称为尾递归。下面的代码返回列表的长度:

(defun length/r (lst)
  (if (null lst)
      0
      (1+ (length/r (cdr lst)))))

这个递归调用不是尾递归,因为当它返回以后,它的值必须传给 1+ 。相反,这是一个尾递归的版本,

(defun length/rt (lst)
  (labels ((len (lst acc)
             (if (null lst)
                 acc
                 (len (cdr lst) (1+ acc)))))
    (len lst 0)))

更准确地说,局部函数 len 是尾递归调用,因为当它返回时,调用函数已经没什么事情可做了。 和 length/r 不同的是,它不是在递归回溯的时候构建返回值,而是在递归调用的过程中积累返回值。 在函数的最后一次递归调用结束之后, acc 参数就可以作为函数的结果值被返回。

出色的编译器能够将一个尾递归编译成一个跳转 (goto),因此也能将一个尾递归函数编译成一个循环。在典型的机器语言代码中,当第一次执行到表示 len 的指令片段时,栈上会有信息指示在返回时要做些什么。由于在递归调用后没有残余的计算,该信息对第二层调用仍然有效:第二层调用返回后我们要做的仅仅就是从第一层调用返回。 因此,当进行第二层调用时,我们只需给参数设置新的值,然后跳转到函数的起始处继续执行就可以了,没有必要进行真正的函数调用。

另一个利用函数调用抽象,却没有开销的方法是使函数内联编译。对于那些调用开销比函数体的执行代价还高的小型函数来说,这种技术非常有价值。例如,以下代码用于判断列表是否仅有一个元素:

(declaim (inline single?))

(defun single? (lst)
  (and (consp lst) (null (cdr lst))))

因为这个函数是在全局被声明为内联的,引用了 single? 的函数在编译后将不需要真正的函数调用。 [2] 如果我们定义一个调用它的函数,

(defun foo (x)
  (single? (bar x)))

当 foo 被编译后, single? 函数体中的代码将会被编译进 foo 的函数体,就好像我们直接写以下代码一样:

(defun foo (x)
  (let ((lst (bar x)))
    (and (consp lst) (null (cdr lst)))))

内联编译有两个限制: 首先,递归函数不能内联。 其次,如果一个内联函数被重新定义,我们就必须重新编译调用它的任何函数,否则调用仍然使用原来的定义。

在一些早期的 Lisp 方言中,有时候会使用宏( 10.2 节)来避免函数调用。这种做法在 Common Lisp 中通常是没有必要的。

不同 Lisp 编译器的优化方式千差万别。 如果你想了解你的编译器为某个函数生成的代码,试着调用 disassemble 函数:它接受一个函数或者函数名,并显示该函数编译后的形式。 即便你看到的东西是完全无法理解的,你仍然可以使用 disassemble 来判断声明是否起效果:编译函数的两个版本,一个使用优化声明,另一个不使用优化声明,然后观察由 disassemble 显示的两组代码之间是否有差异。 同样的技巧也可以用于检验函数是否被内联编译。 不论情况如何,都请优先考虑使用编译参数,而不是手动调优的方式来优化代码。

13.3 类型声明 (Type Declarations)

如果 Lisp 不是你所学的第一门编程语言,那么你也许会感到困惑,为什么这本书还没说到类型声明这件事来?毕竟,在很多流行的编程语言中,类型声明是必须要做的。

在不少编程语言里,你必须为每个变量声明类型,并且变量也只可以持有与该类型相一致的值。 这种语言被称为强类型(strongly typed) 语言。 除了给程序员们徒增了许多负担外,这种方式还限制了你能做的事情。 使用这种语言,很难写出那些需要多种类型的参数一起工作的函数,也很难定义出可以包含不同种类元素的数据结构。 当然,这种方式也有它的优势,比如无论何时当编译器碰到一个加法运算,它都能够事先知道这是一个什么类型的加法运算。如果两个参数都是整数类型,编译器可以直接在目标代码中生成一个固定 (hard-wire) 的整数加法运算。

正如 2.15 节所讲,Common Lisp 使用一种更加灵活的方式:显式类型 (manifest typing) [3] 。有类型的是值而不是变量。变量可以用于任何类型的对象。

当然,这种灵活性需要付出一定的速度作为代价。 由于 + 可以接受好几种不同类型的数,它不得不在运行时查看每个参数的类型来决定采用哪种加法运算。

在某些时候,如果我们要执行的全都是整数的加法,那么每次查看参数类型的这种做法就说不上高效了。 Common Lisp 处理这种问题的方法是:让程序员尽可能地提示编译器。 比如说,如果我们提前就能知道某个加法运算的两个参数是定长数 (fixnums) ,那么就可以对此进行声明,这样编译器就会像 C 语言的那样为我们生成一个固定的整数加法运算。

因为显式类型也可以通过声明类型来生成高效的代码,所以强类型和显式类型两种方式之间的差别并不在于运行速度。 真正的区别是,在强类型语言中,类型声明是强制性的,而显式类型则不强加这样的要求。 在 Common Lisp 中,类型声明完全是可选的。它们可以让程序运行的更快,但(除非错误)不会改变程序的行为。

全局声明以 declaim 伴随一个或多个声明的形式来实现。一个类型声明是一个列表,包含了符号 type ,后跟一个类型名,以及一个或多个变量组成。举个例子,要为一个全局变量声明类型,可以这么写:

(declaim (type fixnum *count*))

在 ANSI Common Lisp 中,可以省略 type 符号,将声明简写为:

(declaim (fixnum *count*))

局部声明通过 declare 完成,它接受的参数和 declaim 的一样。 声明可以放在那些创建变量的代码体之前:如 defun 、 lambda 、let 、 do ,诸如此类。 比如说,要把一个函数的参数声明为定长数,可以这么写:

(defun poly (a b x)
  (declare (fixnum a b x))
  (+ (* a (expt x 2)) (* b x)))

在类型声明中的变量名指的就是该声明所在的上下文中的那个变量 ── 那个通过赋值可以改变它的值的变量。

你也可以通过 the 为某个表达式的值声明类型。 如果我们提前就知道 a 、 b 和 x 是足够小的定长数,并且它们的和也是定长数的话,那么可以进行以下声明:

(defun poly (a b x)
  (declare (fixnum a b x))
  (the fixnum (+ (the fixnum (* a (the fixnum (expt x 2))))
                 (the fixnum (* b x)))))

看起来是不是很笨拙啊?幸运的是有两个原因让你很少会这样使用 the 把你的数值运算代码变得散乱不堪。其一是很容易通过宏,来帮你插入这些声明。其二是某些实现使用了特殊的技巧,即便没有类型声明的定长数运算也能足够快。

Common Lisp 中有相当多的类型 ── 恐怕有无数种类型那么多,如果考虑到你可以自己定义新的类型的话。 类型声明只在少数情况下至关重要,可以遵照以下两条规则来进行:

  1. 当函数可以接受若干不同类型的参数(但不是所有类型)时,可以对参数的类型进行声明。如果你知道一个对 + 的调用总是接受定长数类型的参数,或者一个对 aref 的调用第一个参数总是某种特定种类的数组,那么进行类型声明是值得的。
  2. 通常来说,只有对类型层级中接近底层的类型进行声明,才是值得的:将某个东西的类型声明为 fixnum 或者 simple-array 也许有用,但将某个东西的类型声明为 integer 或者 sequence 或许就没用了。

类型声明对内容复杂的对象特别重要,这包括数组、结构和对象实例。这些声明可以在两个方面提升效率:除了可以让编译器来决定函数参数的类型以外,它们也使得这些对象可以在内存中更高效地表示。

如果对数组元素的类型一无所知的话,这些元素在内存中就不得不用一块指针来表示。但假如预先就知道数组包含的元素仅仅是 ── 比方说 ── 双精度浮点数 (double-floats),那么这个数组就可以用一组实际的双精度浮点数来表示。这样数组将占用更少的空间,因为我们不再需要额外的指针指向每一个双精度浮点数;同时,对数组元素的访问也将更快,因为我们不必沿着指针去读取和写元素。

以上内容是否对您有帮助:
在线笔记
App下载
App下载

扫描二维码

下载编程狮App

公众号
微信公众号

编程狮公众号