Szybsze debugowanie WebAssembly

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

Podczas Chrome Dev Summit 2020 po raz pierwszy zaprezentowaliśmy w internecie obsługę debugowania aplikacji WebAssembly w Chrome. Od tego czasu zespół włożył wiele wysiłku w zapewnienie deweloperom możliwości tworzenia dużych, a nawet bardzo dużych aplikacji. W tym poście pokażemy Ci pokrętła, które dodaliśmy (lub które działają) w różnych narzędziach, oraz jak z nich korzystać.

Skalowalne debugowanie

Zacznijmy tam, gdzie skończyliśmy w 2020 r. Oto przykład, który analizowaliśmy w tamtym czasie:

#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();
}

To nadal dość mały przykład, więc prawdopodobnie nie zobaczysz żadnych rzeczywistych problemów, które występują w przypadku naprawdę dużych aplikacji, ale możemy Ci pokazać nowe funkcje. Konfiguracja i wypróbowanie tej funkcji jest szybkie i łatwe.

W poprzednim poście omawialiśmy kompilowanie i debugowanie tego przykładu. Spróbujmy jeszcze raz, ale tym razem zerknijmy też na //performance//:

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

To polecenie wygeneruje plik binarny wasm o rozmiary 3 MB. Jak można się spodziewać, większość z nich to informacje debugujące. Możesz to sprawdzić za pomocą narzędzia llvm-objdump [1], np.:

$ 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

Wyniki zawierają wszystkie sekcje wygenerowanego pliku wasm. Większość z nich to standardowe sekcje WebAssembly, ale jest też kilka niestandardowych sekcji, których nazwa zaczyna się od .debug_. To właśnie zawiera informacje debugowania. Jeśli zsumujemy wszystkie rozmiary, okaże się, że informacje debugowania zajmują około 2,3 MB z 3 MB pliku. Jeśli time polecenie emcc, widzimy, że na naszym komputerze zajęło to około 1,5 s. Te liczby stanowią przyjemną podstawę, ale są tak małe, że prawdopodobnie nikt nie zwróci na nie uwagi. W rzeczywistych aplikacjach pliki binarne debugowania mogą osiągnąć rozmiar rzędu GB i tworzyć się przez kilka minut.

Pomijanie Binaryen

Podczas kompilowania aplikacji wasm za pomocą Emscripten jeden z końcowych kroków kompilacji to uruchomienie optymalizatora Binaryen. Binaryen to zestaw narzędzi kompilatora, który optymalizuje i legalizuje pliki binarne WebAssembly. Uruchomienie Binaryen w ramach kompilacji jest dość kosztowne, ale jest wymagane tylko w określonych warunkach. W przypadku kompilacji debugujących możemy znacznie przyspieszyć proces kompilacji, jeśli nie będziemy musieli uruchamiać Binaryen. Najczęściej wymagane jest przepuszczenie Binaryen w celu legalizacji podpisów funkcji z użyciem 64-bitowych wartości całkowitych. Aby tego uniknąć, możesz włączyć integrację BigInt w WebAssembly za pomocą -sWASM_BIGINT.

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

Dla pewności dodaliśmy też flagę -sERROR_ON_WASM_CHANGES_AFTER_LINK. Pomaga wykryć, kiedy Binaryen jest uruchomiony i nieoczekiwanie przepisuje plik binarny. Dzięki temu możemy mieć pewność, że trzymamy się ścieżki szybkiego działania.

Mimo że nasz przykład jest dość mały, widać wyraźnie efekt pominięcia Binaryen. Według time to polecenie zajmuje nieco mniej niż 1 s, czyli o pół sekundy mniej niż wcześniej.

Zaawansowane ustawienia

Pomijanie skanowania pliku wejściowego

Zazwyczaj podczas łączenia projektu Emscripten emcc skanuje wszystkie pliki obiektów wejściowych i biblioteki. Ma to na celu implementację dokładnych zależności między funkcjami biblioteki JavaScript a wbudowanymi symbolami w programie. W przypadku większych projektów dodatkowe skanowanie plików wejściowych (za pomocą llvm-nm) może znacznie wydłużyć czas łączenia.

Zamiast tego można użyć opcji -sREVERSE_DEPS=all, która mówi emcc, aby uwzględnić wszystkie możliwe natywne zależności funkcji JavaScript. Ma to niewielki wpływ na rozmiar kodu, ale może przyspieszyć czas tworzenia linków i przydać się w przypadku wersji debugowania.

W przypadku tak małego projektu jak nasz przykład nie ma to większego znaczenia, ale jeśli masz w projekcie setki lub nawet tysiące plików obiektów, może to znacznie skrócić czas łączenia.

Usuwanie sekcji „name”

W dużych projektach, zwłaszcza tych, w których używa się wielu szablonów C++, sekcja „name” (nazwa) WebAssembly może być bardzo duża. W naszym przykładzie jest to tylko niewielka część całkowitego rozmiaru pliku (patrz dane wyjściowe funkcji llvm-objdump powyżej), ale w niektórych przypadkach może być bardzo znacząca. Jeśli sekcja „name” w aplikacji jest bardzo duża, a informacje debugowania w formacie DWARF wystarczają do debugowania, warto usunąć sekcję „name”:

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

Spowoduje to usunięcie sekcji „name” (nazwa) WebAssembly, przy zachowaniu sekcji debugowania DWARF.

Debugowanie funkcji fission

Pliki binarne z dużą ilością danych debugowania nie tylko wydłużają czas kompilacji, ale też czas debugowania. Debuger musi wczytać dane i tworzyć dla nich indeks, aby mógł szybko odpowiadać na zapytania, np. „Jaki jest typ zmiennej lokalnej x?”.

Debug fission umożliwia podzielenie informacji debugowania binarnego na 2 części: jedną, która pozostaje w binarnym, i drugą, która jest zawarta w oddzielnym pliku obiektu DWARF (.dwo). Można go włączyć, przekazując flagę -gsplit-dwarf do 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

Poniżej przedstawiamy różne polecenia i pliki wygenerowane przez kompilację bez danych debugowania, z danymi debugowania i ostatecznie z danymi debugowania i debugowaniem fission.

różne polecenia i generowane pliki.

Podczas dzielenia danych DWARF część danych debugowania jest przechowywana razem z plikiem binarnym, a większa część trafia do pliku mandelbrot.dwo (jak pokazano powyżej).

W przypadku mandelbrot mamy tylko 1 plik źródłowy, ale zazwyczaj projekty są większe i zawierają więcej niż 1 plik. Debugowanie podziału generuje plik .dwo dla każdego z nich. Aby bieżąca wersja beta debugera (0.1.6.1615) mogła wczytać te podzielone informacje debugowania, musimy umieścić je w tak zwanym pakiecie DWARF (.dwp), który wygląda tak:

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

złącz pliki dwo w pakiet DWARF.

Budowanie pakietu DWARF z pojedynczych obiektów ma tę zaletę, że wystarczy przesłać tylko jeden dodatkowy plik. Obecnie pracujemy nad tym, aby w przyszłej wersji można było wczytywać poszczególne obiekty.

Co się dzieje z DWARF 5?

Jak widać, w powyższym poleceniu emcc dodaliśmy jeszcze jedną flagę, -gdwarf-5. Włączenie wersji 5 symboli DWARF, która obecnie nie jest domyślna, to kolejny sposób na szybsze debugowanie. Dzięki temu w głównym pliku binarnym są przechowywane pewne informacje, które nie zostały uwzględnione w wersji 4. Konkretnie możemy określić pełny zestaw plików źródłowych tylko z głównego pliku binarnego. Dzięki temu debuger może wykonywać podstawowe czynności, takie jak wyświetlanie pełnego drzewa źródłowego i ustawianie punktów przerwania, bez wczytywania i analizowania pełnych danych symboli. Dzięki temu debugowanie z użyciem podzielonych symboli jest znacznie szybsze, dlatego zawsze używamy flag wiersza poleceń -gsplit-dwarf-gdwarf-5.

Format debugowania DWARF5 umożliwia też dostęp do innej przydatnej funkcji. Wprowadza on indeks nazw w danych debugowania, które zostaną wygenerowane po przekazaniu flagi -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

Podczas sesji debugowania wyszukiwanie symboli często odbywa się przez wyszukiwanie elementu według nazwy, np. podczas wyszukiwania zmiennej lub typu. Indeks nazw przyspiesza to wyszukiwanie, wskazując bezpośrednio jednostkę kompilacji, która definiuje tę nazwę. Bez indeksu nazw konieczne byłoby wyczerpujące przeszukanie wszystkich danych debugowania w celu znalezienia odpowiedniej jednostki kompilacji, która definiuje poszukiwany przez nas element nazwany.

Dla ciekawskich: sprawdzanie danych debugowania

Aby podejrzeć dane DWARF, możesz użyć llvm-dwarfdump. Spróbujmy:

llvm-dwarfdump mandelbrot.wasm

Dzięki temu mamy przegląd „jednostek kompilacji” (czyli w przybliżeniu plików źródłowych), dla których mamy informacje debugowania. W tym przykładzie mamy tylko informacje debugowania dotyczące mandelbrot.cc. Informacje ogólne pozwolą nam stwierdzić, że mamy szkielet jednostki, co oznacza, że mamy niekompletne dane o tym pliku i że istnieje oddzielny plik .dwo, który zawiera pozostałe informacje debugowania:

mandelbrot.wasm i dane debugowania

Możesz też spojrzeć na inne tabele w tym pliku, np. na tabelę wierszy, która pokazuje mapowanie kodu bajtowego wasm na wiersze C++ (spróbuj użyć llvm-dwarfdump -debug-line).

Możemy też sprawdzić informacje debugowania zawarte w osobnym pliku .dwo:

llvm-dwarfdump mandelbrot.dwo

mandelbrot.wasm i dane debugowania

TL;DR: Jaka jest zaleta korzystania z debugowania w procesie podziału?

Podzielenie informacji debugowania na kilka części ma kilka zalet, jeśli pracujesz z dużymi aplikacjami:

  1. Szybsze łączenie: kompilator nie musi już analizować wszystkich informacji debugowania. Linkery muszą zwykle przeanalizować wszystkie dane DWARF zawarte w pliku binarnym. Dzięki wyodrębnianiu dużych części informacji debugujących w osobnych plikach linkery mają do czynienia z mniejszymi plikami binarnymi, co przekłada się na krótszy czas łączenia (szczególnie w przypadku dużych aplikacji).

  2. Szybsze debugowanie: w przypadku niektórych wyszukiwań symboli debugger może pominąć analizowanie dodatkowych symboli w plikach .dwo/.dwp. W przypadku niektórych wyszukiwań (np. żądań mapowania linii w plikach wasm-to-C++) nie musimy sprawdzać dodatkowych danych debugowania. Dzięki temu oszczędzamy czas, ponieważ nie musimy wczytywać i analizować dodatkowych danych debugowania.

1: jeśli nie masz najnowszej wersji llvm-objdump w swoim systemie i korzystasz z emsdk, możesz ją znaleźć w katalogu emsdk/upstream/bin.

Pobieranie kanałów podglądu

Rozważ użycie jako domyślnej przeglądarki deweloperskiej wersji Canary, Dev lub Beta przeglądarki Chrome. Te kanały wersji wstępnej zapewniają dostęp do najnowszych funkcji DevTools, umożliwiają testowanie najnowocześniejszych interfejsów API platformy internetowej i pomagają znaleźć problemy w witrynie, zanim zrobią to użytkownicy.

Kontakt z zespołem Narzędzi deweloperskich w Chrome

Aby omówić nowe funkcje, aktualizacje lub inne kwestie związane z Narzędziami deweloperskimi, skorzystaj z tych opcji.