Float, bfloat и как биты определяют память модели
Под всей абстракцией нейросеть — это большой набор чисел, называемых параметрами: весов и смещений, которые несёт каждый нейрон в каждом слое, — мы подробно разбирали их в статье о том, как сети учатся. Число параметров говорит, сколько таких чисел хранит модель; числовой формат говорит о размере каждого в байтах. Перемножьте их — и получите объём памяти, который занимает модель, — первое число, которое нужно знать, прежде чем выбирать железо, выбирать точность или решать, влезает ли эта штука вообще на одну GPU.
Возьмём маленькую сеть, которую мы обучили в статье про MNIST, — плотный классификатор с двумя слоями Dense в Keras: 784 развёрнутых в вектор пикселя подаются в скрытый слой из 128 нейронов, а тот — в выходной слой из 10 нейронов (по одному на цифру).
Сокращённая запись этой формы — 784 → 128 → 10, где 784 — размер входа, а не слой. Число параметров складывается так:
По умолчанию Keras хранит каждый параметр как fp32 — 32-битное число с плавающей точкой, 4 байта на штуку. Так что обученная модель занимает памяти при загрузке. Но это игрушечная модель, сделанная для обучения, — настолько маленькая, что её можно отправить по почте. Реальные модели на порядки больше, и вот тут число параметров начинает иметь значение.
Каждая модель, опубликованная на хабе вроде Hugging Face, идёт с карточкой модели — публичным README рядом с файлом весов, где кратко описано, что это за модель, как её обучали, и приведены два интересующих нас числа: сколько у неё параметров и в каком числовом формате хранится каждый.
Возьмём современную опенсорсную OCR-модель: откройте PaddleOCR-VL на Hugging Face, и боковая панель скажет вам обе интересующие нас вещи — количество параметров и формат хранения каждого.

Model size — это общее число параметров, сколько чисел хранит модель. Tensor type — это dtype, сокращение от data type (тип данных).
Буква d нужна, чтобы отличать его от обычного type: в Python type(x) говорит о контейнере (list, numpy.ndarray, torch.Tensor), тогда как dtype говорит об элементах внутри него (float32, int8, bfloat16), — и именно это решает, сколько байт занимает каждый вес.
Термин появился в NumPy, и его подхватил каждый ML-фреймворк.
В коде вы считываете его с любого тензора или массива через атрибут .dtype — и каждый фреймворк сообщает его одинаково, каноническим именем формата:
>>> 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 читает ту же информацию прямо из файла safetensors: safetensors — это формат файлов Hugging Face для сохранения и распространения обученных весов, и dtype каждого тензора хранится в его метаданных, что панель и показывает как «Tensor type».
BF16 в панели означает, что каждый вес модели — это bfloat16, «Brain Float 16», 16-битный формат с плавающей точкой, разработанный в Google для ML. Это float-подобный формат: та же структура «знак / порядок / мантисса», что у float по IEEE-754, только упакованная в 16 бит вместо 64, со своим распределением между порядком и мантиссой.
Различия — в том числе почему bf16 не то же самое, что IEEE-754 float16, хотя оба 16-битные, — мы подробно разберём дальше в статье.
Так что шаги вычисления объёма памяти модели те же, что мы применяли к MNIST, просто с числами покрупнее. Цифра 1.0B — это сумма параметров по всем слоям модели, та же послойная арифметика, что мы делали вручную для MNIST, только Hugging Face уже сделал её за нас, пройдясь по каждому тензору в файле safetensors и сложив количества элементов.
Итак, карточка говорит: 1 миллиард параметров («B» — сокращение от billion, ). Умножаем на 2 байта на bf16-вес и получаем вес модели в памяти:
Это доминирующая статья расхода памяти на инференсе, где активации транзиентны (во время прямого прохода в памяти живёт только один слой), а KV-кэш, если он есть, для типовых нагрузок обычно меньше весов. С обучением всё иначе — той же модели нужны веса плюс градиенты, состояние оптимизатора и закэшированные активации для обратного прохода, — что мы посчитаем в отдельном разделе ниже.
Теперь масштабируем ту же арифметику — и ставки быстро меняются. Модель на 7B параметров занимает 28 ГБ в fp32 — вообще не влезает в 24 ГБ RTX 4090 и оставляет едва 4 ГБ запаса на 32 ГБ RTX 5090 (недостаточно для активаций и KV-кэша). При 14 ГБ в fp16 она комфортно влезает в обе; 7 ГБ в int8 оставляют запас. Модель на 70B в fp16 — это 140 ГБ: ни одна отдельная GPU её не держит, и выбор формата начинает диктовать, нужна ли вам одна GPU, две или восемь.
Хотя формат по умолчанию в обучающих фреймворках — fp32, модели часто публикуют в чём-то более узком: команды регулярно меняют часть точности, с которой обучали, на меньший размер загрузки и меньший расход VRAM при инференсе. Размен принимает две широкие формы — переход к 16-битному формату с плавающей точкой (fp16, bf16) или дальше, к квантизации, отображающей веса в небольшой набор дискретных уровней, обычно на 8 или 4 битах. Опубликуй авторы PaddleOCR-VL в fp32, та же модель занимала бы 4 ГБ. Они выбрали bf16, потому что он вдвое сокращает память по сравнению с fp32 практически без потери качества, — почему этот выбор работает, мы разберём в оставшейся части статьи.
Не всякое число в сети — это вес
Веса модели — не единственные числа в работающей сети. Полная картина включает несколько отдельных категорий, каждая потребляет память и каждая вольна выбрать свой формат. Подробнее о том, откуда берётся каждая, — веса и смещения, активации слоёв на прямом проходе, градиенты на обратном и шаг обновления оптимизатора — в статье о том, как сети учатся.
Две из них универсальны: веса и активации существуют независимо от того, идёт ли у вас инференс или обучение. Веса — это сами обученные параметры: исторически fp32, сегодня bf16 или fp16, либо квантизованные до 8 или 4 бит для инференса на масштабе. Активации — промежуточные выходы каждого слоя на прямом проходе: транзиентные на инференсе (одновременно в памяти жив выход только одного слоя, поскольку вход каждого слоя — это и есть выход предыдущего), закэшированные для обратного прохода при обучении (выходы каждого слоя должны оставаться живыми, чтобы обратное распространение могло их переиспользовать) и обычно хранимые в том же формате, что и веса.
На инференсе LLM на трансформерах несут ещё и KV-кэш — ключи и значения, закэшированные по прошлым токенам во время авторегрессивной генерации, чтобы внимание не пересчитывало их для каждого нового токена. Часто хранится в fp16 или fp8 ради экономии памяти на длинных контекстах.
Во время обучения в картину входят ещё две категории:
- Градиенты — производные, вычисляемые на обратном проходе для обновления весов. Охватывают много порядков величины (особенно малых), поэтому здесь важен диапазон и поэтому для них выигрывают bf16 (а позже fp8 E5M2).
- Состояние оптимизатора — Adam держит два момента на параметр, то есть 2× от числа параметров. Почти всегда fp32, даже когда веса живут в bf16, потому что мелкий дрейф накапливается за тысячи шагов обучения.
В коде dtype живёт в нескольких конкретных местах. Вот соответствующий фрагмент из нашей статьи про обучение на 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"],
)Три разные строки, три разные категории. .astype("float32") задаёт dtype входных данных, который затем распространяется в активации по мере прохода значений вперёд через слои. Конструкторам Dense(...) не требуется аргумент dtype= — Keras по умолчанию берёт fp32 для весов и смещений. Чтобы переопределить это, нужно явно передать 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),
)Обе формы переопределяют только dtype весов и смещений — они ничего не делают с активациями, градиентами или состоянием оптимизатора. Оптимизатор конструируется независимо и владеет dtype своего состояния: у обычного SGD его нет, а Adam выделил бы два fp32-момента на параметр. Обёртки смешанной точности вроде torch.amp.autocast и GradScaler разъединяют эти категории ещё сильнее — например, выполняя прямой проход в bf16, но держа параметры в fp32. Так что выбор dtype= у слоя отвечает ровно на один вопрос: в каком формате хранятся веса. Остальное решается независимо.
Именно это люди имеют в виду под обучением и инференсом со смешанной точностью: одна модель осознанно использует разные форматы для разных ролей, каждый выбран под свои численные требования. Дальше в статье, когда мы говорим «модель в bf16», мы обычно имеем в виду, что в bf16 находятся веса. Остальные роли могут сидеть выше или ниже.
Чтобы оценить реальный счёт за память при обучении, складывают стоимость на параметр. Стандартный рецепт смешанной точности — bf16-веса с fp32-оптимизатором градиентного спуска (чаще всего Adam) — даёт 16 байт на параметр без учёта активаций:
- bf16 веса: 2 байта
- bf16 градиенты: 2 байта (той же формы, что веса)
- fp32 моменты Adam: 8 байт (два момента × 4 байта)
- fp32 мастер-копия весов (хранится оптимизатором для стабильных обновлений): 4 байта
Обратите внимание на множитель. На инференсе каждый вес стоит 2 байта; обучение раздувает стоимость на параметр до 16 байт — в 8 раз больше — ещё до подсчёта активаций. Одно только состояние оптимизатора (fp32-моменты) вчетверо превышает вес самих весов. Активации сверху зависят от размера батча, длины последовательности и архитектуры и легко могут удвоить итог ещё раз.
Так что модели на 7B параметров, которая влезает в ~14 ГБ при инференсе в bf16, для обучения по этому рецепту нужно ~112 ГБ плюс активации — примерно в 8–10 раз больше памяти, чем на обслуживание той же модели. Именно из-за этого разрыва одна A100 (80 ГБ) может комфортно обслуживать 7B-модель, но не может обучать её без шардирования состояния оптимизатора по нескольким GPU (техники вроде ZeRO и FSDP).
Float по умолчанию: 64 бита в каждом знакомом вам языке
Прежде чем смотреть на числовые форматы, которые ML реально использует, полезно начать со знакомых числовых типов, которыми мы каждый день пользуемся в Python и JavaScript:
| Язык | Вид | Тип | Подробности |
|---|---|---|---|
| Python | Целое (без дробной части, точное) | int | Произвольной точности. CPython наращивает хранилище по мере надобности, так что 2**1000 просто работает, и результат точен. |
| Float (64-битный double по IEEE-754) | float | Тот самый формат, разбору которого посвящена остальная часть статьи. Литерал 0.5 в скрипте на Python — это 8 байт. | |
| JavaScript | Целое (без дробной части, точное) | BigInt | Целое произвольной точности, добавленное позже отдельным типом. Пишется с суффиксом n (например, 5n). Ближайший аналог питоновского int. |
| Float (64-битный double по IEEE-754) | Number | Тот же формат, что питоновский float. JavaScript использует его и для целых, и для дробей; отдельного целочисленного типа по умолчанию нет. |
Значение здесь имеют строки про float. Питоновский float и джаваскриптовый Number — это в точности один и тот же 64-битный double по IEEE-754: тот же формат, та же аппаратная FPU выполняет арифметику, так что на уровне битов они ведут себя одинаково — одинаковая двоичная запись 0.1, одинаковое 0.1 + 0.2 = 0.30000000000000004, одинаковое поведение NaN, одинаковое переполнение в бесконечность.
Механику того, как эти 64 бита делятся на знак, порядок и мантиссу, почему порядок хранится со смещением, а не в дополнительном коде, и как округляется 0.1, я подробно разбираю в предыдущих статьях серии. Всё сказанное там применимо к обоим языкам напрямую, потому что под капотом у них один и тот же 64-битный формат.
Важное для остальной части статьи: ни один из этих языков не позволяет попросить 32-битный float на уровне языка. Значение 0.5 занимает 64 бита, а не 32. Для повседневной арифметики это нормально, но ML нужно куда более широкое и специализированное меню числовых типов — поэтому фреймворки вроде NumPy, PyTorch, TensorFlow и JAX выставляют собственную систему dtype с форматами вроде bf16, fp16, int8 и int4.
Как конвертации теряют биты: от языковых float к ML-dtype
Всякий раз, когда значение переходит из одного числового формата в другой — питоновский float в int, float в int8, fp64 в fp16, — есть шанс, что биты не поместятся. Чем-то придётся пожертвовать, и куда денутся лишние биты, зависит от исходного и целевого форматов.
Простейший случай — float → int: отбросить всё после точки.
>>> 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Механически железо читает порядок числа, сдвигает мантиссу так, чтобы точка оказалась между целыми и дробными битами, и читает только целую сторону. Дробные биты физически отбрасываются.
Например, возьмём 5.75. В двоичном это 101.11 — три бита до точки (101 = 4 + 1 = 5) и два после (.11 = 0.5 + 0.25 = 0.75). Однако IEEE-754 хранит это не так: он нормализует число, чтобы перед точкой была ровно одна 1, и запоминает сдвиг в порядке:
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 в памяти держит мантиссу 0111 и порядок 2, а не буквальные цифры 101.11. Чтобы превратить это обратно в целое, железо должно отменить нормализацию: взять мантиссу, приписать неявную 1 и сдвинуть точку вправо на величину порядка, восстановив исходную раскладку:
mantissa: 1.0111
shift by 2: 101.11
^^^ ^^
│ └── fractional bits → discarded
└────── integer bits → 101 = 5Всё справа от точки — .11, то есть 0.75 в десятичном, — выбрасывается, и int(5.75) возвращает 5. Заметьте, что никакого округления не происходит: дробные биты не изучаются, чтобы решить, поднимать ли целое, — их просто отбрасывают. Поэтому int(-3.7) в примере выше даёт -3, а не -4: усечение режет в сторону нуля независимо от того, какими были отброшенные биты.
Тот же механизм работает при переходе от языкового float к более узкому ML-dtype. PyTorch, NumPy, TensorFlow и JAX выполняют преобразование внутри 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Эти четыре строки покрывают канонические сценарии, к которым сводится любое преобразование числового типа. Правило каждый раз одно — исходный формат содержит биты, которые целевой не может удержать, — а реакций железа четыре, в зависимости от того, какое поле переполняется:
| Сценарий | Из | В | Пример | Результат |
|---|---|---|---|---|
| Усечение | float | int | 3.7 → int8 | 3 |
| Заворачивание | int | более узкий int | 1000 → int8 | -24 |
| Насыщение до бесконечности | float | более узкий float | 1e5 → fp16 | inf |
| Потеря точности | int | float | 2^24 + 1 → fp32 | 2^24 |
Каждая строка заслуживает отдельного взгляда — механика различается любопытным образом.
Усечение. 3.7 → int8 оставляет 3. Дробные биты отбрасываются в сторону нуля независимо от их величины, так что 3.01, 3.5 и 3.999 все приземляются на 3. Отрицательные значения тоже режутся к нулю: -3.999 становится -3, а не -4. Это тот же механизм, что мы разобрали для int(5.75) выше: железо отменяет нормализацию, а затем отбрасывает всё справа от точки, даже не заглядывая туда.
Заворачивание. 1000 → int8 даёт -24. int8 хранит 8 бит дополнительного кода, покрывающих , так что когда значение не помещается, выживают только младшие 8 бит, переинтерпретированные в этой знаковой кодировке, — что приземляет 1000 на -24. Соседнее значение вроде 1001 заворачивается в -23, а -129 — в +127: числовая прямая загибается сама на себя каждые 256 шагов, так что int8 трактует диапазон как кольцо, а не как прямую.
Насыщение до бесконечности. 1e5 → fp16 становится inf, а -1e10 — -inf. Поле порядка у fp16 всего 5 бит шириной, и наибольшее конечное значение, которое он может закодировать, — 65 504 (1.1111111111 × 2^{15}). Значению 100 000 потребовался бы порядок 2^{16}, для которого у fp16 нет битового шаблона, — поэтому IEEE-754 делает единственное, что может: возвращает +inf (знаковый бит определяет +inf против -inf, остальной шаблон тот же). В отличие от целочисленного заворачивания, переполнение float не циклится; оно насыщается. 1e5, 1e10 и 1e38 все схлопываются в один и тот же битовый шаблон +inf, а -1e5, -1e10, -1e38 — в один и тот же -inf: после конверсии их ничто не различает.
Потеря точности. 2^24 + 1 → fp32 становится 2^24. Мантисса fp32 — 24 бита (23 явных + 1 неявный), так что каждое целое от 0 до 2^24 (16 777 216) представимо точно, но за этой границей расстояние между представимыми значениями удваивается на каждой степени двойки. Целое 2^24 + 1 попадает между двумя представимыми значениями fp32 и округляется к ближайшему (2^24, поскольку +1 — ровно середина, а ничьи округляются к чётному). Это та же история, что стоит за тем, почему fp64 начинает терять целочисленную точность на 2^53 ≈ 9 × 10^15: Number.MAX_SAFE_INTEGER в JavaScript — это ровно 2^53 − 1.
Вот общее правило для каждой конверсии в оставшейся части статьи: биты, которые не влезают в целевой формат, должны куда-то деться — быть усечёнными, округлёнными, завёрнутыми или сброшенными в бесконечность, — и что именно из четырёх произойдёт, решают исходный и целевой форматы, а не само значение.
Выходим за пределы языкового умолчания
Экосистема ML предлагает куда более широкое меню, чем Python или JavaScript нативно. Каждый крупный численный фреймворк — NumPy, PyTorch, JAX и TensorFlow — позволяет выбрать dtype явно. Вот одна и та же операция в четырёх фреймворках:
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 floatimport 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 bytesimport 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 arraysimport jax.numpy as jnp
x = jnp.array([0.5], dtype=jnp.bfloat16) # bf16 — 2 bytesAPI косметически различаются (torch.tensor(...) против tf.constant(...) против np.array(...)), но контракт один: вы передаёте числовое значение и dtype, а фреймворк упаковывает каждый элемент ровно в такое число байт в непрерывном буфере. NumPy слегка отстаёт по dtype эпохи ML — он не включает bfloat16 в базовые типы, поскольку bfloat16 был введён Google для TPU и стандартизован сначала через другие фреймворки. PyTorch, JAX и TensorFlow поддерживают его нативно.
Литерал 0.5 с точки зрения Python по-прежнему 64-битный double — конверсия происходит при конструировании тензора. Внутри каждый элемент занимает 8, 4, 2 или 2 байта в зависимости от dtype. На fp8 это становится 1 байтом, на int4 — половиной байта.
Чтобы увидеть, что эти байты на самом деле кодируют — и почему их сокращение вдвое не просто делит представимый диапазон пополам, — нужно посмотреть на формат под капотом.
Семейство ML-форматов и почему победил bf16
Современный ML использует горстку форматов с плавающей точкой, и у всех у них одна и та же трёхполевая раскладка, унаследованная от IEEE-754:
- Знак (1 бит) — положительное или отрицательное.
- Порядок — насколько сдвинуть точку, хранится в смещённом двоичном коде.
- Мантисса (значащая часть) — ведущие цифры числа в нормализованной научной форме, с отброшенной неявной ведущей
1.
Они также целиком наследуют соглашения IEEE-754 — неявная ведущая единица, смещённый порядок, специальные битовые шаблоны для ±0 / ±inf / NaN / субнормалей, правило округления по умолчанию «к ближайшему, ничьи к чётному» — и роль каждого поля: биты порядка покупают диапазон (насколько большое или малое число можно представить), биты мантиссы покупают точность (насколько тонко можно различать числа схожей величины).
Это соответствие постоянно для любого float по IEEE-754 независимо от ширины. Между четырьмя форматами ниже варьируется именно то, как биты распределены между двумя полями, — и это распределение и есть весь дизайнерский выбор. Три из четырёх — fp64, fp32 и fp16 — в точности соответствуют стандарту IEEE-754. bf16 — белая ворона: спроектирован в Google для TPU, никогда не стандартизовался через IEEE, но построен на тех же соглашениях насквозь:
| Формат | Всего бит | Знак | Порядок | Мантисса | Байт/параметр |
|---|---|---|---|---|---|
| fp64 (IEEE-754 double) | 64 | 1 | 11 | 52 | 8 |
| fp32 (IEEE-754 single) | 32 | 1 | 8 | 23 | 4 |
| bf16 (Brain Float 16) | 16 | 1 | 8 | 7 | 2 |
| fp16 (IEEE-754 binary16) | 16 | 1 | 5 | 10 | 2 |
Две 16-битные строки — самые интересные: тот же общий бюджет битов, противоположные разбиения. Потяните ползунок ниже, чтобы прочувствовать размен: каждый бит, перенесённый в порядок, удваивает достижимый диапазон и вдвое ухудшает точность, и наоборот. Пресеты fp16 и bf16 защёлкиваются на реальных вариантах форматов.
[1, 4) (128 per octave; gap doubles at 2)Переносите биты в порядок — и максимум представимого растёт очень быстро, но зазор в каждой октаве растёт в ногу (в он равен , в — и так далее, удваиваясь каждую октаву), так что сетка везде становится грубее.
Переносите биты в мантиссу — и зазор сжимается, а максимум обваливается. Пресеты fp16 и bf16 приземляются на противоположных сторонах ровно этого размена при 16 битах: bf16 достаёт до ~10^38 с зазорами ~0.008 около 1, fp16 упирается в ~65 504 с зазорами ~10^-3.
Полоса внизу виджета делает зазор видимым: при выбранном bf16 видны отдельные засечки, расставленные по [1, 2], — эти зазоры и есть предел точности формата на единичном масштабе. Переключитесь на fp16 — и засечки сливаются в сплошную линию, потому что расстояние стало меньше пикселя. Сетка по-прежнему дискретна; просто она достаточно плотная, чтобы дискретности больше не было видно, — а это ровно то, что здесь значит «больше точности».
Полоса также продолжается в следующую октаву [2, 4) (за пунктирную отметку на 2), чтобы стало ясно: удвоение зазора не уникально для диапазона [1, 2) — то же число засечек (по одной на состояние мантиссы) растягивается на вдвое более широкий интервал, так что визуальное расстояние в правой половине вдвое больше, чем в левой. Потяните ползунок к малой мантиссе (например, M=4) — и удвоение станет очевидным.
Для конкретики, первые несколько представимых значений в [1, 2] для каждого пресета:
| bf16 (128 значений, зазор = 1/128) | fp16 (1024 значения, зазор = 1/1024) |
|---|---|
1.0 | 1.0 |
1.0078125 | 1.0009765625 |
1.015625 | 1.001953125 |
1.0234375 | 1.0029296875 |
1.03125 | 1.00390625 |
| … | … |
1.9921875 | 1.9990234375 |
(2.0) | (2.0) |
Каждое очередное значение bf16 приземляется ровно на каждое 8-е значение fp16 — сетка bf16 в этой октаве является строгим подмножеством сетки fp16, просто из каждых восьми засечек убраны семь. Это отношение равно в точности (у fp16 10 бит мантиссы, у bf16 — 7): каждый дополнительный бит мантиссы удваивает число представимых значений на октаву.
Этот равномерный зазор держится только внутри одной октавы вроде [1, 2) — самой плотной части сетки для любого положительного значения, и именно её показывает статистика «зазор в [1, 2)» в виджете.
Как только вы переходите в [2, 4), порядок увеличивается на 1, шаг мантиссы умножается на 2, и зазор удваивается.
Вот как это выглядит для bf16 на нескольких октавах:
| Октава bf16 | Зазор |
|---|---|
[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 |
Итак, bf16 меняет точность на диапазон — он достаёт до экстремальных величин в обе стороны (крошечные дроби вроде с малой стороны и с большой), но грубо округляет близкие значения вместе. fp16 — зеркальная противоположность: он разрешает тонкие различия между близкими значениями, но переполняется на экстремумах в обе стороны. Из-за этого размена оба 16-битных формата сосуществуют в современном ML: bf16 для обучения (градиенты охватывают много порядков, важен диапазон), fp16 для некоторых сценариев инференса (точность важнее, когда значения ограничены).
Почему биты bf16 разбиты именно так
Виджет показал bf16 и fp16 как противоположные разбиения одного 16-битного бюджета. Но дизайн bf16 не был ответом на fp16 — он был ответом на fp32. Google проектировал bf16 для TPU вокруг одного вопроса — как вдвое сократить память fp32, не потеряв его диапазон, — и пришёл к простому ответу. Оставить 8 бит порядка дословно (то же смещение 127, бит в бит как у fp32) и урезать мантиссу с 23 бит до 7. Формат быстро вышел за пределы TPU: NVIDIA Ampere (A100, 2020), AMD CDNA, процессоры Intel (AVX-512 BF16) и ARM Armv8.6-A — все добавили нативную поддержку bf16; на современном железе это 16-битный формат ML по умолчанию.
Так что bf16 — это по сути fp32 с вырезанной мантиссой: тот же диапазон, что у fp32, примерно от до , просто на более грубой сетке. Каждый бит мантиссы вдвое сокращает расстояние между соседними представимыми значениями, так что удаление 16 из них удваивает расстояние 16 раз — в раз грубее при любом данном порядке. Поэтому вес вроде 1.005 хранится в каждом формате по-своему:
| Формат | Хранимое значение | Ошибка |
|---|---|---|
| fp32 | 1.005 (практически точно) | ~10⁻⁸ |
| fp16 | 1.0048828125 | ~1.2 × 10⁻⁴ |
| bf16 | 1.0078125 | ~2.8 × 10⁻³ |
Меньшая ошибка означает, что хранимое значение ближе к оригиналу, — так что fp16 здесь выигрывает по точности (примерно в 24 раза меньшая ошибка, чем у bf16), но проигрывает по диапазону.
Градиент около 1e-7 исчез бы в fp16, но выжил бы в bf16.
У численных форматов в ML два режима отказа, и они не равно плохи. Отказы по диапазону (переполнение/исчезновение порядка) катастрофичны: исчезнувший градиент полностью останавливает обучение этого параметра; переполнившаяся активация даёт NaN и убивает весь прогон. Отказы по точности терпимы: мелкие ошибки округления на каждом весе усредняются по миллионам весов сети (почему — ниже). bf16 отказывает по терпимой оси; fp16 — по катастрофической, — поэтому bf16 предпочтителен для ML, несмотря на проигрыш в точности на вес.
Чтобы всё же использовать fp16 для обучения, несмотря на потолок диапазона, понадобился бы обходной приём под названием масштабирование потерь. Умножьте потери на константу перед обратным проходом — градиенты линейны по потерям, так что все они возвращаются умноженными на ту же , попадая в диапазон fp16, — а затем поделите на перед применением обновления. Без этого градиент 1e-7 исчезает в ноль, и этот параметр вообще не получает обновления. torch.cuda.amp.GradScaler в PyTorch автоматизирует эту бухгалтерию. bf16 всё это пропускает: его диапазон совпадает с fp32, так что градиенты помещаются нативно.
Тот же размен на любой ширине
То, что мы видели в предыдущих разделах — bf16 против fp16 на 16 битах, — это один частный случай более широкой закономерности: чем уже формат, тем острее размен и тем вероятнее, что одна и та же ширина существует в нескольких вариантах. fp8 (8 бит) настолько тесен, что ни одно разбиение не выигрывает, поэтому он поставляется в двух вариантах — E4M3 (больше точности, для весов) и E5M2 (больше диапазона, для градиентов), — которые реальные пайплайны используют бок о бок.
Есть и практический дивиденд от того, что порядок bf16 совпадает с fp32: конверсия fp32 ↔ bf16 практически бесплатна. То же поле порядка, то же смещение 127; вы отбрасываете нижние 16 бит мантиссы — и готово. Железо делает это сдвигом. Конверсия fp16, напротив, может реально переполниться или обнулиться, потому что диапазон её порядка иной. Пайплайны смешанной точности плавно текут между fp32 и bf16; fp32 ↔ fp16 требует аккуратного масштабирования.
fp16 всё ещё в ходу для инференса на старых GPU без поддержки bf16 и в некоторых сценариях развёртывания, где лишние биты мантиссы стоят головной боли с диапазоном. Но для обучения bf16 его фактически вытеснил.
У каждого формата свой кремний
Числовой формат — это всего лишь битовая раскладка; преимущество в скорости даёт выделенный кремний, который нативно умножает и накапливает эту раскладку. Без кремния, реализующего формат нативно, софт всё ещё может его хранить, но любая арифметическая операция откатывается к более широкому типу, и преимущество в пропускной способности, ради которого узкий формат затевался, исчезает.
Каждый широко используемый сегодня ML-формат восходит к конкретному исполнительному блоку конкретного поколения чипов:
| Формат | Где работает нативно |
|---|---|
| fp64 / fp32 | Универсальная FPU на каждом CPU и GPU. Универсально, но медленно для тензорной математики. |
| fp16 | Тензорные ядра NVIDIA начиная с Volta (V100, 2017); другие вендоры подтянулись. |
| int8 | Тензорные ядра NVIDIA Turing (T4, 2018); теперь по сути на каждом современном ускорителе. |
| bf16 | Google 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. |
| int4 | Тензорные ядра эпохи Hopper и новее. |
У каждого вендора свои названия этих блоков — NVIDIA Tensor Cores, AMD Matrix Cores, Intel Gaudi Matrix Math Engines (MME), Google Matrix Multiply Units (MXU), — но идея одна: выделенный кусок кремния, выполняющий слитое умножение-накопление (a × b + c за один округлённый шаг) над плитками чисел низкой точности, обычно с пропускной способностью в 2–16 раз выше (и с меньшим энергопотреблением), чем у универсальной FPU.
Отсюда два вывода. Экономия памяти происходит на любой GPU — bf16-модель вдвое меньше fp32-модели независимо от железа. Ускорение происходит, только когда присутствует соответствующий кремний, — вот откуда берётся кратность 2–16×. bf16-модель работает на полной скорости на A100; на V100 (только fp16) ей нужна либо предварительная конверсия, либо программная конверсия на каждой операции, которая как раз и съедает выигрыш в скорости. Та же история с fp8 на чём угодно старше H100. Так что выбор более узкого формата всегда уменьшает модель в памяти, а ещё сокращает время исполнения, если у чипа есть тензорные ядра (или эквивалент) под этот формат.
Есть и временной аспект: принятие формата отстаёт от железа. Новую раскладку можно предложить на бумаге хоть на следующей неделе, но на масштабе её не будут использовать, пока не выйдет поколение чипов с нативной поддержкой — обычно через два-четыре года. Вот почему современные ускорители продолжают наслаивать блоки (fp16 → bf16 → fp8 → fp4), а не заменять их: каждое поколение добавляет кремний под следующий формат, сохраняя предыдущие ради обратной совместимости.
На практике это значит, что планирование развёртывания начинается с типа тензоров в карточке модели. Прежде чем выбирать GPU, проверьте формат, в котором опубликованы веса (fp32, bf16, fp16, fp8, int8, int4 — Hugging Face показывает это в панели, которую мы видели выше), и сопоставьте его с кремнием, который у вас есть или который можно арендовать: bf16 требует Ampere или новее, fp8 — Hopper, MI300 или Gaudi 2/3, int4 — тензорных ядер эпохи Hopper. Не совпадут — и вы либо платите налог на программную конверсию, выбрасывающий преимущество формата в скорости, либо вообще не можете запустить модель на полной скорости.
Взгляд вперёд: fp8
На переднем крае 16 бит уступают восьми. fp8 пришёл как нативный аппаратный формат — выделенная поддержка тензорных ядер появилась в NVIDIA H100, AMD MI300 и Intel Gaudi 2/3 — и, как и в случае fp16 против bf16, в таком тесном бюджете он не может остановиться на одном разбиении, поэтому существует в двух вариантах:
| Формат | Знак | Порядок | Мантисса | Для чего |
|---|---|---|---|---|
| fp8 E4M3 | 1 | 4 | 3 | веса и активации прямого прохода (смещение в точность) |
| fp8 E5M2 | 1 | 5 | 2 | градиенты (смещение в диапазон) |
Оба были стандартизованы через спецификацию Open Compute Project FP8 Formats for Deep Learning, написанную совместно NVIDIA, Intel и Arm.
Два варианта делят железо: тензорные ядра с поддержкой fp8 (NVIDIA H100, AMD MI300, Intel Gaudi 2/3) декодируют одни и те же 8 бит как E4M3 или E5M2 в зависимости от флага режима на операцию — отдельного кремния для каждого нет. Шаг обучения обычно использует E4M3 для весов и активаций прямого прохода (смещение в точность, поскольку эти значения ограничены дизайном сети) и E5M2 для градиентов обратного прохода (смещение в диапазон, поскольку величины градиентов охватывают много порядков). Чтобы компенсировать крошечный динамический диапазон fp8, каждый тензор несёт масштабный коэффициент на тензор — обычно вычисляемый динамически по наблюдаемому распределению значений, — который отображает его фактический диапазон в представимое окно fp8. В коде:
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Библиотеки более высокого уровня (Transformer Engine от NVIDIA, пути fp8 в torch.amp у PyTorch) оборачивают это масштабирование автоматически — te.fp8_autocast(enabled=True) отслеживает amax по каждому тензору и вычисляет масштабы без участия вызывающего.
Подвигайте ползунок или выберите пресет, чтобы задать максимум значений тензора: зелёные засечки — представимые значения fp8, умноженные на масштабный коэффициент тензора, а синие точки — выборка «данных тензора» в выбранном диапазоне. По мере того как максимум тензора растёт или падает, масштаб меняется, чтобы зелёная сетка оставалась растянутой по данным: нативная сетка fp8 ±448 — это то же железо, но масштаб переразмечает её ось, так что точки сетки оказываются там, где данные реально живут. Мастер-веса остаются в fp32, оптимизатор работает в более высокой точности, а веса приводятся к E4M3 для каждого прямого прохода. Transformer Engine от NVIDIA, torch.float8_e4m3fn / torch.float8_e5m2 в PyTorch и JAX — все реализуют этот рецепт. Инференс использует то же железо, но пропускает обратный проход: и веса, и активации сидят в E4M3, а масштабы на тензор калибруются офлайн и замораживаются при развёртывании.
Большинство развёрнутых сегодня моделей всё ещё в bf16 или квантизованы до int8 для инференса, но fp8 — то, куда движется следующее поколение обучения и инференса, и тот же размен, что мы видели между fp16 и bf16 (точность против диапазона), теперь разыгрывается на битовую ширину ниже.
Почему модели терпят меньшее число бит
Под выбором bf16 против fp16, под разбиением fp8 и под всем, что идёт дальше в квантизации, лежит одно фундаментальное утверждение: отдельные веса ML сами по себе не важны. Форматы с плавающей точкой проектировались в предположении, что каждое число важно само по себе: значение в симуляции гидродинамики, коэффициент в решателе методом конечных элементов, координата в геометрическом алгоритме. Веса ML не такие. Вес — это одно из миллионов взаимно приспособленных шумных значений, эффекты которых суммируются по слою, и мелкие ошибки округления на каждом весе смываются в сумме.
Поэтому инференс в fp16 / bf16 фактически бесплатен для модели, обученной в fp32; поэтому int8 почти бесплатен для большинства нагрузок, хотя каждый отдельный вес заметно менее точен; и поэтому тренд продолжает уходить вниз. Эмпирически, чем больше модель, тем терпимее она становится: маленькая CNN может заметно потерять в точности на int4, а 70B-LLM на int4 обычно нет.
Эмпирические свидетельства охватывают почти десятилетие. Первая волна пришла из сжатия CNN: Deep Compression (Han et al., 2015) показала, что веса CNN можно квантизовать до 8 бит с пренебрежимой потерей точности, а вместе с прунингом и кодированием Хаффмана — сжать AlexNet в 35 раз без ущерба для качества. Несколькими годами позже Mixed Precision Training (Micikevicius et al., 2017) закрепила рецепт «fp16-веса с fp32-мастер-копиями», ставший каноническим умолчанием при обучении: большинство моделей, обученных так, не показывают измеримой разницы в точности против полного fp32.
Эпоха LLM подвинула предел дальше. LLM.int8() (Dettmers et al., 2022) и GPTQ (Frantar et al., 2022) показали, что квантизация только весов до int8 и int4 сохраняет качество для LLM с миллиардами параметров. QLoRA (Dettmers et al., 2023) ввела NF4 — 4-битный неравномерный формат, настроенный под нормальное распределение, которому веса склонны следовать, — и с его помощью дообучила модели на 65B параметров на одной GPU с 48 ГБ. Особенно поразительно, что BitNet b1.58 (2024) обучает LLM с троичными весами — каждый ограничен множеством {-1, 0, +1}, около 1.58 бита на вес — и всё равно соответствует базовым fp16-моделям при том же числе параметров.
Закономерность во всех этих работах одна: формат хранения и статистическое поведение хранимого — два разных вопроса, и нейросети оказываются снисходительны ко второму. Именно на этой снисходительности тихо держался каждый раздел статьи до сих пор — и следующий раздел давит на неё ещё сильнее.
Квантизация — та же идея, доведённая дальше
Всё до сих пор хранило каждый вес как самодостаточный float по IEEE-754, со своими битами знака, порядка и мантиссы. Квантизация использует совсем другую схему хранения: каждый вес становится маленьким целым (1 байт для int8, 4 бита для int4) в своём целочисленном пространстве, а один масштабный коэффициент с плавающей точкой (хранимый по одному на группу весов) делает работу, которую раньше делал порядок каждого значения.
Прежде чем перейти к механике, вот что квантизация реально даёт:
- Память — та самая главная победа, к которой мы шли. Начиная с fp32, int8 в 4 раза меньше, int4 — в 8 раз. На модели с 70B параметров это разница между «нужно 8 GPU» и «влезает в одну».
- Скорость — меньший объём прогоняет меньше данных через иерархию памяти, где и уходит основное время инференса. На поддерживающем железе (и когда развёртывание выполняет матричное умножение в целочисленной арифметике, а не деквантизует сначала в fp16) целочисленное умножение-накопление int8 к тому же быстрее fp16/bf16-MAC на современных тензорных ядрах. Выигрыш по пропускной способности памяти есть в обоих вариантах развёртывания; выигрыш от целочисленного MAC — только при настоящем целочисленном матмуле.
- Возможность развернуть — мобильные NPU, микроконтроллеры и краевые ускорители часто «int8 или ничего». Их кремний спроектирован под арифметику с фиксированной точкой, а не под полноценную плавающую. При аккуратном кодировании (сам масштаб хранится как int32-множитель + число сдвигов вправо, как делает TFLite) весь матмул может выполняться в чисто целочисленных операциях, не трогая FPU. Квантизация — единственный путь к развёртыванию на таком железе; без неё модель там просто не запустится.
- Энергия — целочисленные операции стоят меньше джоулей на операцию, чем операции с плавающей точкой. Это важно и на устройствах с батареей (телефоны, IoT, ассистенты на устройстве), и в масштабе дата-центра, где бюджет мощности — реальный потолок пропускной способности.
На практике сегодня большая часть продакшен-инференса ML работает хотя бы частично квантизованной. Самостоятельно размещённые LLM (через llama.cpp, Ollama, LM Studio) почти всегда работают в int4 или int8 — полный bf16-инференс на потребительском железе редок из-за бюджетов памяти. Обслуживание LLM в дата-центрах на масштабе сильно опирается на fp8 и int8, часто оставляя чувствительные к выбросам слои в более высокой точности. Краевые и мобильные развёртывания нейросетей (распознавание речи на устройстве, компьютерное зрение, слияние сенсоров) практически всегда int8 — кремний не поддерживает ничего другого. Чистый fp32-инференс сегодня в основном ограничен исследовательскими процессами, научными вычислениями и горсткой критичных к точности продакшен-эндпоинтов. Квантизация — не оптимизация, которую можно применить потом; для большинства современных целей развёртывания это умолчание.
Есть два потока собственно квантизации, в зависимости от того, на каком этапе жизненного цикла модели вы её делаете. Они разделяют одну и ту же арифметику «защёлкивания на сетку», которую мы разберём в следующем подразделе.
Квантизация после обучения (PTQ) берёт уже обученную fp32/bf16-модель и квантизует её веса на этапе развёртывания, без дальнейшего обучения — дёшево (GPU-часы не нужны) и сегодня это стандартный поток для инференса LLM. Обычные алгоритмы PTQ для LLM — GPTQ и AWQ; оба построены на рецепте «защёлкни и отмасштабируй», добавляя сверху более умную машинерию. Популярные инструменты, реализующие их, — bitsandbytes (интегрирован с PyTorch, используется в Hugging Face Transformers) и llama.cpp (дружелюбен к CPU и потребительским GPU, со своими форматами квантизации GGUF).
Обучение с учётом квантизации (QAT) имитирует квантизационный шум прямо во время обучения — прямой проход использует псевдоквантизованные веса, чтобы оптимизатор учился это компенсировать. Дороже (нужны обучающий пайплайн и размеченный датасет), но даёт лучшую точность на экстремальных битовых ширинах (int4 и ниже). QAT стандартен в продакшен-развёртываниях CNN (мобильные, краевые), менее распространён для LLM из-за стоимости обучения — хотя ориентированные на LLM варианты вроде LLM-QAT расширяют технику дистилляцией без данных.
У любого потока есть потолок: ошибки округления, чисто усредняющиеся на int8, перестают чисто усредняться на гораздо меньших битовых ширинах. Квантизация весов в int4 ухудшает некоторые задачи с интенсивным рассуждением. То же давление сжатия распространяется и за пределы весов: квантизация KV-кэша на длинном контексте — самостоятельная активная область исследований, где экстремальные форматы вроде 3-битной схемы в TurboQuant работают только за счёт переформулировки задачи (поворот векторов в известное распределение перед квантизацией), так что остаточная ошибка округления информационно-теоретически близка к оптимальной.
Рецепт «защёлкни и отмасштабируй»
Когда модель квантизуется, исходные fp32-веса отбрасываются и заменяются парами (int8-индекс, масштаб S) — по одному индексу на вес, по одному масштабу на группу. Так что веса больше не хранятся как непрерывные float; квантизация защёлкивает их на один из небольшого фиксированного набора дискретных уровней — 256 для int8 (или 255 для симметричного int8), 16 для int4.
И эти уровни не универсальны — они строятся для каждой группы весов, из самих данных.
При этом хранимое целое не является самим весом — это индекс в сетке позиций с плавающей точкой, определённой .
Процесс деквантизации восстанавливает приближение исходного дробного значения: на инференсе индекс × S его реконструирует. На современном железе реконструкция происходит точно в срок — она слита с матмулом и никогда не материализуется в памяти, — так что сеть работает прямо на сжатом представлении, восстанавливая приближённые fp32-значения только в момент, когда они нужны.
Итак, допустим, мы выбрали группу из пяти весов для совместной квантизации:
Процесс состоит из двух шагов, которые виджет ниже позволяет прокликать. На верхнем уровне: сначала мы вычисляем один масштаб для всей группы — он даёт сетку, на которую защёлкнутся все веса. Затем для каждого веса применяем собственно сжатие — защёлкиваем на ближайшую точку сетки (с округлением ничьих к чётному) и сохраняем целочисленный индекс. Шаг 1 выполняется раз на группу; шаг 2 — раз на вес.
Чтобы задать дискретный набор, посмотрите на диапазон значений в группе весов и вычислите масштаб из данных. Вот общая формула для симметричной -битной квантизации:
Знаменатель — это , наибольший абсолютный индекс в симметричном -битном знаковом целом. Для int8 это ; для int4 — ; для int2 — .
Числитель — наибольший по модулю вес в группе: , где — те весов, которые вы квантизуете вместе.
Итак, замысел таков: поделить наибольший по модулю вес на наибольший абсолютный индекс. Это гарантирует, что самый крайний вес отображается ровно в точку сетки — без обрезки, с полным использованием сетки.
Для наших пяти примерных весов и int8 (, то есть знаменатель ):
Заметьте, что играет сразу две роли: это множитель (индекс × S восстанавливает fp32-значение) и шаг сетки (соседние индексы различаются на 1, так что соседние позиции сетки различаются ровно на ). Например, индекс 41 декодируется в 41 × 0.0748 ≈ 3.067, а индекс 42 — в 42 × 0.0748 ≈ 3.142; разрыв между ними ровно 0.0748, то есть . Одно число, два смысла.
Теперь, когда у нас есть , сетка следует прямо из него: каждая точка сетки — это индекс × S для некоторого целого индекса от -127 до +127:
Подставляя наш — каждая точка сетки есть индекс × 0.0748:
Чтобы защёлкнуть каждый вес на ближайшую точку сетки, вспомним, что шаг 1 лишь построил сетку; вот здесь и происходит собственно сжатие каждого веса. Для каждого веса в группе вычисляем его индекс:
Это даёт целочисленный индекс ближайшей точки сетки — та же механика округления к ближайшему из статьи о двоичном округлении: выбрать ближайшее представимое значение, ничьи разрешать к чётному. Разница лишь в том, что множество представимых значений теперь гораздо меньше и явно перечислено. Сохраните однобайтовое целое (или 4 бита для int4); общий путешествует рядом, храня́сь по одному на группу, так что его стоимость амортизируется по всем весам группы. Для наших пяти весов с :
| Вес | Хранится (значение int8) | ||
|---|---|---|---|
3.1416 | 42.0 | 42 | 42 |
-1.7 | -22.7 | -23 | -23 |
0.0234 | 0.31 | 0 | 0 |
1.5 | 20.05 | 20 | 20 |
-9.5 | -127.0 | -127 | -127 |
Пять fp32-весов (20 байт) становятся пятью int8-значениями плюс один fp32-масштаб (9 байт всего) — в 2.2 раза меньше, и экономия только растёт с размером группы.
Виджет ниже делает этот цикл кликабельным на тех же пяти весах — выберите один и посмотрите, как квантизация, защёлкивание и деквантизация проходят от начала до конца:
Выберите вес, чтобы увидеть полный цикл «квантизовать — защёлкнуть — деквантизовать» на тех же пяти значениях из формулы выше. Зелёные засечки — сетка int8 (индекс × S для каждого индекса от −127 до +127). Синяя точка — исходный fp32-вес; красная метка — точка сетки, на которую он защёлкивается. Строка потока показывает арифметику шагов 1 и 2: поделить на , округлить до ближайшего целого (именно оно сохраняется), затем при чтении умножить обратно на . Так 3.1416 / 0.0748 ≈ 42.0 округляется до 42 и реконструируется в 42 × 0.0748 ≈ 3.142 — почти идентично. Попробуйте 0.0234, чтобы увидеть, что происходит, когда крошечный вес проваливается в точку сетки 0.
Стоит отметить: здесь в игре два параллельных множества. Пространство индексов — это то, что реально хранится в памяти: чистые целые по 1 байту. Пространство значений — это то, что сеть считает весом после деквантизации: fp32-числа, полученные умножением каждого индекса на . То же множество, отмасштабированное. Целые — дешёвая система адресации; вещественная сетка — то, с чем сеть на самом деле считает.
Чтобы восстановить вес, железо читает маленькое целое и применяет обратную операцию:
где — хранимое целое, а — масштаб с плавающей точкой. Разные группы весов получают разный — поэтому одно и то же int8-значение 42 в двух разных каналах может декодироваться в два разных вещественных числа.
Заметьте, чего здесь не происходит: нет алгоритма, «пересобирающего» поля знака / порядка / мантиссы, как это делает конверсия IEEE-754. Реконструкция — это всего две стандартные инструкции: приведение int к fp32 (42 → 42.0) и умножение fp32 (42.0 × 0.0748 ≈ 3.142). Мантисса fp32, которую мы получаем обратно, конструируется в момент умножения, а не хранится где-либо — сжатый вес есть просто коэффициент при масштабе.
Восстановленное значение похоже на исходное, но не тождественно ему — поэтому в формуле стоит ≈, а не =. Округление к ближайшему сдвигает любой вес не более чем на S/2 от исходной позиции, так что каждое восстановленное значение находится в пределах половины шага сетки от того, где оно было. Для весов, которые сами меньше S/2, эта ошибка может составлять весь вес целиком (они округляются в 0). Вся ставка квантизации в том, что эти ошибки на каждом весе достаточно малы и некоррелированы, чтобы усредниться по миллионам весов, — как мы видели в разделе Почему модели терпят меньшее число бит.
Где это реально исполняется
На практике реальные развёртывания идут через эту арифметику одним из двух путей:
- Настоящий целочисленный матмул: квантизованы и веса, и активации. Матмул целиком выполняется в целочисленной арифметике, а масштаб применяется только в конце. Этот путь обычно используют в развёртываниях CNN и в дата-центровом обслуживании в int8.
- Квантизация только весов: квантизованы только веса; активации остаются в fp16/bf16. Веса деквантизуются на лету внутри матмула, который затем идёт в плавающей точке. Это доминирующий поток для инференса LLM на GPU.
Оба пути используют одну и ту же математику реконструкции (q × S); различаются они тем, где происходит деквантизация: один раз на выходе матмула (путь 1) против точно в срок внутри GEMM-ядра (путь 2). В любом случае в памяти живёт сжатое представление (int8, S); fp32-значения восстанавливаются только когда арифметике они нужны.
Выбор гранулярности
Мы всё время говорили «один масштаб на группу» — но что именно считается группой, это выбор. Этот выбор — рычаг гранулярности: как часто вы вычисляете свежий масштабный коэффициент:
- На тензор: один масштаб на всю матрицу весов. Самое дешёвое (один масштаб на тензор), но единственный выброс раздувает масштаб и портит точность для каждого веса в матрице.
- На канал: один масштаб на выходной канал слоя. Стандарт для квантизации весов в продакшене: у каналов часто очень разные распределения величин, и масштаб на канал позволяет каждому сохранить своё разрешение.
- На группу: один масштаб на группу из N подряд идущих весов (обычно 32, 64 или 128). Стандарт для очень низких битовых ширин (int4 и ниже), где сетка на канал всё ещё слишком груба, а выброс внутри канала уничтожает локальную точность. Меньшие группы лучше подгоняются под локальные распределения, но платят больше накладных расходов на масштабы в расчёте на параметр.
Как на самом деле решается группировка? Для весов — в основном структурно, а не экспериментально. На тензор тривиально: одна группа — весь тензор. На канал получается бесплатно из геометрии слоя: выходная размерность Linear-слоя уже даёт естественные каналы, а выходные фильтры Conv-слоя делают то же самое. На группу — единственная схема с гиперпараметром, размером группы, и распространённые умолчания (128, 64, 32) идут из исследований и инструментов: GPTQ, AWQ и llama.cpp по умолчанию берут размеры групп около 128 для int4. Меньшие группы улучшают качество ценой больших накладных расходов на масштабы на вес, а правильное значение обычно выбирают, попробовав пару вариантов на отложенной валидационной выборке.
Почему это работает
То, что масштаб-как-множитель математически обоснован, доказано десятилетия назад: его подпирают три классических результата. Теорема Беннетта о шуме квантизации (1948) показывает, что для равномерного квантователя с шагом ошибка округления ограничена при нулевом среднем и дисперсии — квантизационный шум ведёт себя хорошо по построению, та же математика подпирает PCM в каждом цифровом аудиофайле, что вы когда-либо слушали. Отображение «квантизовать, затем деквантизовать» также линейно с точностью до округления, так что расстояния и отношения между весами сохраняются в пределах той же ошибки , и геометрическая структура исходного пространства весов переносится дальше. А поскольку умножение на скаляр дистрибутивно относительно произведения матрицы на вектор, масштаб выносится из матмула — именно поэтому путь 1 выше математически эквивалентен fp32-версии.
Масштаб-как-множитель — прямой потомок того, что и так делает плавающая точка. Порядок float фактически является масштабом на каждое значение (а мантисса на каждое значение — квантизованной полезной нагрузкой). Квантизация просто разделяет один масштаб между многими значениями, обменивая часть точности на меньшее число бит на элемент.
Картинка с сеткой из раздела про float делает структурную разницу видимой: float раскладывает свои представимые значения на логарифмически расставленной сетке, плотной около нуля и растягивающейся с ростом величины: зазоры ~0.008 около 1.0 в bf16 становятся зазорами ~8 около 1024 и ~10⁴ около 10⁶. Квантизация заменяет это линейной сеткой: внутри блока 256 (int8) или 16 (int4) представимых значений расставлены равномерно с шагом s. Никакого логарифмического растяжения, никакого дополнительного разрешения около нуля. Диапазон данных блока фиксирует s, сетка регулярна по этому диапазону, и одна и та же ошибка округления действует, мало значение или велико внутри блока.
Переключайте окно между [0, 4], [0, 16] и [0, 1024], чтобы увидеть контраст: засечки bf16 сбиваются в почти сплошную стену слева и редеют вправо (растягивание логарифмической сетки), тогда как строки int8 и int4 остаются равномерными гребёнками одинаково расставленных засечек при любом размере окна — те же 256 (или 16) уровней, просто растянутые на тот диапазон, который нужен блоку.
Картина хранения при int4 с размером группы 64 выглядит так:
- 4 бита на вес = 0.5 байта
- плюс один fp16-масштаб на 64 веса ≈ 2 байта / 64 = 0.031 байта/вес
- итого ≈ 0.53 байта/вес
Вот почему «int4» на практике цитируют как «0.5 байта на параметр» — накладные расходы реальны, но малы.
Квантизуем MNIST-классификатор
Чтобы сделать это конкретным на модели, с которой мы уже знакомы, применим тот же рецепт к первому Dense-слою MNIST-классификатора из более ранней статьи серии — слою Dense(128, input_shape=(784,)) с матрицей весов (784, 128) в fp32. Возьмём гранулярность на канал: 128 групп, по одной на выходной канал, в каждой по 784 веса. Для каждого канала вычисляем . Если наибольший по модулю вес канала 0 равен 0.234, то , и сетка этого канала — : 256 точек с шагом 0.00184, растянутых ровно на диапазон канала. Канал 1 получает другой , так что то же int8-значение 42 декодируется в — своё вещественное число для каждого канала:
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%Матрица весов первого слоя сжимается с ~400 КБ (fp32) до ~100 КБ (int8) плюс 512 байт масштабов на канал — чистое сокращение в 4 раза. Ошибка на вес порядка половины масштаба канала (обычно ~10⁻⁴ для этого слоя), заметно ниже шумового порога сети, обученной 60 000 стохастических SGD-обновлений. Подстановка W_back вместо весов слоя и повторная оценка модели даёт по сути идентичную тестовую точность.
Применим тот же рецепт к весам (128, 10) второго слоя — и получим картину по всей модели:
| Слой | Форма | fp32-веса | int8-веса | Масштабы на канал | Итого int8 |
|---|---|---|---|---|---|
| Dense 1 | (784, 128) | 401 408 Б | 100 352 Б | 128 × 4 = 512 Б | 100 864 Б |
| Dense 2 | (128, 10) | 5 120 Б | 1 280 Б | 10 × 4 = 40 Б | 1 320 Б |
| Итого | 406 528 Б (~397 КБ) | 102 184 Б (~100 КБ) |
Смещения (их 138, обычно держатся в fp32 ≈ 552 байта) на этом масштабе пренебрежимы. Итог: веса всей MNIST-модели уходят с ~397 КБ до ~100 КБ, чистое сокращение примерно в 4 раза, а масштабы на канал добавляют лишь ~0.55% накладных расходов поверх int8-хранения весов. Int8 PTQ — это ровно оно, повторённое послойно.