很多 Common Lisp 解释器会将源代码编译成汇编语言,因此,Common Lisp 的性能要比其他解释型语言要好很多。

然而,我们有时想要程序跑得更快一些。本章将介绍一些发挥 CPU 极限的技术。

找到瓶颈

获取执行时间

[time](http://www.lispworks.com/documentation/lw51/CLHS/Body/m_time.htm) 宏对找到瓶颈很有用。它接受一个列表,执行然后将其所消耗的时间信息输出到 [*trace-output*](http://www.lispworks.com/documentation/lw71/CLHS/Body/v_debug_.htm#STtrace-outputST),如下所示:

  1. * (defun collect (start end)
  2. "Collect numbers [start, end] as list."
  3. (loop for i from start to end
  4. collect i))
  5. * (time (collect 1 10))
  6. Evaluation took:
  7. 0.000 seconds of real time
  8. 0.000001 seconds of total run time (0.000001 user, 0.000000 system)
  9. 100.00% CPU
  10. 3,800 processor cycles
  11. 0 bytes consed

通过 time 宏,就可以很简单的找出程序的哪一部分消耗的时间过多。

注意,这里的时间信息并不保证在真正的生产环境一样准确。本章只是用来演示如何进行性能优化。

检查汇编代码

函数 [disassemble](http://www.lispworks.com/documentation/lw60/CLHS/Body/f_disass.htm) 接受一个函数,然后将该函数编译后的代码输出到*标准输出(standard-output)*,如:

  1. * (defun plus (a b)
  2. (+ a b))
  3. PLUS
  4. * (disassemble 'plus)
  5. ; disassembly for PLUS
  6. ; Size: 37 bytes. Origin: #x52B8063B
  7. ; 3B: 498B5D60 MOV RBX, [R13+96] ; no-arg-parsing entry point
  8. ; thread.binding-stack-pointer
  9. ; 3F: 48895DF8 MOV [RBP-8], RBX
  10. ; 43: 498BD0 MOV RDX, R8
  11. ; 46: 488BFE MOV RDI, RSI
  12. ; 49: FF1425B0001052 CALL QWORD PTR [#x521000B0] ; GENERIC-+
  13. ; 50: 488B75E8 MOV RSI, [RBP-24]
  14. ; 54: 4C8B45F0 MOV R8, [RBP-16]
  15. ; 58: 488BE5 MOV RSP, RBP
  16. ; 5B: F8 CLC
  17. ; 5C: 5D POP RBP
  18. ; 5D: C3 RET
  19. ; 5E: CC0F BREAK 15 ; Invalid argument count trap

上述代码是在 SBCL 中执行的。在其他的编译器中,如 CLISP, disassemble 的返回结果可能有所不同:

  1. * (defun plus (a b)
  2. (+ a b))
  3. PLUS
  4. * (disassemble 'plus)
  5. Disassembly of function PLUS
  6. 2 required arguments
  7. 0 optional arguments
  8. No rest parameter
  9. No keyword parameters
  10. 4 byte-code instructions:
  11. 0 (LOAD&PUSH 2)
  12. 1 (LOAD&PUSH 2)
  13. 2 (CALLSR 2 55) ; +
  14. 5 (SKIP&RET 3)
  15. NIL

发生以上的情况是因为 SBCL 将 Lisp 代码编译成机器码,而 CLISP 则不是。

使用 declare 表达式

declare 表达式可以对编译器进行多种优化。注意这些优化都是与解释器无关的。一些解释其如 SBCL 支持这个功能,可以阅读其文档获取详细信息。这里只是介绍一些 CLHS 中提到的基础方法。

一般来说, decalre 表达式只会出现在特定格式的头部,如果上下文允许的话,也可以放在文档字符串后。此外,声明表达式将被限制为特定的格式。在这,将介绍一些与性能调优相关的特性。

谨记一点,本章节中介绍的的优化技巧与 Lisp 的解释器有很大的联系。在使用 declare 之前记得查看相应的文档。

速度及安全

Lisp 可以通过 [optimize](http://www.lispworks.com/documentation/lw71/CLHS/Body/d_optimi.htm) 来指定编译器优化质量的属性。每个质量属性可以赋值为 0 到 3,0 表示完全不重要,3表示很重要。

质量中最值得关注的就是安全速度

默认情况下,Lisp 更注重代码的安全性,而不是速度。但是可以通过调整其权重来实现更好的优化。

  1. * (defun max-original (a b)
  2. (max a b))
  3. MAX-ORIGINAL
  4. * (disassemble 'max-original)
  5. ; disassembly for MAX-ORIGINAL
  6. ; Size: 144 bytes. Origin: #x52D450EF
  7. ; 7A7: 8D46F1 lea eax, [rsi-15] ; no-arg-parsing entry point
  8. ; 7AA: A801 test al, 1
  9. ; 7AC: 750E jne L0
  10. ; 7AE: 3C0A cmp al, 10
  11. ; 7B0: 740A jeq L0
  12. ; 7B2: A80F test al, 15
  13. ; 7B4: 7576 jne L5
  14. ; 7B6: 807EF11D cmp byte ptr [rsi-15], 29
  15. ; 7BA: 7770 jnbe L5
  16. ; 7BC: L0: 8D43F1 lea eax, [rbx-15]
  17. ; 7BF: A801 test al, 1
  18. ; 7C1: 750E jne L1
  19. ; 7C3: 3C0A cmp al, 10
  20. ; 7C5: 740A jeq L1
  21. ; 7C7: A80F test al, 15
  22. ; 7C9: 755A jne L4
  23. ; 7CB: 807BF11D cmp byte ptr [rbx-15], 29
  24. ; 7CF: 7754 jnbe L4
  25. ; 7D1: L1: 488BD3 mov rdx, rbx
  26. ; 7D4: 488BFE mov rdi, rsi
  27. ; 7D7: B9C1030020 mov ecx, 536871873 ; generic->
  28. ; 7DC: FFD1 call rcx
  29. ; 7DE: 488B75F0 mov rsi, [rbp-16]
  30. ; 7E2: 488B5DF8 mov rbx, [rbp-8]
  31. ; 7E6: 7E09 jle L3
  32. ; 7E8: 488BD3 mov rdx, rbx
  33. ; 7EB: L2: 488BE5 mov rsp, rbp
  34. ; 7EE: F8 clc
  35. ; 7EF: 5D pop rbp
  36. ; 7F0: C3 ret
  37. ; 7F1: L3: 4C8BCB mov r9, rbx
  38. ; 7F4: 4C894DE8 mov [rbp-24], r9
  39. ; 7F8: 4C8BC6 mov r8, rsi
  40. ; 7FB: 4C8945E0 mov [rbp-32], r8
  41. ; 7FF: 488BD3 mov rdx, rbx
  42. ; 802: 488BFE mov rdi, rsi
  43. ; 805: B929040020 mov ecx, 536871977 ; generic-=
  44. ; 80A: FFD1 call rcx
  45. ; 80C: 4C8B45E0 mov r8, [rbp-32]
  46. ; 810: 4C8B4DE8 mov r9, [rbp-24]
  47. ; 814: 488B75F0 mov rsi, [rbp-16]
  48. ; 818: 488B5DF8 mov rbx, [rbp-8]
  49. ; 81C: 498BD0 mov rdx, r8
  50. ; 81F: 490F44D1 cmoveq rdx, r9
  51. ; 823: EBC6 jmp L2
  52. ; 825: L4: CC0A break 10 ; error trap
  53. ; 827: 04 byte #X04
  54. ; 828: 13 byte #X13 ; OBJECT-NOT-REAL-ERROR
  55. ; 829: FE9B01 byte #XFE, #X9B, #X01 ; RBX
  56. ; 82C: L5: CC0A break 10 ; error trap
  57. ; 82E: 04 byte #X04
  58. ; 82F: 13 byte #X13 ; OBJECT-NOT-REAL-ERROR
  59. ; 830: FE1B03 byte #XFE, #X1B, #X03 ; RSI
  60. ; 833: CC0A break 10 ; error trap
  61. ; 835: 02 byte #X02
  62. ; 836: 19 byte #X19 ; INVALID-ARG-COUNT-ERROR
  63. ; 837: 9A byte #X9A ; RCX
  64. * (defun max-with-speed-3 (a b)
  65. (declare (optimize (speed 3) (safety 0)))
  66. (max a b))
  67. MAX-WITH-SPEED-3
  68. * (disassemble 'max-with-speed-3)
  69. ; disassembly for MAX-WITH-SPEED-3
  70. ; Size: 92 bytes. Origin: #x52D452C3
  71. ; 3B: 48895DE0 mov [rbp-32], rbx ; no-arg-parsing entry point
  72. ; 3F: 488945E8 mov [rbp-24], rax
  73. ; 43: 488BD0 mov rdx, rax
  74. ; 46: 488BFB mov rdi, rbx
  75. ; 49: B9C1030020 mov ecx, 536871873 ; generic->
  76. ; 4E: FFD1 call rcx
  77. ; 50: 488B45E8 mov rax, [rbp-24]
  78. ; 54: 488B5DE0 mov rbx, [rbp-32]
  79. ; 58: 7E0C jle L1
  80. ; 5A: 4C8BC0 mov r8, rax
  81. ; 5D: L0: 498BD0 mov rdx, r8
  82. ; 60: 488BE5 mov rsp, rbp
  83. ; 63: F8 clc
  84. ; 64: 5D pop rbp
  85. ; 65: C3 ret
  86. ; 66: L1: 488945E8 mov [rbp-24], rax
  87. ; 6A: 488BF0 mov rsi, rax
  88. ; 6D: 488975F0 mov [rbp-16], rsi
  89. ; 71: 4C8BC3 mov r8, rbx
  90. ; 74: 4C8945F8 mov [rbp-8], r8
  91. ; 78: 488BD0 mov rdx, rax
  92. ; 7B: 488BFB mov rdi, rbx
  93. ; 7E: B929040020 mov ecx, 536871977 ; generic-=
  94. ; 83: FFD1 call rcx
  95. ; 85: 488B45E8 mov rax, [rbp-24]
  96. ; 89: 488B75F0 mov rsi, [rbp-16]
  97. ; 8D: 4C8B45F8 mov r8, [rbp-8]
  98. ; 91: 4C0F44C6 cmoveq r8, rsi
  99. ; 95: EBC6 jmp L0

如上所示,生成的汇编代码要短很多。编译器可以进行优化。然而,可以通过声明类型做的更好。

类型提示

在[第16章 类型][zh-cn/16.type.md] 中有提到,Lisp 有个强大的类型系统。可以通过给定类型来减少编译器生成代码的体积。

  1. * (defun max-with-type (a b)
  2. (declare (optimize (speed 3) (safety 0)))
  3. (declare (type integer a b))
  4. (max a b))
  5. MAX-WITH-TYPE
  6. * (disassemble 'max-with-type)
  7. ; disassembly for MAX-WITH-TYPE
  8. ; Size: 42 bytes. Origin: #x52D48A23
  9. ; 1B: 488BF7 mov rsi, rdi ; no-arg-parsing entry point
  10. ; 1E: 488975F0 mov [rbp-16], rsi
  11. ; 22: 488BD8 mov rbx, rax
  12. ; 25: 48895DF8 mov [rbp-8], rbx
  13. ; 29: 488BD0 mov rdx, rax
  14. ; 2C: B98C030020 mov ecx, 536871820 ; generic-<
  15. ; 31: FFD1 call rcx
  16. ; 33: 488B75F0 mov rsi, [rbp-16]
  17. ; 37: 488B5DF8 mov rbx, [rbp-8]
  18. ; 3B: 480F4CDE cmovl rbx, rsi
  19. ; 3F: 488BD3 mov rdx, rbx
  20. ; 42: 488BE5 mov rsp, rbp
  21. ; 45: F8 clc
  22. ; 46: 5D pop rbp
  23. ; 47: C3 ret

上面生成的汇编代码缩减为原来的 1/3。那么运行速度呢?

  1. * (time (dotimes (i 10000) (max-original 100 200)))
  2. Evaluation took:
  3. 0.000 seconds of real time
  4. 0.000107 seconds of total run time (0.000088 user, 0.000019 system)
  5. 100.00% CPU
  6. 361,088 processor cycles
  7. 0 bytes consed
  8. * (time (dotimes (i 10000) (max-with-type 100 200)))
  9. Evaluation took:
  10. 0.000 seconds of real time
  11. 0.000044 seconds of total run time (0.000036 user, 0.000008 system)
  12. 100.00% CPU
  13. 146,960 processor cycles
  14. 0 bytes consed

可以看到,通过指定类型,代码的执行要快很多!

等等……如果定义了错误的类型会怎么样呢?答案示情况而定。

比如说,SBCL 对待类型定义有其特殊的方法,叫 sbcl-type。根据不同的安全等级来进行不同等级的类型检查。如果安全登记设置为 0,那么 SBCL 不会进行类型检查。因此错误的类型声明将造成很严重的后果。

更多关于类型定义 declaim

在顶层(即刚启动 sbcl 时)运行 declare,可能会得到以下的异常:

  1. Execution of a form compiled with errors.
  2. Form:
  3. (DECLARE (SPEED 3))
  4. Compile-time error:
  5. There is no function named DECLARE. References to DECLARE in some contexts
  6. (like starts of blocks) are unevaluated expressions, but here the expression is
  7. being evaluated, which invokes undefined behaviour.
  8. [Condition of type SB-INT:COMPILED-PROGRAM-ERROR]

这是因为类型定义有作用域。在上面的例子中,类型定义作用在函数之中。

在开发过程中,为了尽快发现潜在的问题,提高安全的重要性通常是有用的。反之,在部署之后,速度可能更重要。但为每个函数指定声明就过于冗长。

[declaim](http://www.lispworks.com/documentation/lw71/CLHS/Body/m_declai.htm) 宏提供了这种可能。它可以在文件的顶层格式中使用,并且这些声明将在编译时进行。

  1. * (declaim (optimize (speed 0) (safety 3)))
  2. NIL
  3. * (defun max-original (a b)
  4. (max a b))
  5. MAX-ORIGINAL
  6. * (disassemble 'max-original)
  7. ; disassembly for MAX-ORIGINAL
  8. ; Size: 181 bytes. Origin: #x52D47D9C
  9. ...
  10. * (declaim (optimize (speed 3) (safety 3)))
  11. NIL
  12. * (defun max-original (a b)
  13. (max a b))
  14. MAX-ORIGINAL
  15. * (disassemble 'max-original)
  16. ; disassembly for MAX-ORIGINAL
  17. ; Size: 142 bytes. Origin: #x52D4815D

注意,declaim 是在文件的编译时生效。主要用于文件的一些本地声明。在文件被编译后,declaim 编译时的副作用是否会持续,这一点没有明确说明。

声明函数类型

另一个有用的声明是 ftypeftype 会在函数参数类型和返回值的类型之间建立一种关系。如果传入的参数类型与定义的类型一致,返回值类型和定义的类型保持一致。基于这点,函数中可以有多个 ftype 声明。每次函数调用时,ftype 会约束参数的类型,其格式如下:

  1. (declare (ftype (function (arg1 arg2 ...) return-value) function-name1))

但函数返回 nil 时,它返回的类型是 null。该声明本身并没有对参数的类型做限制,只有在参数类型是指定的类型是才有效——否则不会产生任何,且声明也不会生效。比如说,下面的声明中,如果 square 函数的参数是修正数(fixnum),函数的返回值也会是修正数

  1. (declaim (ftype (function (fixnum) fixnum) square))
  2. (defun square (x) (* x x))

如果提供的参数没有声明为 修正数 类型,就不会进行优化:

  1. (defun do-some-arithmetic (x)
  2. (the fixnum (+ x (square x))))

现在,来试着优化速度吧。下面的代码中编译器将声明存在类型不确定:

  1. (defun do-some-arithmetic (x)
  2. (declare (optimize (speed 3) (debug 0) (safety 0)))
  3. (the fixnum (+ x (square x))))
  4. ; compiling (DEFUN DO-SOME-ARITHMETIC ...)
  5. ; file: /tmp/slimeRzDh1R
  6. in: DEFUN DO-SOME-ARITHMETIC
  7. ; (+ TEST-FRAMEWORK::X (TEST-FRAMEWORK::SQUARE TEST-FRAMEWORK::X))
  8. ;
  9. ; note: forced to do GENERIC-+ (cost 10)
  10. ; unable to do inline fixnum arithmetic (cost 2) because:
  11. ; The first argument is a NUMBER, not a FIXNUM.
  12. ; unable to do inline (signed-byte 64) arithmetic (cost 5) because:
  13. ; The first argument is a NUMBER, not a (SIGNED-BYTE 64).
  14. ; etc.
  15. ;
  16. ; compilation unit finished
  17. ; printed 1 note
  18. (disassemble 'do-some-arithmetic)
  19. ; disassembly for DO-SOME-ARITHMETIC
  20. ; Size: 53 bytes. Origin: #x52CD1D1A
  21. ; 1A: 488945F8 MOV [RBP-8], RAX ; no-arg-parsing entry point
  22. ; 1E: 488BD0 MOV RDX, RAX
  23. ; 21: 4883EC10 SUB RSP, 16
  24. ; 25: B902000000 MOV ECX, 2
  25. ; 2A: 48892C24 MOV [RSP], RBP
  26. ; 2E: 488BEC MOV RBP, RSP
  27. ; 31: E8C2737CFD CALL #x504990F8 ; #<FDEFN SQUARE>
  28. ; 36: 480F42E3 CMOVB RSP, RBX
  29. ; 3A: 488B45F8 MOV RAX, [RBP-8]
  30. ; 3E: 488BFA MOV RDI, RDX
  31. ; 41: 488BD0 MOV RDX, RAX
  32. ; 44: E807EE42FF CALL #x52100B50 ; GENERIC-+
  33. ; 49: 488BE5 MOV RSP, RBP
  34. ; 4C: F8 CLC
  35. ; 4D: 5D POP RBP
  36. ; 4E: C3 RET
  37. NIL

现在可以给 x 声明个类型了,所以编译器可以假设 (square x) 表达式是个修正数(fixnum),然后使用修正运算符 +

  1. (defun do-some-arithmetic (x)
  2. (declare (optimize (speed 3) (debug 0) (safety 0)))
  3. (declare (type fixnum x))
  4. (the fixnum (+ x (square x))))
  5. (disassemble 'do-some-arithmetic)
  6. ; disassembly for DO-SOME-ARITHMETIC
  7. ; Size: 48 bytes. Origin: #x52C084DA
  8. ; 4DA: 488945F8 MOV [RBP-8], RAX ; no-arg-parsing entry point
  9. ; 4DE: 4883EC10 SUB RSP, 16
  10. ; 4E2: 488BD0 MOV RDX, RAX
  11. ; 4E5: B902000000 MOV ECX, 2
  12. ; 4EA: 48892C24 MOV [RSP], RBP
  13. ; 4EE: 488BEC MOV RBP, RSP
  14. ; 4F1: E8020C89FD CALL #x504990F8 ; #<FDEFN SQUARE>
  15. ; 4F6: 480F42E3 CMOVB RSP, RBX
  16. ; 4FA: 488B45F8 MOV RAX, [RBP-8]
  17. ; 4FE: 4801D0 ADD RAX, RDX
  18. ; 501: 488BD0 MOV RDX, RAX
  19. ; 504: 488BE5 MOV RSP, RBP
  20. ; 507: F8 CLC
  21. ; 508: 5D POP RBP
  22. ; 509: C3 RET
  23. NIL

内联代码

[inline](http://www.lispworks.com/documentation/lw51/CLHS/Body/d_inline.htm) 的作用是将函数调用替换成函数的主体,如果编译器支持的话。这种做法会降低函数调用的消耗,但会增加代码的体积。最适合使用 inline 的应该是那些使用频繁且简短的函数。下面的代码段展示了何如使用及禁用内联代码。

  1. ;; The globally defined function DISPATCH should be open-coded,
  2. ;; if the implementation supports inlining, unless a NOTINLINE
  3. ;; declaration overrides this effect.
  4. (declaim (inline dispatch))
  5. (defun dispatch (x) (funcall (get (car x) 'dispatch) x))
  6. ;; Here is an example where inlining would be encouraged.
  7. ;; Because function DISPATCH was defined as INLINE, the code
  8. ;; inlining will be encouraged by default.
  9. (defun use-dispatch-inline-by-default ()
  10. (dispatch (read-command)))
  11. ;; Here is an example where inlining would be prohibited.
  12. ;; The NOTINLINE here only affects this function.
  13. (defun use-dispatch-with-declare-notinline ()
  14. (declare (notinline dispatch))
  15. (dispatch (read-command)))
  16. ;; Here is an example where inlining would be prohibited.
  17. ;; The NOTINLINE here affects all following code.
  18. (declaim (notinline dispatch))
  19. (defun use-dispatch-with-declaim-noinline ()
  20. (dispatch (read-command)))
  21. ;; Inlining would be encouraged because you specified it.
  22. ;; The INLINE here only affects this function.
  23. (defun use-dispatch-with-inline ()
  24. (declare (inline dispatch))
  25. (dispatch (read-command)))

注意,当内联函数改变时,所有调用这个内联函数的函数都需要重新编译。

优化通用函数

使用静态分配

在开发时期,通用函数提供了极大的便利和灵活性。然而,灵活性所带来是开销:通用函数要比普通函数慢的多。尤其是在不需要灵活性的时候,其带来的性能消耗将会称为负担。

[inlined-generic-function](https://github.com/guicho271828/inlined-generic-function) 包中有将通用函数转化成静态分配的函数,将分配的开销移到编译的时间中去。只需要将通用函数定义成 inlined-generic-function 类型的函数。

注意事项

这个包被声明为实验性的,因此在正规的软件产品中不推荐使用。否则后果自负!

  1. * (defgeneric plus (a b)
  2. (:generic-function-class inlined-generic-function))
  3. #<INLINED-GENERIC-FUNCTION HELLO::PLUS (2)>
  4. * (defmethod plus ((a fixnum) (b fixnum))
  5. (+ a b))
  6. #<INLINED-METHOD HELLO::PLUS (FIXNUM FIXNUM) {10056D7513}>
  7. * (defun func-using-plus (a b)
  8. (plus a b))
  9. FUNC-USING-PLUS
  10. * (defun func-using-plus-inline (a b)
  11. (declare (inline plus))
  12. (plus a b))
  13. FUNC-USING-PLUS-INLINE
  14. * (time
  15. (dotimes (i 100000)
  16. (func-using-plus 100 200)))
  17. Evaluation took:
  18. 0.018 seconds of real time
  19. 0.017819 seconds of total run time (0.017800 user, 0.000019 system)
  20. 100.00% CPU
  21. 3 lambdas converted
  22. 71,132,440 processor cycles
  23. 6,586,240 bytes consed
  24. * (time
  25. (dotimes (i 100000)
  26. (func-using-plus-inline 100 200)))
  27. Evaluation took:
  28. 0.001 seconds of real time
  29. 0.000326 seconds of total run time (0.000326 user, 0.000000 system)
  30. 0.00% CPU
  31. 1,301,040 processor cycles
  32. 0 bytes consed

默认情况下不启用内联,因为一旦内联,对方法所做的更改将不会生效。

可以在 [*features*](http://www.lispworks.com/documentation/lw71/CLHS/Body/v_featur.htm) 中添加 :inline-generic-function 标志来启用全局内联。

  1. * (push :inline-generic-function *features*)
  2. (:INLINE-GENERIC-FUNCTION :SLYNK :CLOSER-MOP :CL-FAD :BORDEAUX-THREADS
  3. :THREAD-SUPPORT :CL-PPCRE ALEXANDRIA.0.DEV::SEQUENCE-EMPTYP :QUICKLISP
  4. :QUICKLISP-SUPPORT-HTTPS :SB-BSD-SOCKETS-ADDRINFO :ASDF3.3 :ASDF3.2 :ASDF3.1
  5. :ASDF3 :ASDF2 :ASDF :OS-UNIX :NON-BASE-CHARS-EXIST-P :ASDF-UNICODE :ROS.INIT
  6. :X86-64 :64-BIT :64-BIT-REGISTERS :ALIEN-CALLBACKS :ANSI-CL :AVX2
  7. :C-STACK-IS-CONTROL-STACK :CALL-SYMBOL :COMMON-LISP :COMPACT-INSTANCE-HEADER
  8. :COMPARE-AND-SWAP-VOPS :CYCLE-COUNTER :ELF :FP-AND-PC-STANDARD-SAVE ..)

当该全局内联启用时,除非被声明为 notinline,否则其他所有可内联的通用函数函数都会变成内联函数。