您的位置:首页 > Web前端 > Node.js

Sea of nodes 中译文

2020-02-01 22:56 731 查看

原文链接:https://darksi.de/d.sea-of-nodes/

 

简介

这篇文章将讲述我最近学到的Sea of nodes编译器概念。

尽管不是完全必要,但在阅读本文之前,可以先看一下我以前在JIT编译器上发表的一些文章,应该会很有一些帮助:

编译器=翻译器

编译器是软件工程师每天都要使用的工具。令人惊讶的是,即使是那些认为自己不会编写代码的人,在一天当中仍然会大量使用编译器。这是因为大多数Web页都依赖于客户端代码的执行,并且很多这样的客户端程序都以源代码的形式传递给浏览器,例如javascript。

在这里,我们要讨论一件重要的事:尽管源代码(通常)是人类可读的,但对于您的笔记本电脑/计算机/手机/ ...的CPU来说,它几乎是垃圾。 另一方面,计算机可以读取的机器代码几乎总是人类难以阅读的。 我们必须要做一些事情来处理它,此问题的解决方案称为翻译的过程。

很少有编译器只执行一次翻译:就从源代码直接到机器代码。 在实践中,大多数编译器至少要经过两次翻译过程:从源代码到抽象语法树(AST),从AST到机器码。 在这种情况下,AST的作用类似于中间表示(IR),顾名思义,AST只是源代码的另一种表示形式。 这些中间表示链接在一起代表抽象层。

这些层的层级没有限制,每一个新层都让源代码的表示形式更接近机器码。

优化层

但是,不是所有层都仅用于翻译。 许多编译器还另外尝试优化人工编写的代码。 (通常在编写代码时要兼顾代码优雅和代码性能)。

我们来看一个JavaScripte代码的例子:

[code]for (var i = 0, acc = 0; i < arr.length; i++)
acc += arr[i];

如果编译器直接从AST(抽象语法树)翻译到机器码,大致像下面的过程这样(抽象表达,并非真实指令集):

[code]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;
}

可能不是那么显而易见,但是此代码远非最优。 数组的长度实际上不会在循环内部更改,并且根本不需要范围检查。 理想情况下,它应该如下所示:

[code]acc = 0;
i = 0;
len = loadArrayLength(arr);
loop {
if (i >= tmp)
break;

acc += load(arr, i);
i += 1;
}

让我们尝试想象一下如何做到这一点。

 

假设我们手头有一个AST,我们尝试直接从它翻译生成机器码:

(注意:下面的抽象语法树是使用 esprima工具生成)

[code]{ 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之前:

[code]var a = 1;
console.log(a);
a = 2;
console.log(a);

应用SSA之后:

[code]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)。 例如:

[code]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代码:

[code]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 )来调度块中的节点。

 

[code]// 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

  • 点赞
  • 收藏
  • 分享
  • 文章举报
raojun 发布了1 篇原创文章 · 获赞 0 · 访问量 386 私信 关注
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: