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ć:

FormatZnakWykładnikMantysaZakres (maks.)Precyzja dziesiętnaBajty
f32 (single)18233,4 × 10³⁸≈ 7 cyfr4
f16 (half)151065 504≈ 3–4 cyfry2
bf16 (bfloat16)1873,4 × 10³⁸≈ 2–3 cyfry2

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.

bf16brain 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

Dlaczego to ważne dla lokalnego LLM

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.


  1. 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)↩︎

  2. 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)↩︎

  3. 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↩︎ ↩︎

  4. „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↩︎ ↩︎

  5. 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↩︎

  6. Oficjalne nazwy i opisy typów torch.float32, torch.float16 (half) oraz torch.bfloat16 (Brain floating point): PyTorch — Tensor Attributes / torch.dtype↩︎