本文是对发表于ASPLOS'24的文章《Flexible Non-intrusive Dynamic Instrumentation for WebAssembly》的解读。该文提出了一种新的WebAssembly动态分析框架,能够在不中断程序执行的情况下进行灵活的动态插桩。作者在开源的Wizard Research Engine中实现了这一创新设计,提供了一套完整的插桩原语层次结构,支持通过低级可编程探针构建高级复杂分析。与仿真或机器码插桩相比,在字节码级别注入探针增加了表达能力,并大大简化了实现,Wizard重用了引擎的JIT编译器、解释器和去优化机制,而不需要构建新的机制。Wizard支持动态插桩的插入和移除,并提供一致性保证,这是组合多个分析而不互相干扰的关键。作者详细介绍了在高性能多层WebAssembly引擎中的完整实现,展示了专门设计的优化以最小化插桩开销,并评估了在各种分析负载下的性能特征。该设计适合生产环境中的WebAssembly引擎采用,因为在不使用探针时,执行性能不受影响。
随着WebAssembly(Wasm)的广泛应用,尤其是在云计算、边缘计算和物联网等领域,理解和优化Wasm程序的动态行为变得至关重要。WebAssembly作为一种便携、高效的字节码形式,能够在多种环境中提供接近原生的执行性能。然而,现有的Wasm引擎在动态分析方面的支持非常有限,通常只提供基本的字节码级别的调试功能,如单步执行和基本的源代码映射,缺乏详细的动态插桩能力。这使开发者难以深入理解Wasm程序的运行时行为,无法高效进行调试、性能分析和优化。
侵入性强:许多现有的插桩方法会改变程序的语义,导致被插桩的程序行为发生变化。这种侵入性不仅可能引入新的错误,还会影响程序的正常执行,尤其是在需要高精度分析的场景下。 性能开销大:插桩操作往往会引入额外的运行时开销,影响程序的执行效率。特别是在高性能需求的WebAssembly应用中,这种开销可能是不可接受的。 缺乏灵活性:现有的插桩工具和方法在动态插入和移除探针时往往缺乏灵活性,无法在程序运行过程中根据需要调整插桩策略。这限制了动态分析的实时性和准确性。 一致性问题:在动态插入和移除探针时,如何保证插桩操作的一致性,使得多个分析能够无干扰地组合使用,是一个复杂的问题。缺乏一致性保证的插桩操作可能导致分析结果不准确,甚至引起程序崩溃。 多线程支持不足:随着WebAssembly逐渐引入多线程支持,如何在多线程环境中实现一致性和高效的插桩成为新的挑战。多线程环境下的数据竞争和同步问题需要得到有效解决,以确保插桩操作的正确性和性能。
调度表切换:为了避免在未启用全局探针时产生额外开销,Wizard采用了调度表切换技术。当全局探针未启用时,解释器使用正常的调度表;当启用全局探针时,调度表切换到包含探针调用的版本。这种方法确保了在未启用探针时的零开销。
字节码覆盖:局部探针通过覆盖原始字节码的位置来实现。当解释器遇到被覆盖的字节码时,它会调用相应的探针回调。覆盖的原始字节码被保存在旁边,以便在探针回调后继续执行原始指令。 零开销:未被插桩的指令不会产生额外开销,因为它们不需要额外的检查或处理。
图1:在解释器中实现插桩的示意图
状态读取:FrameAccessor对象提供了一系列方法,用于读取执行帧的本地变量、操作数栈等状态信息。探针回调可以通过这些方法获取所需的状态信息。 延迟分配:FrameAccessor对象在第一次被请求时才会分配,从而减少不必要的开销。 一致性保证:为了确保探针回调在多次调用之间的一致性,FrameAccessor对象在执行帧中保留一个引用。这个引用在函数入口被清除,并在函数返回时进行验证。
确定性触发顺序:探针按照插入顺序触发。如果在触发探针时插入了新的探针,新探针将在下一次事件发生时触发。 帧修改一致性:探针对执行帧的修改立即生效,并且修改后的状态会在后续执行中得到反映。这通常需要即时的反优化处理,以确保JIT编译的代码不会依赖已被修改的状态。
JIT内联优化
为了减少探针的开销,Wizard框架在JIT编译过程中对常见探针逻辑进行了内联优化。具体实现如下。
计数器探针内联:对于简单的计数器探针,JIT编译器会将计数器的递增操作直接内联到编译后的代码中,从而避免了调用探针回调的开销。 操作数栈探针优化:对于需要访问操作数栈顶元素的探针,JIT编译器会直接传递栈顶值给探针回调,避免了不必要的状态重建。
即时反优化:当探针修改了执行帧的状态时,当前的JIT编译代码会立即反优化,回退到解释器执行。这确保了后续执行不会依赖已被修改的状态。 动态层次调整:在动态层次配置模式下,反优化后的代码仍然可以根据热度重新编译,以确保性能。
图2: 在PolyBenchC套件中,使用局部探针和全局探针实现的热度监视器(左)和分支监视器(右)的平均相对执行时间。柱状图上方的点表示探针触发次数。
图3: 在 Wizard、Wasabi 和 DynamoRIO 中,所有套件上所有程序的热度监控器(下)和分支监控器(上)的相对执行时间,按绝对执行时间排序。
作者发现,运行在 Wizard 解释器中的监控器的相对开销远低于 JIT,因为解释器运行速度较慢,并且在检查状态时需要做的额外工作较少。对于分支监控器,解释器中的相对执行时间为 1.0–2.2×,而在 JIT 中为 1.0–16.6×。对于热度监控器,解释器中的相对执行时间为 7.0–13.5×,而在 JIT 中为 7.0–134×。
与字节码重写的比较 作者使用 Walrus 实现了通过字节码重写的热度和分支监控器,并在 Wizard 的 JIT 中运行这些转换后的字节码。结果表明,内联优化后的 JIT 执行时间低于字节码重写,见图4。 与 Wasabi 的比较 Wasabi 是一个动态插桩工具,其插桩代码必须用 JavaScript 编写,因此需要一个同时运行 JavaScript 的 Wasm 引擎(如 V8)。结果显示,Wasabi 的插桩大大慢于 Wizard,因为调用 JavaScript 函数的开销很大。热度监控器在 Wasabi 中的执行时间增加了 36.8–6350.2×,而在 Wizard 的 JIT 中为 7–134×(或内联优化后的 2.2–7.7×)。分支监控器在 Wasabi 中的执行时间增加了 29.9–4721.5×,而在 Wizard 的 JIT 中为 1.0–16.6×(或内联优化后的 1.0–2.8×),见图3和图4。 与 DynamoRIO 的比较 作者还比较了本地代码插桩。由于无法直接比较,作者将相同的基准程序编译为 x86-64 汇编,并使用 DynamoRIO 进行插桩。结果显示,DynamoRIO 的热度监控器使执行时间增加了 3.9–192×,而分支监控器增加了 4.4–153×,见图3和图4。作者通过测量多个标准基准测试和插桩方法的分支和热度监控器的相对执行时间来评估 Wizard 的插桩开销。对于稀疏探针的监控器(如分支监控器),局部探针显著提高了性能(相对执行时间为 1.0–2.2×,而全局探针为 7.7–16.4×)。在 Wizard 的 JIT 中运行监控器进一步提高了性能,未内联优化的相对执行时间为 1.0–16.6×,内联优化后的为 1.0–2.8×。这大大优于 DynamoRIO 和 Wasabi,后者的相对执行时间分别为 4.4–153× 和 29.9–4721.5×。JIT 内联优化甚至可以产生比侵入式字节码重写更低的插桩开销。结果表明,Wizard 的插桩架构既灵活又高效。
图4: 在Wizard、Wasabi和DynamoRIO中,三种套件下热度监控器(左)和分支监控器(右)的平均相对执行时间。比率相对于未加工具的执行时间。
编辑:韩宇栋
原文作者:Ben L. Titzer, Elizabeth Gilbert, Bradley Wei Jie Teo, Yash Anand, Kazuyuki Takayama, Heather Miller