Projekt tłumaczenia rozmowy

Model danych

Programy stabilnych HLO to obliczenia na tensorach (tablicach n-wymiarowych), które w bieżącym modelu są zaimplementowane z wykorzystaniem klasy Tensor. Podstawowa klasa pamięci masowej dla obiektu Tensor (detail::Buffer) przechowuje mlir::ShapedType tensora wraz z obiektem mlir::HeapAsmResourceBlob reprezentującym zmienny blob danych tensorów ułożonych jako sąsiednie tablice bajtów w kolejności dużych do mniejszych. detail::Buffer obiekty są liczone jako odwołania, aby ułatwić zarządzanie pamięcią.

Poszczególne elementy tensora są reprezentowane za pomocą klasy Element, która używa dyskryminowanego związku obejmującego jeden z tych elementów: APInt, APFloat lub pair<APFloat,APFloat>. Ostatni służy do przechowywania elementów o złożonych typach.

Tensor ma te interfejsy API, które umożliwiają interakcję z jego poszczególnymi elementami:

  • Element Tensor::get(llvm::ArrayRef<int64_t> index): aby wyodrębnić pojedynczy element tensora w indeksie wielowymiarowym index jako obiekt Element.
  • void Tensor::set(llvm::ArrayRef<int64_t> index, Element element);: Aby zaktualizować obiekt Element element w tensor o indeksie wielowymiarowym index.

Jak działa tłumacz

Funkcja wprowadzania dla tłumacza to

SmallVector<Tensor> eval(func::FuncOp func, ArrayRef<Tensor> args);

który wykona te działania:

  1. Śledzi argumenty SSA funkcji func i powiązane z nimi wartości Tensor środowiska wykonawczego podane w args przy użyciu mapy tabeli symboli (M.
  2. Dla każdej operacji w obrębie func, w kolejności SSACFG:
    • Wywołuje eval w operacji. Dla każdego operandu SSA operacji wyodrębnij wartość środowiska wykonawczego z M, która będzie podana jako argument wywołania eval.
    • Śledzi wyniki SSA operacji i ocenioną wartość w M.

eval na poziomie operacji, o którym mowa w punkcie (2), odpowiada za wdrożenie semantyki wykonania operacji. Poniżej znajdziesz przykład działania stablehlo::AddOp. W tym przykładzie poszczególne elementy tensorów lhs i rhs są wyodrębniane parami w postaci obiektów Element, które następnie są dodawane. Wynik dodawania, czyli obiekt Element, jest przechowywany w końcowym tensorze result.

Tensor eval(AddOp op, const Tensor &lhs, const Tensor &rhs) {
  Tensor result(op.getType());

  for (auto it = result.index_begin(); it != result.index_end(); ++it)
    result.set(*it, lhs.get(*it) + rhs.get(*it));

  return result;
}

Ogólnie rzecz biorąc, projekt interpretatora interpretacji jest zoptymalizowany pod kątem czytelności implementacji funkcji eval w poszczególnych operacjach, ponieważ służy jako implementacja referencyjna StableHLO. Na przykład zamiast definiowania funkcji eval jako funkcji szablonu i określania jej parametrów za pomocą typów elementów, podajemy szczegółowe informacje o sposobie obsługi różnych typów elementów w interfejsie Element::operator+ itp., co upraszcza implementację właściwości eval.

Korzystanie z tłumacza do ciągłego zwijania

Korzystając z mechanizmu interpretera, możemy zwinąć operacje ze stałymi wartościami argumentów operacji. Poniższy fragment kodu przedstawia przykład implementacji zwijania elementu stablehlo::AddOp za pomocą operandów typu zmiennoprzecinkowego:

OpFoldResult AddOp::fold(FoldAdaptor adaptor) {
  auto attrs = adaptor.getOperands();
  DenseElementsAttr lhsData = attrs[0].dyn_cast<DenseElementsAttr>();
  DenseElementsAttr rhsData = attrs[1].dyn_cast<DenseElementsAttr>();
  if (!lhsData || !rhsData) return {};

  auto lhs = Tensor(lhsData);
  auto rhs = Tensor(rhsData);
  auto result = eval(*this, lhs, rhs);

  SmallVector<APFloat> values;
  for (auto i = 0; i < result.getNumElements(); ++i) {
    Element element = result.get(i);
    values.push_back(element.getValue().cast<FloatAttr>().getValue());
  }

  return DenseElementsAttr::get(result.getType(), values);
}

W tej chwili nie pracujemy nad integracją tłumacza ze złożeniem na stałe, ponieważ nie planujemy wdrożenia folderu dla StableHLO. W przyszłości planujemy jednak wykorzystać tłumacza do ciągłego zwijania tekstu w MHLO.Dopracujemy wtedy ergonomię powyższego fragmentu kodu (np. możemy użyć funkcji pomocniczej, która pakuje stałe operandy w obiekty Tensor i rozpakowuje wyniki Tensor w OpFoldResult).

Testowanie interpretera StableHLO

Tłumacz interpretuje jako dane wejściowe (A) program StableHLO oraz (B) wartości danych, które mają być przesłane do programu, i generuje wartości danych wyjściowych, które są dopasowywane do oczekiwanych wartości podanych przez użytkownika. Wartości danych (B) są zakodowane na stałe w samym programie za pomocą operacji stablehlo.constant. Tłumacz ocenia program wprowadzania. Wyniki testowanej operacji są sprawdzane za pomocą testów (np. check.expect_eq, check.expect_almost_eq), jak pokazano poniżej. check.expect_eq i check.expect_eq_const sprawdzają równość bitową dowolnego obsługiwanego typu, a check.expect_almost_eq i check.expect_almost_eq_const sprawdzają niemal równość w obrębie tolerancji, która została wyjaśniona w wytycznych dotyczących testowania (G6), dla typów zmiennoprzecinkowych i złożonych.

// CHECK-LABEL: Evaluated results of function: add_op_test_ui4
func.func @add_op_test_ui4() {
  %0 = stablehlo.constant dense<[0, 2]> : tensor<2xui4>
  %1 = stablehlo.constant dense<[15, 3]> : tensor<2xui4>
  %2 = stablehlo.add %0, %1 : tensor<2xui4>
  check.expect_eq_const %2, [15, 5] : tensor<2xui4>
  func.return
}

Narzędzie testowe stablehlo-translate --interpret (kod) odpowiada za analizowanie programu, interpretując każdą funkcję, w tym operacje, które ją składają. Dla każdej operacji StableHLO mamy specjalny pakiet testów, który składa się z kilku testów wykonujących różne działania w środowisku wykonawczym. Testy te można znaleźć tutaj (np. interpret_*.mlir).

Wskazówki dotyczące przeprowadzania testów

(G1) Czy w przypadku każdej operacji musimy testować wszystkie obsługiwane typy?

Możemy wykorzystać kombinację następujących reguł, aby zdecydować:

  1. Jeśli podczas implementacji operacji w odpowiadającej jej funkcji eval znajduje się kod do obsługi określonego typu, konieczne jest utworzenie testów, które pozwolą na uwzględnienie tego typu. Na przykład w przypadku operacji add dostępny jest wyłączny kod do obsługi typów liczb całkowitych, logicznych, zmiennoprzecinkowych i złożonych, dlatego potrzebujemy 1 testu dla każdej kategorii typów.

  2. Jeśli zbiór typów jest obsługiwany jednolicie w odpowiadającej mu funkcji eval, wystarczający będzie 1 test dla wszystkich tych typów. Na przykład w przypadku operacji add wszystkie warianty typów liczb całkowitych (si4, u4, si8, u8 itd.) są obsługiwane w taki sam sposób za pomocą interfejsów API llvm::APInt, dlatego możemy pominąć dodawanie testów dla każdego z nich i zamiast tego dodać pojedynczy test reprezentatywny. Aby uniknąć niejasności przy wyborze przedstawiciela, postępuj zgodnie z tymi wskazówkami:

    • Jeśli wszystkie typy, obsługiwane jednolicie, mają ten sam typ podstawowy (tzn. jeśli wszystkie są liczbą całkowitą, zmiennoprzecinkową lub złożoną), wybierz ten o maksymalnej szerokości bitowej.
    • Jeśli wszystkie typy (obsługiwane jednolicie) zawierają kombinację typów podstawowych, wybierz ten z typem podstawowym (w malejącej kolejności): liczba całkowita, zmiennoprzecinkowa, logiczna, zespolona.

(G2) Jak określamy liczbę testów niezbędnych do sprawdzenia działania operacji?

Celem jest kompleksowe opracowanie logiki tłumacza podczas operacji (tj. wszystkich narożnych przypadków implementacji) przy minimalnej liczbie testów. Zmniejszenie liczby testów jest ważne ze względu na łatwość użytkowania. Im mniej testów, tym łatwiej je przeglądać i mieć pewność, że w pełni obejmują one cały proces. W efekcie większość prostszych operacji będzie miała tylko jeden test. Jeśli z jakiegoś powodu taki zakres jest niepraktyczny, można go przerwać na poziomie >= 90%. Decyzja o tym będzie ustalana indywidualnie podczas weryfikacji prośby o dołączenie.

(G3) Co z dodatkowymi testami infrastruktury tłumacza?

Infrastruktura tłumacza jest w większości prosta i można ją dodać do naszej bazy zaufania. Jedyna rzecz, która nie jest trywialna, to sposób, w jaki różne typy plików są pakowane do pamięci tłumacza i rozpakowane. Jak już wspomnieliśmy w części G1, będziemy testować tylko te typy operacji, które są obsługiwane w inny sposób. Dlatego kod pakowania i rozpakowywania odpowiadający różnym wariantom typów liczb całkowitych i zmiennoprzecinkowych może nie zostać w pełni pokryty podczas testów. Aby zapewnić pełne pokrycie, możesz wybrać operację taką jak constant, która obsługuje wszystkie typy elementów StableHLO i stworzyć szczegółowe testy.

(G4) Jeśli implementacja operacji zależy od innych operacji, czy należy utworzyć dla niej testy?

Nie. Implementacja batch_norm_grad może na przykład działać na podstawie parametrów divide, subtract, multiply i innych. Powinniśmy unikać testowania tych ostatnich operacji, jednocześnie testując pierwszą.

(G5) Czy mamy tworzyć testy w celu wykonywania zdefiniowanych w implementacji lub niezdefiniowanych zachowań?

Nie należy pisać testów, które wykonują określone w implementacji lub niezdefiniowane zachowanie operacji. Testy obejmujące zachowania zdefiniowane w implementacji demonstrują lokalne zachowanie interpretera, którego nie należy uogólniać. Testy obejmujące niezdefiniowane zachowanie nie wpływają na zrozumienie zachowania grupy.

(G6) Przy pisaniu testów dla typów zmiennoprzecinkowych z jaką precyzją musi być określony oczekiwany wynik?

W przypadku działań podstawowych (dodawanie, odejmowanie, mnożenie, dzielenie i kwadrat) implementacja zgodnie ze specyfikacją IEEE powinna dać zaokrąglony wynik w zakresie 0,5 ULP od dokładnego wyniku matematycznego. Można jednak bez obaw wyobrazić sobie, że oczekiwany wynik tych operacji będzie oddalony o co najmniej 1 odległość. Może się to jednak nie sprawdzić w przypadku funkcji transcendentalnych (sine, cosine itp.), w przypadku których gwarancje precyzji są zdefiniowane w implementacji (uzasadnienie).

Obecna implementacja używa wartości tolerancji „jeden rozmiar” równy 0,0001. Przykład poniżej pokazuje powyższą tolerancję w praktyce.

func.func @check_tolerance() {
  %0 = stablehlo.constant dense<0.2> : tensor<f32>

  // The following check succeeds as %0 is almost equal to the provided
  // constant modulo the tolerance, mentioned above.
  check.expect_almost_eq_const %0, dense<0.19999> : tensor<f32>

  // The following check fails as %0 is not bitwise equal to the provided
  // constant.
  check.expect_eq_const %0, dense<0.19999> : tensor<f32>

  func.return
}

To dopiero pierwszy etap testowania dokładności liczbowej operacji StableHLO. Obecnie jest to niedostatecznie określony obszar specyfikacji StableHLO. Pracujemy nad tym, #1156, na podstawie naszych doświadczeń w praktycznym stosowaniu StableHLO i opinii zainteresowanych osób. W miarę postępów będziemy odpowiednio aktualizować infrastrukturę.

(G7) Czy jest coś w stylu kodowania testów?

  1. Pamiętaj, aby używać rzeczywistych nazw danych wejściowych/wyjściowych zamiast domyślnych wartości SSA (np. %0, %1 itp.)
  2. Jeśli testy są gotowe, upewnij się, że są w nich używany format „ładnie drukowany”.

(G8) Czy powinniśmy uwzględnić przykład podany w specyfikacji? Tak (dla pełnego przetestowania).