Szybsze debugowanie WebAssembly

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

Na konferencji Chrome Dev Summit 2020 po raz pierwszy zaprezentowaliśmy obsługę debugowania aplikacji WebAssembly w internecie. Od tego czasu zespół włożył dużo pracy w udoskonalanie narzędzi programistycznych w przypadku dużych, a nawet ogromnych zastosowań. W tym poście pokażemy, które z nich dodaliśmy (lub wzięliśmy do nich pracę), a także o tym, jak z nich korzystać.

Skalowalne debugowanie

Zacznijmy od tego samego miejsca w poście z 2020 roku. Oto przykład, który omawialiśmy wtedy:

#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 wprawdzie niewielki przykład i prawdopodobnie nie zauważysz żadnych rzeczywistych problemów, które pojawiłyby się w bardzo dużej aplikacji. Mimo to możemy zaprezentować nowe funkcje. Konfiguracja jest prosta i łatwa do skonfigurowania, a także można ją wypróbować samodzielnie.

W poprzednim poście omówiliśmy, jak kompilować i debugować ten przykład. Zróbmy to jeszcze raz, ale zerknijmy też na parametr //performance//:

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

To polecenie generuje plik binarny wasm o rozmiarze 3 MB. Większość z nich, jak można się spodziewać, to informacje o debugowaniu. Możesz to sprawdzić za pomocą narzędzia llvm-objdump [1], na przykład:

$ 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

Te dane wyjściowe pokazują wszystkie sekcje w wygenerowanym pliku Wasm – większość z nich to standardowe sekcje WebAssembly, ale jest też kilka sekcji niestandardowych, których nazwy zaczynają się od .debug_. To tam plik binarny zawiera dane debugowania. Po zsumowaniu wszystkich rozmiarów okazuje się, że dane debugowania zajmują około 2,3 MB z 3 MB. Jeśli dodatkowo użyjemy polecenia emcc w wersji time, zobaczymy, że uruchomienie na komputerze trwało około 1,5 s. Te liczby to fajna baza, ale są tak małe, że nikt by się ich nie zauważył. W prawdziwych aplikacjach plik binarny debugowania może jednak z łatwością osiągnąć rozmiar w GB, a jego utworzenie zajmuje kilka minut.

Pomijam plik binarny

Jednym z ostatnich etapów tworzenia aplikacji Wasm za pomocą Emscripten jest uruchomienie optymalizatora Binaryen. Binaryen to zestaw narzędzi do kompilacji, który zarówno optymalizuje, jak i legalizuje pliki binarne WebAssembly. Uruchomienie Binaryen w ramach kompilacji jest dość kosztowne, ale jest wymagane tylko pod pewnymi warunkami. W przypadku kompilacji do debugowania możemy znacznie skrócić czas kompilacji, jeśli unikniemy konieczności korzystania z karnetów Binaryen. Najczęściej wymagana jest przepustka Binaryen do legalizacji podpisów funkcji obejmujących 64-bitowe wartości całkowite. Możemy tego uniknąć, włączając integrację WebAssembly BigInt 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

Dodaliśmy flagę -sERROR_ON_WASM_CHANGES_AFTER_LINK na wszelki wypadek. Pomaga wykryć, czy plik Binaryen jest uruchomiony, i nieoczekiwanie przepisywać plik binarny. W ten sposób możemy mieć pewność, że nie spieszymy się.

Mimo że nasz przykład jest dość mały, nadal możemy zaobserwować efekty pominięcia Binaryen! Według time to polecenie działa nieco poniżej 1 s, czyli o pół sekundy szybciej niż wcześniej.

Zaawansowane ulepszenia

Pomijam skanowanie plików wejściowych

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

Możesz też użyć polecenia -sREVERSE_DEPS=all, które informuje emcc, że ma uwzględniać wszystkie możliwe natywne zależności funkcji JavaScript. Wiąże się to z niewielkim narzutem rozmiaru kodu, ale może skrócić czas potrzebny na łączenie i przydaje się podczas debugowania kompilacji.

W przypadku tak małego projektu jak w naszym przykładzie nie ma to znaczenia, ale jeśli masz w projekcie setki, a nawet tysiące plików obiektów, może to znacznie skrócić czas trwania linków.

Z usunięcia sekcji „name”

W dużych projektach, zwłaszcza takich, w których często jest używany szablon C++, sekcja „name” WebAssembly może być bardzo duża. W naszym przykładzie jest to tylko niewielka część całkowitego rozmiaru pliku (zobacz dane wyjściowe funkcji llvm-objdump powyżej), ale w niektórych przypadkach może być bardzo duża. Jeśli sekcja „name” aplikacji jest bardzo duża, a dodatkowe informacje na temat debugowania wystarczą, aby zaspokoić potrzeby debugowania, korzystne może być usunięcie sekcji „name”:

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

Spowoduje to usunięcie sekcji „name” WebAssembly przy zachowaniu sekcji debugowania DWARF.

Debugowanie rozszczepienia

Pliki binarne z dużą ilością danych debugowania wywierają presję nie tylko na czas kompilacji, ale także na debugowanie. Debuger musi wczytać dane i utworzyć dla nich indeks, by szybko odpowiadać na zapytania, np. „Jaki jest typ zmiennej lokalnej x?”.

Funkcja Debugowanie rozszczepienia umożliwia podzielenie informacji o debugowaniu pliku binarnego na 2 części: jedną, która pozostaje w pliku binarnym, i drugą znajdującą się w osobnym pliku DWARF (.dwo). Aby go włączyć, przekaż 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 znajdziesz różne polecenia oraz pliki generowane przez kompilację bez danych debugowania, dane debugowania oraz zarówno dane debugowania, jak i rozbicie danych na potrzeby debugowania.

o różnych poleceniach i o tym, jakie pliki są generowane

Podczas podziału danych DWARF oprócz pliku binarnego znajduje się część danych debugowania, a duża część – w pliku mandelbrot.dwo (jak pokazano powyżej).

W przypadku usługi mandelbrot mamy tylko 1 plik źródłowy, ale zazwyczaj projekty są większe i zawierają więcej niż 1 plik. Debugowanie rozszczepienia generuje plik .dwo dla każdego z nich. Aby debuger w bieżącej wersji beta (0.1.6.1615) mógł załadować te podzielone dane debugowania, musimy je spakować w tak zwany pakiet DWARF (.dwp):

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

połącz pliki dwo w pakiet DWARF

Stworzenie pakietu DWARF z pojedynczych obiektów ma tę zaletę, że wystarczy udostępnić tylko 1 dodatkowy plik. Obecnie pracujemy nad załadowaniem wszystkich pojedynczych obiektów w kolejnej wersji.

Co to jest DWARF 5?

Jak już pewnie widzisz, w poleceniu emcc powyżej (-gdwarf-5) umieściliśmy kolejną flagę. Włączenie funkcji DWARF w wersji 5, która obecnie nie jest ustawieniem domyślnym, to kolejna sztuczka, która pomaga nam szybciej rozpocząć debugowanie. W takim przypadku w głównym pliku binarnym są przechowywane pewne informacje, które zostały pominięte przez domyślną wersję 4. W szczególności możemy określić pełny zestaw plików źródłowych tylko na podstawie głównego pliku binarnego. Dzięki temu debuger może wykonywać podstawowe działania, 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 i -gdwarf-5.

Format debugowania DWARF5 daje również dostęp do innej przydatnej funkcji. Zawiera on indeks nazw w danych debugowania, które będą generowane 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 odbywa się często poprzez wyszukiwanie encji według nazwy, np. podczas szukania zmiennej lub typu. Indeks nazw przyspiesza wyszukiwanie, wskazując bezpośrednio jednostkę kompilacji, która określa daną nazwę. Bez indeksu nazw potrzebne byłoby wyczerpujące przeszukanie wszystkich danych debugowania, aby znaleźć odpowiednią jednostkę kompilacji, która definiuje poszukiwany element nazwany.

Dla ciekawostek: dane debugowania

Możesz użyć funkcji llvm-dwarfdump, aby zobaczyć dane DWARF. Spróbujmy:

llvm-dwarfdump mandelbrot.wasm

W ten sposób uzyskasz ogólne informacje o „Kompilowaniu jednostek” (w przybliżeniu plików źródłowych), w przypadku których mamy informacje na temat debugowania. W tym przykładzie mamy tylko dane debugowania dotyczące: mandelbrot.cc. Ogólne informacje poinformują nas, że mamy jednostkę szkieletową. Oznacza to, że dane w tym pliku są niekompletne 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. w tabeli liniowej, która przedstawia mapowanie kodu bajtowego Wasm na wiersze C++ (spróbuj użyć llvm-dwarfdump -debug-line).

Możesz też przejrzeć informacje na temat debugowania w osobnym pliku .dwo:

llvm-dwarfdump mandelbrot.dwo

mandelbrot.wasm i dane debugowania

TL;DR: Jaka jest zaleta użycia rozszczepienia debugowania?

Podział informacji debugowania ma kilka zalet w przypadku pracy z dużymi aplikacjami:

  1. Szybsze łączenie: tag łączący nie musi już analizować wszystkich informacji debugowania. Łączniki zwykle muszą przeanalizować całe dane DWARF, które znajdują się w pliku binarnym. Dzięki wyodrębnianiu dużych części danych debugowania do osobnych plików stosują one obsługę mniejszych plików binarnych, co przyspiesza łączenie (zwłaszcza w przypadku dużych aplikacji).

  2. Szybsze debugowanie: w przypadku niektórych wyszukiwań symboli debuger może pominąć analizowanie dodatkowych symboli w plikach .dwo/.dwp. W przypadku niektórych wyszukiwań (np. żądań w mapowaniu wierszy plików Wasm-to-C++) nie musimy uwzględniać dodatkowych danych debugowania. Dzięki temu nie musimy już wczytywać ani analizować dodatkowych danych debugowania.

1: jeśli nie masz w systemie najnowszej wersji usługi llvm-objdump, ale korzystasz z wersji emsdk, znajdziesz ją w katalogu emsdk/upstream/bin.

Pobieranie kanałów podglądu

Jako domyślnej przeglądarki dla programistów możesz używać Chrome Canary, Dev lub Beta. Te kanały podglądu dają dostęp do najnowszych funkcji Narzędzi deweloperskich, umożliwiają testowanie najnowocześniejszych interfejsów API platform internetowych oraz wykrywanie problemów w witrynie, zanim zdołają zrobić użytkownicy.

Kontakt z zespołem Narzędzi deweloperskich w Chrome

Użyj poniższych opcji, aby omówić nowe funkcje i zmiany w poście lub wszelkie inne kwestie związane z Narzędziami dla deweloperów.

  • Prześlij nam sugestię lub opinię na stronie crbug.com.
  • Aby zgłosić problem z Narzędziami deweloperskimi, kliknij Więcej opcji   Więcej   > Pomoc > Zgłoś problemy z Narzędziami deweloperskimi.
  • zatweetuj na @ChromeDevTools.
  • Napisz komentarz o nowościach w filmach w YouTube dostępnych w Narzędziach deweloperskich lub z poradami dotyczącymi narzędzi dla deweloperów w filmach w YouTube.