Floaty, bfloat i jak bity kształtują pamięć modelu

Pod całą abstrakcją sieć neuronowa to wielki zbiór liczb zwanych parametramiwag i obciążeń, które niesie każdy neuron w każdej warstwie, a które szczegółowo omówiliśmy w artykule o tym, jak sieci się uczą. Liczba parametrów mówi, ile takich liczb model przechowuje; format numeryczny mówi o rozmiarze każdej z nich w bajtach. Pomnóż je, a dostaniesz zajętość pamięci modelu — pierwszą liczbę, jakiej potrzebujesz, zanim wybierzesz sprzęt, wybierzesz precyzję albo zdecydujesz, czy to w ogóle mieści się na jednym GPU.

Weźmy małą sieć, którą wytrenowaliśmy w artykule o MNIST — gęsty klasyfikator z dwiema warstwami Dense w Kerasie: 784 spłaszczone piksele wejściowe zasilają warstwę ukrytą o 128 neuronach, która zasila warstwę wyjściową o 10 neuronach (po jednym na cyfrę). Skrótowy zapis tego kształtu to 784 → 128 → 10, gdzie 784 to rozmiar wejścia, a nie warstwa. Liczba parametrów sumuje się do:

784×128+128hidden layer: 100,480  +  128×10+10output layer: 1,290  =  101,770 parameters.\underbrace{784 \times 128 + 128}_{\text{hidden layer: } 100{,}480} \;+\; \underbrace{128 \times 10 + 10}_{\text{output layer: } 1{,}290} \;=\; 101{,}770 \text{ parameters}.

Keras domyślnie przechowuje każdy parametr jako fp32 — 32-bitową liczbę zmiennoprzecinkową, po 4 bajty. Wytrenowany model zajmuje więc po wczytaniu 101,770×4400 KB101{,}770 \times 4 \approx 400\text{ KB} pamięci. Ale to model-zabawka zbudowany do nauki — na tyle mały, że dałoby się go wysłać mailem. Modele z prawdziwego świata są o rzędy wielkości większe i właśnie tam liczba parametrów zaczyna mieć znaczenie.

Każdy model opublikowany na hubie takim jak Hugging Face przychodzi z kartą modelu — publicznym README leżącym obok pliku z wagami, streszczającym, czym model jest, jak był trenowany, i podającym dwie liczby, które nas tu interesują: ile ma parametrów i w jakim formacie numerycznym każdy z nich jest zapisany.

Weźmy najnowocześniejszy otwarty model OCR — otwórz PaddleOCR-VL na Hugging Face, a pasek boczny powie ci obie interesujące nas rzeczy: liczbę parametrów i format, w jakim każdy jest zapisany.

Pasek boczny Hugging Face dla PaddleOCR-VL-1.5 z Model size: 1.0B params zaznaczonym na czerwono i Tensor type: BF16 zaznaczonym na zielono

Model size to łączna liczba parametrów — ile liczb model przechowuje. Tensor type to dtype — skrót od data type, czyli typ danych. Litera d jest po to, by odróżnić go od zwykłego type: w Pythonie type(x) mówi o kontenerze (list, numpy.ndarray, torch.Tensor), podczas gdy dtype mówi o elementach w środku (float32, int8, bfloat16) — i to właśnie decyduje, ile bajtów zajmuje każda waga. Termin narodził się w NumPy i podchwycił go każdy framework ML.

W kodzie odczytujesz go z dowolnego tensora czy tablicy przez atrybut .dtype — i każdy framework raportuje go tak samo, kanoniczną nazwą formatu:

>>> import torch
>>> torch.tensor([0.5], dtype=torch.bfloat16).dtype
torch.bfloat16

>>> import numpy as np
>>> np.array([0.5], dtype=np.float16).dtype
dtype('float16')

Hugging Face czyta tę samą informację prosto z pliku safetensorssafetensors to format plików Hugging Face do zapisu i dystrybucji wytrenowanych wag, a dtype każdego tensora jest zapisany w jego metadanych, co pasek boczny wyświetla jako „Tensor type”.

BF16 na pasku oznacza, że każda waga w modelu to bfloat16 — „Brain Float 16” — 16-bitowy format zmiennoprzecinkowy zaprojektowany w Google dla ML. To format float-podobny: ta sama struktura znak/wykładnik/mantysa co w liczbie IEEE-754, tylko upakowana w 16 bitów zamiast 64, z własnym podziałem między wykładnik a mantysę. Różnicom — w tym temu, dlaczego bf16 to nie to samo co IEEE-754 float16, mimo że oba mają 16 bitów — przyjrzymy się dalej w artykule.

Kroki obliczenia zajętości pamięci modelu są więc te same, których użyliśmy dla MNIST, tylko z większymi liczbami. Wartość 1.0B to suma parametrów po wszystkich warstwach modelu — ta sama arytmetyka warstwa po warstwie, którą zrobiliśmy ręcznie dla MNIST, tyle że Hugging Face już zrobił ją za nas, przechodząc po każdym tensorze w pliku safetensors i sumując liczby elementów.

Karta mówi więc: 1 miliard parametrów („B” to skrót od billion, 10910^9). Pomnóż to przez 2 bajty na wagę bf16, a dostaniesz zajętość wag modelu:

1,000,000,0001.0B params (sum over all layers)×2bytes per bf16 weight=2,000,000,000 bytes=2 GB.\underbrace{1{,}000{,}000{,}000}_{\text{1.0B params (sum over all layers)}} \times \underbrace{2}_{\text{bytes per bf16 weight}} = 2{,}000{,}000{,}000 \text{ bytes} = 2\text{ GB}.

To dominujący koszt pamięci przy inferencji, gdzie aktywacje są przejściowe (w czasie przebiegu w przód w pamięci żyje naraz tylko jedna warstwa), a cache KV, jeśli występuje, jest dla typowych obciążeń zwykle mniejszy niż wagi. Z treningiem jest inaczej — ten sam model potrzebuje wag plus gradientów, stanu optymalizatora i zbuforowanych aktywacji do propagacji wstecznej — co policzymy w osobnej sekcji poniżej.

Teraz przeskaluj tę samą arytmetykę w górę, a stawka szybko rośnie. Model o 7B parametrów zajmuje w fp32 28 GB — w ogóle nie mieści się na 24 GB RTX 4090 i zostawia ledwie 4 GB zapasu na 32 GB RTX 5090 (za mało na aktywacje i cache KV). Przy 14 GB w fp16 mieści się wygodnie na obu; 7 GB w int8 zostawia zapas. Model 70B w fp16 to 140 GB — żadne pojedyncze GPU go nie pomieści, a wybór formatu zaczyna dyktować, czy potrzebujesz jednego GPU, dwóch, czy ośmiu.

Choć domyślnym formatem we frameworkach treningowych jest fp32, modele często publikuje się w czymś węższym: zespoły rutynowo wymieniają część precyzji, z jaką trenowały, na mniejsze pobrania i niższy koszt VRAM przy inferencji. Wymiana przybiera dwie szerokie formy — zejście do 16-bitowego formatu zmiennoprzecinkowego (fp16, bf16) albo pójście dalej, w kwantyzację, odwzorowującą wagi na mały zbiór dyskretnych poziomów, zwykle na 8 lub 4 bitach. Gdyby autorzy opublikowali PaddleOCR-VL w fp32, ten sam model zajmowałby 4 GB. Wybrali bf16, bo o połowę zmniejsza pamięć względem fp32 praktycznie bez straty jakości — dlaczego ten wybór działa, prześledzimy w dalszej części artykułu.

Nie każda liczba w sieci jest wagą

Wagi modelu to nie jedyne liczby w działającej sieci. Pełny obraz ma kilka odrębnych kategorii — każda zjada pamięć, każda może wybrać własny format. Więcej o tym, skąd bierze się każda z nich — wagi i obciążenia, aktywacje warstw w przebiegu w przód, gradienty w przebiegu wstecznym oraz krok aktualizacji optymalizatora — znajdziesz w artykule o tym, jak sieci się uczą.

Dwie z nich są uniwersalne: wagi i aktywacje istnieją niezależnie od tego, czy prowadzisz inferencję, czy trening. Wagi to same wytrenowane parametry — historycznie fp32, dziś bf16 lub fp16, albo skwantyzowane do 8 czy 4 bitów przy inferencji na dużą skalę. Aktywacje to pośrednie wyjścia każdej warstwy w przebiegu w przód — przejściowe przy inferencji (naraz żyje w pamięci tylko wyjście jednej warstwy, bo wejście każdej warstwy to po prostu wyjście poprzedniej), buforowane na potrzeby przebiegu wstecznego przy treningu (wyjścia każdej warstwy muszą przetrwać, żeby propagacja wsteczna mogła je wykorzystać) i zwykle trzymane w tym samym formacie co wagi.

Przy inferencji LLM-y oparte na transformerach niosą też cache KV — klucze i wartości buforowane z przeszłych tokenów podczas generacji autoregresyjnej, żeby uwaga nie musiała ich przeliczać dla każdego nowego tokenu. Często trzymany w fp16 lub fp8, by oszczędzić pamięć przy długich kontekstach.

Podczas treningu do obrazu wchodzą jeszcze dwie kategorie:

  • Gradienty — pochodne liczone w przebiegu wstecznym w celu aktualizacji wag. Rozciągają się na wiele rzędów wielkości (zwłaszcza te małe), dlatego liczy się tu zakres i dlatego wygrywa dla nich bf16 (a później fp8 E5M2).
  • Stan optymalizatoraAdam trzyma dwa momenty na parametr, czyli 2× liczbę parametrów. Prawie zawsze fp32, nawet gdy wagi żyją w bf16, bo drobny dryf kumuluje się przez tysiące kroków treningu.

W kodzie dtype mieszka w kilku konkretnych miejscach. Oto odpowiedni fragment z naszego artykułu o treningu na MNIST:

# Input data — explicitly cast to float32
X_train = train_images.reshape(-1, 784).astype("float32") / 255.0

# Model — Dense layers default to fp32 for weights and biases (no dtype= passed)
model = keras.Sequential([
    keras.layers.Dense(128, activation="relu", input_shape=(784,)),
    keras.layers.Dense(10, activation="softmax"),
])

# Optimizer — owns its own state, allocated separately
model.compile(
    optimizer=keras.optimizers.SGD(learning_rate=0.1),
    loss="sparse_categorical_crossentropy",
    metrics=["accuracy"],
)

Trzy różne linie, trzy różne kategorie. .astype("float32") ustawia dtype danych wejściowych, który następnie propaguje się do aktywacji, gdy wartości płyną w przód przez warstwy. Konstruktory Dense(...) nie wymagają argumentu dtype= — Keras domyślnie przyjmuje fp32 dla wag i obciążeń. Żeby nadpisać to domyślne zachowanie, trzeba jawnie podać dtype=:

# Same architecture, but weights and biases stored in bf16
model = keras.Sequential([
    keras.layers.Dense(128, activation="relu", input_shape=(784,), dtype="bfloat16"),
    keras.layers.Dense(10, activation="softmax", dtype="bfloat16"),
])
# PyTorch equivalent — nn.Linear takes the dtype directly
model = nn.Sequential(
    nn.Linear(784, 128, dtype=torch.bfloat16),
    nn.ReLU(),
    nn.Linear(128, 10, dtype=torch.bfloat16),
)

Obie formy nadpisują wyłącznie dtype wag i obciążeń — nie robią nic aktywacjom, gradientom ani stanowi optymalizatora. Optymalizator jest konstruowany niezależnie i posiada dtype swojego stanu: zwykły SGD nie ma żadnego, ale Adam alokowałby dwa momenty fp32 na parametr. Nakładki mieszanej precyzji, jak torch.amp.autocast i GradScaler, rozdzielają te kategorie jeszcze bardziej — na przykład wykonując przebieg w przód w bf16, a parametry trzymając w fp32. Wybór dtype= warstwy odpowiada więc na dokładnie jedno pytanie: w jakim formacie przechowywane są wagi. Reszta rozstrzyga się niezależnie.

To właśnie ludzie mają na myśli, mówiąc o treningu i inferencji w mieszanej precyzji: jeden model celowo używa różnych formatów do różnych ról, każdy dobrany do swoich wymagań numerycznych. W dalszej części artykułu, gdy mówimy „model jest w bf16”, zwykle mamy na myśli, że w bf16 są wagi. Pozostałe role mogą leżeć powyżej albo poniżej.

Żeby oszacować faktyczny rachunek za pamięć przy treningu, sumuje się koszt na parametr. Standardowy przepis mieszanej precyzji — wagi bf16 z optymalizatorem spadku gradientu w fp32 (najczęściej Adam) — wychodzi na 16 bajtów na parametr przed aktywacjami:

  • wagi bf16: 2 bajty
  • gradienty bf16: 2 bajty (ten sam kształt co wagi)
  • momenty Adama fp32: 8 bajtów (dwa momenty × po 4 bajty)
  • główna kopia wag fp32 (trzymana przez optymalizator dla stabilnych aktualizacji): 4 bajty

Zwróć uwagę na mnożnik. Przy inferencji każda waga kosztuje 2 bajty; trening rozdmuchuje koszt na parametr do 16 bajtów — 8× więcej — zanim jeszcze policzymy aktywacje. Sam stan optymalizatora (momenty fp32) jest 4× większy niż zajętość wag. Aktywacje na wierzchu zależą od rozmiaru partii, długości sekwencji i architektury i mogą łatwo podwoić całość jeszcze raz.

Zatem model o 7B parametrów, który mieści się w ~14 GB przy inferencji bf16, potrzebuje ~112 GB plus aktywacje, by trenować według tego przepisu — mniej więcej 8–10× więcej pamięci niż serwowanie tego samego modelu. Ta przepaść jest powodem, dla którego pojedyncze A100 (80 GB) może wygodnie serwować model 7B, ale nie może go trenować bez rozproszenia stanu optymalizatora na wiele GPU (techniki takie jak ZeRO i FSDP).

Domyślny float: 64 bity w każdym języku, który znasz

Zanim spojrzymy na formaty numeryczne, których ML faktycznie używa, warto zacząć od znajomych typów liczbowych, których używamy na co dzień w Pythonie i JavaScripcie:

JęzykRodzajTypSzczegóły
PythonCałkowity (bez ułamków, dokładny)intDowolnej precyzji. CPython rozszerza pamięć w miarę potrzeby, więc 2**1000 po prostu działa, a wynik jest dokładny.
Float (64-bitowy double IEEE-754)floatTen sam format, który rozbieramy w reszcie artykułu. Literał 0.5 w skrypcie Pythona to 8 bajtów.
JavaScriptCałkowity (bez ułamków, dokładny)BigIntLiczba całkowita dowolnej precyzji, dodana później jako osobny typ. Zapisywana z sufiksem n (np. 5n). Najbliższy odpowiednik pythonowego int.
Float (64-bitowy double IEEE-754)NumberTen sam format co pythonowy float. JavaScript używa go i do całkowitych, i do ułamków; domyślnie nie ma osobnego typu całkowitego.

Liczą się tu wiersze z floatami. Pythonowy float i javascriptowy Number to dokładnie ten sam 64-bitowy double IEEE-754 — ten sam format, ta sama sprzętowa FPU wykonująca arytmetykę, więc zachowują się identycznie na poziomie bitów: ta sama binarna postać 0.1, to samo 0.1 + 0.2 = 0.30000000000000004, to samo zachowanie NaN, to samo przepełnienie do nieskończoności.

Mechanikę tego, jak te 64 bity dzielą się na znak, wykładnik i mantysę, dlaczego wykładnik jest przechowywany z przesunięciem zamiast w kodzie uzupełnień do dwóch i jak zaokrągla się 0.1, omawiam szczegółowo we wcześniejszych artykułach serii. Wszystko stamtąd stosuje się wprost do obu języków, bo pod spodem to ten sam 64-bitowy format.

Istotne dla reszty tego artykułu jest to, że żaden z tych języków nie pozwala poprosić o 32-bitowy float na poziomie języka. Wartość 0.5 zajmuje 64 bity, nie 32. Do codziennej arytmetyki to w porządku, ale ML potrzebuje znacznie szerszego i bardziej wyspecjalizowanego menu typów liczbowych — dlatego frameworki takie jak NumPy, PyTorch, TensorFlow i JAX wystawiają własny system dtype, z formatami takimi jak bf16, fp16, int8 i int4.

Jak konwersje gubią bity: od floatów językowych do dtype’ów ML

Ilekroć wartość przechodzi z jednego formatu numerycznego do drugiego — pythonowy float do int, float do int8, fp64 do fp16 — jest szansa, że bity się nie zmieszczą. Coś musi ustąpić, a to, dokąd trafiają brakujące bity, zależy od formatu źródłowego i docelowego.

Najprostszy przypadek to float → int: odrzuć wszystko za przecinkiem.

>>> int(3.7)
3
>>> int(-3.7)
-3               # truncates toward zero — drops the fractional bits
>>> import math
>>> math.floor(-3.7)
-4               # floor rounds toward -infinity instead

Mechanicznie sprzęt czyta wykładnik floata, przesuwa mantysę tak, by przecinek znalazł się między bitami całkowitymi a ułamkowymi, i czyta tylko stronę całkowitą. Bity ułamkowe są fizycznie odrzucane.

Weźmy na przykład 5.75. W binarnym to 101.11 — trzy bity przed przecinkiem (101 = 4 + 1 = 5) i dwa po (.11 = 0.5 + 0.25 = 0.75). IEEE-754 nie przechowuje tego jednak w ten sposób; normalizuje liczbę tak, by przed przecinkiem stała dokładnie jedna 1, i zapamiętuje przesunięcie w wykładniku:

   101.11   →   1.0111 × 2^2
   ^^^ ^^       ^ ^^^^   ^
   │   │        │ │      └── exponent: how far to shift the radix back
   │   │        │ └───────── mantissa (fractional part of the 1.xxx form)
   │   │        └─────────── the implicit leading 1 (not stored)
   │   └──────────────────── original fractional bits
   └──────────────────────── original integer bits

Float w pamięci trzyma więc mantysę 0111 i wykładnik 2 — a nie dosłowne cyfry 101.11. Żeby zamienić to z powrotem na liczbę całkowitą, sprzęt musi cofnąć normalizację: wziąć mantysę, dopisać z przodu niejawną 1 i przesunąć przecinek w prawo o wartość wykładnika, odzyskując pierwotny układ:

   mantissa:    1.0111
   shift by 2:  101.11
                ^^^ ^^
                │   └── fractional bits → discarded
                └────── integer bits    → 101 = 5

Wszystko na prawo od przecinka — .11, czyli 0.75 dziesiętnie — zostaje wyrzucone, a int(5.75) zwraca 5. Zauważ, że żadne zaokrąglanie nie zachodzi: bity ułamkowe nie są sprawdzane po to, by zdecydować o podbiciu części całkowitej — są po prostu odrzucane. Dlatego int(-3.7) daje -3, a nie -4 w powyższym fragmencie — obcięcie tnie w stronę zera, niezależnie od tego, jakie faktycznie były odrzucone bity.

Ten sam mechanizm działa, gdy przechodzisz z floata językowego do węższego dtype’u ML. PyTorch, NumPy, TensorFlow i JAX robią tę konwersję wewnątrz tensor(...) / array(...):

>>> torch.tensor([3.7, -3.7], dtype=torch.int8)       # float → int, truncates toward zero
tensor([3, -3], dtype=torch.int8)                     # → fractional bits dropped on each element

>>> torch.tensor([1000, 1001], dtype=torch.int8)      # int → int8, overflows (int8 max is 127)
tensor([-24, -23], dtype=torch.int8)                  # → low 8 bits kept, neighbours wrap together

>>> torch.tensor([1e5, -1e10], dtype=torch.float16)   # fp64 → fp16, overflows (fp16 max is ~65504)
tensor([inf, -inf], dtype=torch.float16)              # → saturate to ±inf; magnitudes are lost

>>> torch.tensor([2**24 + 1, 2**24 + 2], dtype=torch.float32)  # int → fp32, exceeds mantissa precision
tensor([16777216., 16777218.], dtype=torch.float32)            # → +1 rounds away (24-bit mantissa); +2 is exact

Te cztery linie obejmują kanoniczne wzorce, do których sprowadza się każda konwersja typu liczbowego. Za każdym razem ta sama reguła — format źródłowy trzyma bity, których docelowy nie może zatrzymać — i cztery różne reakcje sprzętu, zależnie od tego, które pole się przepełnia:

WzorzecZDoPrzykładWynik
Obcięciefloatint3.7int83
Zawinięcieintwęższy int1000int8-24
Nasycenie do nieskończonościfloatwęższy float1e5fp16inf
Utrata precyzjiintfloat2^24 + 1fp322^24

Każdy wiersz zasługuje na bliższe spojrzenie — mechanika różni się w ciekawy sposób.

Obcięcie. 3.7int8 zostawia 3. Bity ułamkowe są odrzucane w stronę zera niezależnie od tego, jak duże są, więc 3.01, 3.5 i 3.999 wszystkie lądują na 3. Wartości ujemne też tną w stronę zera: -3.999 staje się -3, a nie -4. To ten sam mechanizm, który prześledziliśmy wyżej dla int(5.75) — sprzęt cofa normalizację, a potem odrzuca wszystko na prawo od przecinka, nawet tam nie zaglądając.

Zawinięcie. 1000int8 daje -24. int8 przechowuje 8 bitów kodu uzupełnień do dwóch pokrywających [128,127][-128, 127], więc gdy wartość się nie mieści, przetrwają tylko dolne 8 bitów i zostają zinterpretowane w tym kodowaniu ze znakiem — co ląduje 1000 na -24. Pobliska wartość 1001 zawija się do -23, a -129 do +127 — oś liczbowa zagina się w siebie co 256 kroków, więc int8 traktuje zakres jak pierścień, a nie prostą.

Nasycenie do nieskończoności. 1e5fp16 staje się inf, a -1e10 staje się -inf. Pole wykładnika w fp16 ma tylko 5 bitów, a największa skończona wartość, jaką potrafi zakodować, to 65 504 (1.1111111111 × 2^{15}). Wartość 100 000 wymagałaby wykładnika 2^{16}, dla którego fp16 nie ma wzorca bitowego — więc IEEE-754 robi jedyną rzecz, jaką może: zwraca +inf (bit znaku decyduje o +inf kontra -inf, reszta wzorca jest ta sama). W przeciwieństwie do zawijania liczb całkowitych, przepełnienie floata nie cykluje; ono się nasyca. 1e5, 1e10 i 1e38 zapadają się w ten sam wzorzec bitowy +inf, a -1e5, -1e10, -1e38 w ten sam -inf — po konwersji nic ich nie odróżnia.

Utrata precyzji. 2^24 + 1fp32 staje się 2^24. Mantysa fp32 ma 24 bity (23 jawne + 1 niejawny), więc każda liczba całkowita od 0 do 2^24 (16 777 216) jest dokładnie reprezentowalna, ale powyżej tego odstęp między reprezentowalnymi wartościami podwaja się co każdą potęgę dwójki. Liczba 2^24 + 1 wpada między dwie reprezentowalne wartości fp32 i zostaje zaokrąglona do bliższej (2^24, bo +1 leży dokładnie w połowie, a remisy zaokrągla się do parzystej). To ta sama historia, przez którą fp64 zaczyna tracić precyzję całkowitą przy 2^53 ≈ 9 × 10^15 — javascriptowy Number.MAX_SAFE_INTEGER to dokładnie 2^53 − 1.

Oto ogólna reguła dla każdej konwersji w reszcie artykułu: bity, które nie mieszczą się w formacie docelowym, muszą gdzieś się podziać — zostać obcięte, zaokrąglone, zawinięte albo zepchnięte do nieskończoności — a to, które z tych czterech się zdarzy, rozstrzygają format źródłowy i docelowy, nie wartość.

Wychodząc poza językową domyślność

Ekosystem ML wystawia znacznie szersze menu, niż natywnie robią to Python czy JavaScript. Każdy główny framework numeryczny — NumPy, PyTorch, JAX i TensorFlow — pozwala wybrać dtype jawnie. Oto ta sama operacja w czterech frameworkach:

import torch
x = torch.tensor([0.5])                        # fp32 by default — 4 bytes
y = torch.tensor([0.5], dtype=torch.float16)   # fp16 — 2 bytes
z = torch.tensor([0.5], dtype=torch.bfloat16)  # bf16 — 2 bytes
w = torch.tensor([0.5], dtype=torch.float64)   # fp64 — 8 bytes, same as Python float
import tensorflow as tf
x = tf.constant([0.5])                         # fp32 by default — 4 bytes
y = tf.constant([0.5], dtype=tf.float16)       # fp16 — 2 bytes
z = tf.constant([0.5], dtype=tf.bfloat16)      # bf16 — 2 bytes
w = tf.constant([0.5], dtype=tf.float64)       # fp64 — 8 bytes
import numpy as np
x = np.array([0.5], dtype=np.float32)          # 4 bytes per element
y = np.array([0.5], dtype=np.float16)          # 2 bytes
# NumPy core has no bfloat16 — you need the `ml_dtypes` package, or JAX/TF/Torch arrays
import jax.numpy as jnp
x = jnp.array([0.5], dtype=jnp.bfloat16)       # bf16 — 2 bytes

API różnią się kosmetycznie (torch.tensor(...) kontra tf.constant(...) kontra np.array(...)), ale kontrakt jest ten sam: podajesz wartość liczbową i dtype, a framework pakuje każdy element w dokładnie tyle bajtów w ciągłym buforze. NumPy jest lekko w tyle za dtype’ami ery ML — nie zawiera bfloat16 w typach podstawowych, bo bfloat16 został wprowadzony przez Google dla TPU i ustandaryzowany najpierw przez inne frameworki. PyTorch, JAX i TensorFlow wspierają go natywnie.

Literał 0.5 z punktu widzenia Pythona nadal jest 64-bitowym double — konwersja zachodzi przy konstruowaniu tensora. Wewnętrznie każdy element zajmuje 8, 4, 2 albo 2 bajty, zależnie od dtype. Przy fp8 staje się to 1 bajtem; przy int4 — pół bajta.

Żeby zobaczyć, co te bajty faktycznie kodują — i dlaczego zmniejszenie ich o połowę nie tnie po prostu reprezentowalnego zakresu na pół — musimy przyjrzeć się formatowi pod spodem.

Rodzina formatów ML i dlaczego bf16 wygrał

Współczesny ML używa garstki formatów zmiennoprzecinkowych i wszystkie dzielą ten sam trójpolowy układ odziedziczony po IEEE-754:

  • Znak (1 bit) — dodatnia czy ujemna.
  • Wykładnik — jak daleko przesunąć przecinek, przechowywany jako kod z przesunięciem.
  • Mantysa (znacznik) — wiodące cyfry liczby w znormalizowanej postaci naukowej, z pominiętą niejawną wiodącą 1.

Dziedziczą też konwencje IEEE-754 w całości — niejawną wiodącą jedynkę, wykładnik z przesunięciem, specjalne wzorce bitowe dla ±0 / ±inf / NaN / liczb subnormalnych, domyślną regułę zaokrąglania do najbliższej z remisami do parzystej — oraz rolę każdego pola: bity wykładnika kupują zakres (jak dużą lub małą liczbę da się przedstawić), bity mantysy kupują precyzję (jak drobno da się odróżniać liczby podobnej wielkości).

To odwzorowanie jest stałe dla każdego floata IEEE-754 niezależnie od szerokości. Tym, co różni cztery poniższe formaty, jest sposób podziału bitów między te dwa pola — i ten podział jest całą decyzją projektową. Trzy z czterech — fp64, fp32 i fp16 — dokładnie odpowiadają standardowi IEEE-754. bf16 jest wyjątkiem: zaprojektowany w Google dla TPU, nigdy nieustandaryzowany przez IEEE, ale zbudowany na tych samych konwencjach na wskroś:

FormatBitów łącznieZnakWykładnikMantysaBajty/param
fp64 (IEEE-754 double)64111528
fp32 (IEEE-754 single)3218234
bf16 (Brain Float 16)161872
fp16 (IEEE-754 binary16)1615102

Dwa wiersze 16-bitowe są tu ciekawe — ten sam budżet bitów, przeciwne podziały. Przeciągnij suwak poniżej, żeby poczuć wymianę: każdy bit przeniesiony do wykładnika podwaja osiągalny zakres i o połowę zmniejsza precyzję, i odwrotnie. Presety fp16 i bf16 zaskakują na faktyczne wybory formatów.

16-bit float — how to spend the bits
S
E
E
E
E
E
E
E
E
M
M
M
M
M
M
M
8 exponent·7 mantissa
Max representable
±3.39e+38
Smallest positive normal
1.18e-38
Gap in [1, 2)
0.00781
Representable values in [1, 4) (128 per octave; gap doubles at 2)
124

Przenoś bity do wykładnika, a maksymalna reprezentowalna wartość rośnie bardzo szybko — ale odstęp w każdej oktawie rośnie równo z nią (w [1,2)[1, 2) wynosi 2M2^{-M}, w [2,4)[2, 4) 2×2M2 \times 2^{-M} i tak dalej, podwajając się co oktawę), więc siatka wszędzie robi się rzadsza. Przenieś bity do mantysy, a odstęp maleje, podczas gdy maksimum się zapada. Presety fp16 i bf16 lądują po przeciwnych stronach dokładnie tej wymiany przy 16 bitach: bf16 sięga ~10^38 z odstępami ~0.008 w okolicy 1, fp16 zatrzymuje się na ~65 504 z odstępami ~10^-3.

Pasek u dołu widżetu uwidacznia ten odstęp: przy wybranym bf16 widać pojedyncze znaczniki rozstawione na [1, 2] — te odstępy to granica precyzji formatu w skali jednostkowej. Przełącz na fp16, a znaczniki zlewają się w ciągłą linię, bo odstęp zszedł poniżej piksela. Siatka wciąż jest dyskretna; jest tylko na tyle gęsta, że nie widać już dyskretności — a to dokładnie znaczy tutaj „więcej precyzji”.

Pasek sięga też do kolejnej oktawy [2, 4) (za przerywany znacznik przy 2), by jasno pokazać, że to podwajanie odstępu nie jest szczególne dla zakresu [1, 2) — ta sama liczba znaczników (po jednym na stan mantysy) rozciąga się na dwukrotnie szerszy przedział, więc wizualny odstęp w prawej połowie jest dwa razy większy niż w lewej. Przeciągnij suwak do ustawienia z małą mantysą (np. M=4), a podwojenie stanie się oczywiste.

Dla konkretu, kilka pierwszych reprezentowalnych wartości w [1, 2] dla każdego presetu:

bf16 (128 wartości, odstęp = 1/128)fp16 (1024 wartości, odstęp = 1/1024)
1.01.0
1.00781251.0009765625
1.0156251.001953125
1.02343751.0029296875
1.031251.00390625
1.99218751.9990234375
(2.0)(2.0)

Każda kolejna wartość bf16 ląduje dokładnie na co ósmej wartości fp16 — siatka bf16 w tej oktawie jest ścisłym podzbiorem siatki fp16, tylko z siedmioma z każdych ośmiu znaczników usuniętymi. Ten stosunek to dokładnie 2107=23=82^{10 - 7} = 2^3 = 8 (fp16 ma 10 bitów mantysy, bf16 ma 7) — każdy dodatkowy bit mantysy podwaja liczbę reprezentowalnych wartości na oktawę.

Ten jednolity odstęp obowiązuje tylko w obrębie jednej oktawy, takiej jak [1, 2) — najgęstszej części siatki dla dowolnej wartości dodatniej, i dokładnie to raportuje statystyka „odstęp w [1, 2)” w widżecie. W chwili, gdy przekraczasz do [2, 4), wykładnik podskakuje o 1, krok mantysy zostaje pomnożony przez 2, a odstęp się podwaja.

Oto to dla bf16 na kilku oktawach:

Oktawa bf16Odstęp
[1, 2)1/128 ≈ 0.0078
[2, 4)2/128 = 1/64 ≈ 0.0156
[4, 8)4/128 = 1/32 ≈ 0.0313
[1024, 2048)1024/128 = 8

Zatem bf16 wymienia precyzję na zakres — sięga do skrajnych wielkości w obie strony (drobne ułamki rzędu 103810^{-38} po małej stronie, 103810^{38} po dużej), ale zgrubnie zaokrągla bliskie wartości do siebie. fp16 jest lustrem — rozróżnia drobne różnice między bliskimi wartościami, ale przepełnia się na skrajnościach w obie strony. Ta wymiana jest powodem, dla którego oba formaty 16-bitowe współistnieją we współczesnym ML: bf16 do treningu (gradienty rozciągają się na wiele rzędów wielkości, liczy się zakres), fp16 do niektórych scenariuszy inferencji (precyzja liczy się bardziej, gdy wartości są ograniczone).

Dlaczego bity bf16 są podzielone właśnie tak

Widżet pokazał bf16 i fp16 jako przeciwne podziały tego samego 16-bitowego budżetu. Ale projekt bf16 nie był odpowiedzią na fp16 — był odpowiedzią na fp32. Google zaprojektował bf16 dla TPU wokół jednego pytania — jak o połowę zmniejszyć koszt pamięciowy fp32, nie tracąc jego zakresu — i doszedł do prostej odpowiedzi. Zachować 8 bitów wykładnika dosłownie (to samo przesunięcie 127, bit w bit identyczne z fp32) i ściąć mantysę z 23 bitów do 7. Format szybko rozszedł się poza TPU: NVIDIA Ampere (A100, 2020), AMD CDNA, procesory Intela (AVX-512 BF16) i ARM Armv8.6-A — wszystkie dodały natywne wsparcie bf16; to domyślny 16-bitowy format ML na współczesnym sprzęcie.

bf16 to więc w istocie fp32 z wyciętą mantysą — ten sam zakres co fp32, mniej więcej od 103810^{-38} do 103810^{38}, tylko na rzadszej siatce. Każdy bit mantysy o połowę zmniejsza odstęp między sąsiednimi reprezentowalnymi wartościami, więc usunięcie 16 z nich podwaja odstęp 16 razy — 216=65,536×2^{16} = 65{,}536\times rzadziej przy dowolnym wykładniku. Waga taka jak 1.005 jest więc w każdym formacie przechowywana inaczej:

FormatPrzechowywana wartośćBłąd
fp321.005 (praktycznie dokładnie)~10⁻⁸
fp161.0048828125~1.2 × 10⁻⁴
bf161.0078125~2.8 × 10⁻³

Mniejszy błąd oznacza, że przechowywana wartość leży bliżej oryginału — więc fp16 wygrywa tu na precyzji (około 24× niższy błąd niż bf16), ale przegrywa na zakresie. Gradient rzędu 1e-7 uległby niedomiarowi w fp16, ale przetrwałby w bf16.

ML ma dwa tryby awarii formatów numerycznych i nie są one równie złe. Awarie zakresu (nadmiar/niedomiar) są katastrofalne — zniknięty gradient całkowicie zatrzymuje trening dla danego parametru; przepełniona aktywacja produkuje NaN i zabija cały przebieg. Awarie precyzji są znośne — drobne błędy zaokrągleń na wagę uśredniają się w skali milionów wag sieci (więcej o tym niżej). bf16 zawodzi na osi znośnej; fp16 na katastrofalnej — dlatego bf16 jest preferowany w ML mimo przegranej na precyzji pojedynczej wagi.

Żeby mimo limitu zakresu wciąż używać fp16 do treningu, potrzebna byłaby sztuczka zwana skalowaniem straty. Pomnóż stratę przez stałą skalę SS przed propagacją wsteczną — gradienty są liniowe względem straty, więc wszystkie wracają przeskalowane tą samą SS, lądując wewnątrz zakresu fp16 — a potem podziel przez SS przed zastosowaniem aktualizacji. Bez tego gradient 1e-7 ulega niedomiarowi do zera i ten parametr nie dostaje żadnej aktualizacji. torch.cuda.amp.GradScaler w PyTorchu automatyzuje tę księgowość. bf16 pomija to wszystko: jego zakres odpowiada fp32, więc gradienty mieszczą się natywnie.

Ta sama wymiana przy każdej szerokości

To, co widzieliśmy w poprzednich sekcjach — bf16 kontra fp16 przy 16 bitach — jest jednym przykładem szerszego wzorca: im węższy format, tym ostrzejsza wymiana i tym większe prawdopodobieństwo, że ta sama szerokość istnieje w kilku odmianach. fp8 (8 bitów) jest tak ciasny, że żaden pojedynczy podział nie wygrywa, więc występuje w dwóch wariantach — E4M3 (więcej precyzji, do wag) i E5M2 (więcej zakresu, do gradientów) — których prawdziwe potoki używają obok siebie.

Jest też praktyczna dywidenda z tego, że wykładnik bf16 jest zgodny z fp32: konwersja fp32 ↔ bf16 jest w zasadzie darmowa. To samo pole wykładnika, to samo przesunięcie 127; odrzucasz dolne 16 bitów mantysy i gotowe. Sprzęt robi to przesunięciem. Konwersja fp16 natomiast może naprawdę przepełnić się lub ulec niedomiarowi, bo zakres jej wykładnika jest inny. Potoki mieszanej precyzji płyną między fp32 a bf16 gładko; fp32 ↔ fp16 wymaga starannego skalowania.

fp16 wciąż występuje przy inferencji na starszych GPU bez wsparcia bf16 oraz w niektórych scenariuszach wdrożeniowych, gdzie dodatkowy bit mantysy jest wart kłopotów z zakresem. Ale do treningu bf16 skutecznie go zastąpił.

Każdy format ma własną krzemową obsługę

Format numeryczny to tylko układ bitów — przewaga szybkości bierze się z dedykowanego krzemu, który mnoży i akumuluje ten układ natywnie. Bez krzemu implementującego format natywnie oprogramowanie wciąż potrafi go przechować, ale każda operacja arytmetyczna spada do szerszego typu, a przewaga przepustowości, która motywowała węższy format, znika.

Każdy szeroko używany dziś format ML wywodzi się z konkretnej jednostki wykonawczej w konkretnej generacji układów:

FormatGdzie działa natywnie
fp64 / fp32Uniwersalna FPU w każdym CPU i GPU. Uniwersalne, ale wolne dla matematyki tensorowej.
fp16Rdzenie tensorowe NVIDII od Volty (V100, 2017); inni producenci poszli w ślad.
int8Rdzenie tensorowe NVIDIA Turing (T4, 2018); dziś w zasadzie w każdym nowoczesnym akceleratorze.
bf16Google TPU v2 (2017), NVIDIA Ampere (A100, 2020), Intel AVX-512 BF16, Arm Armv8.6-A BF16.
fp8 (E4M3, E5M2)NVIDIA Hopper (H100, 2022), AMD CDNA 3 (MI300), Intel Gaudi 2/3.
int4Rdzenie tensorowe ery Hoppera i nowsze.

Każdy producent ma własną nazwę tych bloków — NVIDIA Tensor Cores, AMD Matrix Cores, Intel Gaudi Matrix Math Engines (MME), Google Matrix Multiply Units (MXU) — ale idea jest ta sama: dedykowany kawałek krzemu wykonujący złożone mnożenie z akumulacją (a × b + c w jednym zaokrąglonym kroku) na kafelkach liczb o niskiej precyzji, zwykle z przepustowością 2–16× większą (i przy niższej energii) niż uniwersalna FPU.

Dwie rzeczy do zapamiętania. Oszczędność pamięci zachodzi na każdym GPU — model bf16 jest o połowę mniejszy niż model fp32 niezależnie od sprzętu. Przyspieszenie zachodzi tylko wtedy, gdy jest odpowiedni krzem — stamtąd bierze się mnożnik przepustowości 2–16×. Model bf16 chodzi z pełną prędkością na A100; na V100 (tylko fp16) potrzebuje albo konwersji z wyprzedzeniem, albo programowej konwersji na operację, która niweczy zysk prędkości. Ta sama historia z fp8 na czymkolwiek starszym niż H100. Wybór węższego formatu zawsze więc zmniejsza model w pamięci, a dodatkowo skraca czas działania, jeśli układ ma rdzenie tensorowe (lub odpowiednik) dla tego formatu.

Jest też aspekt czasowy: adopcja formatu opóźnia się za sprzętem. Nowy układ można zaproponować na papierze choćby w przyszłym tygodniu, ale nie wejdzie do użycia na skalę, dopóki nie pojawi się generacja układów z natywnym wsparciem — zwykle dwa do czterech lat później. Dlatego współczesne akceleratory ciągle dokładają jednostki (fp16 → bf16 → fp8 → fp4), zamiast je wymieniać: każda generacja dodaje krzem pod następny format, zachowując poprzednie dla kompatybilności wstecznej.

W praktyce oznacza to, że planowanie wdrożenia zaczyna się od typu tensora z karty modelu. Zanim wybierzesz GPU, sprawdź format, w jakim opublikowano wagi (fp32, bf16, fp16, fp8, int8, int4 — Hugging Face pokazuje to na pasku bocznym, który widzieliśmy wcześniej) i dopasuj go do krzemu, który masz albo możesz wynająć: bf16 wymaga Ampere lub nowszego, fp8 wymaga Hoppera albo MI300 albo Gaudi 2/3, int4 wymaga rdzeni tensorowych ery Hoppera. Rozmiń te dwie rzeczy, a albo płacisz podatek programowej konwersji, który wyrzuca przewagę szybkości formatu, albo w ogóle nie uruchomisz modelu z pełną prędkością.

Patrząc naprzód: fp8

Na najdalszym froncie 16 bitów ustępuje ośmiu. fp8 przybył jako natywny format sprzętowy — dedykowane wsparcie rdzeni tensorowych trafiło do NVIDIA H100, AMD MI300 i Intel Gaudi 2/3 — i tak jak fp16 kontra bf16, nie potrafi się ustalić na jednym podziale w tak ciasnym budżecie, więc występuje w dwóch odmianach:

FormatZnakWykładnikMantysaDo czego
fp8 E4M3143wagi i aktywacje przebiegu w przód (przechył ku precyzji)
fp8 E5M2152gradienty (przechył ku zakresowi)

Oba zostały ustandaryzowane przez specyfikację Open Compute Project FP8 Formats for Deep Learning, napisaną wspólnie przez NVIDIĘ, Intela i Arm.

Oba warianty dzielą sprzęt: rdzenie tensorowe z obsługą fp8 (NVIDIA H100, AMD MI300, Intel Gaudi 2/3) dekodują te same 8 bitów jako E4M3 albo E5M2 na podstawie flagi trybu ustawianej per operacja — nie ma osobnego krzemu dla każdego. Krok treningowy zwykle używa E4M3 do wag i aktywacji przebiegu w przód (przechył ku precyzji, bo te wartości są ograniczone konstrukcją sieci) oraz E5M2 do gradientów przebiegu wstecznego (przechył ku zakresowi, bo wielkości gradientów rozciągają się na wiele rzędów). Żeby zrekompensować mikroskopijny zakres dynamiczny fp8, każdy tensor niesie współczynnik skali na tensor — zwykle liczony dynamicznie z obserwowanych rozkładów wartości — który odwzorowuje jego faktyczny zakres w reprezentowalne okno fp8. W kodzie:

import torch

w_fp32 = torch.randn(1024, 1024, dtype=torch.float32) * 0.1

# Per-tensor scale: |max| / fp8_max maps the tensor's range into fp8's
FP8_E4M3_MAX = 448.0
scale = w_fp32.abs().max() / FP8_E4M3_MAX

# Quantize: divide by scale, then cast to fp8
w_fp8 = (w_fp32 / scale).to(torch.float8_e4m3fn)

# Reconstruct on read: cast back, multiply by scale
w_back = w_fp8.to(torch.float32) * scale

Biblioteki wyższego poziomu (Transformer Engine NVIDII, ścieżki fp8 w torch.amp PyTorcha) owijają to skalowanie automatycznie — te.fp8_autocast(enabled=True) śledzi amax na tensor i wylicza skale bez udziału wywołującego.

Per-tensor scaling — matching fp8's grid to the data
Tensor max
±1.00
Scale factor
2.23e-3
fp8 grid covers
±1.00
Tensor data (blue dots) and the scaled fp8 grid (green ticks)
1.150+1.15
fp8's native grid spans ±448. Multiplied by the per-tensor scale factor (2.23e-3), it now covers ±1.00 — matching the data's range. Without scaling, the entire tensor would crowd around fp8's smallest normal value (~0.0156) or fall below it entirely; with scaling, the grid points spread evenly through the values you actually want to store.

Przesuń suwak albo wybierz preset, by ustawić maksymalną wartość tensora: zielone znaczniki to reprezentowalne wartości fp8 pomnożone przez współczynnik skali tensora, a niebieskie kropki to próbka „danych tensora” w wybranym zakresie. Gdy maksimum tensora rośnie lub maleje, skala zmienia się tak, by zielona siatka pozostała rozciągnięta na danych — natywna siatka ±448 fp8 to ten sam sprzęt, ale skala przemianowuje jej oś, tak by punkty siatki wypadały tam, gdzie dane faktycznie mieszkają. Główne wagi zostają w fp32, optymalizator działa w wyższej precyzji, a wagi są rzutowane w dół do E4M3 na każdy przebieg w przód. Transformer Engine NVIDII, torch.float8_e4m3fn / torch.float8_e5m2 w PyTorchu i JAX — wszystkie implementują ten przepis. Inferencja używa tego samego sprzętu, ale pomija przebieg wsteczny — wagi i aktywacje siedzą w E4M3, a skale na tensor są kalibrowane offline i zamrażane w chwili wdrożenia.

Większość wdrożonych dziś modeli wciąż jest w bf16 albo skwantyzowana do int8 na potrzeby inferencji, ale to fp8 jest kierunkiem, w którym zmierza następna generacja treningu i inferencji — a ta sama wymiana, którą widzieliśmy między fp16 a bf16 (precyzja kontra zakres), rozgrywa się teraz o jedną szerokość bitową niżej.

Dlaczego modele tolerują mniej bitów

Pod wyborem bf16 kontra fp16, podziałem fp8 i wszystkim, co nadchodzi w kwantyzacji, leży jedno fundamentalne twierdzenie: pojedyncze wagi ML nie mają znaczenia same z siebie. Formaty zmiennoprzecinkowe projektowano przy założeniu, że każda liczba liczy się sama w sobie — wartość w symulacji dynamiki płynów, współczynnik w solwerze elementów skończonych, współrzędna w algorytmie geometrycznym. Wagi ML takie nie są. Waga to jedna z milionów wzajemnie dopasowanych, zaszumionych wartości, których efekty sumują się w warstwie, a drobne błędy zaokrągleń na wagę wypłukują się w tej sumie.

Dlatego inferencja fp16 / bf16 jest praktycznie darmowa dla modelu trenowanego w fp32, dlatego int8 jest niemal darmowy dla większości obciążeń, mimo że każda pojedyncza waga jest wyraźnie mniej precyzyjna, i dlatego trend schodzi coraz niżej. Empirycznie: im większy model, tym bardziej tolerancyjny: mała CNN może stracić zauważalną dokładność przy int4, ale 70B LLM przy int4 zwykle nie.

Dowody empiryczne rozciągają się na prawie dekadę. Pierwsza fala przyszła z kompresji CNN: Deep Compression (Han i in., 2015) pokazała, że wagi CNN można skwantyzować do 8 bitów przy pomijalnej stracie dokładności, a w połączeniu z przycinaniem i kodowaniem Huffmana zmniejszyła AlexNet 35× bez szkody dla wydajności. Kilka lat później Mixed Precision Training (Micikevicius i in., 2017) ustanowiła przepis „wagi fp16 z głównymi kopiami fp32”, który stał się kanonicznym domyślnym ustawieniem treningu — większość modeli trenowanych w ten sposób nie wykazuje mierzalnej różnicy dokładności względem pełnego fp32.

Era LLM przesunęła granicę dalej. LLM.int8() (Dettmers i in., 2022) i GPTQ (Frantar i in., 2022) pokazały, że kwantyzacja samych wag do int8 i int4 zachowuje jakość dla LLM-ów o miliardach parametrów. QLoRA (Dettmers i in., 2023) wprowadziła NF4 — 4-bitowy nierównomierny format dostrojony do rozkładu normalnego, któremu wagi zwykle podlegają — i użyła go do dostrojenia modeli o 65B parametrów na jednym GPU 48 GB. Najbardziej uderzająco, BitNet b1.58 (2024) trenuje LLM-y z wagami ternarnymi — każda ograniczona do {-1, 0, +1}, około 1,58 bita na wagę — i wciąż dorównuje bazom fp16 przy tej samej liczbie parametrów.

Wzorzec we wszystkich tych pracach jest ten sam — format przechowywania i statystyczne zachowanie przechowywanej rzeczy to dwa osobne pytania, a sieci neuronowe akurat są wyrozumiałe co do tego drugiego. Ta wyrozumiałość jest dokładnie tym, na czym po cichu opierała się dotąd każda sekcja tego artykułu — i na co następna sekcja naciska jeszcze mocniej.

Kwantyzacja to ta sama myśl, posunięta dalej

Wszystko dotąd przechowywało każdą wagę jako samodzielny float IEEE-754, z własnymi bitami znaku, wykładnika i mantysy. Kwantyzacja używa zupełnie innego schematu przechowywania — każda waga staje się małą liczbą całkowitą (1 bajt dla int8, 4 bity dla int4) we własnej przestrzeni całkowitej, a jeden zmiennoprzecinkowy współczynnik skali (trzymany raz na grupę wag) wykonuje pracę, którą wcześniej wykonywał wykładnik przy każdej wartości.

Zanim przejdziemy do mechaniki, oto co kwantyzacja faktycznie daje:

  • Pamięć — nagłówkowa wygrana, do której zmierzaliśmy. Startując z fp32, int8 jest 4× mniejszy, int4 8× mniejszy. Na modelu o 70B parametrach to różnica między „potrzeba 8 GPU” a „mieści się na jednym”.
  • Szybkość — mniejszy rozmiar przepycha mniej danych przez hierarchię pamięci, a właśnie tam schodzi większość czasu inferencji. Na sprzęcie, który to wspiera (i gdy wdrożenie wykonuje mnożenie macierzy w arytmetyce całkowitej, zamiast najpierw dekwantyzować do fp16), mnożenie z akumulacją int8 działa też szybciej niż MAC fp16 / bf16 na współczesnych rdzeniach tensorowych. Zysk z przepustowości pamięci obowiązuje w obu wdrożeniach; zysk z całkowitego MAC tylko przy prawdziwym całkowitym matmulu.
  • Możliwość wdrożenia — mobilne NPU, mikrokontrolery i akceleratory brzegowe często obsługują int8 albo nic. Ich krzem zaprojektowano pod arytmetykę stałoprzecinkową, nie pełnokrwistą zmiennoprzecinkową. Przy starannym kodowaniu (sama skala zapisana jako mnożnik int32 + liczba przesunięć w prawo, jak robi TFLite) cały matmul może działać na czystych operacjach całkowitych, bez tykania FPU. Kwantyzacja to jedyna droga do wdrożenia na tym sprzęcie — bez niej model po prostu tam nie ruszy.
  • Energia — operacje całkowite kosztują mniej dżuli na operację niż zmiennoprzecinkowe. Ma to znaczenie i na urządzeniach bateryjnych (telefony, IoT, asystenci na urządzeniu), i w skali centrum danych, gdzie budżet mocy jest prawdziwym sufitem przepustowości.

W praktyce dziś większość produkcyjnej inferencji ML działa przynajmniej częściowo skwantyzowana. Samodzielnie hostowane LLM-y (przez llama.cpp, Ollamę, LM Studio) niemal zawsze działają w int4 lub int8 — pełna inferencja bf16 jest rzadkością na sprzęcie konsumenckim z powodu budżetów pamięci. Serwowanie LLM-ów w centrach danych mocno opiera się na fp8 i int8 na skalę, często trzymając warstwy wrażliwe na wartości odstające w wyższej precyzji. Wdrożenia brzegowe i mobilne (rozpoznawanie mowy na urządzeniu, wizja komputerowa, fuzja sensorów) są w zasadzie zawsze int8 — krzem nie wspiera niczego innego. Czysta inferencja fp32 ogranicza się dziś głównie do przepływów badawczych, obliczeń naukowych i garstki krytycznych pod względem dokładności punktów produkcyjnych. Kwantyzacja nie jest optymalizacją, którą może kiedyś zastosujesz; dla większości współczesnych celów wdrożeniowych to ustawienie domyślne.

Są dwa przepływy właściwej kwantyzacji, zależnie od tego, kiedy w cyklu życia modelu ją robisz. Dzielą tę samą arytmetykę „przyciągania do siatki”, którą zbadamy w następnej podsekcji.

Kwantyzacja po treningu (PTQ) bierze już wytrenowany model fp32 / bf16 i kwantyzuje jego wagi w chwili wdrożenia, bez dalszego treningu — tanio (nie trzeba godzin GPU) i to standardowy dziś przepływ dla inferencji LLM. Typowe algorytmy PTQ dla LLM-ów to GPTQ i AWQ — oba budują na przepisie „przyciągnij i przeskaluj”, dokładając sprytniejszą maszynerię na wierzchu. Popularne narzędzia, które je implementują, to bitsandbytes (zintegrowany z PyTorchem, używany przez Hugging Face Transformers) i llama.cpp (przyjazny CPU i konsumenckim GPU, z własnymi formatami kwantyzacji GGUF).

Trening świadomy kwantyzacji (QAT) symuluje szum kwantyzacji już podczas treningu — przebieg w przód używa pseudo-skwantyzowanych wag, żeby optymalizator nauczył się to kompensować. Droższy (potrzebuje potoku treningowego i oznaczonego zbioru), ale daje lepszą dokładność przy skrajnych szerokościach bitowych (int4 i niżej). QAT jest standardem w produkcyjnych wdrożeniach CNN (mobile, brzeg), rzadszy dla LLM-ów z powodu kosztu treningu — choć warianty celowane w LLM-y, jak LLM-QAT, rozszerzają technikę o destylację bez danych.

Każdy przepływ ma jednak sufit — błędy zaokrągleń, które ładnie się uśredniają przy int8, przestają się ładnie uśredniać przy dużo niższych szerokościach bitowych. Kwantyzacja wag do int4 pogarsza niektóre zadania wymagające intensywnego rozumowania. Ta sama presja kompresji sięga też poza wagi — kwantyzacja cache’u KV przy długim kontekście to osobny, aktywny obszar badań, gdzie skrajne formaty, jak schemat 3-bitowy w TurboQuant, działają tylko dzięki przestawieniu problemu (obrót wektorów do znanego rozkładu przed kwantyzacją), tak by pozostały błąd zaokrąglenia był informacyjnie bliski optymalnemu.

Przepis „przyciągnij i przeskaluj”

Gdy model jest kwantyzowany, oryginalne wagi fp32 są odrzucane i zastępowane parami (indeks int8, skala S) — jeden indeks na wagę, jedna skala na grupę. Wagi nie są więc już przechowywane jako ciągłe floaty; kwantyzacja przyciąga je do jednego z małego, ustalonego zbioru dyskretnych poziomów — 256 dla int8 (albo 255 dla symetrycznego int8), 16 dla int4. A te poziomy nie są uniwersalne — buduje się je na grupę wag, z samych danych.

Przechowywana liczba całkowita nie jest jednak samą wagą — to indeks w siatce pozycji zmiennoprzecinkowych zdefiniowanej przez SS. Proces dekwantyzacji odzyskuje przybliżenie oryginalnej wartości ułamkowej: przy inferencji indeks × S ją rekonstruuje. Na współczesnym sprzęcie rekonstrukcja jest w samą porę — wtopiona w matmul, nigdy nie materializowana w pamięci — więc sieć wykonuje pracę bezpośrednio na skompresowanej reprezentacji, odzyskując przybliżone wartości fp32 dopiero w chwili, gdy są potrzebne.

Załóżmy więc, że wybraliśmy grupę pięciu wag do wspólnej kwantyzacji:

3.1416,1.7,0.0234,1.5,9.53.1416,\quad -1.7,\quad 0.0234,\quad 1.5,\quad -9.5

Proces ma dwa kroki, które widżet poniżej pozwala przeklikać. Na wysokim poziomie: najpierw liczymy jedną skalę SS dla całej grupy — to daje siatkę, do której przyciągną się wszystkie wagi. Potem dla każdej wagi stosujemy właściwą kompresję — przyciągamy do najbliższego punktu siatki (z zaokrąglaniem remisów do parzystej) i zapisujemy indeks całkowity. Krok 1 wykonuje się raz na grupę; krok 2 raz na wagę.

Żeby zdefiniować zbiór dyskretny, spójrz na zakres wartości w grupie wag i policz skalę SS z danych. Oto ogólny wzór dla symetrycznej kwantyzacji kk-bitowej:

S=max(w1, w2, , wn)2k11S = \frac{\max(|w_1|,\ |w_2|,\ \ldots,\ |w_n|)}{2^{k-1} - 1}

Mianownik to 2k112^{k-1} - 1 — największy indeks co do wartości bezwzględnej w symetrycznej kk-bitowej liczbie całkowitej ze znakiem. Dla int8 to 271=1272^7 - 1 = 127; dla int4 231=72^3 - 1 = 7; dla int2 211=12^1 - 1 = 1.

Licznik to największa co do wartości bezwzględnej waga w grupie: max(w1,w2,,wn)\max(|w_1|, |w_2|, \ldots, |w_n|), gdzie w1,,wnw_1, \ldots, w_n to nn wag kwantyzowanych razem.

Zamysł jest więc taki: podziel największą co do modułu wagę przez największy indeks co do modułu. Gwarantuje to, że najbardziej skrajna waga odwzorowuje się dokładnie na punkt siatki ±(2k11)\pm(2^{k-1} - 1) — bez obcinania, przy pełnym wykorzystaniu siatki.

Dla naszych pięciu przykładowych wag i int8 (k=8k = 8, więc mianownik 127127):

S=max(3.1416, 1.7, 0.0234, 1.5, 9.5)127=9.51270.0748S = \frac{\max(|3.1416|,\ |-1.7|,\ |0.0234|,\ |1.5|,\ |-9.5|)}{127} = \frac{9.5}{127} \approx 0.0748

Zauważ, że SS pełni naraz dwie role: jest mnożnikiem (indeks × S odzyskuje wartość fp32) i odstępem siatki (kolejne indeksy różnią się o 1, więc kolejne pozycje siatki różnią się dokładnie o SS). Na przykład indeks 41 dekoduje się na 41 × 0.0748 ≈ 3.067, a indeks 42 na 42 × 0.0748 ≈ 3.142 — odstęp między nimi to dokładnie 0.0748, czyli SS. Ta sama liczba, dwa znaczenia.

Skoro mamy SS, siatka wynika wprost: każdy punkt siatki to indeks × S dla pewnego indeksu całkowitego od -127 do +127:

{127S, 126S, , 0, , 126S, 127S}\{-127S,\ -126S,\ \ldots,\ 0,\ \ldots,\ 126S,\ 127S\}

Podstawiając nasze S=0.0748S = 0.0748 — każdy punkt siatki to indeks × 0.0748:

{9.5, 9.42, , 0, , 9.42, 9.5}\{-9.5,\ -9.42,\ \ldots,\ 0,\ \ldots,\ 9.42,\ 9.5\}

Żeby przyciągnąć każdą wagę do najbliższego punktu siatki, przypomnij sobie, że krok 1 tylko zbudował siatkę; tutaj zachodzi właściwa kompresja pojedynczej wagi. Dla każdej wagi ww w grupie policz jej indeks:

q=round(w/S)q = \text{round}(w / S)

To daje całkowity indeks najbliższego punktu siatki — ta sama mechanika zaokrąglania do najbliższej co w artykule o zaokrąglaniu binarnym: wybierz najbliższą reprezentowalną wartość, remisy rozstrzygaj ku parzystej. Jedyna różnica jest taka, że zbiór reprezentowalnych wartości jest teraz znacznie mniejszy i jawnie wyliczony. Zapisz jednobajtową liczbę całkowitą (albo 4 bity dla int4); wspólne SS podróżuje obok, zapisane raz na grupę, więc jego koszt amortyzuje się na wszystkie wagi w grupie. Dla naszych pięciu wag z S0.0748S \approx 0.0748:

Waga www/Sw / Sq=round(w/S)q = \text{round}(w / S)Zapisane (wartość int8)
3.141642.04242
-1.7-22.7-23-23
0.02340.3100
1.520.052020
-9.5-127.0-127-127

Pięć wag fp32 (20 bajtów) staje się pięcioma wartościami int8 plus jedną skalą fp32 (łącznie 9 bajtów) — 2,2× mniej, a oszczędność rośnie tylko wraz z rozmiarem grupy.

Widżet poniżej czyni ten cykl klikalnym na tych samych pięciu wagach — wybierz jedną i patrz, jak kwantyzacja, przyciąganie i dekwantyzacja przebiegają od początku do końca:

Scale-as-multiplier: how a float snaps onto the int8 grid
S =0.0748(= max(|values|) / 127)
Pick a weight:
Weight (fp32)
3.1416
÷ S
w / S
41.9982
round →
int8 index
42
× S
Reconstructed
3.1417
|error|
1.32e-4
int8 grid (each tick is index × S; range is ±9.50)
index space
−1270+127
9.500+9.50
value space (= index × S)
Original weight Snapped grid point int8 grid (every index × S)
Zoom: 7 grid points around the snap
2.9173392.9921403.0669413.1417423.2165433.2913443.366145|err| = 1.32e-4
Top number: value (= index × S). Bottom number: int8 index. Red = snapped grid point.

Wybierz wagę, by zobaczyć pełny cykl kwantyzacja–przyciągnięcie–dekwantyzacja na tych samych pięciu wartościach ze wzoru powyżej. Zielone znaczniki to siatka int8 (indeks × S dla każdego indeksu od −127 do +127). Niebieska kropka to oryginalna waga fp32; czerwony znacznik to punkt siatki, do którego się przyciąga. Wiersz przepływu pokazuje arytmetykę z kroku 1 i 2: podziel przez SS, zaokrąglij do najbliższej liczby całkowitej (to właśnie zostaje zapisane), a potem przy odczycie pomnóż z powrotem przez SS. Zatem 3.1416 / 0.0748 ≈ 42.0 zaokrągla się do 42 i rekonstruuje do 42 × 0.0748 ≈ 3.142 — prawie identycznie. Spróbuj 0.0234, żeby zobaczyć, co się dzieje, gdy maleńka waga ulega niedomiarowi do punktu siatki 0.

Warto odnotować: w grze są tu dwa równoległe zbiory. Przestrzeń indeksów {127,126,,127}\{-127, -126, \ldots, 127\} to to, co faktycznie ląduje w pamięci — czyste liczby całkowite, po 1 bajcie. Przestrzeń wartości {127S,126S,,127S}\{-127S, -126S, \ldots, 127S\} to to, co sieć traktuje jako wagę po dekwantyzacji — liczby fp32 uzyskane przez pomnożenie każdego indeksu przez SS. Ten sam zbiór, przeskalowany. Liczby całkowite są tanim systemem adresowania; siatka rzeczywista jest tym, na czym sieć naprawdę liczy.

Żeby zrekonstruować wagę, sprzęt odczytuje małą liczbę całkowitą i stosuje odwrotność:

wSqw \approx S \cdot q

gdzie qq to zapisana liczba całkowita, a SS to zmiennoprzecinkowa skala. Różne grupy wag dostają różne SS — dlatego ta sama wartość int8 42 w dwóch różnych kanałach może dekodować się na dwie różne liczby rzeczywiste.

Zauważ, czego tu nie ma: nie ma algorytmu „składającego z powrotem” pola znaku / wykładnika / mantysy, jak robi to konwersja IEEE-754. Rekonstrukcja to tylko dwie standardowe instrukcje — rzutowanie int na fp32 (4242.0) i mnożenie fp32 (42.0 × 0.0748 ≈ 3.142). Mantysa fp32, którą odzyskujemy, jest konstruowana w chwili mnożenia, a nie przechowywana gdziekolwiek — skompresowana waga to po prostu współczynnik przy skali.

Zrekonstruowana wartość jest podobna do oryginału, ale z nim nietożsama — dlatego we wzorze jest , a nie =. Zaokrąglanie do najbliższej przesuwa dowolną wagę o co najwyżej S/2 od pierwotnej pozycji, więc każda zrekonstruowana wartość mieści się w połowie odstępu siatki od miejsca startu. Dla wag, które same są mniejsze niż S/2, ten błąd może być całą wagą (zaokrąglają się do 0). Cały zakład kwantyzacji polega na tym, że te błędy na wagę są dostatecznie małe i nieskorelowane, by uśrednić się na milionach wag — jak widzieliśmy w sekcji Dlaczego modele tolerują mniej bitów.

Gdzie to faktycznie działa

W praktyce prawdziwe wdrożenia idą przez tę arytmetykę jedną z dwóch ścieżek:

  • Prawdziwy całkowity matmul: skwantyzowane są zarówno wagi, jak i aktywacje. Mnożenie macierzy działa w całości w arytmetyce całkowitej, a skalę stosuje się dopiero na końcu. To ścieżka powszechnie stosowana we wdrożeniach CNN i w serwowaniu int8 w centrach danych.
  • Kwantyzacja tylko wag: skwantyzowane są tylko wagi; aktywacje zostają w fp16/bf16. Wagi są dekwantyzowane w locie wewnątrz matmula, który dalej działa zmiennoprzecinkowo. To dominujący przepływ dla inferencji LLM na GPU.

Obie ścieżki używają tej samej matematyki rekonstrukcji (q × S); różnią się tym, gdzie zachodzi dekwantyzacja — raz na wyjściu matmula (ścieżka 1) kontra w samą porę wewnątrz jądra GEMM (ścieżka 2). Tak czy inaczej, w pamięci mieszka skompresowana reprezentacja (int8, S); wartości fp32 są rekonstruowane dopiero, gdy arytmetyka ich potrzebuje.

Wybór granulacji

Cały czas mówiliśmy „jedna skala na grupę” — ale to, co liczy się jako grupa, jest wyborem. Ten wybór to dźwignia granulacji — jak często liczysz świeży współczynnik skali:

  • Na tensor: jedna skala na całą macierz wag. Najtaniej (jedna skala na tensor), ale pojedyncza wartość odstająca rozdmuchuje skalę i psuje precyzję każdej wagi w macierzy.
  • Na kanał: jedna skala na kanał wyjściowy warstwy. Standard w produkcyjnej kwantyzacji wag: kanały często mają bardzo różne rozkłady wielkości, a skala na kanał pozwala każdemu zachować własną rozdzielczość.
  • Na grupę: jedna skala na grupę N kolejnych wag (typowo 32, 64 albo 128). Standard przy bardzo niskich szerokościach bitowych (int4 i niżej), gdzie siatka na kanał jest wciąż zbyt zgrubna, a wartość odstająca wewnątrz kanału niszczy lokalną precyzję. Mniejsze grupy lepiej dopasowują się do lokalnych rozkładów, ale płacą większy narzut na współczynniki skali w przeliczeniu na parametr.

Jak faktycznie ustala się grupowanie? Dla wag — głównie strukturalnie, nie eksperymentalnie. Na tensor jest trywialne — jedna grupa to cały tensor. Na kanał wynika za darmo z geometrii warstwy: wymiar wyjściowy warstwy Linear daje już naturalne kanały, a filtry wyjściowe warstwy Conv robią to samo. Na grupę to jedyny wariant z hiperparametrem — rozmiarem grupy — a popularne wartości domyślne (128, 64, 32) biorą się z badań i narzędzi: GPTQ, AWQ i llama.cpp domyślnie używają rozmiarów grup rzędu 128 dla int4. Mniejsze grupy poprawiają jakość przy większym narzucie skal na wagę, a właściwą wartość zwykle dobiera się, próbując paru opcji na wydzielonym zbiorze walidacyjnym.

Dlaczego to działa

Od dziesięcioleci udowodnione jest, że skala-jako-mnożnik jest matematycznie poprawna — podpierają to trzy klasyczne wyniki. Twierdzenie Bennetta o szumie kwantyzacji (1948) pokazuje, że dla równomiernego kwantyzatora o kroku SS błąd zaokrąglenia jest ograniczony przez S/2S/2, ma zerową średnią i wariancję S2/12S^2/12 — szum kwantyzacji jest z konstrukcji dobrze zachowany, ta sama matematyka podpiera PCM w każdym cyfrowym pliku audio, jaki kiedykolwiek odtwarzałeś. Odwzorowanie kwantyzuj-a-potem-dekwantyzuj jest też liniowe z dokładnością do zaokrągleń, więc odległości i stosunki między wagami są zachowane z dokładnością do tego samego błędu S/2S/2, a struktura geometryczna oryginalnej przestrzeni wag przenosi się dalej. A ponieważ mnożenie przez skalar rozdziela się względem iloczynu macierz-wektor, skala wychodzi przed matmul — dokładnie dlatego ścieżka 1 powyżej jest matematycznie równoważna wersji fp32.

Skala-jako-mnożnik to bezpośredni potomek tego, co zmiennoprzecinkowość i tak już robi. Wykładnik floata jest faktycznie skalą na wartość (z mantysą na wartość jako skwantyzowanym ładunkiem). Kwantyzacja po prostu dzieli jedną skalę na wiele wartości, wymieniając nieco precyzji na mniej bitów na element.

Obrazek siatki z sekcji o floatach uwidacznia różnicę strukturalną: float rozkłada swoje reprezentowalne wartości na siatce rozstawionej logarytmicznie, gęstej przy zerze i rozciągającej się wraz ze wzrostem wielkości — odstępy ~0.008 w okolicy 1.0 w bf16 stają się odstępami ~8 w okolicy 1024 i ~10⁴ w okolicy 10⁶. Kwantyzacja zastępuje to siatką liniową: w obrębie bloku 256 (int8) albo 16 (int4) reprezentowalnych wartości jest rozstawionych równomiernie co s. Żadnego logarytmicznego rozciągania; żadnej dodatkowej rozdzielczości przy zerze. Zakres danych bloku ustala s, siatka jest regularna na całym tym zakresie, a ten sam błąd zaokrąglenia obowiązuje niezależnie od tego, czy wartość jest w bloku mała czy duża.

Representable values: float vs quantization
bf16log-spaced grid (denser near 0; many sub-pixel ticks at this scale aren't drawn)
int8256 levels, uniform spacing s = 0.0627
int416 levels, uniform spacing s = 1.07

Przełącz okno między [0, 4], [0, 16] i [0, 1024], by zobaczyć kontrast: znaczniki bf16 tłoczą się w niemal litą ścianę po lewej i rzedną ku prawej (rozciąganie siatki logarytmicznej), podczas gdy wiersze int8 i int4 pozostają równymi grzebieniami równomiernie rozstawionych znaczników przy każdym rozmiarze okna — te same 256 (albo 16) poziomów, po prostu rozciągnięte tak, by pokryć zakres, jakiego potrzebuje blok.

Obraz przechowywania wygląda wtedy tak przy int4 z rozmiarem grupy 64:

  • 4 bity na wagę = 0,5 bajta
  • plus jedna skala fp16 na 64 wagi ≈ 2 bajty / 64 = 0,031 bajta/wagę
  • razem ≈ 0,53 bajta/wagę

Dlatego „int4” w praktyce podaje się jako „0,5 bajta na parametr” — narzut jest realny, ale mały.

Kwantyzacja klasyfikatora MNIST

Żeby uczynić to konkretnym na modelu, który już poznaliśmy, zastosujmy ten sam przepis do pierwszej warstwy Dense klasyfikatora MNIST z wcześniejszej części serii — warstwy Dense(128, input_shape=(784,)) z macierzą wag (784, 128) w fp32. Wybierzmy granulację na kanał: 128 grup, po jednej na kanał wyjściowy, każda zawierająca 784 wagi. Dla każdego kanału cc policz Sc=max(W[:,c])/127S_c = \max(|W[:, c]|) / 127. Jeśli największa co do modułu waga kanału 0 to 0.234, to S00.00184S_0 \approx 0.00184, a siatka tego kanału to {0.234, , 0, , 0.234}\{-0.234,\ \ldots,\ 0,\ \ldots,\ 0.234\} — 256 punktów rozstawionych co 0.00184, rozciągniętych dokładnie na zakres kanału. Kanał 1 dostaje inne S1S_1, więc ta sama wartość int8 42 dekoduje się na 42Sc42 \cdot S_c — inną liczbę rzeczywistą dla każdego kanału:

import keras
import numpy as np

model = keras.models.load_model('mnist_classifier.keras')
W = model.layers[0].kernel.numpy()         # (784, 128), fp32
print(W.shape, W.dtype, W.nbytes)          # (784, 128) float32 401408

INT8_MAX = 127

# Per-channel int8: one scale per output channel (128 channels)
scales = np.abs(W).max(axis=0) / INT8_MAX  # (128,), fp32

# Quantize: divide by scale (broadcasts), round to nearest int, cast to int8
W_q = np.round(W / scales).astype(np.int8) # (784, 128), int8

# Dequantize on read: cast to fp32, multiply by per-channel scale
W_back = W_q.astype(np.float32) * scales

# Storage: 100,352 bytes (int8 weights) + 128 * 4 = 512 bytes (scales)
# vs original 401,408 bytes — ~4× smaller, scale overhead ~0.13%

Macierz wag pierwszej warstwy kurczy się z ~400 KB (fp32) do ~100 KB (int8) plus 512 bajtów skal na kanał — czyste zmniejszenie 4×. Błąd na wagę jest rzędu połowy skali kanału (typowo ~10⁻⁴ dla tej warstwy), grubo poniżej progu szumu sieci trenowanej 60 000 stochastycznych aktualizacji SGD. Podstawienie W_back w miejsce wag warstwy i ponowna ewaluacja modelu daje w zasadzie identyczną dokładność testową.

Zastosuj ten sam przepis do wag (128, 10) drugiej warstwy, a mamy obraz całego modelu:

WarstwaKształtWagi fp32Wagi int8Skale na kanałRazem int8
Dense 1(784, 128)401 408 B100 352 B128 × 4 = 512 B100 864 B
Dense 2(128, 10)5 120 B1 280 B10 × 4 = 40 B1 320 B
Razem406 528 B (~397 KB)102 184 B (~100 KB)

Obciążenia (jest ich 138, zwykle trzymane w fp32 ≈ 552 bajty) są przy tej skali pomijalne. Wynik netto: wagi całego modelu MNIST schodzą z ~397 KB do ~100 KB, czyste zmniejszenie ~4×, przy czym skale na kanał dokładają tylko ~0,55% narzutu ponad przechowywanie wag int8. int8 PTQ to dokładnie to, powtórzone warstwa po warstwie.