더 빠르게 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 단위 크기에 쉽게 도달할 수 있으며 빌드하는 데 몇 분이 걸릴 수 있습니다!

바이너리 건너뛰기

Emscripten을 사용하여 wasm 애플리케이션을 빌드할 때 최종 빌드 단계 중 하나는 Binaryen 옵티마이저를 실행하는 것입니다. Binaryen은 WebAssembly(유사) 바이너리를 최적화하고 합법화하는 컴파일러 툴킷입니다. 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 '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, 개발자 또는 베타를 기본 개발 브라우저로 사용하는 것이 좋습니다. 이러한 Preview 채널을 통해 개발자는 최신 DevTools 기능에 액세스하고 최첨단 웹 플랫폼 API를 테스트하며 다른 사용자보다 먼저 사이트에서 문제를 찾을 수 있습니다.

Chrome DevTools팀에 문의하기

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

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