仓库源文

.. Kenneth Lee 版权所有 2020

:Authors: Kenneth Lee :Version: 1.0

编译阶段和运行阶段算力


本文尝试建模一下把算力需求放到编译阶段还是运行阶段的关系。

写惯底层代码的人(比如我),直觉上总是觉得算力都是在运行阶段完成的。如果我们采 用GNU编译时用的build(编译代码的平台)和target(运行代码的平台)的概念,在源代 码上写下的每行逻辑,我们都预期最终被target的机器执行,而不是由build的机器执行。

但即使如此,这些平台也会存在在build阶段执行的算力。比如下面这段代码:

    .. code-block:: c

    const int unit_of_m = 1024 * 1024;

这里的1024*1024就是在编译阶段完成计算的。

对于更高级的代码,这就更加复杂了,比如ocaml中的match语法:

    .. code-block:: ocaml

    let rec sum ls = 
      match ls with
      [] -> 0
      | head :: tail -> head + sum(tail)

先给不熟悉函数式编程的同学科普一下这个语法的含义:这里定义了一个递归函数sum,参 数是ls,当ls是个空集的时候,返回0,否则把ls分成head和tail两个部分,返回head加上 递归的sum(tail)作为结果。

这是个数学形态的定义,在计算机的算力分配上,我们有两个计算需求:

  1. 发现ls是个什么类型,并根据这个类型决定操作细节(比如这里的+号的具体二进制实 现),甚至在发现整个程序使用sum的时候一开始就是个空集,就直接生成第一部分的 代码,忽略递归的代码。

  2. 进行递归运算

这两部分分别使用的就是Build和Target的算力。Haskell上也有大量这种语法,比如:

    .. code-block:: haskell

    diff x y = if x > y then gap else -gap where gap=x-y

我们不需要定义x, y的类型,也不需要知道x-y是否可以成立,只要diff函数被使用的时候 我们能从我们的语义数据库(Build阶段的数据)中拿到x-y怎么做,拿到gap是什么意思, 我们就可以组成代码。不需要到运行的时候再做相关的判断。

只要我们像其他编译型语言那样,不要认为它的定义是被顺序解释的,而是在全文 定义完成以后再决定具体行为的,我们就很容易理解它。

现在我们讨论这个问题:到底什么逻辑应该放在build上完成,什么逻辑应该应该放在 target上完成。

一个最朴素的做法当然是:所有可以放在build上完成的计算,都应该在build阶段完成。

这是最容易定义的,只要一个计算的条件中没有包含变量,它就是可以在build阶段完成, 只要它包含了变量了,就只能等target阶段完成。

但这不一定对,在很多HPC的计算中,我们知道所有的变量(的取值),为什么不是Build 阶段就完成这个计算。这个问题的判断依据是什么?

这样想一下,似乎这个问题也不值得讨论:这取决于我们如何分布算力。比如一个AI训练 ,你有一组训练集,你先送进去1/3的数据,得到一组参数,你把这部分数据放在编译阶段 ,你的程序中就包含了,1/3的数据,1/3的算力就分布到build上了,剩下的就属于target 的。

这确实是个很无聊的话题,但这给我们打开一个思路,我们可以从有什么数据是可以提前 获得的,让编译的机器分担部分的算力,特别是分担部分适合重新调度程序执行行为的数 据,我们就可以在更大的范围中找一个更好的调度策略。

但我还没有想好怎么利用这个思路,先记录一下吧。

补充1:

这个讨论似乎解释了为什么函数式编程为什么这么喜欢在类型和类型匹配上下工夫,因为 类型恰好就是编译器工作的范围,可以让我们清晰地分清楚。我们的算力什么时候放在Build 上,什么时候放在Target上。当你说:

.. code-block:: ocaml

let calc a = match a with | type1 -> calc1 | type2 -> calc2 | None -> 0

的时候,你其实是让Build来做match,而让Target来做calcX。