「加载期计算」,是指: 在代码加载时执行运算或者这种能力

编译期计算 相比,加载期计算可能适用范围更广

场景

加载期计算主要适用于以下的场景:

  1. 程序运行时需要的某些信息,如环境变量等,无法在编译期间确定
  2. 这些信息在程序启动后几乎不会发生变化
  3. 程序运行时需要根据不同的环境信息,执行不同的运算

比较典型的场景就是跨平台应用,以及使用静态配置的应用,这些应用在真正运行前是无法获得目标平台、或者目标配置的 值。而运行后,要么对这些信息进行缓存以便反复使用,要么就是每次使用这些信息时,都重新进行一次读取/查询。

而如果程序支持「加载期计算」,则可以在代码加载时,读取相关信息,然后直接将信息写入到调用的代码处,甚至,可以根据 信息的值来选择性生成不同的处理代码。

这样,对于程序而言,这些信息就变成了静态的常量了,且无用的代码分支也会被裁剪掉,这样就减少了程序在缓存、调用、 分支预测上的开销了

Load-Time Hook/Callback

部分语言,比如 Erlang、C#(Unity) 等支持在模块、类、程序集加载时,执行某个函数,这种行为可以看作是代码加载时的一个 回调,或者说是一种受限的「加载期计算」,因为这样的计算,执行的都是编译期已经生成好的代码,无法通过在加载期间执行的运算来影响到原有的代码逻辑。

C# (.Net) 可以在加载时,找到对应属性的程序集、类、方法、乃至字段等,然后动态修改、生成代码。 这种操作,可以算是在代码加载期间执行的 JIT 操作,而「加载期计算」更多的是强调「计算」或者说 「求值」, 但总的来说,区别不大,可以看作是一回事。

示例,零开销配置

这里做一个简单的演示,使用 message 文本文件做为配置文件,里面的内容为:

Hello, World!

定义函数 get-message ,直接读取整个文本文件,当作是配置读取操作:

(defun get-message ()
  (uiop:read-file-string "~/message"))

定义函数 test , 直接使用 get-message 模拟读取配置操作

(defun test ()
  (let ((msg (get-message)))
    msg))

为了防止编译器优化,这里写的很啰嗦,第三行的 msg 实际上可以看作是具体的配置操作过程,只是这里简单的用返回这个行为 来模拟

定义一个函数 test2 , 使用加载期计算,在代码加载时,求出 get-message 的值,然后直接写入到 test2 的代码内

(eval-when (:load-toplevel)
  (let ((msg (get-message)))
    (defun test2 ()
      (let ((in-msg `msg))
        in-msg))))

现在来看, test 生成的汇编代码:

;disassembly for RT::TEST
;Size: 40 bytes. Origin: #x5359C06C                           RT::TEST
;6C:       498B4510         MOV RAX, [R13+16]                 thread.binding-stack-pointer
;70:       488945F8         MOV [RBP-8], RAX
;74:       4883EC10         SUB RSP, 16
;78:       31C9             XOR ECX, ECX
;7A:       48892C24         MOV [RSP], RBP
;7E:       488BEC           MOV RBP, RSP
;81:       B8A2C73E50       MOV EAX, #x503EC7A2               #<FDEFN RT::GET-MESSAGE>
;86:       FFD0             CALL RAX
;88:       480F42E3         CMOVB RSP, RBX
;8C:       488BE5           MOV RSP, RBP
;8F:       F8               CLC
;90:       5D               POP RBP
;91:       C3               RET
;92:       CC10             INT3 16                           Invalid argument count trap

可以看到, test 调用了 get-message ,且整个执行过程指令很多,一直到 91 处才 RET

然后, test2 的汇编代码:

;disassembly for RT::TEST2
;Size: 38 bytes. Origin: #x535D21F0                           RT::TEST2
;1F0:       498B4510         MOV RAX, [R13+16]                thread.binding-stack-pointer
;1F4:       488945F0         MOV [RBP-16], RAX
;1F8:       488BE5           MOV RSP, RBP
;1FB:       F8               CLC
;1FC:       5D               POP RBP
;1FD:       C3               RET
;1FE: L0:   FF24255800A052   JMP QWORD PTR [#x52A00058]       ALLOC-TRAMP
;205:       CC10             INT3 16                          Invalid argument count trap
;207:       6A20             PUSH 32
;209:       E8F0FFFFFF       CALL L0
;20E:       5E               POP RSI
;20F:       E976FFFFFF       JMP #x535D218A
;214:       CC10             INT3 16                          Invalid argument count trap

可以看到, test2 中没有调用 get-message 函数,且在 1FD 处就已经 RET 了,整个执行过程实际上只有一条 MOV 指令,用于把常量区的文本地址拷贝到寄存器中。

上面的示例比较简单,实际上使用时,可以实现的很复杂,比如跨平台应用,如果不想针对不同的平台进行单独打包,希望一个包 可以运行在所用平台上,但又不想在代码执行时不断的做平台判断,就可以使用加载期计算,在代码加载时,读取当前的平台信息, 然后使用和上面示例中一样的方法,只生成/插入对应平台的代码就行。

另外一些有趣的应用,可以看作是手动 JIT,如果程序运行时,会生成一些统计信息,比如各个分支的成功概率等,那么下次代码重新 加载时,可以读取进行信息,然后重新优化分支顺序,提高分支预测的成功率等。