Назад к блогу

GGUF-кванты llama.cpp в Transformers: как это устроено внутри

GGUF-кванты llama.cpp в Transformers: как это устроено внутри

В Transformers появился режим, в котором GGUF-веса из llama.cpp больше не нужно распаковывать в плотную модель: линейные слои умеют считать прямо по блокам квантования, экономя память. В статье разбирается устройство загрузчика — от разбора заголовка и таблицы тензоров до распаковки блоков разных форматов — и объясняется, когда срабатывает резервный путь с полной деквантизацией.

GGUF — формат файлов из мира llama.cpp, в котором веса модели лежат в блочных форматах квантования. Раньше Transformers умел только распаковывать такой файл в плотную модель. Теперь появился второй путь: веса остаются упакованными, а линейные слои считают прямо по блокам. Ниже — как загрузчик отличает GGUF от обычного чекпоинта, как читается файл, как распаковываются блоки разных форматов и что из этого следует на практике.

Как загрузчик понимает, что перед ним GGUF

GGUF-загрузка включается аргументом gguf_file у from_pretrained: он передаёт путь к .gguf-файлу с весами. Этот путь попадает в поле gguf_file конфигурации GgufConfig. Конструктор GgufConfig выставляет quant_method в QuantizationMethod.GGUF, сохраняет gguf_file и флаг dequantize.

Поле gguf_file можно не указывать в GgufConfig, если путь задан через from_pretrained(..., gguf_file=...) — тот его заполнит. Конфигурацию можно передать и явно, вместе с gguf_file и dtype:

quantization_config = GgufConfig(dequantize=True)
model = AutoModelForCausalLM.from_pretrained(
    model_id, gguf_file=filename, quantization_config=quantization_config, dtype=torch.bfloat16
)

dequantize по умолчанию False: при таком значении веса остаются упакованными, а при True каждый вес распаковывается в обычную плотную модель прямо при загрузке. Это всегда корректный, но теряющий экономию памяти путь; он же включается автоматически как запасной вариант, когда для типов квантизации файла нет доступного matmul-ядра.

Разбор файла: заголовок, метаданные, таблица тензоров

GgufHeader — это NamedTuple с полями path, architecture, tensors (кортеж TensorInfo) и data_start — смещением начала секции данных после выровненных метаданных. TensorInfo описывает один тензор: имя, форму (уже в порядке torch), тип ggml, смещение от начала секции данных и число байт.

При разборе _read_metadata читает магию GGUF, версию, число тензоров и число пар метаданных, затем сами пары ключ-значение. Из метаданных берётся general.architecture, а general.alignment — со значением по умолчанию 32, как принято в llama.cpp. _read_tensor_table для каждого тензора читает имя, число измерений, сами измерения (переворачивая их в порядок torch, потому что ggml хранит быстрее всего меняющееся измерение первым), затем тип ggml и смещение; число байт вычисляется по типу и числу элементов.

Из заголовка выводятся три производных свойства. Первое — словарь «имя тензора → тип ggml». Второе показывает, есть ли в файле квантованные веса: оно истинно, если хотя бы один тип хранится блоками, а не обычными значениями. Третье — тип модели: он не определён, если множество типов не является подмножеством известных torch-типов (то есть где-то в файле лежат именно блоки, а не значения); иначе bfloat16 при наличии BF16, float16 при наличии F16, иначе float32. Логика опирается на то, что F32-тензоры (например, нормы) соседствуют с половинными, и тип модели определяется по половинному.

Почему секция данных начинается на границе выравнивания

from_file сначала читает метаданные и таблицу тензоров, получая pos — смещение сразу после таблицы. Затем округляет его вверх до ближайшей границы alignment:

# the data section starts at the next alignment boundary after the tensor table
data_start = (pos + alignment - 1) // alignment * alignment

Это значение сохраняется в GgufHeader.data_start как начало секции данных. _read_tensor_table возвращает pos — смещение сразу после последней записи таблицы, и именно оно используется для вычисления data_start. load_gguf_state_dict получает заголовок с уже вычисленным data_start и отображает файл в память.

Почему файл маппится как байты

Файл отображается в память как байты, потому что в numpy нет типа bfloat16: _mapped возвращает np.memmap(gguf_path, mode="r", dtype=np.uint8) — дешёвое отображение, страницы которого читаются только при материализации тензора. LazyGgufTensor хранит этот байтовый вид и тип ggml, а при обращении к элементу копирует байты в тензор torch и переинтерпретирует их в нужный тип с последующим reshape к форме тензора.

Значения остаются в том типе, в котором записаны в файле, потому что преобразования этого требуют: норма хранится как w + 1, и округление до вычитания потратило бы точность около 1.0 на гораздо меньший вес. Приведение к типу модели выполняет Cast как последняя операция каждой цепочки. Для квантованных типов, которых нет среди известных torch-типов, форма корректируется по блокам — последнее измерение пересчитывается через число элементов и байт в блоке, — и возвращается reshape без переинтерпретации.

Деквантование: как распаковываются блоки llama.cpp

Блочные форматы хранят веса не по одному значению, а группами фиксированного размера, поэтому корректно нарезать плоский массив байтов на строки можно только зная точное число байт на строку. Этим занимается row_bytes: она берёт из таблицы GGML_BLOCK пару «элементов в блоке» и «байт в блоке», делит число входных признаков на размер блока и умножает на байты блока. Для GGML_Q4_0 блок — это 32 элемента и 18 байт.

Сама dequantize принимает плоский массив байтов и возвращает плоский массив значений: данные перестраиваются в блоки по block_bytes, что и требует знания размера блока.

Q4_K и Q6_K

_dequant_q4_k читает два fp16-скаляра на блок: d из байтов 0–1 и dmin из байтов 2–3, затем распаковывает 12 байт blocks[:, 4:16] в scale и minimum — по шесть четырёхбитных полей каждое. Внутри распаковщика промежуточные значения остаются в uint8, потому что шестибитные поля его не переполняют, а повышение типа сначала стоило бы копии; к float они приводятся только в самом конце. Четырёхбитные кванты q извлекаются из blocks[:, 16:144] сдвигами на 0 и 4 с маской 0xF. Результат считается как (d * scale).to(dtype)[..., None] * q - (dmin * minimum).to(dtype)[..., None] — приведение к целевому типу происходит уже после умножения fp16-скаляров на распакованные поля.

_dequant_q6_k извлекает ql (байты 0–127), qh (128–191) и scales (192–207), а fp16-скаляр d читается из байтов 208–209. Масштаб формируется как произведение этого скаляра на scales, приведённые к int8 и float, затем к целевому типу. Шесть бит кванта собираются из четырёх младших (из ql сдвигами 0 и 4 с маской 0xF) и двух старших (из qh сдвигами 0, 2, 4, 6 с маской 3), объединяются, приводятся к int8 и смещаются на 32. Итог — умножение масштаба на кванты, разложенные по 16 группам.

IQ-форматы

IQ-блоки хранят не значения, а индексы в общий кодбук. Таблица уровней распаковывается из упакованных байтов в тензор формы «число точек × ширина», при этом на каждый индекс читается столько бит, сколько нужно, чтобы различить все уровни. Каждый байт разворачивается в восемь знаков ±1, а семибитный индекс через таблицу знаков даёт те же восемь знаков. Из каждых нескольких байт собирается одно little-endian целое, а значение размножается, сдвигаясь на каждый указанный сдвиг.

Различия между форматами — в том, откуда берутся индекс точки, знаки и масштаб. В _dequant_iq2_xxs масштаб db = d * (0.5 + (meta >> 28)) * 0.25, знаки — из meta, точки — из таблицы IQ2_XXS по индексам из aux; итог — db * points * sign. В _dequant_iq2_xs точки берутся по qs & 511, знаки — по qs >> 9, а масштаб — из четырёхбитных полей. В _dequant_iq2_s знаки читаются прямо из байтов, а индекс точки — это младшие байты, объединённые с двумя старшими битами из отдельного поля. В _dequant_iq3_xxs точки берутся из таблицы IQ3_XXS, знаки — из meta, масштаб — d * (0.5 + (meta >> 28)) * 0.5.

Смещение IQ1 и размазанный fp16-скейл

В _dequant_iq1_s константа _IQ1_DELTA (значение 0.125) задаёт смещение, которое прибавляется к каждому уровню из таблицы IQ1; знак смещения выбирается по старшему биту слова qh, а итог считается как dl * (points + delta). В _dequant_iq1_m та же константа используется так же, но знак берётся по биту 3 каждого полубайта qh.

fp16-скейл в _dequant_iq1_m размазан по верхнему нибблу четырёх слов скейлов, потому что сами слова содержат и 3-битные поля масштаба, и биты fp16-экспоненты и мантиссы. Верхние нибблы извлекаются, склеиваются в одно 16-битное значение, переинтерпретируются как fp16 и умножаются на (2 * scale + 1), где scale — это 3-битные поля из тех же слов.

Загрузка модели: quantizer, device_map, dtype

Проверки окружения

validate_environment сначала ничего не делает, если архитектура не поддерживается или если распаковка уже запрошена. Если в файле нет квантованных блоков, он включает распаковку и выходит. Иначе он запрашивает matmul-ядро; если ядра нет, тоже включает распаковку и логирует причину — либо пакет kernels не установлен или его версия вне поддерживаемого диапазона, либо для устройства не опубликовано ядро.

attn_implementation здесь не выбирается: update_attn_implementation лишь запоминает, был ли он задан явно. Сам выбор происходит позже, после загрузки весов: если веса упакованы и attention не запрашивался, ставится реализация ggml-org/ggml-attn.

update_device_map сначала запрещает выгрузку на диск, затем при включённой распаковке возвращает карту как есть, а если карта не задана и доступен MPS — кладёт всё на torch.device("mps").

Подмена слоёв

_process_model_before_weight_loading строит отображение имён. Если распаковка включена, метод возвращается, не подменяя слои. Иначе вызывается replace_with_gguf_modules, который заменяет nn.Linear на GgufLinear, nn.Embedding на GgufEmbedding, а модули, оканчивающиеся на .experts, — на GgufExperts, при условии что ядро поддерживает соответствующий тип.

update_weight_conversions возвращает операции загрузки для тензоров, которые не остались упакованными и требуют распаковки. Конверсии самого модели при этом отбрасываются: они описывают safetensors-чекпоинт и ничего не совпадают в GGUF, а при сохранении будут восстановлены исходные операции модели.

Выбор dtype

update_dtype сначала проверяет, передан ли dtype. Если нет и архитектура не поддерживается — берётся тип по умолчанию torch. Если нет, но архитектура поддерживается — берётся тип из заголовка файла, а при его отсутствии float32. Для упакованного пути при dtype, отличном от float32, выдаётся предупреждение: веса остаются квантованными и дополнительной памяти не требуют, но каждое matmul будет приводить вход и выход. На MPS упакованная загрузка автоматически использует float32, потому что так быстрее.

Если совместимого ядра квантования нет, validate_environment включает распаковку и предупреждает, что модель будет распакована целиком, потребует в несколько раз больше памяти, чем файл, и загрузится медленнее.

Упакованный путь: GgufLinear, GgufExperts, GgufEmbedding

GgufLinear.forward сначала приводит вход к форме «строки × входные признаки» и при необходимости переставляет столбцы. Затем выбирает путь по числу строк: если строк немного, вызывается умножение матрицы на вектор прямо по упакованному весу, иначе — распаковка кусками. Порог задаёт, до какого числа строк выгоднее считать умножение матрицы на вектор по упакованному весу, не распаковывая его; при большем числе строк выбирается путь с распаковкой кусками.

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

После этого добавляется bias, если он есть, и форма выхода восстанавливается. Приведение к типу входа выполняется только если типы не совпали:

# The gemv returns f32 whatever `x` is, so this is usually a real cast -- but when the model
# already runs in f32 it is not, and skipping it saves a dispatch on every linear.
return out if out.dtype == x.dtype else out.to(x.dtype)

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

Связанные веса и предупреждения

replace_with_gguf_modules сначала обновляет план для связанных весов: если исходное имя есть в плане, а целевого там нет, целевой получает тип источника. Затем проходит по модулям. Для .experts берутся типы gate_up_proj и down_proj; слой пропускается, если хотя бы один тип отсутствует, и заменяется на GgufExperts, только если ядро поддерживает оба типа, — иначе неподдерживаемые типы запоминаются. Для остальных модулей проверяется наличие f"{module_name}.weight" в плане, поддержка ядром и выполняется замена.

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

Упакованный путь сейчас поддерживает Qwen3.5 и Qwen3.5 MoE; другие архитектуры идут через legacy-загрузчик, который всегда распаковывает.

Практика: загрузка, генерация, сервинг

Минимальная загрузка — это передача Hub model_id и имени файла как gguf_file в from_pretrained. Дополнительная конфигурация не нужна: при упакованных весах на Metal transformers автоматически загружает совместимые ядра ggml/Metal.

model_id = "unsloth/Qwen3.5-4B-GGUF"
filename = "Qwen3.5-4B-Q4_K_M.gguf"
tokenizer = AutoTokenizer.from_pretrained(model_id, gguf_file=filename)
model = AutoModelForCausalLM.from_pretrained(model_id, gguf_file=filename)

Это единственный шаг, специфичный для GGUF; всё после него — стандартный API transformers: применение chat template, model.generate(**inputs, max_new_tokens=256), декодирование.

Для сервинга зависимости ставятся командой pip install -U "transformers[serving] @ git+https://github.com/huggingface/transformers.git" kernels, сервер запускается командой transformers serve "unsloth/Qwen3.5-4B-GGUF:Qwen3.5-4B-Q4_K_M.gguf". Аргумент имеет формат <model_id>:<filename>.gguf: до двоеточия — репозиторий на Hub, после — загружаемый файл, что выбирает конкретную квантизацию из репозитория, где их может быть несколько. Сервер перечисляет каждый .gguf-файл репозитория как отдельную модель, и запросы называют её тем же способом. Для моделей, чей chat template поддерживает thinking, можно добавить --reasoning off или --reasoning on; по умолчанию --reasoning auto следует значению из chat template. Клиент подключается через OpenAI-совместимый провайдер с Base URL http://localhost:8000/v1 и Model ID unsloth/Qwen3.5-4B-GGUF:Qwen3.5-4B-Q4_K_M.gguf.

Явное деквантование включается передачей GgufConfig(dequantize=True) вместе с gguf_file и нужным dtype. При этом распаковывается каждый вес во время загрузки и возвращается обычная плотная модель в переданном dtype.

Как сравнивали и что получилось

Стенд и методология

Замеры проводились на Apple Silicon; целевой сценарий — одна интерактивная беседа. Установка: pip install -U "git+https://github.com/huggingface/transformers.git" kernels — Transformers ставится из git-репозитория вместе с пакетом kernels. Упомянутые ядра — это Metal-ядра для Apple Silicon (MPS) с fused dequantization + matmul; упакованный путь инференса пока только для MPS.

Запуск Transformers: модель unsloth/Qwen3.5-4B-GGUF с файлом Qwen3.5-4B-Q4_K_M.gguf, загрузка через AutoModelForCausalLM.from_pretrained(model_id, gguf_file=filename). Прогрев — generate с max_new_tokens=8, min_new_tokens=8, do_sample=False, затем синхронизация MPS. Далее три прогона: перед каждым пауза 90 секунд для охлаждения машины, затем замер generate с max_new_tokens=128, min_new_tokens=128, do_sample=False и вывод 128 / (time.perf_counter() - start) в токенах/с. Пауза нужна потому, что подряд идущие прогоны теряют 10% и больше.

Запуск llama.cpp: llama-bench -hf unsloth/Qwen3.5-4B-GGUF:Q4_K_M -p 0 -n 128 -r 3 — 0 токенов prefill, 128 токенов decode, 3 повтора.

Результаты

Измерения проводились для чекпоинта Q4_K_M. Transformers близок к llama.cpp по всем трём чекпоинтам (BF16, Q8_0, Q4_K_M).

Оговорки

Условия замеров не идентичны: измерение Transformers включает prefill, а llama-bench сообщает только decode throughput. Упакованный путь инференса пока только для MPS, а импорт GGUF через деквантование — отдельная опция; поддержка формата файла не означает наличия упакованных ядер на каждом устройстве. Паддинг и батчинг ещё требуют доработки: непаддированные входы выигрывают от оптимизации маски, а паддированные батчи не могут использовать тот же короткий путь и могут иметь более низкую производительность. Покрытие архитектур ограничено: упакованный загрузчик охватывает только плотную и MoE архитектуры Qwen3.5, включая совместимые чекпоинты Qwen3.8.

Что из этого следует на практике

  • Упакованный путь — это MPS-only и Qwen3.5-only. На других устройствах и для других архитектур загрузчик уйдёт в legacy-режим и распакует всё, потеряв экономию памяти. Проверить, остались ли веса упакованными, можно по предупреждениям при загрузке: если ни один модуль не удалось заменить, будет сказано, что все квантованные тензоры распакуются.
  • Отсутствие ядра kernels тихо превращает загрузку в распаковку. Причина логируется: либо пакет не установлен или его версия вне диапазона, либо для устройства нет опубликованного ядра. В обоих случаях модель займёт в несколько раз больше памяти, чем файл, и загрузится медленнее.
  • На MPS не стоит задавать dtype для упакованной загрузки. Автоматически выбирается float32, потому что так быстрее; любой другой тип даст предупреждение и приведёт к приведению входа и выхода на каждом matmul.
  • Память при генерации не равна размеру файла. На упакованном пути умножение матрицы на вектор всегда даёт float32, а распаковка кусками идёт порциями строк — весь вес не материализуется, но промежуточные результаты занимают место.
  • Выбор квантизации — компромисс размера и качества. Для Qwen3.5-4B файл BF16 занимает 8.42 GB, Q6_K — 3.53 GB, Q5_K_M — 3.14 GB, Q4_K_M — 2.74 GB. Начинать рекомендуется с Q4_K_M, затем пробовать Q5_K_M или Q6_K при наличии памяти; более агрессивное квантование помогает вместить крупные модели, но компромисс по качеству зависит от модели и задачи.
  • Сервинг называет модель парой «репозиторий:файл». Один репозиторий может содержать несколько квантизаций, и именно суффикс после двоеточия выбирает нужную.

Где смотреть в коде

Источники

Похожее