原文链接:https://darksi.de/d.sea-of-nodes/
简介
这篇文章将讲述我最近学到的Sea of nodes编译器概念。
尽管不是完全必要,但在阅读本文之前,可以先看一下我以前在JIT编译器上发表的一些文章,应该会很有一些帮助:
编译器=翻译器
编译器是软件工程师每天都要使用的工具。令人惊讶的是,即使是那些认为自己不会编写代码的人,在一天当中仍然会大量使用编译器。这是因为大多数Web页都依赖于客户端代码的执行,并且很多这样的客户端程序都以源代码的形式传递给浏览器,例如javascript。
在这里,我们要讨论一件重要的事:尽管源代码(通常)是人类可读的,但对于您的笔记本电脑/计算机/手机/ ...的CPU来说,它几乎是垃圾。 另一方面,计算机可以读取的机器代码几乎总是人类难以阅读的。 我们必须要做一些事情来处理它,此问题的解决方案称为翻译的过程。
很少有编译器只执行一次翻译:就从源代码直接到机器代码。 在实践中,大多数编译器至少要经过两次翻译过程:从源代码到抽象语法树(AST),从AST到机器码。 在这种情况下,AST的作用类似于中间表示(IR),顾名思义,AST只是源代码的另一种表示形式。 这些中间表示链接在一起代表抽象层。
这些层的层级没有限制,每一个新层都让源代码的表示形式更接近机器码。
优化层
但是,不是所有层都仅用于翻译。 许多编译器还另外尝试优化人工编写的代码。 (通常在编写代码时要兼顾代码优雅和代码性能)。
我们来看一个JavaScripte代码的例子:
for (var i = 0, acc = 0; i < arr.length; i++)
acc += arr[i];
如果编译器直接从AST(抽象语法树)翻译到机器码,大致像下面的过程这样(抽象表达,并非真实指令集):
acc = 0;
i = 0;
loop {
// Load `.length` field of arr
tmp = loadArrayLength(arr);
if (i >= tmp)
break;
// Check that `i` is between 0 and `arr.length`
// (NOTE: This is necessary for fast loads and
// stores).
checkIndex(arr, i);
// Load value
acc += load(arr, i);
// Increment index
i += 1;
}
可能不是那么显而易见,但是此代码远非最优。 数组的长度实际上不会在循环内部更改,并且根本不需要范围检查。 理想情况下,它应该如下所示:
acc = 0;
i = 0;
len = loadArrayLength(arr);
loop {
if (i >= tmp)
break;
acc += load(arr, i);
i += 1;
}
让我们尝试想象一下如何做到这一点。
假设我们手头有一个AST,我们尝试直接从它翻译生成机器码:
(注意:下面的抽象语法树是使用 esprima工具生成)
{ type: 'ForStatement',
//
// This is `var i = 0;`
//
init:
{ type: 'VariableDeclaration',
declarations:
[ { type: 'VariableDeclarator',
id: { type: 'Identifier', name: 'i' },
init: { type: 'Literal', value: 0, raw: '0' } },
{ type: 'VariableDeclarator',
id: { type: 'Identifier', name: 'acc' },
init: { type: 'Literal', value: 0, raw: '0' } }],
kind: 'var' },
//
// `i < arr.length`
//
test:
{ type: 'BinaryExpression',
operator: '<',
left: { type: 'Identifier', name: 'i' },
right:
{ type: 'MemberExpression',
computed: false,
object: { type: 'Identifier', name: 'arr' },
property: { type: 'Identifier', name: 'length' } } },
//
// `i++`
//
update:
{ type: 'UpdateExpression',
operator: '++',
argument: { type: 'Identifier', name: 'i' },
prefix: false },
//
// `arr[i] += 1;`
//
body:
{ type: 'ExpressionStatement',
expression:
{ type: 'AssignmentExpression',
operator: '+=',
left: { type: 'Identifier', name: 'acc' },
right:
{ type: 'MemberExpression',
computed: true,
object: { type: 'Identifier', name: 'arr' },
property: { type: 'Identifier', name: 'i' } } } }
上面的JSON数据也可以可视化为下图:
这是一棵树,因此从顶部到底部遍历访问它很自然,当我们访问AST节点时就生成对应的机器码。 这种方法的问题在于,有关变量的信息非常稀疏,并且分布在不同的树节点上。
同样,为了安全地将长度查找移出循环,我们需要知道数组长度在循环的迭代之间不会改变。 人们只要看一下源代码就可以轻松地做到这一点,但是编译器需要做大量工作才能从AST中提取到这些信息。
像许多其他编译器问题一样,通常可以通过将数据提升到更合适的抽象层(即中间表示)中来解决此问题。 在这个特例里,IR的选择称为数据流图(DFG)。 与其关注语法实体(例如用于循环,表达式等),不如关注数据本身(读取,变量值)以及它们如何在程序中变化。
数据流图(DFG,Data-flow Graph)
在这个例子中,我们感兴趣的数据是变量arr的值。 我们希望能够轻松观察它的所有用法,以验证没有越界访问,也没有任何其他会改变数组长度的修改。
这是通过在不同数据值之间引入“def-use”(定义和使用)关系来实现的。 具体而言,这意味着该值已被声明过一次(节点),并且已在某处用于创建新值(每条边代表一次使用)。 显然,将不同的值连接在一起将形成一个数据流图,如下所示:
译者注:图中实线箭头表示该值的用途,虚线表示控制依赖项。
注意这张大图中的红色图框,实线箭头表示该值的用途。 通过这些实线遍历各节点,编译器可以得出在以下位置使用了array的值:
- loadArrayLength
- checkIndex
- load
如果以破坏性方式(即保存长度大小)访问array节点的值,明确地“克隆”array节点来构造此图形。每当遇到array节点,观察它的用法,我们总是可以确定它的值不会改变。
听起来可能很复杂,但是图形的这个属性非常容易实现。 该图应遵循Single Static Assignment(SSA,单一静态赋值)规则。 简而言之,要将任何程序转换为SSA,编译器需要为所有赋值操作的变量以及其后续的使用改名,以确保每个变量仅赋值一次。
来看个例子,应用SSA之前:
var a = 1;
console.log(a);
a = 2;
console.log(a);
应用SSA之后:
var a0 = 1;
console.log(a0);
var a1 = 2;
console.log(a1);
这样,我们可以确定当谈论a0时--实际上是在谈论它的单个赋值。 这与人们在函数式语言中的工作方式非常接近!
由于loadArrayLeng没有控制依赖项(即没有虚线;我们将在稍后讨论它们),编译器可能会得出结论:该节点可以自由地移动到它想要的任何位置,并且可以放置在循环之外。通过进一步查看这个图,我们可以观察到ssa:phi节点的值始终在0和arr.length之间,因此可以将checkIndex一起删除。
很整洁,不是吗?
控制流图(CFG, Control Flow Graph)
我们使用了某种形式的数据流分析(data-flow analysis ),来从程序中提取信息。 这使我们可以对如何优化进行安全的假设。
这种数据流表示形式在许多其他情况下非常有用。 唯一的问题是,通过将我们的代码转换成这种图形,我们在表示链(从源代码到机器代码)中倒退了一步。 这种中间表示甚至比AST更不适合生成机器码。
原因在于机器指令是顺序命令列表,CPU依次执行这些指令。 我们得到的图形无法表达这一点。 实际上,它根本没有排序。
通常,这可以通过将图节点分组为块来解决这个问题。 这种表示形式称为控制流程图(CFG)。 例如:
b0 {
i0 = literal 0
i1 = literal 0
i3 = array
i4 = jump ^b0
}
b0 -> b1
b1 {
i5 = ssa:phi ^b1 i0, i12
i6 = ssa:phi ^i5, i1, i14
i7 = loadArrayLength i3
i8 = cmp "<", i6, i7
i9 = if ^i6, i8
}
b1 -> b2, b3
b2 {
i10 = checkIndex ^b2, i3, i6
i11 = load ^i10, i3, i6
i12 = add i5, i11
i13 = literal 1
i14 = add i6, i13
i15 = jump ^b2
}
b2 -> b1
b3 {
i16 = exit ^b3
}
它被称为图不是没有原因的。 例如,bXX块表示节点,bXX-> bYY箭头表示边。 让我们对其进行可视化:
如您所见,循环之前的代码在块b0,循环头在b1,循环测试在b2,循环主体在b3,退出节点在b4。
从这种形式转换为机器码就非常容易了。 我们只需将iXX标识符替换为CPU寄存器名称(从某种意义上讲,CPU寄存器是某种变量,CPU的寄存器数量有限,因此我们需要注意不要耗尽它们),并且一行接一行地为每条指令生成机器码。
回顾一下,CFG具有数据流关系并且有序。 这使我们能够将其用于数据流分析和机器代码生成。 但是,通过操纵块及其包含的内容来优化CFG可能会很快变得复杂且容易出错。
Clifford Click和Keith D. Cooper建议使用一种称为sea-of-nodes的方法来改善,这是本文的主题!
Sea-of-Nodes
还记得前面数据流图中的虚线吗? 正是这些虚线能让数据流图成为Sea-of-Nodes图。
我们选择将控件依赖项声明为图中的虚线边,而不是将节点分组放到块里并对节点进行排序。 如果我们拿到这个数据流图,删除所有未用虚线连接的节点,然后对它们进行分组,我们将得到下图:
通过一点想象和对节点重新排序,我们就可以看到此图与简化的CFG图相同:
让我们再看一下sea-of-nodes表示形式:
该图与CFG的显著区别在于,除了具有控制依赖性的节点(换言之,参与控制流的节点)之外,其他的节点没有排序。
这种表示形式是查看代码非常有效的方法。 它具有一般数据流图的所有内容,并且可以轻松更改,而无需不断删除/替换块中的节点。
简化(reductions)
说到更改,我们来讨论下修改图形的方法。 Sea-of-nodes图通常通过对图进行简化来修改。 我们将图中的所有节点排队,然后为队列中的每个节点调用简化函数。 简化函数涉及的所有操作(更改,替换)都将排入队列,稍后传递给该函数。 如果你有很多简化操作,则可以把它们堆叠在一起并在队列中的每个节点上调用它们,如果它们依赖于彼此的最终状态,则可以逐个应用它们。事情简单的就像念一个符咒!
我为sea-of-nodes实践编写了一个JavaScript工具集,其中包括:
- json-pipeline -图的生成器和标准库。 提供创建节点,向节点添加输入,更改其控制依赖性以及向/从可打印数据导出/导入图的方法!
- json-pipeline-reducer – 简化(reductions)引擎。 只需创建一个reducer实例,为它提供几个reduce函数,然后在现有的json-pipeline图上执行这个reducer。
- json-pipeline-scheduler – 这是一个库,用于将无序图放回由控制边(虚线)连接在一起的有限数量的块中
这些工具结合在一起,可以解决许多用数据流方式表示的问题。
下面有一个简化(reductions)的示例,它将优化这段JS代码:
for (var i = 0, acc = 0; i < arr.length; i++)
acc += arr[i];
简化(reductions)相关的代码块很大,如果你想跳过它,可以只看下面这些简介:
- 计算各个节点的整数范围:literal, add, phi
- 计算适用于分支部分的限制
- 应用范围(range)和限制(limit)信息 (
i
始终是受arr.length
限制的非负数 ) 而得出结论,长度检查是不必要的,可以删除 json-pipeline-scheduler
会自动将arr.length
移出循环,这是因为它执行全局代码移动(Global Code Motion )来调度块中的节点。
// Just for viewing graphviz output
var fs = require('fs');
var Pipeline = require('json-pipeline');
var Reducer = require('json-pipeline-reducer');
var Scheduler = require('json-pipeline-scheduler');
//
// Create empty graph with CFG convenience
// methods.
//
var p = Pipeline.create('cfg');
//
// Parse the printable data and generate
// the graph.
//
p.parse(`pipeline {
b0 {
i0 = literal 0
i1 = literal 0
i3 = array
i4 = jump ^b0
}
b0 -> b1
b1 {
i5 = ssa:phi ^b1 i0, i12
i6 = ssa:phi ^i5, i1, i14
i7 = loadArrayLength i3
i8 = cmp "<", i6, i7
i9 = if ^i6, i8
}
b1 -> b2, b3
b2 {
i10 = checkIndex ^b2, i3, i6
i11 = load ^i10, i3, i6
i12 = add i5, i11
i13 = literal 1
i14 = add i6, i13
i15 = jump ^b2
}
b2 -> b1
b3 {
i16 = exit ^b3
}
}`, { cfg: true }, 'printable');
if (process.env.DEBUG)
fs.writeFileSync('before.gv', p.render('graphviz'));
//
// Just a helper to run reductions
//
function reduce(graph, reduction) {
var reducer = new Reducer();
reducer.addReduction(reduction);
reducer.reduce(graph);
}
//
// Create reduction
//
var ranges = new Map();
function getRange(node) {
if (ranges.has(node))
return ranges.get(node);
var range = { from: -Infinity, to: +Infinity, type: 'any' };
ranges.set(node, range);
return range;
}
function updateRange(node, reducer, from, to) {
var range = getRange(node);
// Lowest type, can't get upwards
if (range.type === 'none')
return;
if (range.from === from && range.to === to && range.type === 'int')
return;
range.from = from;
range.to = to;
range.type = 'int';
reducer.change(node);
}
function updateType(node, reducer, type) {
var range = getRange(node);
if (range.type === type)
return;
range.type = type;
reducer.change(node);
}
//
// Set type of literal
//
function reduceLiteral(node, reducer) {
var value = node.literals[0];
updateRange(node, reducer, value, value);
}
function reduceBinary(node, left, right, reducer) {
if (left.type === 'none' || right.type === 'none') {
updateType(node, reducer, 'none');
return false;
}
if (left.type === 'int' || right.type === 'int')
updateType(node, reducer, 'int');
if (left.type !== 'int' || right.type !== 'int')
return false;
return true;
}
//
// Just join the ranges of inputs
//
function reducePhi(node, reducer) {
var left = getRange(node.inputs[0]);
var right = getRange(node.inputs[1]);
if (!reduceBinary(node, left, right, reducer))
return;
if (node.inputs[1].opcode !== 'add' || left.from !== left.to)
return;
var from = Math.min(left.from, right.from);
var to = Math.max(left.to, right.to);
updateRange(node, reducer, from, to);
}
//
// Detect: phi = phi + <positive number>, where initial phi is number,
// report proper range.
//
function reduceAdd(node, reducer) {
var left = getRange(node.inputs[0]);
var right = getRange(node.inputs[1]);
if (!reduceBinary(node, left, right, reducer))
return;
var phi = node.inputs[0];
if (phi.opcode !== 'ssa:phi' || right.from !== right.to)
return;
var number = right.from;
if (number <= 0 || phi.inputs[1] !== node)
return;
var initial = getRange(phi.inputs[0]);
if (initial.type !== 'int')
return;
updateRange(node, reducer, initial.from, +Infinity);
}
var limits = new Map();
function getLimit(node) {
if (limits.has(node))
return limits.get(node);
var map = new Map();
limits.set(node, map);
return map;
}
function updateLimit(holder, node, reducer, type, value) {
var map = getLimit(holder);
if (!map.has(node))
map.set(node, { type: 'any', value: null });
var limit = map.get(node);
if (limit.type === type && limit.value === value)
return;
limit.type = type;
limit.value = value;
reducer.change(holder);
}
function mergeLimit(node, reducer, other) {
var map = getLimit(node);
var otherMap = getLimit(other);
otherMap.forEach(function(limit, key) {
updateLimit(node, key, reducer, limit.type, limit.value);
});
}
//
// Propagate limit from: X < Y to `if`'s true branch
//
function reduceIf(node, reducer) {
var test = node.inputs[0];
if (test.opcode !== 'cmp' || test.literals[0] !== '<')
return;
var left = test.inputs[0];
var right = test.inputs[1];
updateLimit(node.controlUses[0], left, reducer, '<', right);
updateLimit(node.controlUses[2], left, reducer, '>=', right);
}
//
// Determine ranges and limits of
// the values.
//
var rangeAndLimit = new Reducer.Reduction({
reduce: function(node, reducer) {
if (node.opcode === 'literal')
reduceLiteral(node, reducer);
else if (node.opcode === 'ssa:phi')
reducePhi(node, reducer);
else if (node.opcode === 'add')
reduceAdd(node, reducer);
else if (node.opcode === 'if')
reduceIf(node, reducer);
}
});
reduce(p, rangeAndLimit);
//
// Now that we have ranges and limits,
// time to remove the useless array
// length checks.
//
function reduceCheckIndex(node, reducer) {
// Walk up the control chain
var region = node.control[0];
while (region.opcode !== 'region' && region.opcode !== 'start')
region = region.control[0];
var array = node.inputs[0];
var index = node.inputs[1];
var limit = getLimit(region).get(index);
if (!limit)
return;
var range = getRange(index);
// Negative array index is not valid
if (range.from < 0)
return;
// Index should be limited by array length
if (limit.type !== '<' ||
limit.value.opcode !== 'loadArrayLength' ||
limit.value.inputs[0] !== array) {
return;
}
// Check is safe to remove!
reducer.remove(node);
}
var eliminateChecks = new Reducer.Reduction({
reduce: function(node, reducer) {
if (node.opcode === 'checkIndex')
reduceCheckIndex(node, reducer);
}
});
reduce(p, eliminateChecks);
//
// Run scheduler to put everything
// back to the CFG
//
var out = Scheduler.create(p).run();
out.reindex();
if (process.env.DEBUG)
fs.writeFileSync('after.gv', out.render('graphviz'));
console.log(out.render({ cfg: true }, 'printable'));
感谢阅读此文。 敬请期待有关这种sea-of-nodes方法的更多信息。
特别感谢 Paul Fryzel对此文进行了校对,并提供了宝贵的反馈和语法修改!
译注:如果想测试上面的js代码,需要安装node、mocha,用mocha 运行上面的js。
如果打开debug模式,会输出优化前后的有向图文件,可以用GraphViz工具打开。
附录:附加库 assert-text