Optymalizuj z mniejszą precyzją

Format numeryczny danych graficznych i obliczeń programu do cieniowania może mieć duży wpływ na wydajność gry.

Optymalne formaty mają takie działanie:

  • Zwiększ wydajność wykorzystania pamięci podręcznej GPU
  • Zmniejsz zużycie pamięci, oszczędzając energię i zwiększając wydajność
  • Maksymalizuj przepustowość obliczeniową w programach do cieniowania
  • Minimalizuj wykorzystanie procesora RAM przez grę

Formaty liczb zmiennoprzecinkowych

Większość obliczeń i danych we współczesnej grafice 3D wykorzystuje liczby zmiennoprzecinkowe. Interfejs Vulkan na Androidzie używa liczb zmiennoprzecinkowych o rozmiarze 32 lub 16 bitów. 32-bitowa liczba zmiennoprzecinkowa jest często nazywana pojedynczą precyzją lub pełną precyzją. 16-bitowa liczba zmiennoprzecinkowa o połowie precyzji.

Interfejs Vulkan definiuje 64-bitowy typ zmiennoprzecinkowy, ale urządzenia z Androidem nie obsługują tego typu liczb i nie zalecamy ich stosowania. 64-bitowa liczba zmiennoprzecinkowa jest często nazywana podwójną precyzją.

Formaty liczb całkowitych

Podpisane i nieoznaczone liczby całkowite są również używane do danych i obliczeń. Standardowy rozmiar liczby całkowitej to 32 bity. Obsługa innych rozmiarów bitów zależy od urządzenia. Urządzenia Vulkan z Androidem zwykle obsługują 16-bitowe i 8-bitowe liczby całkowite. Interfejs Vulkan definiuje 64-bitowy typ liczby całkowitej, ale urządzenia z Androidem nie obsługują tego typu często i nie zalecamy korzystania z niego.

Nieoptymalne zachowanie półdokładności

Nowoczesne architektury GPU łączą 2 16-bitowe wartości w parę 32-bitową i implementują instrukcje działające na parze. Aby uzyskać optymalną wydajność, unikaj używania skalarnych 16-bitowych zmiennych liczby zmiennoprzecinkowej. Dane należy przekształcać w wektory dwu- lub 4-elementowe. Kompilator do cieniowania może używać wartości skalarnych w operacjach dotyczących wektorów. Jeśli jednak korzystasz z kompilatora do optymalizacji skalarów, sprawdź dane wyjściowe kompilatora, aby sprawdzić wektorizację.

Konwersja do 32-bitowej i 16-bitowej precyzyjnej liczby zmiennoprzecinkowej wiąże się z kosztami obliczeniowymi. Mniejsze nakłady pracy dzięki zminimalizowaniu liczby precyzyjnych konwersji w kodzie.

Porównanie różnic w wydajności 16-bitowych i 32-bitowych wersji algorytmów. Połowa precyzji nie zawsze prowadzi do poprawy wydajności, zwłaszcza w przypadku skomplikowanych obliczeń. Algorytmy, które intensywnie korzystają z instrukcji FMA w postaci ujednoliconego dodawania danych wektorowych, są dobrymi kandydatami do poprawy wydajności przy o połowie dokładności.

Obsługa formatów liczbowych

Wszystkie urządzenia Vulkan z Androidem obsługują 32-bitowe liczby zmiennoprzecinkowe i 32-bitowe liczby zmiennoprzecinkowe w obliczeniach danych i funkcji cieniowania. Nie gwarantujemy, że obsługa innych formatów będzie dostępna. Nie możemy zagwarantować, że obsługa innych formatów będzie dostępna we wszystkich przypadkach użycia.

Interfejs Vulkan obsługuje 2 kategorie obsługi opcjonalnych formatów liczb: arytmetyczną i pamięć. Zanim użyjesz określonego formatu, sprawdź, czy urządzenie obsługuje go w obu kategoriach.

Obsługa arytmetyki

Urządzenie Vulkan musi zadeklarować obsługę arytmetyczną formatu liczbowego, aby można było go używać w programach do cieniowania. Urządzenia Vulkan na Androidzie zwykle obsługują następujące formaty arytmetyczne:

  • 32-bitowa liczba całkowita (wymagane)
  • 32-bitowa liczba zmiennoprzecinkowa (wymagane)
  • 8-bitowa liczba całkowita (opcjonalnie)
  • 16-bitowa liczba całkowita (opcjonalnie)
  • 16-bitowa półprecyzja zmiennoprzecinkowa o połowie precyzji (opcjonalnie)

Aby sprawdzić, czy urządzenie Vulkan obsługuje 16-bitowe liczby całkowite na potrzeby arytmetyki, pobierz jego funkcje, wywołując funkcję vkGetPhysicalDeviceFeatures2() i sprawdzając, czy pole shaderInt16 w strukturze wyników VkPhysicalDeviceFeatures2 ma wartość prawda.

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

  1. Sprawdź, czy urządzenie obsługuje rozszerzenie Vulkan VK_KHR_shader_float16_int8. Jest ono wymagane do obsługi 16-bitowych liczb zmiennoprzecinkowych i 8-bitowych liczb całkowitych.
  2. Jeśli funkcja VK_KHR_shader_float16_int8 jest obsługiwana, dołącz wskaźnik struktury VkPhysicalDeviceShaderFloat16Int8Features do łańcucha VkPhysicalDeviceFeatures2.pNext.
  3. Sprawdź pola shaderFloat16 i shaderInt8 struktury wyników VkPhysicalDeviceShaderFloat16Int8Features po wywołaniu metody vkGetPhysicalDeviceFeatures2(). Jeśli wartość pola to true, format jest obsługiwany w arytmecie programu do cieniowania.

Chociaż interfejs Vulkan 1.1 ani profil podstawowy w Androidzie z 2022 r. nie są wymagane, obsługa rozszerzenia VK_KHR_shader_float16_int8 jest bardzo powszechna na urządzeniach z Androidem.

Obsługa miejsca na dane

Urządzenie z interfejsem Vulkan musi zadeklarować obsługę opcjonalnego formatu liczbowego dla określonych typów pamięci masowej. Rozszerzenie VK_KHR_16bit_storage deklaruje obsługę 16-bitowych i 16-bitowych formatów liczb zmiennoprzecinkowych. Rozszerzenie definiuje 4 typy pamięci masowej. Urządzenie może obsługiwać 16-bitowe liczby w żadnej, niektórych lub wszystkich typach pamięci.

Dostępne typy pamięci masowej:

  • Obiekty bufora pamięci masowej
  • Jednolite obiekty bufora
  • Wypchnij bloki stałe
  • Interfejsy wejściowe i wyjściowe funkcji Shader

Większość urządzeń z interfejsem Vulkan 1.1 na Androidzie obsługuje formaty 16-bitowe w obiektach bufora pamięci masowej, ale nie wszystkie. Nie zakładaj obsługi na podstawie modelu GPU. Urządzenia ze starszymi sterownikami dla danego GPU mogą nie obsługiwać obiektów bufora pamięci, a urządzenia z nowszymi sterownikami już tak.

Obsługa formatów 16-bitowych w jednolitych buforach, wstrzykiwaniu stałych bloków i interfejsach wejścia/wyjścia programu do cieniowania zależy głównie od producenta GPU. W przypadku Androida układ graficzny zwykle obsługuje wszystkie te 3 rodzaje lub nie obsługuje żadnego z nich.

Przykładowa funkcja do testowania obsługi formatu arytmetycznego i pamięci masowej platformy 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 przy niższej dokładności niż liczba zmiennoprzecinkowa o pojedynczej precyzji. Połowa precyzji jest często prostym i perceptycznie bezstratnym wyborem zamiast metody pojedynczej precyzji. Jednak połowa precyzji może nie być praktyczna we wszystkich przypadkach użycia. W przypadku niektórych typów danych mniejszy zakres i dokładność mogą skutkować pojawieniem się artefaktów graficznych lub nieprawidłowym renderowaniem.

Typy danych, które są dobrym kandydatem do reprezentacji w przypadku zmiennoprzecinkowej półdokładności, to m.in.:

  • Dane dotyczące położenia we współrzędnych przestrzeni lokalnej
  • Tekstury UV w przypadku mniejszych tekstur z ograniczonym zawijaniem UV, które można ograniczyć do zakresu współrzędnych od -1,0 do 1,0
  • Dane normalne, tangens i bitangenty
  • Dane o kolorach wierzchołków
  • Dane o niskim wymaganiu dokładności skupione na 0,0

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

  • Dane o pozycji we współrzędnych świata
  • Tekstury UV do zastosowań o wysokiej precyzji, np. współrzędne elementów interfejsu w arkuszu atlasu

Dokładność kodu cienia

Języki programowania cieniowania OpenGL Shader Language (GLSL) i High-level Shader Language (HLSL) obsługują specyfikację o swobodnej lub jednoznacznej precyzji w przypadku typów liczbowych. Większa precyzja jest traktowana jako rekomendacja dla kompilatora do cieniowania. Jawna precyzja jest wymagana przez określoną precyzję. Urządzenia z obsługą interfejsu Vulkan na Androidzie zwykle używają formatów 16-bitowych, jeśli są sugerowane ze względu na mniejszą precyzję. Inne urządzenia z interfejsem Vulkan, zwłaszcza komputery z grafiką nieobsługującą formatów 16-bitowych, mogą ignorować mniejszą precyzję i nadal korzystać z formatów 32-bitowych.

Rozszerzenia miejsca na dane w GLSL

Odpowiednie rozszerzenia GLSL muszą być zdefiniowane, aby umożliwić obsługę 16- lub 8-bitowych formatów liczbowych w pamięci masowej i jednolitą strukturę buforów. Odpowiednie deklaracje rozszerzeń:

// 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 odnoszą się tylko do GLSL i nie mają swoich odpowiedników w HLSL.

Większa precyzja w GLSL

Użyj kwalifikatora highp przed typem liczby zmiennoprzecinkowej, aby zaproponować liczbę zmiennoprzecinkową o pojedynczej precyzji i kwalifikator mediump w przypadku liczby zmiennoprzecinkowej o połowie precyzji. Kompilatory GLSL na potrzeby interfejsu Vulkan interpretują starszy kwalifikator lowp jako mediump. Przykłady swobodnej precyzji:

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

Jawna precyzja w GLSL

Aby umożliwić korzystanie z 16-bitowych typów liczb zmiennoprzecinkowych, dodaj do kodu GSL rozszerzenie GL_EXT_shader_explicit_arithmetic_types_float16:

#extension GL_EXT_shader_explicit_arithmetic_types_float16 : require

Zadeklaruj w GLSL 16-bitowe typy zmiennoprzecinkowych skalarnych, wektorowych i matryc, używając tych słów kluczowych:

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

Zadeklaruj w GLSL 16-bitowe typy wektorów i skalarnych liczb całkowitych, korzystając z tych słów kluczowych:

int16_t     i16vec2     i16vec3    i16vec4
uint16_t    u16vec2     u16vec3    u16vec4

Większa precyzja w HLSL

W HLSL zamiast precyzji w języku angielskim używa się określenia minimalna precyzja. Słowo kluczowe typu minimalna precyzja określa minimalną precyzję, ale kompilator może zastąpić większą precyzję, jeśli dla sprzętu docelowego lepszym wyborem jest większa precyzja. 16-bitową liczbę zmiennoprzecinkową o minimalnej dokładności jest określana przez słowo kluczowe min16float. 16-bitowe liczby całkowite podpisane i niepodpisane z minimalną precyzją są określane odpowiednio przez słowa kluczowe min16int i min16uint. Dodatkowe przykłady deklaracji minimalnej precyzji:

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

Jawna precyzja w HLSL

Wartość zmiennoprzecinkowa o połowie precyzji jest określana 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_t i uint16_t. Dodatkowe przykłady jawnych deklaracji precyzji:

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