graalvm 编译过程分析工具

背景

希望观察graalvm的编译过程输出,以便后续调试和分析问题。

信息导出

主要的入口资料在
https://www.graalvm.org/graalvm-as-a-platform/language-implementation-framework/Optimizing/
完整的详细选项可以参考
https://www.graalvm.org/graalvm-as-a-platform/language-implementation-framework/Options/
graal提供大量的导出命令,部分信息可以在终端上直接查看,核心的导出文件需要使用专用的可视化工具进行分析。

示例的导出命令,非常完整的信息dump出来

1
2
3
4
5
6
7
8
9
10
./js --jvm   --engine.TraceCompilationAST  --engine.BackgroundCompilation=false --experimental-options  --engine.CompileImmediately --vm.Dgraal.Dump  --vm.XX:+UnlockDiagnosticVMOptions --vm.XX:+PrintAssembly   --vm.Dgraal.PrintBackendCFG=true     --vm.XX:TieredStopAtLevel=0     hello.js

#打印LIR,供C1 visualizer看
./node --polyglot --jvm --engine.TraceCompilationAST --engine.BackgroundCompilation=false --experimental-options --engine.CompileImmediately --vm.Dgraal.Dump --vm.XX:+UnlockDiagnosticVMOptions --vm.XX:+PrintAssembly --vm.XX:TieredStopAtLevel=0 --vm.Dgraal.ObjdumpExecutables=objdump --vm.Dgraal.PrintCFG=true --vm.Dgraal.PrintBackendCFG=true --vm.Dgraal.PrintLIRWithAssembly=true --vm.Dgraal.PrintGraph=Network inter.js

#简单看guest语言相关节点
./js --polyglot --vm.Dgraal.Dump=Truffle:2 --vm.Dgraal.PrintGraph=Network ./inter_in_func.js

#可以先行使用cpusampler 参数将热点函数打印出来,后续可以直接分析热点函数
./js --cpusampler --cpusampler.Delay=10000 --polyglot ./inter_in_func.js

选项补充说明:

选项 说明
–jvm 打印汇编需要该选项,看起来似乎native image的运行时没有提供打印功能,使用openjdk vm时可以打印
–vm.Dgraal.Dump 可以调整为-vm.Dgraal.Dump=:99,以导出各个优化过程后的结果, 是调高后dump的数据太多,可能导致整个运行过程非常缓慢
–vm.XX:+PrintAssembly 该选项可以将最终的汇编打印到终端,但是需要安装插件hsids。可以直接下载,但是需要改名并放置在正确的目录下,如lib/hsdis-amd64.so
–vm.Dgraal.PrintGraph=Network 该选项可以直接将dump数据通过本地网络送到已经在本地启动的visualizer中,可以节省查找dump文件的时间
-Dgraal.MethodFilter=class.method For example, -Dgraal.MethodFilter=java.lang.String.*,HashMap.get will produce diagnostic data only for methods in the java.lang.String class as well as methods named get in a class whose non-qualified name is HashMap.

图形化分析工具

按照oracle的官方介绍,整个编译过程的输出分成了两个部分:
图形式的HIR(graal-ir)使用Ideal Graph Visualizer 展示。
接近机器码的LIR使用c1Visualizer 展示。

HIR部分还有一个开源的实现seafoam可以使用,其界面更简洁一些,但是功能弱于Ideal Graph Visualizer,比较适合入门学习。

Ideal Graph Visualizer

该工具是oracle官方提供的分析工具,需要到oracle网站上注册后才能下载。其基本介绍可参考 https://docs.oracle.com/en/graalvm/enterprise/21/docs/tools/igv/ 处的帮助。
Visualizer 有几个非常重要的功能,包括 基本块切分、节点名称搜索、源代码位置展示等,可以帮助我们快速定位问题。
参考下图。

图中还有四个较为重要的功能没有展示出来。
一是在graph的条目上点右键,选择’difference to current graph’功能,可以对不同的graph进行差异对比。
二是,在主展示区双击一个node,可以仅展示与这个选中节点相关的节点。通过不断双击相关节点(尤其是沿着红色的控制流走),可以以我们关心的node为中心展开graph,较快理清楚前后逻辑。同时,在选中节点的情况下,还可以切换graph的阶段,可以看到各个阶段变换对我们关心node的影响。
三是,可以自定义filter,使用简单的js语言接口编程,具体可以双击Coloring等过滤条目来查看示例代码。
四是,在单个节点上点右键,可以快速找到与其相连的node,由于图中有些节点相距太远,Visualizer不会把连接线画出来,用这个功能可以很快找到当前节点的前后节点。

c1Visualizer

在真正生成二进制代码前,HIR需要被Lower到LIR,在LIR表达形式下还需要做寄存器分配和调度等重要的工作。
c1Visualizer 是展示这些过程的工具。
通常在该工具中,我们最关心的是汇编代码与HIR间的对应关心。如下图所示。

seafoam

参考https://chrisseaton.com/truffleruby/basic-graal-graphs/
https://github.com/Shopify/seafoam

可以认为这是一个开源简化版本的Ideal Graph Visualizer。

使用工具分析实际问题

问题描述

使用如下的测试用例,测试js+c跨语言优化能力,可以发现同样的代码使用graal中的js运行速度很慢,而graal中的node能做到跨语言优化,使得js+c混合运行速度与纯js运行同样逻辑的速度一致。

1
2
3
4
5
6
$ cat inter_c.c

int test_add(int a, int b)
{
return a + b;
}
1
2
3
4
5
6
7
8
9
10
11
12
$ cat inter.js
var cpart = Polyglot.evalFile("llvm" ,"inter_c.so");
var j = 0;
var rep = 10;

for (; rep >0 ; rep--)
{
for (var i = 0; i < 10000*10000; i++) {
j = cpart.test_add(i, i);
}
console.log(j);
}

使用如下命令编译出所需的动态库。

1
2
3
4
cd graalvm-ce-java11-21.0.0.2/bin
./gu install llvm-toolchain
export LLVM_TOOLCHAIN=`./lli --print-toolchain-path`
$LLVM_TOOLCHAIN/clang -shared inter_c.c -lgraalvm-llvm -o inter_c.so -O1

再以如下的命令运行测试,可以看到使用node和js分别启动完全相同的程序,性能差了10倍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
$ time ./js --polyglot inter.js
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998

real 0m10.348s
user 0m12.513s
sys 0m0.290s
$ time ./node --polyglot inter.js
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998

real 0m1.199s
user 0m2.107s
sys 0m0.155s

使用纯js进行基准测试。

1
2
3
4
5
6
7
8
9
10
11
$cat inter_pure.js
var j = 0;
var rep = 10;

for (; rep >0 ; rep--)
{
for (var i = 0; i < 10000*10000; i++) {
j = i+i;
}
console.log(j);
}

得到如下数据

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
time ./node  inter_pure.js
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998

real 0m1.092s
user 0m1.932s
sys 0m0.096
time node inter_pure.js
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998

real 0m1.128s
user 0m1.113s
sys 0m0.016s

这里可以看到graal-node的js+c运行速度与纯js运行的速度已经一样了,语言互相调用的瓶颈被消除。

为了进一步标定性能,使用了如下c代码。

1
2
3
4
5
6
7
8
9
10
11
12
$ cat per.c
void main()
{
int j = 0;
for (int rep = 60; rep >0 ; rep--)
{
for (int i = 0; i < 10000*10000; i++) {
j = i +i ;
}
printf("%d\n",j);
}
}

得到的性能数据如下。可以看到联合优化后js+c的性能比纯c无优化还略微更快一些。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ gcc per.c -o per -O0;time ./per
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998

real 0m1.485s
user 0m1.485s
sys 0m0.000s

工具分析过程

打开Ideal Graph Visualize,使用如下命令分别获取两个导出图。

1
2
./js --jvm  --polyglot   --engine.TraceCompilationAST  --engine.BackgroundCompilation=false   --experimental-options  --engine.CompileImmediately --vm.Dgraal.Dump=:1 --vm.Dgraal.PrintGraph=Network    ./inter.js
./node --jvm --polyglot --engine.TraceCompilationAST --engine.BackgroundCompilation=false --experimental-options --engine.CompileImmediately --vm.Dgraal.Dump=:1 --vm.Dgraal.PrintGraph=Network ./inter.js

循环在graal导出中会被做成一个独立节点,选中js导出文件中的 Truffle::WhileNode$WhileDoRepeatingNode@7dee8838 inter.js:5~。
按照前面介绍的方法,可以查找到c 中的加法节点,并以此为中心来展开循环,观察js和nodejs的差异。
具体操作后,得到下图所示的两张图。

可以看到nodejs的循环非常短,除开55418这个写入数字3的节点不清楚缘由外(现在已经基本清楚了,写入的数字3是在记录stackslot写入数据对应的数据类型,后面读取时会校验,失败时会deoptimize),其余的动作都很容易映射到循环逻辑上。
而js的循环展开后节点非常多,流程很长,很多节点无法与循环逻辑对应。

剩下的问题是找到js循环中多余节点的来源。
可以选中加法后的控制流节点,查看其详细的nodeSourcePosition属性,对比如下图所示。

对比可以看到js中多出了DualNode的节点,退回到AST 的graph中可以非常明显的看到nodejs的节点没有DualNode这一层而js将循环挂到了DualNode下。进一步分析,需要查看graaljs和graal-nodejs在parser部分的差异,这个不属于工具的范畴这里暂时不再深入。
参考 https://github.com/oracle/graaljs/issues/411 ,nodejs和js 执行模型有差异。主要是graal-node会把全局的代码片段放到一个匿名函数中。为了评估这个差异,使用如下所示的测试代码。程序的语义没有变化的情况下,graaljs的运行速度大幅度提升,但是仍然明显比node更慢。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
 cat ./inter_in_func.js
function myFunction() {
var cpart = Polyglot.evalFile("llvm" ,"inter_c.so");
var j = 0;
var rep = 10;

for (; rep >0 ; rep--)
{
for (var i = 0; i < 10000*10000; i++) {
j = cpart.test_add(i, i);
}
console.log(j);
}
}
myFunction()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
$ time ./js --polyglot ./inter_in_func.js
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998
199999998

real 0m2.150s
user 0m3.943s
sys 0m0.189s

再次dump出inter_in_func的graphs,与前面graal-nodejs的对比。在AST层级使用图的diff功能,可以看到两者的差异极小,只有调用console.log前的一个节点不同(可以从差异节点中id最小的一个开始看,差异节点被单独列出来了)。
考虑到vm和编译可能会对较短运行时间的程序产生干扰,为了减小干扰,将循环次数改为60次后,再测试。这次node和js的性能就基本一致了。

1
2
3
4
5
6
7
8
9
10
$ time ./node --jvm   --polyglot     ./inter.js

real 0m10.401s
user 0m16.021s
sys 0m0.269s
$ time ./js --jvm --polyglot ./inter_in_func.js

real 0m9.440s
user 0m11.115s
sys 0m0.223s

遗留问题

PrintAssembly 会有一些报错,原因未知。
Ideal Graph Visualizer state的作用未知
Ideal Graph Visualizer 中使用node 不加–jvm导出时,其出现了很多单独的graph,看起来是整个程序(有jvm选项时,graph一般从属于一个函数)。并且图中的节点没有Source信息。原因未知。