Float, bfloat и как биты определяют память модели

Под всей абстракцией нейросеть — это большой набор чисел, называемых параметрами: весов и смещений, которые несёт каждый нейрон в каждом слое, — мы подробно разбирали их в статье о том, как сети учатся. Число параметров говорит, сколько таких чисел хранит модель; числовой формат говорит о размере каждого в байтах. Перемножьте их — и получите объём памяти, который занимает модель, — первое число, которое нужно знать, прежде чем выбирать железо, выбирать точность или решать, влезает ли эта штука вообще на одну GPU.

Возьмём маленькую сеть, которую мы обучили в статье про MNIST, — плотный классификатор с двумя слоями Dense в Keras: 784 развёрнутых в вектор пикселя подаются в скрытый слой из 128 нейронов, а тот — в выходной слой из 10 нейронов (по одному на цифру). Сокращённая запись этой формы — 784 → 128 → 10, где 784 — размер входа, а не слой. Число параметров складывается так:

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 хранит каждый параметр как fp32 — 32-битное число с плавающей точкой, 4 байта на штуку. Так что обученная модель занимает 101,770×4400 KB101{,}770 \times 4 \approx 400\text{ KB} памяти при загрузке. Но это игрушечная модель, сделанная для обучения, — настолько маленькая, что её можно отправить по почте. Реальные модели на порядки больше, и вот тут число параметров начинает иметь значение.

Каждая модель, опубликованная на хабе вроде Hugging Face, идёт с карточкой модели — публичным README рядом с файлом весов, где кратко описано, что это за модель, как её обучали, и приведены два интересующих нас числа: сколько у неё параметров и в каком числовом формате хранится каждый.

Возьмём современную опенсорсную OCR-модель: откройте PaddleOCR-VL на Hugging Face, и боковая панель скажет вам обе интересующие нас вещи — количество параметров и формат хранения каждого.

Боковая панель Hugging Face для PaddleOCR-VL-1.5, где Model size: 1.0B params подсвечен красным, а Tensor type: BF16 — зелёным

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, 10910^9). Умножаем на 2 байта на bf16-вес и получаем вес модели в памяти:

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}.

Это доминирующая статья расхода памяти на инференсе, где активации транзиентны (во время прямого прохода в памяти живёт только один слой), а 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

Эти четыре строки покрывают канонические сценарии, к которым сводится любое преобразование числового типа. Правило каждый раз одно — исходный формат содержит биты, которые целевой не может удержать, — а реакций железа четыре, в зависимости от того, какое поле переполняется:

СценарийИзВПримерРезультат
Усечениеfloatint3.7int83
Заворачиваниеintболее узкий int1000int8-24
Насыщение до бесконечностиfloatболее узкий float1e5fp16inf
Потеря точностиintfloat2^24 + 1fp322^24

Каждая строка заслуживает отдельного взгляда — механика различается любопытным образом.

Усечение. 3.7int8 оставляет 3. Дробные биты отбрасываются в сторону нуля независимо от их величины, так что 3.01, 3.5 и 3.999 все приземляются на 3. Отрицательные значения тоже режутся к нулю: -3.999 становится -3, а не -4. Это тот же механизм, что мы разобрали для int(5.75) выше: железо отменяет нормализацию, а затем отбрасывает всё справа от точки, даже не заглядывая туда.

Заворачивание. 1000int8 даёт -24. int8 хранит 8 бит дополнительного кода, покрывающих [128,127][-128, 127], так что когда значение не помещается, выживают только младшие 8 бит, переинтерпретированные в этой знаковой кодировке, — что приземляет 1000 на -24. Соседнее значение вроде 1001 заворачивается в -23, а -129 — в +127: числовая прямая загибается сама на себя каждые 256 шагов, так что int8 трактует диапазон как кольцо, а не как прямую.

Насыщение до бесконечности. 1e5fp16 становится 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 + 1fp32 становится 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 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 косметически различаются (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)64111528
fp32 (IEEE-754 single)3218234
bf16 (Brain Float 16)161872
fp16 (IEEE-754 binary16)1615102

Две 16-битные строки — самые интересные: тот же общий бюджет битов, противоположные разбиения. Потяните ползунок ниже, чтобы прочувствовать размен: каждый бит, перенесённый в порядок, удваивает достижимый диапазон и вдвое ухудшает точность, и наоборот. Пресеты fp16 и bf16 защёлкиваются на реальных вариантах форматов.

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

Переносите биты в порядок — и максимум представимого растёт очень быстро, но зазор в каждой октаве растёт в ногу (в [1,2)[1, 2) он равен 2M2^{-M}, в [2,4)[2, 4)2×2M2 \times 2^{-M} и так далее, удваиваясь каждую октаву), так что сетка везде становится грубее. Переносите биты в мантиссу — и зазор сжимается, а максимум обваливается. Пресеты 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.01.0
1.00781251.0009765625
1.0156251.001953125
1.02343751.0029296875
1.031251.00390625
1.99218751.9990234375
(2.0)(2.0)

Каждое очередное значение bf16 приземляется ровно на каждое 8-е значение fp16 — сетка bf16 в этой октаве является строгим подмножеством сетки fp16, просто из каждых восьми засечек убраны семь. Это отношение равно в точности 2107=23=82^{10 - 7} = 2^3 = 8 (у 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 меняет точность на диапазон — он достаёт до экстремальных величин в обе стороны (крошечные дроби вроде 103810^{-38} с малой стороны и 103810^{38} с большой), но грубо округляет близкие значения вместе. 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, примерно от 103810^{-38} до 103810^{38}, просто на более грубой сетке. Каждый бит мантиссы вдвое сокращает расстояние между соседними представимыми значениями, так что удаление 16 из них удваивает расстояние 16 раз — в 216=65,5362^{16} = 65{,}536 раз грубее при любом данном порядке. Поэтому вес вроде 1.005 хранится в каждом формате по-своему:

ФорматХранимое значениеОшибка
fp321.005 (практически точно)~10⁻⁸
fp161.0048828125~1.2 × 10⁻⁴
bf161.0078125~2.8 × 10⁻³

Меньшая ошибка означает, что хранимое значение ближе к оригиналу, — так что fp16 здесь выигрывает по точности (примерно в 24 раза меньшая ошибка, чем у bf16), но проигрывает по диапазону. Градиент около 1e-7 исчез бы в fp16, но выжил бы в bf16.

У численных форматов в ML два режима отказа, и они не равно плохи. Отказы по диапазону (переполнение/исчезновение порядка) катастрофичны: исчезнувший градиент полностью останавливает обучение этого параметра; переполнившаяся активация даёт NaN и убивает весь прогон. Отказы по точности терпимы: мелкие ошибки округления на каждом весе усредняются по миллионам весов сети (почему — ниже). bf16 отказывает по терпимой оси; fp16 — по катастрофической, — поэтому bf16 предпочтителен для ML, несмотря на проигрыш в точности на вес.

Чтобы всё же использовать fp16 для обучения, несмотря на потолок диапазона, понадобился бы обходной приём под названием масштабирование потерь. Умножьте потери на константу SS перед обратным проходом — градиенты линейны по потерям, так что все они возвращаются умноженными на ту же SS, попадая в диапазон fp16, — а затем поделите на SS перед применением обновления. Без этого градиент 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); теперь по сути на каждом современном ускорителе.
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.
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 E4M3143веса и активации прямого прохода (смещение в точность)
fp8 E5M2152градиенты (смещение в диапазон)

Оба были стандартизованы через спецификацию 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 по каждому тензору и вычисляет масштабы без участия вызывающего.

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.

Подвигайте ползунок или выберите пресет, чтобы задать максимум значений тензора: зелёные засечки — представимые значения 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. И эти уровни не универсальны — они строятся для каждой группы весов, из самих данных.

При этом хранимое целое не является самим весом — это индекс в сетке позиций с плавающей точкой, определённой SS. Процесс деквантизации восстанавливает приближение исходного дробного значения: на инференсе индекс × S его реконструирует. На современном железе реконструкция происходит точно в срок — она слита с матмулом и никогда не материализуется в памяти, — так что сеть работает прямо на сжатом представлении, восстанавливая приближённые fp32-значения только в момент, когда они нужны.

Итак, допустим, мы выбрали группу из пяти весов для совместной квантизации:

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

Процесс состоит из двух шагов, которые виджет ниже позволяет прокликать. На верхнем уровне: сначала мы вычисляем один масштаб SS для всей группы — он даёт сетку, на которую защёлкнутся все веса. Затем для каждого веса применяем собственно сжатие — защёлкиваем на ближайшую точку сетки (с округлением ничьих к чётному) и сохраняем целочисленный индекс. Шаг 1 выполняется раз на группу; шаг 2 — раз на вес.

Чтобы задать дискретный набор, посмотрите на диапазон значений в группе весов и вычислите масштаб SS из данных. Вот общая формула для симметричной kk-битной квантизации:

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

Знаменатель — это 2k112^{k-1} - 1, наибольший абсолютный индекс в симметричном kk-битном знаковом целом. Для int8 это 271=1272^7 - 1 = 127; для int4 — 231=72^3 - 1 = 7; для int2 — 211=12^1 - 1 = 1.

Числитель — наибольший по модулю вес в группе: max(w1,w2,,wn)\max(|w_1|, |w_2|, \ldots, |w_n|), где w1,,wnw_1, \ldots, w_n — те nn весов, которые вы квантизуете вместе.

Итак, замысел таков: поделить наибольший по модулю вес на наибольший абсолютный индекс. Это гарантирует, что самый крайний вес отображается ровно в точку сетки ±(2k11)\pm(2^{k-1} - 1) — без обрезки, с полным использованием сетки.

Для наших пяти примерных весов и int8 (k=8k = 8, то есть знаменатель 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

Заметьте, что SS играет сразу две роли: это множитель (индекс × S восстанавливает fp32-значение) и шаг сетки (соседние индексы различаются на 1, так что соседние позиции сетки различаются ровно на SS). Например, индекс 41 декодируется в 41 × 0.0748 ≈ 3.067, а индекс 42 — в 42 × 0.0748 ≈ 3.142; разрыв между ними ровно 0.0748, то есть SS. Одно число, два смысла.

Теперь, когда у нас есть SS, сетка следует прямо из него: каждая точка сетки — это индекс × S для некоторого целого индекса от -127 до +127:

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

Подставляя наш S=0.0748S = 0.0748 — каждая точка сетки есть индекс × 0.0748:

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

Чтобы защёлкнуть каждый вес на ближайшую точку сетки, вспомним, что шаг 1 лишь построил сетку; вот здесь и происходит собственно сжатие каждого веса. Для каждого веса ww в группе вычисляем его индекс:

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

Это даёт целочисленный индекс ближайшей точки сетки — та же механика округления к ближайшему из статьи о двоичном округлении: выбрать ближайшее представимое значение, ничьи разрешать к чётному. Разница лишь в том, что множество представимых значений теперь гораздо меньше и явно перечислено. Сохраните однобайтовое целое (или 4 бита для int4); общий SS путешествует рядом, храня́сь по одному на группу, так что его стоимость амортизируется по всем весам группы. Для наших пяти весов с S0.0748S \approx 0.0748:

Вес www/Sw / Sq=round(w/S)q = \text{round}(w / S)Хранится (значение int8)
3.141642.04242
-1.7-22.7-23-23
0.02340.3100
1.520.052020
-9.5-127.0-127-127

Пять fp32-весов (20 байт) становятся пятью int8-значениями плюс один fp32-масштаб (9 байт всего) — в 2.2 раза меньше, и экономия только растёт с размером группы.

Виджет ниже делает этот цикл кликабельным на тех же пяти весах — выберите один и посмотрите, как квантизация, защёлкивание и деквантизация проходят от начала до конца:

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.

Выберите вес, чтобы увидеть полный цикл «квантизовать — защёлкнуть — деквантизовать» на тех же пяти значениях из формулы выше. Зелёные засечки — сетка int8 (индекс × S для каждого индекса от −127 до +127). Синяя точка — исходный fp32-вес; красная метка — точка сетки, на которую он защёлкивается. Строка потока показывает арифметику шагов 1 и 2: поделить на SS, округлить до ближайшего целого (именно оно сохраняется), затем при чтении умножить обратно на SS. Так 3.1416 / 0.0748 ≈ 42.0 округляется до 42 и реконструируется в 42 × 0.0748 ≈ 3.142 — почти идентично. Попробуйте 0.0234, чтобы увидеть, что происходит, когда крошечный вес проваливается в точку сетки 0.

Стоит отметить: здесь в игре два параллельных множества. Пространство индексов {127,126,,127}\{-127, -126, \ldots, 127\} — это то, что реально хранится в памяти: чистые целые по 1 байту. Пространство значений {127S,126S,,127S}\{-127S, -126S, \ldots, 127S\} — это то, что сеть считает весом после деквантизации: fp32-числа, полученные умножением каждого индекса на SS. То же множество, отмасштабированное. Целые — дешёвая система адресации; вещественная сетка — то, с чем сеть на самом деле считает.

Чтобы восстановить вес, железо читает маленькое целое и применяет обратную операцию:

wSqw \approx S \cdot q

где qq — хранимое целое, а SS — масштаб с плавающей точкой. Разные группы весов получают разный SS — поэтому одно и то же int8-значение 42 в двух разных каналах может декодироваться в два разных вещественных числа.

Заметьте, чего здесь не происходит: нет алгоритма, «пересобирающего» поля знака / порядка / мантиссы, как это делает конверсия IEEE-754. Реконструкция — это всего две стандартные инструкции: приведение int к fp32 (4242.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) показывает, что для равномерного квантователя с шагом SS ошибка округления ограничена S/2S/2 при нулевом среднем и дисперсии S2/12S^2/12 — квантизационный шум ведёт себя хорошо по построению, та же математика подпирает PCM в каждом цифровом аудиофайле, что вы когда-либо слушали. Отображение «квантизовать, затем деквантизовать» также линейно с точностью до округления, так что расстояния и отношения между весами сохраняются в пределах той же ошибки S/2S/2, и геометрическая структура исходного пространства весов переносится дальше. А поскольку умножение на скаляр дистрибутивно относительно произведения матрицы на вектор, масштаб выносится из матмула — именно поэтому путь 1 выше математически эквивалентен fp32-версии.

Масштаб-как-множитель — прямой потомок того, что и так делает плавающая точка. Порядок float фактически является масштабом на каждое значение (а мантисса на каждое значение — квантизованной полезной нагрузкой). Квантизация просто разделяет один масштаб между многими значениями, обменивая часть точности на меньшее число бит на элемент.

Картинка с сеткой из раздела про float делает структурную разницу видимой: float раскладывает свои представимые значения на логарифмически расставленной сетке, плотной около нуля и растягивающейся с ростом величины: зазоры ~0.008 около 1.0 в bf16 становятся зазорами ~8 около 1024 и ~10⁴ около 10⁶. Квантизация заменяет это линейной сеткой: внутри блока 256 (int8) или 16 (int4) представимых значений расставлены равномерно с шагом s. Никакого логарифмического растяжения, никакого дополнительного разрешения около нуля. Диапазон данных блока фиксирует s, сетка регулярна по этому диапазону, и одна и та же ошибка округления действует, мало значение или велико внутри блока.

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

Переключайте окно между [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 веса. Для каждого канала cc вычисляем Sc=max(W[:,c])/127S_c = \max(|W[:, c]|) / 127. Если наибольший по модулю вес канала 0 равен 0.234, то S00.00184S_0 \approx 0.00184, и сетка этого канала — {0.234, , 0, , 0.234}\{-0.234,\ \ldots,\ 0,\ \ldots,\ 0.234\}: 256 точек с шагом 0.00184, растянутых ровно на диапазон канала. Канал 1 получает другой S1S_1, так что то же int8-значение 42 декодируется в 42Sc42 \cdot S_c — своё вещественное число для каждого канала:

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 — это ровно оно, повторённое послойно.