更快地调试 WebAssembly

Philip Pfaffe
Kim-Anh Tran
Kim-Anh Tran
Eric Leese
Eric Leese
Sam Clegg

2020 年 Chrome 开发者峰会上,我们首次在网络上演示了 Chrome 对 WebAssembly 应用的调试支持。自那以后,该团队投入了大量精力,致力于让开发者体验能够适应大型甚至超大型应用。在本博文中,我们将向您展示我们在不同工具中添加(或使其发挥作用)的旋钮,以及如何使用这些旋钮!

可扩缩的调试

我们接着 2020 年发布的文章继续聊。下面是当时我们查看的示例:

#include <SDL2/SDL.h>
#include <complex>

int main() {
  // Init SDL.
  int width = 600, height = 600;
  SDL_Init(SDL_INIT_VIDEO);
  SDL_Window* window;
  SDL_Renderer* renderer;
  SDL_CreateWindowAndRenderer(width, height, SDL_WINDOW_OPENGL, &window,
                              &renderer);

  // Generate a palette with random colors.
  enum { MAX_ITER_COUNT = 256 };
  SDL_Color palette[MAX_ITER_COUNT];
  srand(time(0));
  for (int i = 0; i < MAX_ITER_COUNT; ++i) {
    palette[i] = {
        .r = (uint8_t)rand(),
        .g = (uint8_t)rand(),
        .b = (uint8_t)rand(),
        .a = 255,
    };
  }

  // Calculate and draw the Mandelbrot set.
  std::complex<double> center(0.5, 0.5);
  double scale = 4.0;
  for (int y = 0; y < height; y++) {
    for (int x = 0; x < width; x++) {
      std::complex<double> point((double)x / width, (double)y / height);
      std::complex<double> c = (point - center) * scale;
      std::complex<double> z(0, 0);
      int i = 0;
      for (; i < MAX_ITER_COUNT - 1; i++) {
        z = z * z + c;
        if (abs(z) > 2.0)
          break;
      }
      SDL_Color color = palette[i];
      SDL_SetRenderDrawColor(renderer, color.r, color.g, color.b, color.a);
      SDL_RenderDrawPoint(renderer, x, y);
    }
  }

  // Render everything we've drawn to the canvas.
  SDL_RenderPresent(renderer);

  // SDL_Quit();
}

这只是一个非常小的示例,您可能看不到在非常大的应用中看到的任何实际问题,但我们仍然可以向您展示新功能。设置和试用都非常简单!

在上一篇博文中,我们讨论了如何编译和调试此示例。我们再来做一次,同时也来看看 //performance//

$ emcc -sUSE_SDL=2 -g -O0 -o mandelbrot.html mandelbrot.cc -sALLOW_MEMORY_GROWTH

此命令会生成一个 3MB 的 wasm 二进制文件。如您所料,其中大部分是调试信息。例如,您可以使用 llvm-objdump 工具 [1] 进行验证:

$ llvm-objdump -h mandelbrot.wasm

mandelbrot.wasm:        file format wasm

Sections:
Idx Name          Size     VMA      Type
  0 TYPE          0000026f 00000000
  1 IMPORT        00001f03 00000000
  2 FUNCTION      0000043e 00000000
  3 TABLE         00000007 00000000
  4 MEMORY        00000007 00000000
  5 GLOBAL        00000021 00000000
  6 EXPORT        0000014a 00000000
  7 ELEM          00000457 00000000
  8 CODE          0009308a 00000000 TEXT
  9 DATA          0000e4cc 00000000 DATA
 10 name          00007e58 00000000
 11 .debug_info   000bb1c9 00000000
 12 .debug_loc    0009b407 00000000
 13 .debug_ranges 0000ad90 00000000
 14 .debug_abbrev 000136e8 00000000
 15 .debug_line   000bb3ab 00000000
 16 .debug_str    000209bd 00000000

此输出显示了生成的 wasm 文件中的所有部分,其中大多数是标准 WebAssembly 部分,但也有一些名称以 .debug_ 开头的自定义部分。二进制文件中包含我们的调试信息!如果我们将所有大小相加,就会发现调试信息占据了 3MB 文件的大约 2.3MB。如果我们同时time执行 emcc 命令,则会发现在计算机上大约需要 1.5 秒才能完成运行。这些数字是一个不错的基准,但它们太小了,可能没人会注意到。不过,在实际应用中,调试二进制文件的大小很容易达到数 GB,并且需要几分钟才能构建!

跳过 Binaryen

使用 Emscripten 构建 wasm 应用时,其最终构建步骤之一是运行 Binaryen 优化器。Binaryen 是一个编译器工具包,可优化和合法化 WebAssembly(类似)二进制文件。在 build 过程中运行 Binaryen 的开销相当高,但只有在特定情况下才需要这样做。对于调试 build,如果不需要 Binaryen 通行证,我们可以大幅缩短构建时间。最常见的必需 Binaryen 传递是用于合法化涉及 64 位整数值的函数签名。通过使用 -sWASM_BIGINT 选择启用 WebAssembly BigInt 集成,我们可以避免这种情况。

$ emcc -sUSE_SDL=2 -g -O0 -o mandelbrot.html mandelbrot.cc -sALLOW_MEMORY_GROWTH -sWASM_BIGINT -sERROR_ON_WASM_CHANGES_AFTER_LINK

我们抛出了 -sERROR_ON_WASM_CHANGES_AFTER_LINK 标记是个不错的选择。它有助于检测 Binaryen 何时运行并意外重写二进制文件。这样,我们就可以确保始终走在快速发展道路上。

尽管我们的示例非常小,但我们仍然可以看到跳过 Binaryen 的影响!根据 time,此命令的运行时间不到 1 秒,比之前快了半秒!

高级调整

跳过输入文件扫描

通常,在关联 Emscripten 项目时,emcc 会扫描所有输入对象文件和库。这样做是为了在您的程序中实现 JavaScript 库函数与原生符号之间的精确依赖关系。对于大型项目,额外扫描输入文件(使用 llvm-nm)可能会显著增加链接时间。

可以改为使用 -sREVERSE_DEPS=all 运行,后者会告知 emcc 包含 JavaScript 函数的所有可能的原生依赖项。这的代码开销较小,但可以缩短链接时间,并且对调试 build 非常有用。

对于像我们示例这样小的项目没有实际意义,但如果您的项目中有数百甚至数千个对象文件,则可以有效缩短链接时间。

移除“name”部分

在大型项目(尤其是使用大量 C++ 模板的项目)中,WebAssembly“name”部分可能非常大。在我们的示例中,它只占整个文件大小的一小部分(请参阅上面的 llvm-objdump 输出),但在某些情况下,它可能非常大。如果应用的“name”部分非常大,并且矮人调试信息足以满足您的调试需求,则剥离“name”部分会很有帮助:

$ emstrip --no-strip-all --remove-section=name mandelbrot.wasm

这会剥离 WebAssembly“name”部分,同时保留 DWARF 调试部分。

调试中断

包含大量调试数据的二进制文件不仅会增加构建时间,还会增加调试时间。调试程序需要加载数据并为其构建索引,以便快速响应查询,例如“本地变量 x 的类型是什么?”。

借助调试分裂,我们可以将二进制文件的调试信息拆分为两部分:一部分保留在二进制文件中,另一部分包含在单独的所谓 DWARF 对象 (.dwo) 文件中。您可以通过将 -gsplit-dwarf 标志传递给 Emscripten 来启用该 API:

$ emcc -sUSE_SDL=2 -g -gsplit-dwarf -gdwarf-5 -O0 -o mandelbrot.html mandelbrot.cc  -sALLOW_MEMORY_GROWTH -sWASM_BIGINT -sERROR_ON_WASM_CHANGES_AFTER_LINK

下面,我们展示了不同的命令以及通过在不使用调试数据进行编译、使用调试数据以及最后使用调试数据和调试 fission 进行编译时生成的文件。

不同的命令以及生成的文件

拆分 DWARF 数据时,部分调试数据会与二进制文件一起存储,而大部分数据会放入 mandelbrot.dwo 文件(如上所示)。

对于 mandelbrot,我们只有一个源文件,但项目通常大于这个大小,并且包含多个文件。调试分裂会为每个模块生成一个 .dwo 文件。为了让当前的调试程序 Beta 版 (0.1.6.1615) 能够加载这些分屏调试信息,我们需要将所有这些信息打包到所谓的 DWARF 软件包 (.dwp) 中,如下所示:

$ emdwp -e mandelbrot.wasm -o mandelbrot.dwp

将 dwo 文件捆绑到 DWARF 软件包中

从各个对象构建 DWARF 软件包的好处在于,您只需提供一个额外的文件!我们目前正在努力在未来的版本中加载所有单个对象。

DWARF 5 有什么问题?

您可能已经注意到,我们在上面的 emcc 命令中偷偷添加了另一个标志:-gdwarf-5。启用 DWARF 符号版本 5(目前不是默认版本)是另一个有助于我们更快开始调试的技巧。借助它,某些信息会存储在默认版本 4 遗漏的主要二进制文件中。具体而言,我们只需通过主二进制文件即可确定一整套源文件。这样一来,调试程序便可以执行一些基本操作,例如显示完整的源代码树和设置断点,而无需加载和解析完整的符号数据。这可以大幅提高使用拆分符号进行调试的速度,因此我们始终会同时使用 -gsplit-dwarf-gdwarf-5 命令行标志!

借助 DWARF5 调试格式,我们还可以使用另一项实用功能。它会在传递 -gpubnames 标志时生成的调试数据中引入名称索引

$ emcc -sUSE_SDL=2 -g -gdwarf-5 -gsplit-dwarf -gpubnames -O0 -o mandelbrot.html mandelbrot.cc -sALLOW_MEMORY_GROWTH -sWASM_BIGINT -sERROR_ON_WASM_CHANGES_AFTER_LINK

在调试会话期间,符号查找通常是通过按名称搜索实体来完成的,例如,在搜索变量或类型时。名称索引会直接指向定义该名称的编译单元,从而加快此搜索速度。如果没有名称索引,则需要对整个调试数据进行彻底搜索,才能找到定义我们要查找的命名实体的正确编译单元。

满足好奇心:查看调试数据

您可以使用 llvm-dwarfdump 来查看 DWARF 数据。我们来试一试:

llvm-dwarfdump mandelbrot.wasm

这样,我们就可以大致了解我们拥有调试信息的“编译单元”(大致相当于源文件)。在此示例中,我们只有 mandelbrot.cc 的调试信息。从一般信息中,我们可以了解到我们有一个框架单元,这只是表示我们对此文件的数据不完整,并且有一个单独的 .dwo 文件包含其余的调试信息:

mandelbrot.wasm 和调试信息

您还可以查看此文件中的其他表,例如行表,该表显示了 wasm 字节码与 C++ 行之间的映射(请尝试使用 llvm-dwarfdump -debug-line)。

我们还可以查看单独的 .dwo 文件中包含的调试信息:

llvm-dwarfdump mandelbrot.dwo

mandelbrot.wasm 和调试信息

要点:使用调试裂变的优势有哪些?

如果您要处理大型应用,拆分调试信息有以下几点好处:

  1. 更快的链接速度:链接器不再需要解析整个调试信息。链接器通常需要解析二进制文件中的整个 DWARF 数据。通过将大部分调试信息提取到单独的文件中,链接器可以处理较小的二进制文件,从而缩短链接时间(对于大型应用,这一点尤为重要)。

  2. 调试速度更快:调试程序可以跳过对 .dwo/.dwp 文件中的其他符号进行解析,以执行某些符号查找。对于某些查找(例如对 wasm 到 C++ 文件的行映射的请求),我们无需查看其他调试数据。这样一来,我们就不必加载和解析额外的调试数据,从而节省了时间。

1:如果您的系统上没有较新版本的 llvm-objdump,并且您使用的是 emsdk,则可以在 emsdk/upstream/bin 目录中找到它。

下载预览渠道

请考虑将 Chrome Canary开发者版Beta 版用作您的默认开发浏览器。通过这些预览版渠道,您可以使用最新的 DevTools 功能、测试尖端的 Web 平台 API,并帮助您在用户发现问题之前发现网站上的问题!

与 Chrome 开发者工具团队联系

您可以使用以下选项讨论与 DevTools 相关的新功能、更新或任何其他内容。