더 빠르게 WebAssembly 디버깅

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

Google은 Chrome Dev Summit 2020에서 처음으로 웹에서 WebAssembly 애플리케이션에 대한 Chrome의 디버깅 지원을 시연했습니다. 그 이후로 팀은 개발자 환경을 대규모 및 대규모 애플리케이션에 맞게 확장하는 데 많은 에너지를 투자했습니다. 이 게시물에서는 다양한 도구에 추가했거나 작업하게 된 노브와 그 사용 방법을 살펴보겠습니다.

확장 가능한 디버깅

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를 차지하는 것을 알 수 있습니다. emcc 명령어도 time하면 머신에서 실행하는 데 약 1.5초가 걸렸음을 알 수 있습니다. 이 숫자는 훌륭한 기준이지만, 너무 작아서 아무도 신경 쓰지 않을 것입니다. 그러나 실제 애플리케이션에서는 디버그 바이너리가 GB 단위의 크기에 쉽게 도달할 수 있고 빌드하는 데 몇 분 정도 걸릴 수 있습니다.

Binaryen 건너뛰기

Emscripten을 사용하여 wasm 애플리케이션을 빌드할 때 최종 빌드 단계 중 하나는 Binaryen 옵티마이저를 실행하는 것입니다. Binaryen은 WebAssembly와 유사한 바이너리를 최적화하고 합법화하는 컴파일러 툴킷입니다. 빌드의 일부로 Binaryen을 실행하는 데는 상당한 비용이 들지만 특정 조건에서만 필요합니다. 디버그 빌드의 경우 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초 미만으로 실행되므로 이전보다 0.5초 더 빠릅니다.

고급 조정

입력 파일 스캔 건너뛰기

일반적으로 Emscripten 프로젝트를 연결할 때 emcc는 모든 입력 객체 파일 및 라이브러리를 스캔합니다. 프로그램의 JavaScript 라이브러리 함수와 네이티브 기호 간에 정확한 종속 항목을 구현하기 위해 이 작업을 실행합니다. 대규모 프로젝트의 경우 이 추가 입력 파일 검사 (llvm-nm 사용)로 인해 연결 시간이 크게 늘어날 수 있습니다.

대신 emcc에 JavaScript 함수의 가능한 모든 네이티브 종속 항목을 포함하도록 지시하는 -sREVERSE_DEPS=all를 사용하여 실행할 수 있습니다. 이는 코드 크기의 오버헤드는 작지만 링크 시간을 단축할 수 있으며 디버그 빌드에 유용할 수 있습니다.

이 예시처럼 작은 프로젝트에서는 실질적인 차이가 없지만 프로젝트에 수백, 수천 개의 객체 파일이 있다면 링크 시간을 크게 개선할 수 있습니다.

'name' 섹션 제거

대규모 프로젝트, 특히 C++ 템플릿을 많이 사용하는 프로젝트에서는 WebAssembly 'name' 섹션이 매우 클 수 있습니다. 이 예에서는 전체 파일 크기의 극히 일부에 불과하지만 (위의 llvm-objdump 출력 참고) 어떤 경우에는 매우 클 수 있습니다. 애플리케이션의 'name' 섹션이 매우 크고 dwarf 디버그 정보가 디버깅 요구사항에 충분한 경우 다음과 같이 'name' 섹션을 삭제하는 것이 유리할 수 있습니다.

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

이렇게 하면 DWARF 디버그 섹션은 유지하면서 WebAssembly 'name' 섹션이 제거됩니다.

분열 디버그

디버그 데이터가 많은 바이너리는 빌드 시간뿐만 아니라 디버깅 시간에도 부담을 줍니다. 디버거는 "로컬 변수 x의 유형은 무엇인가요?"와 같은 쿼리에 신속하게 응답할 수 있도록 데이터를 로드하고 이에 대한 색인을 빌드해야 합니다.

디버그 분할을 통해 바이너리의 디버그 정보를 두 부분으로 분할할 수 있습니다. 하나는 바이너리에 남아 있고 다른 하나는 별도의 DWARF 객체 (.dwo) 파일에 포함되는 부분입니다. -gsplit-dwarf 플래그를 Emscripten에 전달하여 사용 설정할 수 있습니다.

$ 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

아래에서는 다양한 명령어와 디버그 데이터 없이 컴파일하여 어떤 파일이 생성되는지 보여줍니다. 디버그 데이터와 함께, 마지막으로 디버그 데이터와 디버그 분열을 모두 사용하여 컴파일하면 됩니다.

생성되는 파일을 살펴보고

DWARF 데이터를 분할할 때 디버그 데이터의 일부는 바이너리와 함께 상주하는 반면, 큰 부분은 mandelbrot.dwo 파일에 저장됩니다 (위 그림 참고).

mandelbrot의 경우 소스 파일이 하나만 있지만 일반적으로 프로젝트는 이보다 크고 두 개 이상의 파일을 포함합니다. 디버그 분열은 모든 항목에 .dwo 파일을 생성합니다. 디버거의 현재 베타 버전 (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-to-C++ 파일의 행 매핑 요청)의 경우 추가 디버그 데이터를 살펴볼 필요가 없습니다. 이렇게 하면 추가 디버그 데이터를 로드하고 파싱할 필요가 없어 시간이 절약됩니다.

1: 시스템에 최신 버전의 llvm-objdump가 없고 emsdk를 사용 중인 경우 emsdk/upstream/bin 디렉터리에서 찾을 수 있습니다.

미리보기 채널 다운로드

Chrome Canary, Dev 또는 베타를 기본 개발 브라우저로 사용해 보세요. 이러한 미리보기 채널을 통해 최신 DevTools 기능에 액세스하고, 최첨단 웹 플랫폼 API를 테스트하고, 사용자보다 먼저 사이트에서 문제를 발견할 수 있습니다.

Chrome DevTools 팀에 문의하기

다음 옵션을 사용하여 게시물의 새로운 기능과 변경 사항 또는 DevTools와 관련된 다른 사항에 대해 논의하세요.

  • crbug.com을 통해 제안이나 의견을 보내주세요.
  • 옵션 더보기   더보기 >를 사용하여 DevTools 문제 신고 도움말 > DevTools에서 DevTools 문제를 신고합니다.
  • @ChromeDevTools에서 트윗하세요.
  • DevTools의 새로운 기능 YouTube 동영상 또는 DevTools 도움말 YouTube 동영상에 의견을 남겨주세요.