WASM性能分析-插桩方案

简介: 本文结合了代码插桩和性能火焰图的技术,以 WebAssembly 为例介绍了性能分析的方法和相关实现。

最近对 WebAssembly 代码性能分析(Profiling)做了一些实践,发现一些思路和方法对其他即时编译(JIT)代码也是通用的。本文以 WebAssembly 为例介绍一下性能分析的方法。


WebAssembly 和 WAVM


首先简单介绍一下 WebAssembly (下简称 WASM)[1],WASM是一个 low-level 的字节码格式的汇编语言,可以从 C/C++,Rust 等高级语言编译而来,最初设计是在Web端实现接近原生的执行效率(目前主流浏览器都支持了),应用的例子[2]有 Web 版的 Unity,Tensorflow, AutoCAD,Google Earth 等。


由于 WASM 可移植性好,很多项目也开始在非浏览器环境用 WASM,即通过 VM/Runtime 运行 WASM。本文讨论的就是对 VM 执行 WASM 进行性能分析,WASM 的 VM/Runtime 很多,近年也有一些陆续做了性能分析的支持,因此,我们就选暂无性能分析支持的 WAVM 做尝试。


WAVM 执行 WASM 是采用 JIT 的模式,如下图所示,开发者可以通过不同高级语言编译成 WASM ,然后 WAVM 通过 LLVM 将 WASM 编成 LLVM IR,最后编到不同平台的机器码。

image.png


性能分析-对 WASM 插桩

OK 回到我们最初的目的,如何对 WASM 进行性能分析?回归最原始的方法,进入函数先记个时间戳,退出时再记一个,二者相减即可;又或者在函数调用前后记个时间戳,一减就是开销。如下图,两种方案区别不大,为了方便,我们只选用第一种方案。

image.png


插桩函数设计

基于哈希表的实现

由于同一个函数可能被不同的函数调用,在perf_startperf_end中不仅需要记录执行时间,也需要记录调用栈结构,对此,最直观的设计是在记录时维护一个调用栈,如下:


perf_start 时将当前函数名和入口时间入栈。

image.png

perf_end时出栈,记录栈顶函数开销。同时对调用栈进行一次遍历,得到栈顶函数的执行路径,将二者存到哈希表中。

image.png

然而,在测试中,我们发现该方案虽然直观,但是在插桩函数中需要对栈进行遍历并进行字符串合并,开销较大。这会引入观察者效应,导致分析结果不可靠,如下图,对于一个程序,complex开销为 80,simple开销为 20,若上述插桩开销为 20,每个函数调用次数相同,则最终观测结果就成了 complex 100,simple40。

image.png

优化:基于树实现


为缩减开销,可以用树来记录调用图,如下:


perf_start 时,查询已存在子节点或新建子节点,更新入口时间,全局节点指针转移到子节点。

image.png

如此一来,对于多次调用的函数,插桩函数的时间开销基本缩减到只有取时间。进行一系列优化后,最终我们测试出开销降到了 3% 左右,由此得来的误差是在可以接受范围之内的。

void perf_start(int32_t func_id) {
  PerfNode* cur_node = perf_data->perf_node();
  if (!cur_node) {
    return;
  }
  // 获取或新建子节点
  PerfNode* child_node = cur_node->GetChildNode(perf_data->buffer(), func_id);
  if (!child_node){
    perf_data->UpdatePerfNode(NULL);
    return;
  }
  // 记录入口时间
  child_node->RecordEntry();
  // g_cur_node 指向 child_node
  perf_data->UpdatePerfNode(child_node);
}

void perf_end() {
  PerfNode* cur_node = perf_data->perf_node();
  if (!cur_node) {
    return;
  }
  // 记录开销
  cur_node->RecordExit();
  // g_cur_node 指向 parent
  perf_data->UpdatePerfNode(cur_node->parent());
}

其他优化:


1、对新建子节点时的空间分配进行池化处理。

2、采样记录:除了采用常规的时间函数,可以替换为性能更高rdtsc指令直接从寄存器中获取。附常用计时工具性能对比[3]。

3、限制插桩范围:如只针对一些函数进行插桩,而忽略开销极小的函数,如提供一个插桩清单,只对清单函数插桩。


插桩 WASM

插桩过程

下面是一份 C 代码和文本格式的 WASM 对照,右侧我们需要关心三个部分:

  1. Type Section:存放所有 WASM 的函数(包括 import 的函数)的类型。
  2. Import Function:即 WASM 调用 WAVM 侧实现的接口。
  3. Funtion Section:函数的具体实现。

image.png

具体的插桩过程包括以下四步。


1、先把我们上面的 perf_startperf_end加到 Import Section,至于函数类型,基于上述插桩函数设计,perf_start 需要当前函数的索引 ID(i32),而 perf_end 不需要参数,二者均没有返回值。若 Type Section 没有这两个函数类型,还需要补充上去。


2、由于 WASM 函数调用通过函数索引表示,因此添加 Import 函数后,全体函数的索引也需要跟随调整,包括每个函数定义前的索引和 call指令的目标函数。


3、在每个函数中分别插桩:

perf_startperf_endperf_start需要当前函数索引作为参数,因此需要事先进行压栈,而 perf_end 需要插桩在所有 return指令的前面或函数末尾。


4、若 WASM 包含 Name Section ,则还需要在 Name Section 中加入这两个函数的函数名。

插桩完成后对应的 WASM (文本格式)如下:

image.png

插桩工具

WASM 插桩本质上就是解析 WASM 字节码,然后重新编码。相关依赖库包括 Rust 的 wasmparser, wasm-encoder, wasmprinter。C++ 则可以通过 wabt[4] 实现。当前已经有一些对 WASM 进行插桩的工具和依赖库,比如 Ewasm 的 wasm-gas [5],paritytech/wasm-instrument[6] 等。我这里就是修改 wasm-instrument 做的插桩。


下面是 WASM 插桩的核心代码,包含上述 2、3 步,其他 WASM Section 的修改不在此赘述。

// 插桩 perf_start
func_builder.instruction(&Instruction::I32Const((current_func_index+2) as i32));
func_builder.instruction(&Instruction::Call(perf_start));

// block深度用于判断函数是否结束
let mut block_depth = 0;
for op in operator_reader {
    let op = op?;
    match op {
        // 调用目标索引 +2
        Operator::Call { function_index } => {
            handle_in_function_call(&mut func_builder, entry_func_index, exit_func_index, function_index)?;
        },
        // return 插桩 perf_end
        Operator::Return => {
            func_builder.instruction(&Instruction::Call(exit_func_index));
            func_builder.instruction(&Instruction::Return);
        },
        // 统计block深度
        Operator::Block { .. } | Operator::Loop { .. } | Operator::If { .. } | Operator::Try { .. } => {
            block_depth += 1;
            func_builder.instruction(&DefaultTranslator.translate_op(&op)?);
        },
        // 无 return 函数结束,插桩 perf_end
        Operator::End => {
            if block_depth == 0 {
                func_builder.instruction(&Instruction::Call(exit_func_index));
            }
            func_builder.instruction(&DefaultTranslator.translate_op(&op)?);
            block_depth -= 1;
        },
        _ =>{
            func_builder.instruction(&DefaultTranslator.translate_op(&op)?);
        },
    }
}
code_section_builder.function(&func_builder);


插桩 LLVM IR

前面提到 WAVM 是 JIT 执行 WASM 的,后端采用的是 LLVM ,即先将 WASM 编译为 LLVM IR,然后从 IR 到各个平台的机器码。除了在 WASM 层面进行插桩,还可以在 LLVM IR 层面插桩。插桩原理与上面几乎相同,但不同 VM 对后端的实现不同,这样修改不仅工作量和复杂度更大,还难以移植,因此不作推荐。


输出火焰图

火焰图是对性能分析做可视化的一个很好用的脚本,能将函数开销占比表现出来。其过程一般包括:

  1. $FG_DIR/stackcollapse-perf.pl perf.unfold > perf.folded
  2. $FG_DIR/flamegraph.pl perf.folded > perf.svg

我们现在已经通过插桩获得了调用开销图,如何将这个调用开销图转化为火焰图?我们分别看了 perf.unfold 和 perf.folded 的格式。

perf.unfold 包含 perf 工具每次采样的调用栈和采样周期,文件较大。

image.png

perf.folded 对上述采样进行统计,输出文件简单。

image.png

显然,perf.folded 更简单,而且调用栈不就是到叶节点的路径嘛,可以直接通过深度优先遍历调用树输出。下面是核心代码:

std::string call_stack = "wavm";
while (!node_stack.empty()) {
    PerfNode* cur_node = node_stack.top();
    if (!cur_node->visited_) {
      // Push children into stack
      // Calculate current call stack cost.
      uint64_t cur_cost = cur_node->time_cost_;
      for (size_t i = 0; i < cur_node->children_size_; i++) {
        node_stack.push(cur_node->children_[i]);
        cur_cost -= cur_node->children_[i]->time_cost_;
      }
      // Update call stack and output.
      call_stack.append(";");
      call_stack.append(wasm_func_names.functions[cur_node->func_id_].name);
      call_length_stack.push(call_stack.size());
      fprintf(fp, "%s %ld\n", call_stack.c_str(), cur_cost);

      cur_node->visited_ = true;
      visited_node.push_back(cur_node);
    }
    else {
      node_stack.pop();
      call_length_stack.pop();
      call_stack.resize(call_length_stack.top());
    }
  }

进行上述实现后,只需要打包一个 PerfOutput 接口,就可以在 WASM 执行完毕后输出火焰图所需的 .folded 文件了。


Sample


OK 接下来写个 Sample,一个简单的 fibonacci 计算。

#include <stdio.h>
#include <stdlib.h>

int fibonacci(int n) {
    if (n <= 0)
        return 0;
    if (n == 1)
        return 1;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

int main(int argc, char **argv) {
    int n = atoi(argv[1]);
    printf("fibonacci(%d)=%d\n", n, fibonacci(n));
    return 0;
}

然后通过下面的命令编译成 WASM ,并从字节码格式转为可读的文本格式(WAT)。

# emcc 来自 Emscripten SDK ,是 WASM 的编译器
# -O0 禁用优化,保持源码结构 -g 禁用优化,在 WASM 中加入 debug 信息
emcc -O0 -g fib.c -o fib.wasm

# wasm2wat 来自 WebAssembly Binary Toolkit
wasm2wat fib.wasm > fib.wat

fib.wat 里可以找到 fibonacci函数:

(func $fibonacci (type 3) (param i32) (result i32)
    (local i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)
    global.get $__stack_pointer
    ...
    global.set $__stack_pointer
    local.get 25
    return)

其中, (func 表示这个括号内是一个函数,后面是函数签名和局部变量,第 3 行开始是函数内容,一直到最后 return 都是函数内容。

wasm-instrument instrument fib.wasm -o fib_i.wasm

wasm2wat fib_i.wasm > fib_i.wat

fib_i.wat 内容,如下,可见插桩已经完成。

(func $fibonacci (type 3) (param i32) (result i32)
    (local i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32 i32)
    i32.const 7
    call $perf_start
    global.get $__stack_pointer
    ...
    global.set $__stack_pointer
    local.get 25
    call $perf_end
    return)

运行和输出。

# 运行,产生 fib.folded
wavm run -o fib.folded fib.wasm 40

# 转为火焰图
$FG_DIR/flamegraph.pl fib.folded > fib.svg

得到火焰图如下:

image.png

从中可以清楚看到 fib_i.wasm 的调用流程和开销占比,达到了性能分析的目的。


小结


总的来说,通过插桩计时对 WASM 进行性能分析是可行的,但是也存在一些不足:

  1. 插桩后 WASM 执行效率下降,上面我们通过缩小插桩范围等方式才将开销控制到 3%,若需要观测到每个小函数,开销就会超出预期,而开销一旦超出预期,观察者效应就导致准确率下降了,这是最大的痛点。
  2. 性能分析流程复杂,直观来说,增加了一个插桩的过程,对 WASM 文件需要重新构建,加大了实际分析的复杂性。

参考链接:

[1]https://webassembly.org/

[2]https://madewithwebassembly.com/

[3]https://zsummer.github.io/2021/02/19/2021-04-02-perf-clock/

[4]https://github.com/WebAssembly/wabt

[5]https://github.com/ewasm/sentinel-rs

[6]https://github.com/paritytech/wasm-instrument




来源  |  阿里云开发者公众号

作者  |  贾缃

相关文章
|
2月前
|
监控 IDE Java
【Java性能调优新工具】JDK 22性能分析器:深度剖析,优化无死角!
【9月更文挑战第9天】JDK 22中的性能分析器为Java应用的性能调优提供了强大的支持。通过深度集成、全面监控、精细化分析和灵活报告生成等核心优势,性能分析器帮助开发者实现了对应用性能的全面掌控和深度优化。在未来的Java开发过程中,我们期待性能分析器能够继续发挥重要作用,为Java应用的性能提升贡献更多力量。
|
3月前
|
监控 算法 测试技术
项目优化:对已有项目进行性能分析和优化。
项目优化:对已有项目进行性能分析和优化。
79 0
|
3月前
|
存储 JavaScript Java
hyengine 解释问题之wasm引擎性能瓶颈如何解决
hyengine 解释问题之wasm引擎性能瓶颈如何解决
|
4月前
|
缓存 编解码 前端开发
页面加载性能分析时,有哪些常见的性能瓶颈需要特别注意?
页面加载性能分析时,有哪些常见的性能瓶颈需要特别注意?
|
6月前
|
缓存 人工智能 算法
编写高效的Python脚本:性能优化的策略与技巧
编写高效的Python脚本需要综合考虑多个方面,包括代码结构、数据结构和算法选择等。本文将探讨在Python编程中提高脚本性能的方法,包括优化数据结构、选择合适的算法、使用Python内置函数以及通过并行和异步编程提升效率。这些技巧旨在帮助开发者在不同应用场景中编写出高性能的Python代码。
|
6月前
|
监控 NoSQL MongoDB
|
PHP 数据库 开发者
Laravel 使用 Debugbar、Blackfire 性能分析定位程序问题
本文介绍了如何使用 Laravel 的 Debugbar 和 Blackfire 工具进行性能分析和排查程序问题。通过详细的代码示例和演示的代码执行结果,展示了如何使用这些工具以及它们的常见实用方法。
495 1
|
测试技术 数据库 UED
06 性能分析之通过标准
06 性能分析之通过标准
|
缓存 算法 固态存储
如何进行代码优化以提高PHP应用的性能?
如何进行代码优化以提高PHP应用的性能?
|
监控 关系型数据库 MySQL
eBCC性能分析最佳实践(0) - 开启性能分析新篇章
BCC是基于4.x kernel版本上的ebpf发展出来的一套性能分析工具集; eBCC,顾名思义则是extended BCC的缩写,是阿里巴巴内核团队在Aliyun Linux上对BCC项目的拓展,包含BCC本身已有的工具集,和我们新开发的一些小的工具; eBCC则是基于在最新的BCC版本0.9之上做了一些拓展。
2346 0