더 빠르게 WebAssembly 디버깅

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

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 사용)을 추가로 스캔하면 연결 시간이 크게 늘어날 수 있습니다.

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

이 예와 같이 작은 프로젝트의 경우 실제로 차이가 없지만 프로젝트에 수백 또는 수천 개의 객체 파일이 있는 경우 링크 시간을 상당히 개선할 수 있습니다.

'이름' 섹션 제거

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

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

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

분열 디버그

디버그 데이터가 많은 바이너리는 빌드 시간뿐만 아니라 디버깅 시간에도 부담을 줍니다. 디버거는 '로컬 변수 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, 개발자 또는 베타를 기본 개발 브라우저로 사용하는 것이 좋습니다. 이러한 미리보기 채널을 사용하면 최신 DevTools 기능에 액세스하고, 최신 웹 플랫폼 API를 테스트하고, 사용자가 발견하기 전에 사이트에서 문제를 찾을 수 있습니다.

Chrome DevTools팀에 문의하기

다음 옵션을 사용하여 DevTools와 관련된 새로운 기능, 업데이트 또는 기타 사항을 논의하세요.