Odpalasz convert_hf_to_gguf.py na świeżym modelu z Hugging Face, lecisz z palca --outtype
i nagle podpowiedź wypluwa ci listę: f32, f16, bf16, q8_0… Zatrzymujesz się na tych
trzech pierwszych. f16 znasz, f32 to „ten większy", ale bf16 mruga jak nieznajomy na
imprezie, o którym wszyscy mówią, że jest spoko, tylko nikt nie potrafi powiedzieć czemu.
Klikasz f16, bo tak robiłeś zawsze, i ruszasz dalej — a z tyłu głowy zostaje to samo pytanie,
które wraca za każdym razem, gdy w konfiguracji treningu widzisz compute_dtype=torch.bfloat16:
czym one się właściwie różnią, skoro obie „szesnastki" ważą tyle samo?
To jeden z klasycznych sporów o dwie „szesnastki" — ktoś wrzuca skwantyzowany model, ktoś inny zaraz pyta,
czemu baza jest w bf16, a nie f16, i rozkręca się wątek na sto odpowiedzi, z których połowa
myli precyzję z zakresem. Bo to naprawdę nieoczywiste. Dwa formaty, oba po 16 bitów, oba po
2 bajty na liczbę — a jednak jeden wygrał trening wielkich modeli, a drugi został przy inferencji
i grafice. Cała różnica siedzi w tym, jak te 16 bitów pokroić. No to pokrójmy.
Liczba zmiennoprzecinkowa to trzy szufladki
Każda liczba w tych formatach — czy to f32, czy f16, czy bf16 — składa się z trzech kawałków.
Wyobraź sobie zapis naukowy z podstawówki: −1,5 × 10⁴. Masz tam znak (minus), masz cyfry
znaczące (1,5) i masz potęgę, która mówi, gdzie postawić przecinek (10⁴). Komputer robi
dokładnie to samo, tylko na potęgach dwójki, i pakuje to w trzy pola bitów:
- bit znaku (sign bit) — jeden bit, plus albo minus. Tu bez niespodzianek.
- wykładnik (exponent) — potęga dwójki, czyli skala liczby. To on decyduje, jak wielką albo jak małą liczbę w ogóle da się zapisać. Nazywamy to zakresem dynamicznym.
- mantysa (significand, po staremu mantissa) — cyfry znaczące, czyli dokładność. To ona decyduje, ile pozycji po przecinku odróżnisz od siebie. Nazywamy to precyzją.
I teraz sedno, które trzeba wbić do głowy jak mantrę: zakres bierze się z wykładnika, precyzja z mantysy. To dwie niezależne osie. Możesz mieć liczbę o gigantycznym zakresie i marnej dokładności — albo odwrotnie. Nie idą w parze. Cała gra w tych formatach polega na tym, ile z szesnastu (albo trzydziestu dwóch) dostępnych bitów oddasz jednemu, a ile drugiemu.
Jak to rozdzielono w praktyce? Tak — i to jest tabelka, którą warto zapamiętać:
| Format | Znak | Wykładnik | Mantysa | Zakres (maks.) | Precyzja dziesiętna | Bajty |
|---|---|---|---|---|---|---|
| f32 (single) | 1 | 8 | 23 | 3,4 × 10³⁸ | ≈ 7 cyfr | 4 |
| f16 (half) | 1 | 5 | 10 | 65 504 | ≈ 3–4 cyfry | 2 |
| bf16 (bfloat16) | 1 | 8 | 7 | 3,4 × 10³⁸ | ≈ 2–3 cyfry | 2 |
f32, czyli single precision z normy IEEE 754, to 1 bit znaku, 8 bitów wykładnika i 23 bity
mantysy.1 To złoty standard „normalnego" liczenia na komputerze — szeroki zakres i siedem
cyfr znaczących. Kosztuje 4 bajty na liczbę i przez lata nikt się nad tym nie zastanawiał.
Dwie szesnastki, dwa różne cięcia
I teraz robi się ciekawie, bo f16 i bf16 to obie „połówki" — po 16 bitów, po 2 bajty. Tyle że
tort pokrojony zupełnie inaczej.
f16, czyli half precision, też z IEEE 754, tnie tak: 1 bit znaku, 5 bitów wykładnika,
10 bitów mantysy.2 Zauważasz? Oddano dużo miejsca mantysie, mało wykładnikowi. Efekt:
przyzwoita precyzja jak na 16 bitów, ale wąziutki zakres — największa skończona liczba, jaką
f16 w ogóle potrafi zapisać, to 65 504. Przekroczysz to i masz overflow, liczba ucieka
w nieskończoność. Zejdziesz za nisko — underflow, liczba znika do zera. Dla grafiki, skąd f16
się wywodzi, to bez znaczenia; kolory i tak siedzą w rozsądnym przedziale.
bf16 — brain floating point, dziecko Google Brain, i uwaga: nie jest częścią IEEE 754 —
kroi zupełnie inaczej: 1 bit znaku, 8 bitów wykładnika, 7 bitów mantysy.3 Ten
sam wykładnik co f32. Osiem bitów, ten sam bias 127, ta sama arytmetyka skali. A to znaczy, że
bf16 ma dokładnie taki sam zakres dynamiczny jak f32 — od jakichś 10⁻³⁸ do 3,4 × 10³⁸.
Google w dokumentacji TPU pisze to wprost, bez owijania: zakresy dynamiczne bfloat16 i float32
są równoważne, bfloat16 po prostu zjada połowę pamięci.4
Cena? Mantysa skurczona do siedmiu bitów. Czyli bf16 jest mniej dokładny liczbowo niż f16 —
odróżnia mniej cyfr znaczących. I tu wielu ludzi się wykłada, bo intuicja podpowiada: skoro bf16
ma mniej precyzji, to jest gorszą szesnastką. A to nieprawda. To po prostu inny kompromis, zrobiony
pod inny cel.
Czemu bf16 wygrał trening, choć jest mniej dokładny
To jest ta część, która zaskakuje każdego, kto pierwszy raz na to trafia. Bierzemy format o gorszej precyzji i to on stał się domyślnym wyborem do trenowania największych modeli świata. Jak to możliwe?
Bo w treningu sieci neuronowej najgroźniejszym wrogiem nie jest to, że liczba jest odrobinę
niedokładna. Najgroźniejsze jest to, że liczba wypadnie poza zakres. Gradienty potrafią być
malutkie jak 10⁻⁷, a aktywacje potrafią chwilowo strzelić wysoko. f16 ze swoim sufitem na
65 504 i płytką podłogą po prostu tego nie łapie — wartości albo overflowują do nieskończoności,
albo underflowują do zera, a wyzerowany gradient to gradient, który nic już nie uczy. Dlatego
trening w f16 wymagał sztuczek w rodzaju loss scalingu — ręcznego podbijania skali strat, żeby
zmieścić gradienty w tym wąskim oknie.
bf16 rozwiązuje to od strzału, bo ma zakres f32. Gradient rzędu 10⁻³⁰? Mieści się. Aktywacja,
która na moment wystrzeliła? Też się mieści. Owszem, każda z tych liczb jest zapisana ciut mniej
dokładnie — ale sieci neuronowe są na taki szum zdumiewająco odporne, a na wypadnięcie z zakresu
już nie. Pokazało to twardo osobne badanie tego formatu — praca „A Study of BFLOAT16 for Deep
Learning Training" z 2019 roku (zespół inżynierów Intela i Facebooka): trening w bf16 osiąga te same
wyniki co f32, w tej samej liczbie iteracji, bez ruszania hiperparametrów, bez loss scalingu —
czego f16 nigdy nie dawał za darmo.5
Zapamiętaj to jako jedno zdanie: bf16 = zakres f32, precyzja gorsza niż f16; f16 = precyzja
lepsza niż bf16, ale zakres wąski jak balkon. Do treningu i tam, gdzie liczy się stabilność
numeryczna, wybierasz zakres — więc bf16 (na sprzęcie, który go wspiera natywnie; szybka akceleracja
bf16 w Tensor Core to dopiero Ampere i nowsze). Dodatkowa dokładność f16 na nic ci się zda, jeśli
połowa liczb ucieknie do nieskończoności. To dlatego „więcej bitów mantysy" nie znaczy „lepszy format".
Jest jeszcze jeden bohater drugiego planu, o którym warto wiedzieć, żeby się nie pogubić: TF32
od NVIDIA. To nie jest format przechowywania danych — to wewnętrzny tryb obliczeniowy Tensor Core,
z 8-bitowym wykładnikiem (zakres jak f32) i 10-bitową mantysą (precyzja jak f16).3 Dane
w pamięci nadal leżą jako f32; karta tylko liczy na nich szybciej, po cichu przycinając mantysę
w trakcie mnożenia. Nie pomyl go z bf16 jako typem tensora — to inna bajka, tryb liczenia, a nie
etykieta na danych.
Po co ci to przy lokalnym LLM
„Fajnie, ale ja nic nie trenuję, tylko odpalam gotowca" — i słusznie, więc przełóżmy to na twoje podwórko.
Przy inferencji f16 albo bf16 to typowy compute dtype, czyli format, w którym faktycznie
lecą obliczenia — 2 bajty na parametr, dwa razy taniej niż f32, przy jakości wyjścia praktycznie
nie do odróżnienia. Ten sam podział bitów wraca też w KV-cache: bufor, w którym model trzyma
przemielony kontekst, standardowo siedzi w f16 lub bf16, po 2 bajty na wartość — i przy długim
oknie to właśnie on, a nie wagi, robi się największym żłobem na VRAM (rozgryzaliśmy to bliżej
tutaj). Compute dtype pojawi ci się też, jak zajrzysz do
QLoRA — tam wagi leżą w 4 bitach, ale liczenie i tak idzie w bf16.
A f32? Przy samej inferencji lokalnego LLM to zwykle przepłacanie pamięcią. Ładujesz dwa razy
cięższy tensor, zżerasz dwa razy więcej VRAM-u, a jakości odpowiedzi i tak nie odróżnisz od f16.
Pełne f32 ma sens tam, gdzie liczy się stabilność akumulacji — sumowanie tysięcy iloczynów
cząstkowych, niektóre warstwy normalizacyjne, stany optymalizatora przy treningu. Nie przypadkiem
TPU mnoży w bf16, ale sumy akumuluje w f32: to klasyka mixed precision — licz tanio, sumuj
dokładnie.4 Ale to dzieje się pod maską frameworka, nie w twoim --outtype.
No i konkret, od którego zaczęliśmy — konwersja do GGUF. Gdy robisz bazę pod dalszą kwantyzację,
--outtype f16 albo bf16 to punkt wyjścia: pełna szesnastka jako fundament, z którego dopiero
schodzisz do Q4_K_M, Q5_K_M czy Q8_0 (o tych nazwach jest osobno
tu). Prosta zasada kciuka:
# model wytrenowano w bf16 (dziś większość) -> zachowaj zakres, bierz bf16:
python convert_hf_to_gguf.py ./Meta-Llama-3-8B --outtype bf16 --outfile llama3-8b-bf16.gguf
# starszy model natywnie w f16 albo docelowy sprzęt bez akceleracji bf16 -> f16:
python convert_hf_to_gguf.py ./stary-model --outtype f16 --outfile model-f16.gguf
Skąd wiesz, w czym model żył oryginalnie? Zajrzyj do config.json na karcie modelu — pole
torch_dtype. Jeśli stoi tam bfloat16 (a przy nowych modelach zwykle stoi), to konwersja do
f16 po drodze przycięłaby zakres i mogłaby narobić drobnych przekłamań w skrajnych wartościach.
Trzymaj bazę w tym, w czym model się urodził. Nazwy torch.float32, torch.float16 i torch.bfloat16
to zresztą dokładnie te trzy formaty z tabelki wyżej, tyle że w słowniku PyTorcha.6
Bo cała ta szarpanina o bity sprowadza się do jednej sceny: masz 16 kratek do wypełnienia i decydujesz,
ile z nich pójdzie na „jak wielka może być liczba", a ile na „jak dokładnie ją znam". f16 upchnął
dokładność i został z ciasnym balkonem zamiast horyzontu. bf16 odpuścił parę cyfr po przecinku
i dostał w zamian cały zakres f32 w połowie miejsca — i właśnie dlatego, kiedy następnym razem
zawahasz się nad --outtype, nie będziesz już zgadywał. Będziesz wiedział, że wybierasz nie „lepszą
szesnastkę", tylko odpowiedni kształt tego samego pudełka.
Struktura pola
binary32(single precision) wg IEEE 754 — 1 bit znaku, 8 bitów wykładnika (bias 127), 23 bity mantysy, zakres do ok. 3,4 × 10³⁸, ok. 7 cyfr znaczących: Single-precision floating-point format (Wikipedia / IEEE 754). ↩︎Struktura pola
binary16(half precision) wg IEEE 754-2008 — 1 bit znaku, 5 bitów wykładnika (bias 15), 10 bitów mantysy, maksymalna skończona wartość 65 504: Half-precision floating-point format (Wikipedia / IEEE 754). ↩︎Dokładny układ bitów FP32 / FP16 / BF16 / TF32 (m.in. „FP32… 1 sign bit, an 8-bit exponent, and a 23-bit mantissa", FP16 max = 65504, TF32 jako tryb obliczeniowy Tensor Core): NVIDIA TensorRT — Accuracy Considerations. ↩︎ ↩︎
„The dynamic range of bfloat16 and float32 are equivalent. However, bfloat16 uses half of the memory space" oraz mnożenie w bfloat16 z akumulacją w float32: Google Cloud — The bfloat16 numerical format. ↩︎ ↩︎
Empiryczny dowód, że trening w BF16 osiąga wyniki SOTA jak FP32, w tej samej liczbie iteracji i bez zmiany hiperparametrów, dzięki zachowaniu zakresu dynamicznego FP32: Kalamkar i in., „A Study of BFLOAT16 for Deep Learning Training", arXiv:1905.12322. ↩︎
Oficjalne nazwy i opisy typów
torch.float32,torch.float16(half) oraztorch.bfloat16(Brain floating point): PyTorch — Tensor Attributes / torch.dtype. ↩︎