Перейти к содержимому
Ступень 77.2 Квантование и локальный запуск

Квантование весов

Как хранить веса в 8 и 4 битах почти без потери качества — симметричное и асимметричное квантование, масштабы на тензор, канал и группу, GPTQ и AWQ, форматы GGUF и обучение с учётом квантования в Kimi K2 Thinking и DeepSeek-V4.

УРОКstage-7/7-2-quantization/01-quantization.mdx2 чУрок 1 из 2
После урока вы сможете
  • Квантовать и восстанавливать тензоры в INT8 и INT4 — симметрично и с нулевой точкой — и считать ошибку и память с учётом масштабов
  • Объяснить, зачем нужны масштабы на канал и на группу, как выбросы ломают квантование и почему для локального запуска стандарт — 4-битные веса и 16-битные активации
  • Пересказать идеи GPTQ и AWQ, устройство типов GGUF и разницу между PTQ и QAT на примере Kimi K2 Thinking и DeepSeek-V4

Llama 3 70B в BF16 весит 141 ГБ: не помещается даже в H100 на 80 ГБ. В 4 битах она весит около 40 ГБ — это две ваши RTX 4080. И дело не только в объёме. Из урока 7.1.1 вы знаете, что скорость decode равна пропускной способности памяти, делённой на байты весов. В 3–4 раза меньше байт — в 3–4 раза быстрее генерация.

КвантованиеквантованиеEN quantization — это хранение чисел меньшим числом бит: вместо 16-битного числа с плавающей точкой — маленькое целое плюс общий для группы чисел масштаб. Форматы с плавающей точкой (BF16, FP8, FP4) мы разобрали в уроке 5.4.1. Здесь главные герои — целые INT8 и INT4, на которых работает почти весь локальный запуск LLM.

Квантовать можно три вещи:

  • веса — больше всего памяти и главный выигрыш для decode;
  • активации — чтобы само умножение шло в INT8/FP8 и ускорялся prefill;
  • KV-кэш — чтобы влезли длинный контекст и большой батч.

INT8 хранит целые от −128 до 127, INT4 — от −8 до 7, то есть всего 16 значений. Общее правило: bb бит дают 2b2^b разных кодов; если нужен знак, это целые от −2b−1-2^{b-1} до 2b−1−12^{b-1} - 1 (для b=4b = 4: от −8 до 7). Квантование — это наложение такой сетки на вещественные числа. Каждое число заменяется ближайшим узлом, и ошибка — это округление.

Самая простая схема — симметричное квантованиесимметричное квантование (absmax)EN symmetric (absmax) quantizationСетка целых чисел симметрична относительно нуля: scale = max|x| / qmax, q = round(x / scale). Ноль кодируется точно. Стандарт для весов.. Растягиваем сетку так, чтобы самое большое по модулю число попало в крайний узел qmax⁡=2b−1−1q_{\max} = 2^{b-1} - 1 (127 для INT8, 7 для INT4). Код −128 (или −8 в INT4) симметричная схема не использует: так сетка одинакова по обе стороны от нуля.

scale=max⁡∣x∣qmax⁡,q=clip(round(xscale), −qmax⁡, qmax⁡),x^=q⋅scale.\begin{gathered} \text{scale} = \frac{\max \lvert x \rvert}{q_{\max}}, \\ q = \mathrm{clip}\left(\mathrm{round}\left(\frac{x}{\text{scale}}\right),\ -q_{\max},\ q_{\max}\right), \\ \hat{x} = q \cdot \text{scale}. \end{gathered}

Хранится целое qq и одно вещественное число — масштабмасштаб квантования (scale)EN quantization scaleЧисло, на которое делят вещественные значения перед округлением и умножают после. Хранится рядом с целыми кодами — на тензор, на канал или на группу. (scale). Ошибка любого числа не больше половины шага сетки, scale/2\text{scale} / 2.

Пример: x=[0.5, −1.0, 2.54, 0.02]x = [0.5,\ -1.0,\ 2.54,\ 0.02].

  • INT8: scale=2.54/127=0.02\text{scale} = 2.54 / 127 = 0.02, коды q=[25, −50, 127, 1]q = [25,\ -50,\ 127,\ 1]. Восстанавливается всё точно.
  • INT4: scale=2.54/7≈0.363\text{scale} = 2.54 / 7 \approx 0.363, коды q=[1, −3, 7, 0]q = [1,\ -3,\ 7,\ 0], восстановленные значения [0.363, −1.089, 2.54, 0][0.363,\ -1.089,\ 2.54,\ 0]. Число 0.02 превратилось в ноль, а 0.5 — в 0.363: шаг сетки огромен.

Асимметричное квантование (с нулевой точкой)

Заголовок раздела «Асимметричное квантование (с нулевой точкой)»

Если данные не симметричны (например, все от −1 до 3), заметная часть симметричной сетки пропадает: уровни от −7 до −3 никогда не используются. асимметричное квантованиеасимметричное квантование (с нулевой точкой)EN asymmetric (zero-point) quantizationСетка 0 … 2^b − 1 натягивается на отрезок [min, max]. Не тратит уровни впустую, если данные не симметричны (например, после ReLU). натягивает сетку 0…2b−10 \dots 2^b - 1 на отрезок [min⁡,max⁡][\min, \max]:

scale=max⁡−min⁡2b−1,z=round(−min⁡scale),q=clip(round(xscale)+z, 0, 2b−1),x^=(q−z)⋅scale.\begin{gathered} \text{scale} = \frac{\max - \min}{2^b - 1}, \qquad z = \mathrm{round}\left(-\frac{\min}{\text{scale}}\right), \\ q = \mathrm{clip}\left(\mathrm{round}\left(\frac{x}{\text{scale}}\right) + z,\ 0,\ 2^b - 1\right), \\ \hat{x} = (q - z) \cdot \text{scale}. \end{gathered}

Целое zz — нулевая точканулевая точка (zero point)EN zero pointЦелый код, который соответствует вещественному нулю в асимметричном квантовании. Восстановление — x̂ = (q − zero_point) · scale.: код, который в точности означает вещественный ноль. Это важно: нули встречаются постоянно (паддинг, выходы ReLU), и ошибаться на них нельзя. Чтобы ноль всегда попадал в сетку, отрезок расширяют до нуля: min⁡\min берут не больше 0, а max⁡\max — не меньше 0 (так сделано и в задании урока).

Пример: x=[−1, 0, 0.5, 3]x = [-1,\ 0,\ 0.5,\ 3] в 8 битах. scale=4/255≈0.0157\text{scale} = 4 / 255 \approx 0.0157, z=round(63.75)=64z = \mathrm{round}(63.75) = 64, коды q=[0, 64, 96, 255]q = [0,\ 64,\ 96,\ 255]. Ноль закодирован как 64 и восстанавливается точно.

Веса LLM почти симметричны вокруг нуля, поэтому для них обычно берут симметричную схему. Асимметричную чаще применяют к активациям, а в некоторых форматах (например, k-квантах llama.cpp) у групп весов хранят и масштаб, и минимум.

Гранулярность: на тензор, на канал, на группу

Заголовок раздела «Гранулярность: на тензор, на канал, на группу»

Матрица весов слоя содержит миллионы чисел. Один масштаб на всю матрицу — дёшево, но опасно: если где-то есть выбросвыброс (outlier)EN outlierЗначение, во много раз большее типичного. Задаёт масштаб своей группы и «сплющивает» остальные числа к нулю. В активациях LLM от ~6.7B параметров появляются целые каналы-выбросы (LLM.int8()). в 50 раз больше типичного веса, он задаёт масштаб, и все обычные веса округляются в ноль.

Поэтому масштаб делают мельче:

  • на тензор — один масштаб на матрицу;
  • на каналпоканальное квантованиеEN per-channel quantizationСвой масштаб у каждой строки матрицы весов (выходного канала). Точнее, чем один масштаб на весь тензор, почти без накладных расходов. — свой масштаб у каждой строки (выходного канала);
  • на группуквантование по группамEN group-wise quantizationСвой масштаб у каждых g соседних весов (обычно g = 32, 64 или 128). Плата — 16/g лишних бит на вес за FP16-масштаб. Основа INT4-форматов и GGUF. — свой масштаб у каждых gg соседних весов строки, обычно g=32g = 32, 64 или 128.

Это та же идея, что поблочное FP8 в DeepSeek-V3 (мелкозернистое (поблочное) квантованиемелкозернистое (поблочное) квантованиеEN fine-grained quantizationСвой масштаб у каждой маленькой группы чисел, а не у всего тензора. В DeepSeek-V3: тайлы 1×128 для активаций и блоки 128×128 для весов. Выброс портит только свою группу., урок 5.4.1). Выброс портит только свою группу.

Цифры из задания этого урока: матрица 64×51264 \times 512 из нормального распределения N(0, 0.022)\mathcal{N}(0,\ 0.02^2); для столбца «С выбросом» один её вес заменён на 1.0. Относительная ошибка восстановления ∥W−W^∥/∥W∥\lVert W - \hat{W} \rVert / \lVert W \rVert (здесь ∥⋅∥\lVert \cdot \rVert — норма Фробениусанорма ФробениусаEN Frobenius normКорень из суммы квадратов всех элементов матрицы — «L2-норма матрицы, вытянутой в вектор». Равна √(Σ σᵢ²).: корень из суммы квадратов всех элементов матрицы; ошибка 1% значит, что «длина» матрицы ошибок — сотая доля «длины» самой матрицы весов):

СхемаБит на весБез выбросаС выбросом
INT8, на тензор81.03%11.03%
INT8, на канал8.030.75%1.53%
INT8, группы по 328.50.54%0.62%
INT4, на тензор418.60%96.31%
INT4, на канал4.0313.54%18.07%
INT4, группы по 1284.12511.85%13.08%
INT4, группы по 324.59.82%10.00%

В строке «INT4 на тензор с выбросом» ошибка 96%: почти все веса стали нулями. Группы по 32 убирают проблему полностью, а платим за это 16/32=0.516 / 32 = 0.5 бита на вес за FP16-масштаб каждой группы:

бит на вес=b+бит масштаба+бит нулевой точкиg.\text{бит на вес} = b + \frac{\text{бит масштаба} + \text{бит нулевой точки}}{g}.

Llama 3 70B (70.6 млрд параметров) в INT4 с группами по 128 займёт 70.6⋅4.125/8≈36.470.6 \cdot 4.125 / 8 \approx 36.4 ГБ, с группами по 32 — 39.7 ГБ.

С весами всё неплохо: они распределены почти нормально. С активациями сложнее. Тим Деттмерс с соавторами (LLM.int8(), 2022) обнаружили, что начиная примерно с 6.7 млрд параметров в активациях появляются систематические выбросы: несколько каналов скрытого состояния во всех слоях и на большинстве токенов во много раз больше остальных. Один масштаб на токен «сплющивает» все остальные каналы. Их решение — разложение: каналы-выбросы считаются в FP16, остальное — в INT8.

Позже SmoothQuant (MIT HAN Lab, 2022) предложил переносить «трудность» из активаций в веса: умножить столбцы весов на поканальные масштабы и разделить на них активации. Результат умножения не меняется, а выбросы сглаживаются. Так появилась рабочая схема W8A8: и веса, и активации в 8 битах.

На практике сложились три схемы:

СхемаЧто в низкой точностиЧто ускоряетГде встречается
W4A16только веса (4 бита)decode при малом батчеllama.cpp и GGUF, AWQ и GPTQ в vLLM, локальный запуск
W8A8 (INT8 или FP8)веса и активациии prefill, и decode, большие батчисерверы; DeepSeek-V3 публикует веса в FP8
KV-кэш в 8 или 4 битахключи и значениядлинный контекст, больше пользователейvLLM, SGLang, LMDeploy, Ollama

Почему квантование только весовквантование только весов (W4A16)EN weight-only quantizationВеса хранятся в 4 или 8 битах, активации остаются в 16 битах; веса восстанавливаются «на лету» внутри ядра умножения. Ускоряет decode, который упирается в чтение весов. ускоряет decode, но не prefill? Ядро умножения читает 4-битные веса, распаковывает их прямо в регистрах GPU (самой быстрой и самой маленькой памяти внутри вычислительных блоков) в 16 бит и умножает как обычно. Decode упирается в чтение весов — оно стало почти вчетверо короче. Prefill упирается в вычисления, а они остались 16-битными, да ещё и с распаковкой сверху. Чтобы ускорить prefill, нужно, чтобы считало само железо в 8 битах — это W8A8 и FP8.

Всё, что выше, — квантование после обученияквантование после обучения (PTQ)EN post-training quantization (PTQ)Готовую модель квантуют без обучения, иногда с помощью небольшого калибровочного набора. RTN, GPTQ, AWQ, k-кванты llama.cpp — всё это PTQ. (post-training quantization, PTQ): берём готовую модель и округляем. Простейший вариант — RTN (round-to-nearest): каждое число — к ближайшему узлу. Но цель на самом деле другая: важна не ошибка весов, а ошибка выхода слоя на реальных входах:

min⁡W^∥XW⊤−XW^⊤∥2,\min_{\hat{W}} \lVert X W^\top - X \hat{W}^\top \rVert^2,

где XX — активации, собранные на нескольких сотнях текстов (калибровочные данныекалибровочные данныеEN calibration dataНебольшой набор текстов (обычно сотня-другая примеров), на которых собирают статистику активаций для GPTQ, AWQ или imatrix. Для русскоязычного применения берите русские тексты.). Два самых популярных метода решают эту задачу по-разному.

GPTQ: ошибку одного столбца исправляют другие

Заголовок раздела «GPTQ: ошибку одного столбца исправляют другие»

GPTQGPTQEN GPTQМетод PTQ (2022) — квантует столбцы весов по одному и переносит ошибку округления на ещё не квантованные столбцы, используя статистику входов H = 2XᵀX. (Франтар и соавторы, IST Austria и ETH Zürich, 2022) квантует столбцы весов (входные каналы) по одному. Округлили столбец jj — получили ошибку. Если вход jj коррелирует с ещё не квантованными входами j′j', то ошибку можно частично компенсировать, слегка подвинув веса столбцов j′j'. Насколько двигать, подсказывает матрица H=2X⊤XH = 2 X^\top X — она описывает, как входы связаны между собой (это матрица вторых производных ошибки выхода по весам; для понимания идеи достаточно смысла «насколько входы похожи»). Идея пришла из классических методов прореживания сетей — удаления части весов (pruning), например Optimal Brain Surgeon.

Метод делает один проход без обучения, квантует модель на 175B параметров примерно за четыре GPU-часа и держит качество в 3–4 битах намного лучше RTN. GPTQ-версии моделей (например, Qwen2.5-7B-Instruct-GPTQ-Int4) запускаются в vLLM и SGLang.

AWQAWQ (квантование с учётом активаций)EN activation-aware weight quantization (AWQ)Метод PTQ (MIT HAN Lab, 2023) — около 1% весов, которые умножаются на большие активации, защищают поканальным масштабированием перед округлением. (MIT HAN Lab, 2023, лучшая статья MLSys 2024) начинается с наблюдения: важны не все веса одинаково. Если оставить в FP16 всего около 1% весов, качество 4-битной модели почти восстанавливается. Но выбирать их надо по активациям, а не по самим весам: важен вес, который умножается на большой вход.

Держать 1% весов в другом формате неудобно для железа. Поэтому AWQ делает трюк: умножает столбец важного входного канала на s>1s > 1, а вход делит на ss. Произведение не меняется, но вес стал крупнее относительно шага сетки, и его относительная ошибка округления уменьшилась примерно в ss раз. Масштаб считают как sj=(средний∣xj∣)αs_j = (\text{средний} \lvert x_j \rvert)^\alpha, а α\alpha подбирают по сетке на калибровочных данных. Посмотрим, зачем нужен подбор:

# AWQ на пальцах: защищаем веса, которые умножаются на большие активации
import numpy as np
rng = np.random.default_rng(0)
d_in, d_out, G = 512, 256, 128
W = rng.normal(0, 0.02, size=(d_out, d_in)) # веса слоя
X = rng.normal(0, 1, size=(1024, d_in)) # «калибровочные» активации
salient = rng.choice(d_in, size=5, replace=False) # 1% входных каналов с большими активациями
X[:, salient] *= 20
def quant_groups(w, bits=4, g=G):
"""Симметричное INT4 по группам из g соседних входных каналов (как в задании урока)."""
qmax = 2 ** (bits - 1) - 1
wg = w.reshape(w.shape[0], -1, g) # [строки, число групп, g]: каждая строка нарезана на группы
scale = np.abs(wg).max(-1, keepdims=True) / qmax # свой масштаб у каждой группы
return (np.clip(np.round(wg / scale), -qmax, qmax) * scale).reshape(w.shape)
def out_err(W_hat):
"""Ошибка на выходе слоя — то, что на самом деле важно."""
return np.linalg.norm(X @ W.T - X @ W_hat.T) / np.linalg.norm(X @ W.T)
print(f"RTN (просто округлить): {out_err(quant_groups(W)):.4f}")
a = np.abs(X).mean(0) # важность канала = средний |x|
for alpha in [0.25, 0.5, 0.75, 1.0]:
s = a ** alpha # масштаб канала
# W * s: s — вектор длины d_in, broadcasting умножает столбец j на s_j (это и есть W·diag(s));
# после квантования делим обратно — в реальном AWQ деление переносят на активации
W_hat = quant_groups(W * s) / s
print(f"AWQ-масштабы, α = {alpha:4.2f}: {out_err(W_hat):.4f}")
RTN (просто округлить): 0.1172
AWQ-масштабы, α = 0.25: 0.0773
AWQ-масштабы, α = 0.50: 0.0979
AWQ-масштабы, α = 0.75: 0.1764
AWQ-масштабы, α = 1.00: 0.2824

При α=0.25\alpha = 0.25 ошибка выхода на треть меньше, чем у RTN. А при α=1\alpha = 1 — в 2.4 раза больше: раздутый важный вес становится максимумом своей группы, шаг сетки растёт, и остальные веса группы квантуются грубее. Отсюда поиск α\alpha по сетке. Упрощённые AWQ и GPTQ вы реализуете сами на уровне Boss домашней работы модуля.

Для локального запуска стандарт де-факто — GGUFGGUF (формат файлов llama.cpp)EN GGUFОдин файл с весами (обычно квантованными), токенизатором, шаблоном чата и метаданными. Типы квантования — Q8_0, Q6_K, Q4_K_M и др. Используют llama.cpp, Ollama, LM Studio.: один файл, в котором лежат квантованные веса, токенизатор, chat-шаблон и метаданные. Его читают llama.cpp, Ollama и LM Studio. Названия типов выглядят загадочно, но устроены просто:

ТипКак устроен≈ бит на вес
Q8_0блоки по 32 веса, один FP16-масштаб8.5
Q4_0блоки по 32 веса, один FP16-масштаб4.5
Q4_Kсуперблок 256 весов = 8 блоков по 32; у блоков 6-битные масштабы и минимумы4.5
Q5_K / Q6_Kпохожие схемы для 5 и 6 бит5.5 / 6.56
Q4_K_Mсмесь: в основном Q4_K, но часть самых чувствительных матриц (V во внимании, выход FFN) — в Q6_K≈4.8–4.9
Q3_K_M, Q2_K, IQ-типы3- и 2-битные k-кванты; IQ-типы (от IQ1 до IQ4) особенно нуждаются в матрице важности (imatrix)≈1.5–4

Буква K — «k-кванты» с двухуровневыми масштабами: масштабы маленьких блоков сами квантованы и хранятся относительно масштаба суперблока. Суффиксы S, M, L говорят, сколько тензоров получили повышенную точность. imatrix — матрица важности, собранная на калибровочном тексте, — та же идея, что в AWQ: точнее квантовать то, что сильнее влияет на выход.

Кроме GGUF вы встретите AWQ- и GPTQ-чекпоинты в формате safetensors (стандартный формат файлов весов на Hugging Face; для vLLM и SGLang), FP8-версии моделей и NF4 из bitsandbytes — его мы разбирали в уроке о QLoRA.

flowchart LR
HF["Веса на Hugging Face<br/>(BF16, safetensors)"] --> C["convert_hf_to_gguf.py<br/>→ F16 GGUF"]
C --> Q["llama-quantize<br/>Q8_0 / Q4_K_M / …"]
CAL["Калибровочный текст<br/>(русский!)"] --> IM["llama-imatrix"]
IM -.->|"--imatrix"| Q
Q --> RUN["llama.cpp, Ollama,<br/>LM Studio"]
HF --> AW["AWQ / GPTQ<br/>(калибровка)"] --> V["vLLM, SGLang,<br/>LMDeploy"]
Два пути квантования: GGUF для локального запуска и AWQ/GPTQ для серверов

Ниже 4 бит PTQ упирается в стену: сколько ни подбирай масштабы, у сетки слишком мало узлов. Выход — дать модели привыкнуть к округлению. В обучении с учётом квантованияобучение с учётом квантования (QAT)EN quantization-aware training (QAT)В прямом проходе модель видит уже округлённые (например, 4-битные) веса и учится быть к этому устойчивой. Так получены INT4-веса Kimi K2 Thinking. (QAT) в прямом проходе используются уже округлённые веса, и модель учится работать именно с ними.

Есть проблема: у округления нулевая производная почти везде, и градиент не пройдёт. Используют «прямой» оценщик градиента«прямой» оценщик градиента (STE)EN straight-through estimator (STE)Приём QAT — в прямом проходе веса округляются, а в обратном округление считается тождественной функцией, и градиент проходит «насквозь». (STE): в обратном проходе делаем вид, что округления не было. В PyTorch это одна строка:

w^=w+sg(Q(w)−w),\hat{w} = w + \mathrm{sg}\big(Q(w) - w\big),

где sg\mathrm{sg} — stop-gradient (.detach()). Вперёд проходит Q(w)Q(w), а градиент течёт в ww как через тождество. Проверим на маленькой сети, которая различает три «спирали» на плоскости:

# PTQ против QAT: маленькая сеть; в 2 битах у весов всего три значения — −1, 0, +1 (× масштаб строки)
import torch
import torch.nn as nn
import torch.nn.functional as F
torch.manual_seed(0)
torch.set_num_threads(1) # сеть крошечная: один поток быстрее
# Данные: 3 класса-«спирали» на плоскости
n = 600
t = torch.linspace(0, 1, n // 3)
X, y = [], []
for c in range(3):
r = t * 2
ang = t * 4 + c * 2.1 + torch.randn(n // 3) * 0.15
X.append(torch.stack([r * torch.sin(ang), r * torch.cos(ang)], 1))
y.append(torch.full((n // 3,), c))
X, y = torch.cat(X), torch.cat(y)
def fake_quant(w, bits=2):
"""Квантуем и сразу восстанавливаем: absmax по строкам. Градиент — «прямо насквозь» (STE)."""
qmax = 2 ** (bits - 1) - 1
scale = w.abs().amax(dim=1, keepdim=True) / qmax
w_q = torch.clamp(torch.round(w / scale), -qmax, qmax) * scale
return w + (w_q - w).detach() # вперёд — w_q, назад — как будто тождество
class MLP(nn.Module):
def __init__(self, quant_bits=None):
super().__init__()
self.l1, self.l2, self.l3 = nn.Linear(2, 64), nn.Linear(64, 64), nn.Linear(64, 3)
self.quant_bits = quant_bits # None — обычные веса, иначе fake-quant в прямом проходе
def forward(self, x):
for i, layer in enumerate([self.l1, self.l2, self.l3]):
w = fake_quant(layer.weight, self.quant_bits) if self.quant_bits else layer.weight
x = F.linear(x, w, layer.bias) # то же, что x @ w.T + bias, но с нашими (квантованными) весами
if i < 2:
x = F.relu(x)
return x
def train(model, steps, lr=0.01):
opt = torch.optim.Adam(model.parameters(), lr=lr)
for _ in range(steps):
loss = F.cross_entropy(model(X), y)
opt.zero_grad()
loss.backward()
opt.step()
def acc(model):
with torch.no_grad():
return (model(X).argmax(1) == y).float().mean().item()
model = MLP()
train(model, 1500)
print(f"{'FP32':<26} точность {acc(model):.1%}")
for bits in (8, 4, 3, 2): # PTQ: просто округлили обученные веса
model.quant_bits = bits
print(f"{f'PTQ, {bits}-битные веса':<26} точность {acc(model):.1%}")
train(model, 300, lr=0.003) # QAT: дообучаем, видя округлённые 2-битные веса
print(f"{'QAT, 2 бита (+300 шагов)':<26} точность {acc(model):.1%}")
FP32 точность 99.7%
PTQ, 8-битные веса точность 99.7%
PTQ, 4-битные веса точность 98.5%
PTQ, 3-битные веса точность 46.0%
PTQ, 2-битные веса точность 28.3%
QAT, 2 бита (+300 шагов) точность 99.2%

Картина типичная: 8 и 4 бита почти без потерь, на 3 битах обрыв, на 2 битах — уровень случайного угадывания (33%). Но 300 шагов QAT возвращают 2-битной сети почти всю точность. У этой сети веса троичные — {−1,0,+1}\{-1, 0, +1\}, умноженные на масштаб строки. Такие же троичные веса у BitNet b1.58 (Microsoft Research Asia, Пекин, arXiv:2402.17764), только её обучают в троичных весах с нуля.

QAT дороже PTQ — нужно обучение, но для больших лабораторий это копейки по сравнению с претрейном. Поэтому в 2025–2026 годах точность весов стала частью архитектуры, которую выбирают при обучении (см. «Китайский угол»).

Точность весовЧто происходит с качествомКогда брать
16 бит (BF16)эталонесли память позволяет
8 бит (Q8_0, INT8, FP8)практически без потерьсервер, качество важнее всего
5–6 бит (Q5_K_M, Q6_K)почти неотличимо от 16 битлокально, если влезает
4 бита (Q4_K_M, AWQ, GPTQ)небольшая потеря, лучший компромиссстандарт локального запуска
3 битазаметное падениеогромные модели, которые иначе не влезают
2 бита и нижесильное падение без QATтолько с QAT или для очень больших MoE

Важный вывод из работы Деттмерса и Зеттлмойера «The case for 4-bit precision» (arXiv:2212.09720): при фиксированном объёме памяти 4 бита почти всегда оптимальны. Большая модель в 4 битах обычно лучше маленькой в 16: Qwen3-32B в Q4_K_M (≈20 ГБ), как правило, сильнее Qwen3-8B в BF16 (16 ГБ). Чувствительнее всего к квантованию математика, код и длинные рассуждения, поэтому проверяйте модель на своей задаче.

Реализуйте симметричное и асимметричное квантование, квантование по группам, замер ошибки и подсчёт бит на вес. Кнопка «Запустить» выведет сравнение «на тензор против групп» для матрицы с выбросом.

INT8 и INT4 по группам: квантование и замер ошибкиnumpy · нужен интернет7 тестов
import numpy as np


def quantize_symmetric(x, bits=8):
    """Симметричное квантование (absmax) с одним масштабом на весь массив.

    qmax = 2**(bits - 1) - 1          (127 для INT8, 7 для INT4)
    scale = max|x| / qmax              (если все x равны 0 — scale = 1.0)
    q = clip(round(x / scale), -qmax, qmax), тип np.int32

    Возвращает (q, scale). Пример: [0.5, -1.0, 2.54, 0.02] в INT8 → scale = 0.02, q = [25, -50, 127, 1].
    """
    # TODO: ваш код
    ...


def dequantize(q, scale):
    """Восстановление: x̂ = q · scale (scale может быть числом или массивом — работает broadcasting)."""
    # TODO: ваш код
    ...


def quantize_asymmetric(x, bits=8):
    """Асимметричное квантование с нулевой точкой: сетка 0 … 2**bits − 1 натягивается на [min, max].

    Диапазон расширяем так, чтобы он включал 0: lo = min(x.min(), 0), hi = max(x.max(), 0).
    scale = (hi − lo) / (2**bits − 1)
    zero_point = int(round(−lo / scale))
    q = clip(round(x / scale) + zero_point, 0, 2**bits − 1), тип np.int32
    Восстановление: x̂ = (q − zero_point) · scale.

    Возвращает (q, scale, zero_point). Пример: [-1, 0, 0.5, 3] в 8 битах → zero_point = 64, q = [0, 64, 96, 255].
    """
    # TODO: ваш код
    ...


def quantize_groups(w, bits=4, group_size=32):
    """Симметричное квантование по группам: свой масштаб у каждых group_size соседних чисел строки.

    w — матрица (rows, cols), cols делится на group_size.
    Возвращает (q той же формы, что w; scales формы (rows, cols // group_size)).
    Подсказка: w.reshape(rows, cols // group_size, group_size) и max по последней оси.
    """
    # TODO: ваш код
    ...


def dequantize_groups(q, scales, group_size=32):
    """Обратная операция к quantize_groups: каждую группу умножаем на её масштаб, форма как у q."""
    # TODO: ваш код
    ...


def relative_error(ref, approx):
    """Относительная ошибка ‖ref − approx‖ / ‖ref‖ (норма Фробениуса, np.linalg.norm)."""
    # TODO: ваш код
    ...


def bits_per_weight(bits, group_size=None, scale_bits=16, zero_bits=0):
    """Сколько бит в среднем уходит на один вес с учётом масштабов (и нулевых точек) групп.

    Без групп (group_size=None) накладными расходами пренебрегаем: ответ — bits.
    С группами: bits + (scale_bits + zero_bits) / group_size.
    bits_per_weight(4, 32) == 4.5 (как формат Q4_0 в GGUF); bits_per_weight(4, 128) == 4.125
    """
    # TODO: ваш код
    ...


def demo():
    """Матрица весов 64×512 с одним выбросом: per-tensor против групп."""
    rng = np.random.default_rng(0)
    w = rng.normal(0, 0.02, size=(64, 512))
    w[3, 100] = 1.0  # выброс в 50 раз больше типичного веса
    for bits in (8, 4):
        q, s = quantize_symmetric(w, bits)
        e_tensor = relative_error(w, dequantize(q, s))
        qg, sg = quantize_groups(w, bits, 32)
        e_group = relative_error(w, dequantize_groups(qg, sg, 32))
        print(f"INT{bits}: на тензор {e_tensor:.3%}, группы по 32: {e_group:.3%}, "
              f"бит на вес {bits_per_weight(bits, 32):.2f}")


try:
    print(quantize_symmetric(np.array([0.5, -1.0, 2.54, 0.02]), 8))
    print(quantize_asymmetric(np.array([-1.0, 0.0, 0.5, 3.0]), 8))
    demo()
except TypeError as e:  # функции пока возвращают None
    print("Сначала реализуйте функции:", e)
Проверь себя6 вопросов
1 Симметричное INT8-квантование, максимум модуля в тензоре 3.81. Какой целый код получит число 0.6?
2 INT4 с группами по 64 веса и FP16-масштабом на группу. Сколько бит в среднем уходит на один вес?
3 Почему квантование по группам спасает от выбросов?
4 Выберите все верные утверждения.Можно выбрать несколько ответов
5 Сколько гигабайт займёт модель на 70 млрд параметров в Q4_K_M (≈4.9 бита на вес)? Округлите до целого.
6 Как получены INT4-веса Kimi K2 Thinking?
  • Квантование заменяет числа целыми кодами и масштабом. Симметричная схема (absmax) — стандарт для весов, асимметричная с нулевой точкой — для несимметричных данных.
  • Чем мельче гранулярность масштаба (тензор → канал → группа из 32–128), тем меньше ущерб от выбросов; плата — 16/g16/g бит на вес.
  • W4A16 ускоряет decode и уменьшает память весов в 3–4 раза (с учётом масштабов); W8A8 и FP8 ускоряют и вычисления; KV-кэш тоже квантуют.
  • GPTQ компенсирует ошибку через соседние столбцы, AWQ защищает веса при больших активациях; оба используют калибровочные данные — берите их на своём языке. GGUF с k-квантами — стандарт локального запуска.
  • QAT учит модель жить с округлением и открывает путь к 4 битам и ниже: Kimi K2 Thinking (INT4), DeepSeek-V4 (FP4, по обзорам техотчёта).

Дальше: запускаем Qwen и DeepSeek на своём компьютере — llama.cpp и Ollama, выбор размера модели под память, OpenAI-совместимый сервер и замер токенов в секунду.