优化 JS 程序的一个小方法

 

 就像在学习之前先要识字,我想在介绍优化 JavaScript 代码之前,先介绍一下自己对编程语言的理解。故事要从一只叫做 Theseus 的机械鼠和其发明人克劳德-香农(Claude Shannon)说起。在传记《A Mind at Play:How Claude Shannon Invented the Information Age》中,作者 Jimmy Soni 和 Rob Goodman 强烈希望将香农的作品 Theseus 展示给广大读者。面对复杂的迷宫,Theseus 仅用一堆继电器、ROM 存储等简单而古老的电子元器件,就完成了对复杂迷宫的探索和成功线路的记忆,第二次沿着正确道路走出迷宫的 Theseus 没犯一点儿错误。大多数人认为这不过是骗人的把戏和小玩意儿,弃之如敝履。少数聪明人眼里 Theseus 蕴含的惊人智慧简直可以和牛顿、爱因斯坦媲美,香农凭借一己之力将布尔代数引入电子电路设计启发了后世数字电路乃至计算机的发明。

数字电路和程序的关系

 

 

 

                                                       图 4-44 我在 2011 年做的智能调光调色电路

如图 4-44 就是一个在香农启发下产生的数字电路,Theseus 中古老的电子元器件已经变成了右侧红框的集成电路。通过将两个电容和一个晶振组成左侧红框的振荡电路,给集成电路提供时钟。集成电路上有数模转换电路,把高低电平代表的布尔代数运算电路计算结果转换成模拟信号输出,可调光调色的 LED 模组上就产生了功率输出的不同:颜色、频率输出的不同:亮度。在香农的启发和帮助下,图 4-44 的电路实现了一个完整的程序功能:随时钟变化不断改变 LED 颜色和亮度。

如果图 4-44 的完整程序直接通过翻查集成电路手册,按照图 4-45 对照引脚定义和手册里对寄存器操作的地址写入对应的指令和数据。

 

 

                         图 4-45 我的电路中用到的集成电路手册信息(摘录自 ATMEL 官网) 

从这个例子中可以看到,在香农的启发和帮助下,数字电路最大的好处就是把电路进行了抽象,让程序逻辑和数字电路在这个抽象层面上统一起来。在这个新的统一抽象层面上,程序逻辑的控制可以类比为逻辑电路中的控制,程序的输入输出可以类比为数字电路中的存储(ROM、RAM)。时钟电路给数字电路中信号传输提供标尺,中央处理器根据这些标尺来控制数字电路中信号的传输和流转,这种传输和流转则把点状的控制变成控制流,把点状的存储变成数据流。因此,在程序中最重要的是控制流和数据流。

为了不必每次都查手册用引脚去烧写 ROM 为数字电路注入控制指令和数据,前辈们发明了一套烧写系统,用汇编或 C 语言来定义和描述控制流和数据流,再由编译器翻译成图 4-45 对应的寄存器地址和指令、数据,然后通过烧写器变成数字信号通过集成电路引脚传输到集成电路内部完成程序的烧写。这套系统让我们摆脱手册(当然不是完全摆脱,有时候还是要查但频率大幅降低)直接用变成语言去描述程序,再通过模拟器(类似于前端 MOCK 数据)完成调试和模拟测试,让我们对数字电路编程变得异常简单。

 

 

                        图 4-46 对数字电路进行编程(摘自 ATMEL 官网)

如图 4-46 这种方式控制数字电路就容易多了,您可以在淘宝买一个 Arduino 开发板,然后按照下面的代码自己试试从本质上理解程序是什么?数字电路是什么?计算的本质是什么?

unsigned long colorT[] = {  0xff3300,0xff3800,0xff4500,0xff4700,0xff5200,0xff5300,0xff5d00,0xff5d00,0xff6600,0xff6500, 0xff6f00,0xff6d00,0xff7600,0xff7300,0xff7c00,0xff7900,0xff8200,0xff7e00,0xff8700,0xff8300, 可以自己继续添加
}
int R_Pin = 11;
int G_Pin = 10;
int B_Pin = 9;
// 这里就是手册中集成电路输出信号的引脚和 LED 模块连接方式对应
int red,green,blue = 0;
int i = 0;
int l = sizeof(colorT);
void setup(){
  pinMode(12, OUTPUT);
  pinMode(R_Pin, OUTPUT);
  pinMode(G_Pin, OUTPUT);
  pinMode(B_Pin, OUTPUT);
  digitalWrite(12, LOW);
}
void setColor(int redValue, int greenValue, int blueValue){
  analogWrite(R_Pin, redValue);
  analogWrite(G_Pin, greenValue);
  analogWrite(B_Pin, blueValue);
}
void  loop(){
  red = (colorT[i] >> 16) & 0xff;
  green = (colorT[i] >> 8) & 0xff;
  blue = (colorT[i] >> 0) & 0xff;
  setColor(red, green, blue);
  i++;
  if(i >= l){
    i = 0;
  }
  delay(200); // 控制时钟信号
}

神游了一圈儿,接下来我们来看看如何观察 JavaScript 的控制流和数据流。前面提到要用 parser 对原始的代码文本(字符串)进行处理,最常见的处理目的是生成“抽象语法树”(AST)。当然,对于 D2C Schema 和 DesignToken 的 parsing 是为了输出正确的、内联 CSS 的完整 HTML 文档。

 

抽象语法树 AST

 

之所以要把 JavaScript 代码文本转换成 AST 是因为编译器无法对字符串构成的程序文本进行直接操作,只有把程序文本从“1+2”变成new BinaryExpression(ADD, new Number(1), new Number(2))这种形式,才能被编译器理解。怎么样?是不是和 ATMEL 集成电路的编程很像?操作ADD和数据new Number(1)。因此,从程序文本到 AST 的过程可以类比成解码过程 parsing,用于解码的那段儿代码就叫做 parser。知道了这些,我们就可以把程序文本也就是经常挂在嘴边的“代码”翻来覆去的玩儿,代码文本变成 AST 推荐 esprima 这个工具,遍历 AST 节点并进行一些修修补补(优化性能?),最后把修改过的 AST 再转换成代码文本可以用 escodegen 。

 
// 生成 AST 抽象语法树
const esprima = require('esprima');
const AST = esprima.parseScript(jsCode);
// 遍历和修改 AST
const estraverse = require('estraverse');
const escodegen = require('escodegen');
function toEqual(node){
  if(node.operator === '=='){
    node.operator = '===';
  }
}
function walkIn(ast){
  estraverse.traverse(ast, {
    enter: (node) => {
      toEqual(node);
    }
  });
}
// 从 AST 进行代码生成
const escodegen = require('escodegen');
const code = escodegen.generate(ast);

具备上面的技能后,让我们那一段儿真实的代码来练练手。

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

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

用 esprima 提供的 parser 把这段儿代码转换成 AST 后,我借助 GraphViz 工具把 AST 从 JSON 格式转换成 digraph 格式的 .gv 文件,然后生成图 4-47。

 

 

                                                  图 4-47 对 AST 进行可视化

数据流图 DFG

图 4-47 是一棵树,所以能很方便的进行遍历,当我们访问 AST 节点时生成对应的机器代码。这个方法的问题在于,关于变量的信息非常稀少,并分散在不同的树节点上。为了优化安全地将长度查找移出循环,我们需要知道数组长度不会在循环迭代之间变化。人类只需查看源代码即可轻松完成,但编译器需要做大量工作,才能自信地直接从 AST 中提取这些事实。与许多其他编译器问题一样,这通常通过将数据提升到更合适的抽象层,即中间表示(IR)来解决。在这个特定情况下,IR 的选择被称为数据流图(DFG)。与其谈论语法实体(如for loop、expressions、...),我们应该谈论数据本身(读取、变量值),以及它如何在程序中变化。

在我们的特定示例中,我们感兴趣的数据是变量arr的值。我们希望能够轻松观察它的所有使用,以验证没有越界访问或任何其他更改来修改数组的长度,这是我们优化的前提。通过引入不同数据值之间的“使用”(定义和使用)关系来实现的。具体而言,这意味着该值已声明一次(图 4-47 中的_节点_),并且它已用于创建新值(图 4-47 的_边_)。显然,将不同的值连接在一起将形成如图 4-48 的数据流图。

 

                                                        图 4-48 数据流图

注意数据流图 4-48 中的红色array框,离开它的实心箭头表示此值的用法。通过在这些边上迭代,编译器可以导出array的值用于:

 

  • loadArrayLength

 

  • checkIndex

 

  • load


如果以破坏性方式访问数组节点的值(即存储、长度大小),则此类图的构造方式是显式“克隆”数组节点。每当我们看到array节点并观察其用途时,总是确定它的值不会改变。这听起来可能很复杂但很容易实现,该数据流图遵循单一静态分配(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);

通过 SSA 后我们可以确定,当谈论a0时实际上是在谈论它的单个任务。

 

控制流图 CFG

 

使用数据流分析来从程序中提取信息,使我们能够就如何优化它做出安全假设。这种数据流表示在许多情况下非常有用,唯一的问题是通过将代码转换为数据流图,在表示链(从源代码到机器代码)中与 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
}

如图 4-49 所示,我们可以按照之前的方法把他编程一张控制流图。

 

                                     图 4-49 控制流图

如您所见:块b0中的循环前有代码,b1中的循环头,b2中的循环测试,b3中的循环主体,b4中的退出节点。从这个例子翻译成机器代码非常容易,将iXX替换为CPU寄存器名称(就像前文在 ATMEL 手册上查到的寄存器地址),并为每个指令逐行生成机器代码。

CFG 具有数据流关系和顺序,这使我们能够将其用于数据流分析和机器代码生成。然而,试图通过操纵其中包含的块及其内容来优化 CFG,会变得复杂且容易出错。相反,Clifford Click 和 Keith D Cooper 提议使用一种叫做“节点海”的方法,来消除 CFG 和复杂的数据流图带来的麻烦。

 

节点海 Node Sea

 还记得带有虚线的花哨数据流图吗?这些虚线实际上是使该图成为节点海图的原因。我们选择将控制依赖项声明为图中的虚线边缘,而不是将节点分组并对其进行排序。如果我们删除所有未破线的东西,并稍微分组一些事情,我们将得到图 4-50 所示的节点海图。

 

                                                    图 4-50 节点海(Node Sea)

图 4-50 节点海是查看代码非常强大的方式,它具有一般数据流图的所有信息,无需不断删除/替换块中的节点即可轻松更改以实现优化。节点海图通常通过图约简进行修改,我们只需将图表中的所有节点排队,为队列中的每个节点调用我们的函数,此函数涉及的所有内容(更改、替换)都将放入另一个队列,稍后将传递给优化函数。如果您有许多优化点,例如:合并/减少网络请求、合并/减少 JSBridge 调用、合并/减少本地存储 API 调用等,您可以将它们堆叠在一起,并在队列中的每个节点上应用它们,如果它们依赖于彼此的最终状态,您也可以逐一应用它们。

 

作者 | 甄焱鲲(甄子)

posted @ 2022-10-04 21:21  古道轻风  阅读(119)  评论(0编辑  收藏  举报