Optymalizuj z mniejszą precyzją

Format liczbowy danych graficznych i obliczeń cieniowania może mieć znaczący wpływ na wydajność gry.

Optymalne formaty:

  • Zwiększanie efektywności korzystania z pamięci podręcznej GPU
  • zmniejszenie zużycia przepustowości pamięci, co pozwala oszczędzać energię i zwiększać wydajność;
  • Maksymalizacja przepustowości obliczeniowej w programach cieniowania
  • Minimalizowanie wykorzystania pamięci RAM procesora przez grę

Formaty liczb zmiennoprzecinkowych

Większość obliczeń i danych w nowoczesnej grafice 3D wykorzystuje liczby zmiennoprzecinkowe. Vulkan na Androidzie używa liczb zmiennoprzecinkowych o rozmiarze 32 lub 16 bitów. 32-bitowa liczba zmiennoprzecinkowa jest zwykle określana jako pojedyncza precyzja lub pełna precyzja, a 16-bitowa liczba zmiennoprzecinkowa jako półprecyzja.

Interfejs Vulkan definiuje 64-bitowy typ zmiennoprzecinkowy, ale nie jest on powszechnie obsługiwany przez urządzenia z Androidem, które korzystają z interfejsu Vulkan, i nie zaleca się jego używania. 64-bitowa liczba zmiennoprzecinkowa jest zwykle określana jako liczba podwójnej precyzji.

Formaty liczb całkowitych

Do danych i obliczeń używane są też liczby całkowite ze znakiem i bez znaku. Standardowy rozmiar liczby całkowitej to 32 bity. Obsługa innych rozmiarów bitów zależy od urządzenia. Urządzenia z Androidem obsługujące Vulkan zwykle obsługują 16-bitowe i 8-bitowe liczby całkowite. Vulkan definiuje 64-bitowy typ liczby całkowitej, ale nie jest on powszechnie obsługiwany przez urządzenia z Androidem, które korzystają z Vulkana, i nie zaleca się jego używania.

Suboptymalne działanie w przypadku półprecyzyjnych liczb zmiennoprzecinkowych

Nowoczesne architektury GPU łączą 2 wartości 16-bitowe w parę 32-bitową i wdrażają instrukcje, które działają na tej parze. Aby uzyskać optymalną wydajność, unikaj używania skalarnych 16-bitowych zmiennych zmiennoprzecinkowych. Wektoryzuj dane do wektorów dwu- lub czteroelementowych. Kompilator cieniowania może używać wartości skalarnych w operacjach wektorowych. Jeśli jednak polegasz na kompilatorze w zakresie optymalizacji skalarów, sprawdź dane wyjściowe kompilatora, aby zweryfikować wektoryzację.

Konwersja między 32-bitową i 16-bitową precyzją zmiennoprzecinkową wiąże się z kosztem obliczeniowym. Zmniejsz obciążenie, minimalizując konwersje precyzyjne w kodzie.

Porównaj wydajność wersji 16-bitowej i 32-bitowej swoich algorytmów. Półprecyzyjne obliczenia nie zawsze poprawiają wydajność, szczególnie w przypadku skomplikowanych obliczeń. Algorytmy, które w dużym stopniu korzystają z instrukcji mnożenia i dodawania z fuzją (FMA) na danych wektorowych, mogą uzyskać lepszą wydajność przy użyciu półprecyzyjnych liczb zmiennoprzecinkowych.

Obsługa formatu liczbowego

Wszystkie urządzenia z Androidem obsługujące Vulkan w obliczeniach danych i shaderów obsługują liczby zmiennoprzecinkowe 32-bitowe o pojedynczej precyzji oraz 32-bitowe liczby całkowite. Obsługa innych formatów nie jest gwarantowana, a jeśli jest dostępna, nie jest gwarantowana we wszystkich przypadkach użycia.

Vulkan obsługuje 2 kategorie opcjonalnych formatów liczbowych: arytmetyczne i pamięci. Zanim użyjesz konkretnego formatu, upewnij się, że urządzenie obsługuje go w obu kategoriach.

Obsługa działań arytmetycznych

Urządzenie Vulkan musi deklarować obsługę arytmetyczną formatu liczbowego, aby można było go używać w programach cieniujących. Urządzenia z Vulkanem na Androidzie zwykle obsługują te formaty operacji arytmetycznych:

  • Liczba całkowita 32-bitowa (obowiązkowa)
  • 32-bitowa liczba zmiennoprzecinkowa (wymagana)
  • 8-bitowa liczba całkowita (opcjonalnie)
  • 16-bitowa liczba całkowita (opcjonalnie)
  • 16-bitowe liczby zmiennoprzecinkowe o połowicznej precyzji (opcjonalnie)

Aby sprawdzić, czy urządzenie Vulkan obsługuje 16-bitowe liczby całkowite w operacjach arytmetycznych, pobierz funkcje urządzenia, wywołując funkcję vkGetPhysicalDeviceFeatures2() i sprawdzając, czy pole shaderInt16 w strukturze wyniku VkPhysicalDeviceFeatures2 ma wartość true.

Aby sprawdzić, czy urządzenie Vulkan obsługuje 16-bitowe liczby zmiennoprzecinkowe lub 8-bitowe liczby całkowite, wykonaj te czynności:

  1. Sprawdź, czy urządzenie obsługuje rozszerzenie Vulkan VK_KHR_shader_float16_int8. Rozszerzenie jest wymagane do obsługi 16-bitowych liczb zmiennoprzecinkowych i 8-bitowych liczb całkowitych.
  2. Jeśli VK_KHR_shader_float16_int8 jest obsługiwany, dołącz wskaźnik struktury VkPhysicalDeviceShaderFloat16Int8Features do łańcucha VkPhysicalDeviceFeatures2.pNext.
  3. Po wywołaniu funkcji vkGetPhysicalDeviceFeatures2() sprawdź pola shaderFloat16shaderInt8 struktury wyniku VkPhysicalDeviceShaderFloat16Int8Features. Jeśli wartość pola to true, format jest obsługiwany w przypadku działań arytmetycznych w programie cieniującym.

Chociaż nie jest to wymagane w Vulkanie 1.1 ani w profilu podstawowym Androida z 2022 roku, obsługa rozszerzenia VK_KHR_shader_float16_int8 jest bardzo powszechna na urządzeniach z Androidem.

Obsługa pamięci

Urządzenie Vulkan musi deklarować obsługę opcjonalnego formatu numerycznego dla określonych typów pamięci. Rozszerzenie VK_KHR_16bit_storage deklaruje obsługę 16-bitowych formatów liczb całkowitych i 16-bitowych formatów zmiennoprzecinkowych. Rozszerzenie definiuje 4 typy pamięci. Urządzenie może obsługiwać 16-bitowe liczby w przypadku żadnego, niektórych lub wszystkich typów pamięci masowej.

Typy pamięci masowej:

  • Obiekty bufora pamięci masowej
  • Obiekty bufora jednolitego
  • Przesuwanie bloków stałych
  • Interfejsy wejścia i wyjścia shadera

Większość urządzeń z Androidem obsługujących Vulkan 1.1 obsługuje 16-bitowe formaty w obiektach bufora pamięci, ale nie wszystkie. Nie zakładaj obsługi na podstawie modelu GPU. Urządzenia ze starszymi sterownikami danego procesora GPU mogą nie obsługiwać obiektów bufora pamięci, podczas gdy urządzenia z nowszymi sterownikami obsługują je.

Obsługa 16-bitowych formatów w jednolitych buforach, blokach stałych push i interfejsach wejścia/wyjścia shadera zależy zwykle od producenta procesora graficznego. Na urządzeniach z Androidem procesor GPU zwykle obsługuje wszystkie 3 typy lub żaden z nich.

Przykładowa funkcja, która sprawdza obsługę formatów arytmetycznych i pamięci interfejsu Vulkan:

struct ReducedPrecisionSupportInfo {
  // Arithmetic support
  bool has_8_bit_int_ = false;
  bool has_16_bit_int_ = false;
  bool has_16_bit_float_ = false;
  // Storage support
  bool has_16_bit_SSBO_ = false;
  bool has_16_bit_UBO_ = false;
  bool has_16_bit_push_ = false;
  bool has_16_bit_input_output_ = false;
  // Use 16-bit floats if we have arithmetic
  // support and at least SSBO storage support.
  bool use_16bit_floats_ = false;
};

void CheckFormatSupport(VkPhysicalDevice physical_device,
    ReducedPrecisionSupportInfo &info) {

  // Retrieve the device extension list so we
  // can check for our desired extensions.
  uint32_t device_extension_count;
  vkEnumerateDeviceExtensionProperties(physical_device, nullptr,
      &device_extension_count, nullptr);
  std::vector<VkExtensionProperties> device_extensions(device_extension_count);
  vkEnumerateDeviceExtensionProperties(physical_device, nullptr,
      &device_extension_count, device_extensions.data());

  bool has_16_8_extension = HasDeviceExtension("VK_KHR_shader_float16_int8",
      device_extensions);

  // Initialize the device features structure and
  // chain the storage features structure and 8/16-bit
  // support structure if applicable.
  VkPhysicalDeviceFeatures2 device_features;
  memset(&device_features, 0, sizeof(device_features));
  device_features.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FEATURES_2;

  VkPhysicalDeviceShaderFloat16Int8Features f16_int8_features;
  memset(&f16_int8_features, 0, sizeof(f16_int8_features));
  f16_int8_features.sType =
      VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_FLOAT16_INT8_FEATURES_KHR;

  VkPhysicalDevice16BitStorageFeatures storage_features;
  memset(&storage_features, 0, sizeof(storage_features));
  storage_features.sType =
      VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_16BIT_STORAGE_FEATURES;
  device_features.pNext = &storage_features;

  if (has_16_8_extension) {
    storage_features.pNext = &f16_int8_features;
  }

  vkGetPhysicalDeviceFeatures2(physical_device, &device_features);

  // Parse the storage features and determine
  // what kinds of 16-bit storage access are available.
  if (storage_features.storageBuffer16BitAccess ||
      storage_features.uniformAndStorageBuffer16BitAccess) {
    info.has_16_bit_SSBO_ = true;
  }
  info.has_16_bit_UBO_ = storage_features.uniformAndStorageBuffer16BitAccess;
  info.has_16_bit_push_ = storage_features.storagePushConstant16;
  info.has_16_bit_input_output_ = storage_features.storageInputOutput16;

  info.has_16_bit_int_ = device_features.features.shaderInt16;
  if (has_16_8_extension) {
    info.has_16_bit_float_ = f16_int8_features.shaderFloat16;
    info.has_8_bit_int_ = f16_int8_features.shaderInt8;
  }

  // Get arithmetic and at least some form of storage
  // support before enabling 16-bit float usage.
  if (info.has_16_bit_float_ && info.has_16_bit_SSBO_) {
    info.use_16bit_floats_ = true;
  }
}

Poziom dokładności danych

Liczba zmiennoprzecinkowa o połowie precyzji może reprezentować mniejszy zakres wartości z mniejszą precyzją niż liczba zmiennoprzecinkowa o pojedynczej precyzji. Półprecyzyjne liczby zmiennoprzecinkowe są często prostym i percepcyjnie bezstratnym wyborem w porównaniu z pojedynczą precyzją. Jednak w niektórych przypadkach półprecyzyjne wartości zmiennoprzecinkowe mogą nie być praktyczne. W przypadku niektórych typów danych zmniejszony zakres i precyzja mogą powodować artefakty graficzne lub nieprawidłowe renderowanie.

Typy danych, które dobrze nadają się do reprezentacji w formacie zmiennoprzecinkowym o połowie precyzji, to:

  • Dane pozycji w lokalnych współrzędnych przestrzennych
  • Współrzędne UV tekstur dla mniejszych tekstur z ograniczonym zawijaniem UV, które można ograniczyć do zakresu współrzędnych od -1,0 do 1,0.
  • Dane normalne, styczne i dwustyczne
  • Dane koloru wierzchołka
  • Dane o niskich wymaganiach dotyczących precyzji, których wartość środkowa wynosi 0,0

Typy danych, które nie są zalecane do reprezentacji w postaci liczby zmiennoprzecinkowej o połowie precyzji, to:

  • Dane o pozycji w globalnych współrzędnych świata
  • Współrzędne UV tekstury w przypadku zastosowań wymagających dużej precyzji, takich jak współrzędne elementów interfejsu w arkuszu atlasu.

Precyzja w kodzie shadera

OpenGL Shading Language (GLSL)High-level Shader Language (HLSL) to języki programowania shaderów, które obsługują specyfikację obniżonej lub jawnej precyzji w przypadku typów liczbowych. Obniżona precyzja jest traktowana jako rekomendacja dla kompilatora cieniowania. Jawna precyzja jest wymagana w przypadku określonej precyzji. Urządzenia z Androidem obsługujące Vulkan zwykle używają 16-bitowych formatów, gdy sugeruje to mniejsza precyzja. Inne urządzenia z Vulkanem, zwłaszcza komputery stacjonarne z kartami graficznymi, które nie obsługują 16-bitowych formatów, mogą ignorować obniżoną precyzję i nadal używać formatów 32-bitowych.

Rozszerzenia pamięci w GLSL

Aby w strukturach pamięci i buforach jednolitych włączyć obsługę 16-bitowych lub 8-bitowych formatów liczbowych, należy zdefiniować odpowiednie rozszerzenia GLSL. Odpowiednie deklaracje rozszerzeń to:

// Enable 16-bit formats in storage and uniform buffers.
#extension GL_EXT_shader_16bit_storage : require
// Enable 8-bit formats in storage and uniform buffers.
#extension GL_EXT_shader_8bit_storage : require

Te rozszerzenia są specyficzne dla GLSL i nie mają odpowiednika w HLSL.

Zmniejszona precyzja w GLSL

Użyj kwalifikatora highp przed typem zmiennoprzecinkowym, aby zasugerować liczbę zmiennoprzecinkową pojedynczej precyzji, a kwalifikatora mediump w przypadku liczby zmiennoprzecinkowej o połowie precyzji. Kompilatory GLSL dla Vulkana interpretują starszy kwalifikator lowp jako mediump. Przykłady obniżonej precyzji:

mediump vec4 my_vector; // Suggest 16-bit half precision
highp mat4 my_matrix;   // Suggest 32-bit single precision

Jawna precyzja w GLSL

Aby włączyć używanie 16-bitowych typów zmiennoprzecinkowych, dodaj do kodu GLSL rozszerzenie GL_EXT_shader_explicit_arithmetic_types_float16:

#extension GL_EXT_shader_explicit_arithmetic_types_float16 : require

Deklarowanie 16-bitowych typów zmiennoprzecinkowych skalarnych, wektorowych i macierzowych w GLSL za pomocą tych słów kluczowych:

float16_t   f16vec2     f16vec3    f16vec4
f16mat2     f16mat3     f16mat4
f16mat2x2   f16mat2x3   f16mat2x4
f16mat3x2   f16mat3x3   f16mat3x4
f16mat4x2   f16mat4x3   f16mat4x4

Deklaruj 16-bitowe typy skalarne i wektorowe liczb całkowitych w GLSL, używając tych słów kluczowych:

int16_t     i16vec2     i16vec3    i16vec4
uint16_t    u16vec2     u16vec3    u16vec4

Zmniejszona precyzja w HLSL

W języku HLSL zamiast terminu „obniżona precyzja” używa się terminu minimalna precyzja. Słowo kluczowe typu minimalna precyzja określa minimalną precyzję, ale kompilator może zastąpić ją wyższą, jeśli będzie ona lepszym wyborem dla docelowego sprzętu. Minimalna precyzja 16-bitowej liczby zmiennoprzecinkowej jest określana przez słowo kluczowe min16float. Minimalna precyzja 16-bitowych liczb całkowitych ze znakiem i bez znaku jest określana odpowiednio przez słowa kluczowe min16int i min16uint. Dodatkowe przykłady deklaracji o minimalnej precyzji:

// Four element vector and four-by-four matrix types
min16float4 my_vector4;
min16float4x4 my_matrix4x4;

Jawna precyzja w HLSL

Liczby zmiennoprzecinkowe o połowie precyzji są określane przez słowa kluczowe half lub float16_t. Podpisane i niepodpisane 16-bitowe liczby całkowite są określane odpowiednio przez słowa kluczowe int16_tuint16_t. Dodatkowe przykłady deklaracji precyzyjności:

// Four element vector and four-by-four matrix types
half4 my_vector4;
half4x4 my_matrix4x4;