[LLVM翻译]创建BOLT COMPILER:第5部分 活性和别名数据流分析教程

939 阅读13分钟

原文地址:mukulrathi.co.uk/create-your…

原文作者:mukulrathi.co.uk/

发布时间:2020年6月27日-7分钟阅读

系列。创建BOLT编译器

即将推出


数据流分析--大局观

在本系列的上一篇文章中,我们研究了核心语言的类型检查是如何工作的。这种分析是对流程不敏感的--它不依赖于程序执行的流程。如果一个表达式的类型是int,那么无论在它之前或之后执行了什么,它的类型都是int。

然而,一些程序属性确实取决于程序采取的执行路径。数据流分析跟踪一个特定的值如何随着程序的执行而传播。

例如,一个值可能会别名--你可以有多个指向该值的引用。两个引用x和y是否别名不能通过孤立地看它们的赋值来确定--我们需要跟踪执行情况来确定它们是否有相同的值赋值。

let x = someObject
...
let y = someObject // x and y alias as both point to same object

另一个例子是有效性分析--如果一个值在程序中的某个点可以被使用,我们就说它是活的,否则就是死的。

let x = someValue // someValue is live
...
print(x) // as we use someValue here
x = someOtherValue // someOtherValue isn't used - so dead

为什么我们要关心别名分析和活泼度分析?好吧,原来Rust的borrow-checker使用这些的组合来确定一个引用何时被借用。Bolt的类型系统中有一个线性能力,其作用也是一样的。

Rust的借用检查器和Bolt的能力都能防止数据竞赛--我的论文里有解释为什么。

简而言之,Rust的工作原理是所有权原则:你要么有一个可以读/写的引用(所有者)(可变引用),要么你可以借用(也就是别名)这个引用。

为了执行这个原则,我们关心2件事情。

  1. 什么时候借用一个引用?(别名分析)
  2. 借用多长时间?(活期分析)

我们来详细说说第二点。什么时候是借用引用?Rust 的第一个版本的 borrow checker 说这是在别名在范围内的时候。

let x = something // this is pseudocode not Rust
{
  let y = x
  ... // y will be used in future
  print(y)
  // no longer using y
  x = somethingElse
}
// y is out of scope

这意味着在y脱离范围之前,我们不能重新分配例子中的x。这就是一个 "词性寿命"--y的借用寿命由其词性范围决定。所以这意味着x不能被重新赋值,因为此时它已经被借用了。但是y没有被使用,所以x肯定不是还在被借用?

这就是非逻辑生命期背后的想法--当y的值死掉时,借贷就结束了--也就是说,它没有被使用。

在 Bolt 中,我们将跟踪对象引用的寿命。

让我们开始实现它吧

别名分析

第一步是判断两个值何时别名。在没有实际执行程序的情况下,我们怎么知道两个引用会指向同一个对象呢?

我们使用抽象解释。

抽象解释

抽象解释是模拟程序的执行过程,但只存储我们关心的程序属性。所以在我们的案例中,我们并不关心程序执行的实际结果是什么,我们只是跟踪别名的集合。

这其中隐含了一套程序执行的规则,我们称之为它的操作语义。我们在这里就不细说了,但它的内容是这样的。

  • 我们是从左到右还是从右到左评估表达式?
  • 在调用一个函数时,我们是完全评估参数表达式,然后用该参数的值来调用函数,还是直接将未评估的参数表达式插入函数中,只在函数体中使用该参数表达式的地方进行评估?前者叫做按值调用,在Java和Python等大多数主流语言中使用,后者叫做按名调用,在Haskell和Lisp中使用。 关于这一点,还有很多话要说--也许值得写一篇自己的博文?如果你想要的话,请给我发一条微博吧

我们什么时候会有别名?当一个新的变量被声明或被重新分配时。为了简单起见(也因为我们不希望别名的寿命大于原始值),我们不允许通过重新赋值一个现有的变量来进行别名(例如:x := y)。

所以我们关心的是这种形式的表达式。

let x = e

表达式e在执行时可以还原为一个值,例如1+2还原为3。

如果在执行e的时候将其还原为一个参考值y,那么整体的表达式会是这样的。

let x = y

所以,让我们写一个函数,给定一个表达式,将返回它可能还原成的标识符列表。这将告诉我们 x 可能会被别名。

我们将把这个帮助函数存储在Bolt编译器的数据竞赛类型检查阶段使用的帮助函数的 "环境 "中:data_race_checker_env.mli。

这个OCaml函数的类型签名和我们预期的一样。

val reduce_expr_to_obj_ids : expr -> identifier list

提醒一下上一篇文章中定义的expr类型--loc编码表达式的行号和位置--用于错误信息。

type identifier =
  | Variable of type_expr * Var_name.t
  | ObjField of Class_name.t * Var_name.t * type_expr * Field_name.t
      (** class of the object, type of field *)
type expr =
  | Integer     of loc * int
  | Boolean     of loc * bool
  | Identifier  of loc * identifier
  | Constructor of loc * type_expr * Class_name.t * constructor_arg list
  | Let         of loc * type_expr * Var_name.t * expr
  | Assign      of loc * type_expr * identifier * expr
  | If          of loc * type_expr * expr * block_expr * block_expr
  ...

and block_expr =
  | Block of loc * type_expr * expr list

从简单的案例开始,很明显一个整数或布尔值不会还原成一个标识符,而一个标识符表达式会还原成该标识符。一个新的SomeClass()构造函数也不会还原成一个标识符。

let rec reduce_expr_to_obj_ids expr =
  match expr with
  | Integer _ | Boolean _ -> []
  | Identifier (_, id) -> [id]
  | Constructor (_, _, _, _) -> []

如果我们有一个 let 表达式或赋值给一个标识符,那么它就会被还原成该标识符(例如 let x = ___还原成 x,x.f:= __还原成 x.f)。

  ...
  | Let (_, _, _, bound_expr) -> reduce_expr_to_obj_ids bound_expr
  | Assign (_, _, _, assigned_expr) -> reduce_expr_to_obj_ids assigned_expr

我们可以对其他情况继续这样做。但是如果语句呢?这个表达式是还原成x还是y?

if (someCondition) {
  x
} else {
  y
}

一般来说,不实际执行表达式,我们就不知道。所以我们必须要进行近似计算。

让我们提醒自己,我们的目标是--如果一个值被别名,则将其标记为借用/非线性(Rust / Bolt等价术语)。我们试图消除数据竞赛,所以我们必须保守--假设它可能是别名的,即使它不是。最坏的情况是让一个数据种族溜走。抽象解释总是错误地站在合理性的一边。

所以我们将超前估计表达式可能还原成的标识符列表--我们不知道是哪个分支,所以我们将计算两个分支的标识符。

  ...
  | If (_, _, _, then_expr, else_expr) ->
      let then_ids = reduce_block_expr_to_obj_ids then_expr in
      let else_ids = reduce_block_expr_to_obj_ids else_expr in
      then_ids @ else_ids

所以在上面的例子中,我们会返回一个列表[x,y]。

计算所有的别名

所以我们知道,如果我们有一个表达式让y=e,e还原为x,那么我们有让y=x,所以y是x的别名。

在下面的表达式中,我们也会运行抽象解释(在这种情况下很简单)来发现z和w是y的别名。

let y = x
...
let z = y
if(y.f > 1){
  ...
}
else {

}
...
let w = y

根据反转性,z和w也必须是x的别名。

我喜欢把它想象成一个图,其中每个边都是一个直接/即时的别名。我们可以找到所有的别名,通过以 "广度优先搜索 "的方式反复应用抽象解释--每一次迭代我们都在扩大边界。而每一次迭代我们都试图找到我们找到的别名的别名--所以第一次迭代我们找到x的别名,然后下一次迭代我们找到x和y的别名,以此类推。

所以我们重复,直到我们没有找到更多的别名。完整的代码在 repo 中有链接,并且包含了一个匹配字段的选项 (即我们是否应该考虑 x.f 的别名以及 x?),在这种情况下,我们不会这样做,但是 Bolt 的数据竞赛类型检查的其他方面确实使用了这一点。

但函数式编程的好处是,函数可以说明我们在做什么,而不需要模板。

  let rec get_all_obj_aliases should_match_fields curr_aliases block_expr =
    find_immediate_aliases_in_block_expr should_match_fields name_to_match curr_aliases
      block_expr
    |> fun updated_aliases ->
    if var_lists_are_equal updated_aliases curr_aliases
       then curr_aliases (* we're done! *)
    else get_all_obj_aliases should_match_fields updated_aliases block_expr
    (* repeat with an expanded frontier *)

活力分析

好了,我们已经完成了一半--我们已经确定了我们的别名,现在我们需要找出它们什么时候是活的。请记住,如果一个值在未来的某个程序执行路径上会被使用,那么它就是活的。

控制流图

为此,让我们把程序中 "执行路径 "的概念形式化。我们要把一个程序表示成一个指令图,用边代表执行中的步骤。我们称之为程序的控制流图。

然而,如果每一条语句都代表图上的一个节点,它们之间有边,那么这个图就会大得惊人(一个100行的程序会有100条语句)。我们可以更聪明一点,把那些总是紧接着执行的语句组合在一起。

如果在一组语句中,从开始语句到结束语句只有一条路径,我们可以将其表示为控制流图中的一个节点。我们称这个节点为基本语句块。下面你可以看到用不同颜色高亮显示的代码行,以显示它们所在的基本块。

请注意,控制流图显示了两条不同的执行路径,对应采取的if-else分支。

在我们的图中,我们可以选择程序中的任何一条语句,然后问,变量y的值在此时是否是活的?

一个天真的检查方法是向前遍历图,直到看到y的这个值被使用(所以y是活的),或者直到到达终点(因此你知道y是死的)。但这是无奈的浪费--每一条语句我们都要向前遍历图形,检查变量的是否被使用。

我们可以换个角度看,如果我们向后遍历,那么我们遇到的y的第一次使用就是最后一次使用,如果我们向前遍历的话。因此,当我们向后遍历时,只要我们看到y被使用,我们就知道y是被定义的,它是活的。

图片会有帮助,所以让我们看一下我们的图。

所以总结一下,在活络度分析中,我们。

  • 向后遍历控制流图
  • 追踪一组活值
  • 如果我们第一次看到一个值,我们就把它添加到我们的集合中
  • 如果我们看到一个值的定义,我们就删除它

实施我们的别名有效性检查器

现在我们有了理论上的认识,我们来实现检查器。

每当遇到一个标识符,我们需要知道原来的对象引用是什么,可能的别名集,并分别知道那些是活的。

let type_alias_liveness_identifier obj_name possible_aliases
  filter_linear_caps_fn live_aliases id =

  let id_name = get_identifier_name id in

我们的函数根据标识符名称的不同,处理三种情况。

  • 它是原始对象引用
  • 它可能是一个别名
  • 这是另一个我们不关心的参考资料

在第一种情况下,如果有活别名,我们要过滤线性能力(因为引用不是线性的,所以不能使用它们)。除了更新的标识符,我们还返回没有变化的活别名集。

  if id_name = obj_name then (* original object reference *)
    let maybe_updated_capabilities =
      update_capabilities_if_live_aliases filter_linear_caps_fn
      live_aliases (get_identifier_capabilities id) in
    (set_identifier_capabilities id maybe_updated_capabilities, live_aliases)

否则,我们需要检查标识符是否是可能的别名之一--如果是,我们就把它添加到我们正在跟踪的实时别名集中。所以我们会将这个更新后的实时别名集和标识符一起返回。

else
  ( match
      List.find
        ~f:(fun poss_alias ->
                identifier_matches_var_name poss_alias id)
        possible_aliases
    with
  | Some alias -> alias :: live_aliases
  | None       -> live_aliases )
  |> fun updated_live_aliases -> (id, updated_live_aliases)

而在我们的type_alias_liveness_expr函数中的二元就是我们看到我们的let x = e表达式。在这里,记得我们是先执行e,然后再将其赋值给x,所以在倒退遍历时,我们先将x从活别名集中删除(因为我们已经看到了它的定义),然后再遍历绑定表达式。

然后我们遍历约束表达式:

  | Let (loc, type_expr, var_name, bound_expr) ->
      (* remove this var from the set of live aliases *)
      type_alias_liveness_expr_rec
        (List.filter ~f:(fun name -> not (var_name = name)) live_aliases)
        bound_expr
      |> fun (updated_bound_expr, updated_live_aliases) ->
      (Let (loc, type_expr, var_name, updated_bound_expr), updated_live_aliases)

在我们的控制流图中,一个有趣的情况是if-else拆分。我们将每个分支相互独立地处理,因为它们是我们图中的独立路径。

  | If (loc, type_expr, cond_expr, then_expr, else_expr) ->
      type_alias_liveness_block_expr_rec live_aliases then_expr
      |> fun (updated_then_expr, then_live_aliases) ->
      type_alias_liveness_block_expr_rec live_aliases else_expr
      |> fun (updated_else_expr, else_live_aliases) ->

我们如何将两个分支重新组合?嗯,活泼度的定义是,如果有一些路径中使用了这个值。同样,我们不知道哪个分支被采取了,因为我们不能执行程序,所以我们过虑了--我们假设两条路径都可能被采取--所以在遍历if-else条件表达式时,将它们的活别名集联合起来。

      type_alias_liveness_expr_rec (then_live_aliases @ else_live_aliases) cond_expr
      |> fun (updated_cond_expr, cond_live_aliases) ->
      ( If (loc, type_expr, updated_cond_expr, updated_then_expr, updated_else_expr)
      , cond_live_aliases )

另一个有趣的情况是当我们有一个while循环。我们不能只遍历一次循环,因为我们可能会错过一些可以在后续迭代中使用的值,因此是活的。我们不知道我们会循环多少次,所以我们会过度估计--我们会一直循环下去,直到活别名集没有变化(即我们已经得到了所有可能的活别名)。

and type_alias_liveness_loop_expr aliased_obj_name possible_aliases
  filter_linear_caps_fn live_aliases loop_expr =
  type_alias_liveness_block_expr aliased_obj_name possible_aliases filter_linear_caps_fn
    live_aliases loop_expr
  |> fun (updated_loop_expr, updated_live_aliases) ->
  if var_lists_are_equal live_aliases updated_live_aliases then
     (* done! *)
    (updated_loop_expr, updated_live_aliases)
  else
  (* loop again! *)
    type_alias_liveness_loop_expr aliased_obj_name possible_aliases
      filter_linear_caps_fn updated_live_aliases updated_loop_expr

这与Bolt的关系如何?

我们的活泼度分析适合于Bolt的数据竞赛类型检查器中线性能力的整体类型检查。

let type_linear_object_references obj_name obj_class class_defns block_expr =
  let obj_aliases = ...
  ...
  |> fun updated_block_expr ->
  type_alias_liveness_block_expr obj_name obj_aliases
     filter_linear_caps_fn [] (* we start with empty set of live aliases *)
     updated_block_expr
  |> fun (typed_linear_obj_ref_block_expr, _) -> typed_linear_obj_ref_block_expr\

我们稍后将详细讨论数据竞赛类型检查的其他方面,然而本教程的下一阶段是关于去ugaring--将高级语言简化为低级表示的过程。这将为我们在后面的编译器系列中针对LLVM做好准备。


www.deepl.com 翻译