llama.cpp AI LLM Engine — Как это работает
Предисловие
llama.cpp — это программа (движок) для запуска больших языковых моделей (LLM) на компьютере или на мобильных устройствах — на процессоре (CPU) или видеокарте (GPU); я написал приложение Offline AI Launcher, которое позволяет использовать этот же движок на Android.
Зачем это нужно:
- чтобы получать ответы от нейросетевых моделей (чат, дополнение текста, код) без отправки данных в облако;
- выбор языка C++ нужен для быстрой работы с памятью и железом; один и тот же код собирается под Windows, Linux, macOS и Android.
«Веса» модели — огромный набор чисел (миллионы или миллиарды), на которых основаны все вычисления нейросети: матрицы умножения, смещения и т.д. Их получают при обучении модели и сохраняют в файл. При инференсе движок только читает эти числа и применяет их к входным данным, не меняя сами веса. Инференс — это процесс «запроса» к уже обученной модели: вы даёте текст, модель по шагам выдаёт ответ.
Движок работает с форматом файлов GGUF. GGUF — формат, в котором лежит сохранённая модель:
- «веса» (числа нейросети);
- метаданные (размеры, тип архитектуры);
- словарь (соответствие «текст ↔ числа» для токенов).
По сути это один файл-контейнер, из которого движок читает всё нужное в память. Поддерживается квантизация (уменьшение размера модели за счёт более грубого хранения чисел) и гибридные вычисления (часть на CPU, часть на GPU). Модели в формате GGUF можно брать с Hugging Face.
Цель статьи — по шагам разобрать, как в llama.cpp устроен инференс: от загрузки модели до появления очередного токена в ответе.
Токен — это число, соответствующее кусочку текста (слову или части слова); модель работает только с числами, а словарь переводит «текст → токены» и обратно.
Статья выстроена как сценарий:
- сначала подготовка (инициализация, загрузка модели, создание контекста);
- затем генерация по запросу (токенизация, батч, decode, сэмплинг).
С какими моделями работает движок:
- LLaMA (Meta);
- Qwen (Alibaba);
- Gemma (Google);
- Mistral;
- Phi и другие.
Отличия — в размере, длине контекста и деталях; в коде это разные гиперпараметры и тензоры весов. Общий сценарий один и тот же.
В каких проектах используется:
- LM Studio;
- Ollama;
- GPT4All;
- Offline AI Launcher (запуск моделей на смартфоне);
- KoboldCpp;
- Text Generation WebUI.
Репозиторий: github.com/ggml-org/llama.cpp. Код в статье приведён по ветке master на коммите 8887a48f, сразу после релиза b10736; в другой версии детали могут отличаться.
Как читать статью:
- разделы идут в порядке выполнения сценария;
- в каждом разделе для действия даётся цитата из кода и объяснение, что происходит и для чего;
- файлы исходников указаны в тексте;
- статья рассчитана на неподготовленного читателя: все термины объясняются в глоссарии в начале статьи, все шаги снабжены цитатами кода и пояснениями.
Глоссарий терминов
Батч (batch) — набор токенов (или эмбеддингов), позиций и флагов логитов, обрабатываемых за один вызов decode. При Prefill в батче много токенов промпта; при Decode — один новый токен.
Бэкенд (backend) — «движок» вычислений: CPU или видеокарта (GPU). Планировщик распределяет узлы графа по бэкендам.
Decode — этап генерации по одному токену: в батче один новый токен, граф вычисляет логиты для этой позиции, K и V дописываются в KV-cache.
Эмбеддинг (embedding) — вектор чисел, в который превращается токен перед подачей в слои модели; одна строка матрицы эмбеддингов модели.
EOS (end of sequence) — специальный токен «конец вывода»; по нему приложение прекращает генерацию.
GGUF — формат файла модели: заголовок, метаданные (ключ–значение), данные тензоров. Поддерживается квантизация и mmap.
Гиперпараметры (hparams) — числа, задающие размеры модели: длина контекста, число слоёв, размер эмбеддинга, число голов внимания и т.д.
Инференс — процесс получения ответа от модели: подаётся промпт, модель по шагам выдаёт следующий токен.
KV-cache — кэш ключей и значений механизма внимания; хранит уже посчитанные K и V по всем предыдущим позициям, чтобы не пересчитывать их на каждом шаге.
Логиты (logits) — «сырые» оценки модели по каждому токену словаря перед softmax; по ним сэмплер выбирает следующий токен.
Prefill — этап обработки промпта: в батче все (или много) токенов промпта, для них считаются K и V и записываются в KV-cache.
Сэмплинг — выбор одного токена по логитам (жадный, случайный с temperature/top_p и т.д.).
Тензор — многомерный массив чисел (веса модели, эмбеддинги, ключи, значения, логиты и т.д.).
Токен — целое число (ID), соответствующее кусочку текста (слову или части слова); модель работает только с токенами.
Токенайзер — компонент словаря, превращающий текст в токены (SPM, BPE и т.д.) и обратно.
Вычислительный граф — список операций (матрицы, сложения, активации) и связей между ними; по нему движок выполняет все вычисления модели.
Подбатч (ubatch) — часть батча, которая обрабатывается за один вызов process_ubatch. Если промпт длиннее n_ubatch, батч разбивается на подбатчи по n_ubatch токенов; каждый подбатч прогоняется через модель по очереди.
Для чего:
- чтобы ограничить пиковое потребление памяти при длинном промпте.
Планировщик (sched) — компонент GGML, который распределяет узлы вычислительного графа по бэкендам (CPU, GPU) и выделяет под граф буферы на этих устройствах. При выполнении графа планировщик обходит узлы в топологическом порядке и запускает операции на выбранных устройствах.
Схема процесса: все шаги по порядку
Ниже — вся цепочка от запуска движка до появления очередного токена в ответе. Каждый пункт дальше в статье разбирается подробно, с цитатами кода и пояснениями.
Подготовка (один раз при старте или смене модели):
- шаг 1: инициализация бэкенда (
llama_backend_init) - шаг 2: загрузка модели из файла (
llama_model_load_from_file→ загрузчик GGUF) - шаг 3: определение типа модели — архитектура, уже прочитанная загрузчиком из ключа GGUF general.architecture, определяет, какой класс модели создаётся (
llama_model_create) - шаг 4: загрузка гиперпараметров — размеры, контекст (
load_hparams) - шаг 5: загрузка словаря и токенайзера (
load_vocab) - шаг 6: загрузка весов в память или на GPU (
load_tensors) - шаг 7: создание контекста инференса — KV-cache, планировщик (
llama_init_from_model).
Генерация (для каждого сообщения и каждого нового токена в ответе):
- шаг 8: приходит текст промпта от пользователя
- шаг 9: токенизация — текст превращается в последовательность токенов (
llama_tokenize) - шаг 10: формирование батча — токены упаковываются для одного вызова (
llama_batch_get_oneилиcommon_batch_add), а внутри вызова батч проверяется и раскладывается (balloc->init) - шаг 11:
llama_decode— точка входа шага генерации: батч проверяется и передаётся во внутренний путь декодирования (llama_context::decode) - шаг 12: батч при длинном промпте разбивается на подбатчи (
memory->init_batch) - шаг 13: каждый подбатч прогоняется через модель: построение графа → выполнение на CPU/GPU (
process_ubatch→build_graph→graph_compute) - шаг 14: логиты копируются с выхода графа в буфер контекста — по одной строке на каждую позицию, помеченную в батче на вывод; в цикле генерации это только последняя позиция
- шаг 15: сэмплинг — по логитам выбирается один следующий токен
- шаг 16: если токен завершает генерацию (
llama_vocab_is_eog— сюда входят EOS, EOT и другие токены конца генерации), цикл завершается; иначе токен переводится в текст и выводится (llama_token_to_piece), из этого одного токена собирается новый батч, и управление возвращается к шагу 11.
Общий процесс инференса LLM
Выше дана схема: что за чем происходит. По смыслу процесс делится на два этапа. Первый — подготовка: инициализация бэкенда, загрузка модели из GGUF (архитектура, гиперпараметры, словарь, веса) и создание контекста. Он выполняется один раз при старте или при смене модели. Второй этап — генерация: при каждом сообщении пользователя текст превращается в токены, упаковывается в батч, прогоняется через модель (llama_decode), из логитов выбирается следующий токен, он переводится в текст и выводится; цикл повторяется до токена «конец вывода» (EOS) или лимита. Этот цикл повторяется для каждого нового сообщения и для каждого нового токена в ответе. Ниже каждый шаг из схемы разбирается по отдельности: что именно вызывается в коде, что происходит и что может быть неочевидно неподготовленному читателю.
Структура репозитория и основные файлы
В репозитории llama.cpp основные части движка разнесены по папкам и файлам.
Для чего так сделано:
- чтобы разнести ответственность: загрузка файла, модель, словарь, контекст и батч — в разных файлах;
- так проще искать код и отлаживать.
Ниже перечислены файлы, которые прямо относятся к загрузке модели и инференсу; для каждого указано, что в нём лежит и зачем это нужно.
Точка входа и загрузка модели:
-
src/llama.cpp— здесь живут функцииllama_backend_init,llama_model_load_from_file,llama_model_load(статическая).Для чего: это «входная дверь» в движок: приложение вызывает эти функции, чтобы инициализировать библиотеку и загрузить модель; здесь же создаётся загрузчик и запрашивается объект модели под архитектуру, прочитанную из файла. Саму фабрику
llama_model_create(она спрашивает архитектуру у загрузчика черезget_arch) этот файл только вызывает — живёт она вsrc/llama-model.cpp. Затем по очереди вызываютсяload_hparams,load_vocabиload_tensors. -
src/llama-model-loader.cpp— классllama_model_loader: открытие GGUF-файла, построение индекса тензоров (weights_map), методыget_weightиget_tensor_metaдля поиска тензора по имени иload_all_dataдля чтения самих данных тензоров.Для чего: загрузчик нужен, чтобы по имени тензора знать, где в файле лежат его данные и как их прочитать или отобразить в память (mmap); без него нельзя по шагам загружать архитектуру, гиперпараметры, словарь и веса.
Модель и словарь:
-
src/llama-model.cpp— классllama_modelи его базовая реализацияllama_model_base: методыload_hparams,load_vocab,load_tensors, создание тензоров и назначение буферов. То, что различается от архитектуры к архитектуре, вынесено вload_arch_hparamsиload_arch_tensors, которые каждое семейство моделей реализует в своём файле вsrc/models/.Для чего: объект модели хранит всё, что прочитано из файла: тип архитектуры, гиперпараметры, словарь и сами веса (тензоры); методы load_* по очереди заполняют эти данные из загрузчика. Саму архитектуру определяют раньше, при создании объекта модели:
llama_model_createспрашивает её у загрузчика (get_arch) и строит класс подходящего семейства. -
src/llama-vocab.cpp— классllama_vocab, реализацияllama_vocab::impl::load(загрузка словаря из GGUF), токенизация и обратный перевод токенов в текст (tokenize,token_to_piece,detokenize).Для чего: словарь нужен, чтобы превращать текст в числа (токены) при вводе и числа обратно в текст при выводе; без него модель не сможет ни принять промпт, ни выдать читаемый ответ.
Контекст и decode:
-
src/llama-context.cpp— классllama_context: создание контекста (память, планировщик, резерв графов), методdecode,process_ubatch,llama_get_logits_ith.Для чего: контекст — это «рабочая среда» одного сеанса генерации: в нём задаётся размер контекста, KV-cache, планировщик вычислений; метод decode прогоняет батч через модель и возвращает логиты.
-
src/llama-graph.cpp— построение вычислительного графа: классllm_graph_contextсbuild_inp_embd(поиск эмбеддингов по таблице) и результат графаllm_graph_resultс методамиset_inputsиcan_reuse.Для чего: граф — это то, что реально считается на decode: здесь собираются его входные узлы, перед запуском заполняются данными подбатча и решается, можно ли переиспользовать прошлый граф вместо того, чтобы строить его заново.
-
src/llama-batch.cpp— классllama_batch_allocrи его методinit(заполнение позиций и флагов логитов); сама структураllama_batchобъявлена вinclude/llama.h.Для чего: батч — это «пакет» токенов для одного вызова decode; аллокатор проверяет батч и при отсутствии полей заполняет их (позиции из памяти, логиты только для последнего токена), чтобы вызывающему коду не нужно было вручную всё выставлять.
-
src/llama-kv-cache.cpp— выделение и обновление KV-cache и егоinit_batch(разбиение на подбатчи и резервирование под них мест в кэше). Сам интерфейс объявлен отдельно, вsrc/llama-memory.h, потому что модель может использовать память другого рода — рекуррентное состояние или гибрид, — и каждый вариант реализует тот жеinit_batchв своём файле.Для чего: KV-cache хранит уже посчитанные ключи и значения по всем предыдущим позициям, чтобы не пересчитывать их на каждом шаге;
init_batchразбивает большой батч на подбатчи ограниченного размера, чтобы не переполнить память. -
src/llama-sampler.cpp— сэмплеры и цепочка из них:llama_sampler_chain_init,llama_sampler_chain_add,llama_sampler_sample.Для чего: после decode логиты ещё нужно превратить в один токен: цепочка по очереди прогоняет сэмплеры и возвращает выбранный токен.
Библиотеки и бэкенды:
-
ggml/(каталог внутри репозитория — библиотека лежит прямо здесь, а не подключена подмодулем) — вычислительный граф (GGML), типы тензоров, планировщик (ggml_backend_sched) и бэкенды CPU/GPU; публичные заголовки — вggml/include/, бэкенды — в подкаталогахggml/src/.Для чего: граф описывает, какие операции (умножения матриц, активации и т.д.) выполнить и в каком порядке; планировщик решает, на каком устройстве (CPU или GPU) считать каждый узел графа.
-
gguf (
ggml/include/gguf.hиggml/src/gguf.cpp— часть ggml, а не отдельная библиотека) — чтение и запись формата GGUF: заголовок, метаданные и тензоры.Для чего: формат GGUF задаёт, как в файле лежат заголовок, метаданные и данные тензоров; эти функции читают их без ручного разбора байтов.
-
include/llama.h— заголовок API для приложений: объявленияllama_model_load_from_file,llama_context,llama_decode,llama_tokenize,llama_get_logits_ith, сэмплеры и т.д.Для чего: приложение подключает этот заголовок и вызывает объявленные функции, не заходя во внутренние файлы движка.
Основные типы и структуры (справочно)
Для ориентирования в коде полезно знать основные типы. Ниже — что хранит каждый тип и для чего он нужен.
Основные структуры:
-
llama_model— объект загруженной модели. В нём хранятся: гиперпараметры (hparams), словарь (vocab), тензоры весов, список устройств (devices), разбиение слоёв по CPU/GPU.Для чего: модель — это всё, что прочитано из файла и нужно для вычислений; один объект модели можно использовать для нескольких контекстов (несколько сеансов генерации).
-
llama_context— контекст инференса. В нём: ссылка на модель, параметры контекста (cparams:n_ctx,n_batch,n_ubatch,n_threadsи т.д.), аллокатор батча (balloc), память KV-cache (memory), планировщик (sched), последний построенный граф, сохранённый для переиспользования (gf_res_prev), буфер логитов.Для чего: контекст — «рабочая среда» одного сеанса: размер контекста, кэш ключей и значений, планировщик и графы для decode; при каждом запросе вызывается decode именно для этого контекста.
-
llama_batch— массив токенов (или эмбеддингов), позиций, идентификаторов последовательностей и флагов логитов.Для чего: один вызов
llama_decodeпринимает один батч; в нём передаётся, какие токены обработать, на каких позициях и для каких позиций вернуть логиты (обычно только для последней). -
llama_model_loader— загрузчик GGUF. В нём: метаданные, прочитанные из файла (metadata), карта тензоров (weights_map), открытые файлы (files) и их отображения в память (mappings).Для чего: загрузчик живёт только во время загрузки модели; по нему по очереди читаются архитектура, гиперпараметры, словарь и тензоры; после загрузки он не нужен.
-
ggml_context— контекст графа GGML: в нём создаются узлы и тензоры графа.Для чего: граф описывает последовательность операций (умножения, активации и т.д.); все узлы графа создаются в одном таком контексте.
-
ggml_backend_sched— планировщик: список бэкендов и логика распределения узлов графа по устройствам.Для чего: планировщик решает, на каком устройстве (CPU или GPU) выполнять каждый узел графа, и выделяет под граф буферы на этих устройствах.
Что возвращают основные функции:
-
llama_model_load_from_fileвозвращаетllama_model*илиnullptrпри ошибке.Для чего: приложение проверяет указатель: если не nullptr, модель загружена и можно создавать контекст.
-
llama_model_load— вспомогательная функция внутриsrc/llama.cpp, в публичном заголовке её нет — возвращает пару из статуса и модели: 0 при успехе, -1 при ошибке, -2 при отмене по колбэку прогресса.Для чего: при отрицательном статусе указатель на модель возвращается уже нулевым, так что вызывающий код только пишет в лог причину и возвращает приложению nullptr.
-
llama_decodeвозвращает 0 при успехе. Положительное значение — это предупреждение, а не фатальная ошибка: 1 означает, что для этого батча не нашлось свободного места в KV-cache (нужно уменьшить батч или увеличить контекст), 2 — что вызов был прерван. -1 означает, что некорректен сам батч, всё, что меньше -1, — фатальная ошибка.Для чего: приложение по возврату понимает, удалось ли выполнить decode и можно ли читать логиты; при 1 можно повторить с батчем поменьше, а после 2 или фатальной ошибки часть батча уже попала в память контекста, так что докуда он дошёл, приходится спрашивать у контекста отдельно.
-
llama_tokenizeвозвращает число записанных токенов, но не больше размера переданного буфера. Если буфер мал, возвращается отрицательное число, по модулю равное тому, сколько токенов получилось бы, — поэтому обычно её вызывают сначала с пустым буфером, выделяют столько элементов и вызывают ещё раз.Для чего: чтобы знать, сколько элементов массива токенов заполнено и какого размера должен быть массив.
-
llama_get_logits_ithвозвращает указатель на массив float размеромn_vocab— логиты i-го токена последнего decode; при неотрицательном i аргумент — это индекс токена внутри батча, который контекст переводит в строку выходов черезoutput_ids, так что для токена, логиты которого не запрашивали, вернётся NULL; отрицательный индекс отсчитывается по строкам выходов, то есть -1 — последняя. При некорректном индексе возвращается NULL.Для чего: по этому массиву сэмплер выбирает следующий токен (по одному числу на каждый токен словаря); в цикле это почти всегда индекс -1, только что посчитанная позиция.
Шаг 1: Инициализация бэкенда
Шаг 1 в схеме процесса — инициализация бэкенда. Перед загрузкой модели и инференсом приложение один раз вызывает llama_backend_init(). Вызов делает три вещи: запускает точный таймер, по которому потом замеряют время загрузки и decode, создаёт и тут же освобождает пустой контекст GGML и — это главное — заполняет реестр бэкендов, если тот ещё пуст, чтобы движку было на чём считать: процессор, а если для видеокарты есть бэкенд, то и она. Без зарегистрированного бэкенда загрузка не деградирует молча, а падает: движок возвращает nullptr и пишет, что бэкенды не загружены. Ниже — цитата целиком и по фрагментам (файл src/llama.cpp).
// Шаг 1: единственная точка входа инициализации движка; вызывается один раз при старте приложенияvoid llama_backend_init(void) { ggml_time_init(); // точный таймер; на Windows его нужно инициализировать явно
// нужно для инициализации таблиц f16 { struct ggml_init_params params = { 0, NULL, false }; struct ggml_context * ctx = ggml_init(params); ggml_free(ctx); }
// если ещё ничего не зарегистрировано — подтянуть бэкенды (CPU, CUDA, Metal, Vulkan ...); // именно это даёт движку устройства, на которых он будет считать if (!ggml_backend_reg_count()) { ggml_backend_load_all(); }}Что в коде происходит по шагам:
// Фрагмент 1: таймер нужен, чтобы потом замерить время загрузки модели и время decodeggml_time_init();
// Фрагмент 2: временный контекст с нулевым буфером — создаётся и тут же освобождается.// Комментарий в исходниках называет это «инициализацией таблиц f16», но в нынешнем ggml первый// вызов ggml_init() только запускает таймер — блок остался как рудимент и ничего не стоитstruct ggml_init_params params = { 0, NULL, false };struct ggml_context * ctx = ggml_init(params);ggml_free(ctx);
// Фрагмент 3: содержательная часть — если реестр пуст, загрузить библиотеки бэкендов// (CPU, CUDA, Metal, Vulkan и так далее); без них считать не на чемif (!ggml_backend_reg_count()) { ggml_backend_load_all();}Сначала включается таймер — потом по нему замеряют время загрузки и время decode. Затем создаётся и сразу освобождается временный контекст GGML с нулевым буфером; комментарий рядом с ним в исходниках по-прежнему говорит, что так инициализируются таблицы f16, но в нынешнем ggml первый вызов ggml_init только запускает таймер, а таблицы для половинной точности строятся позже, когда регистрируется бэкенд CPU. Настоящую работу делает последний блок: если ни один бэкенд ещё не зарегистрирован, ggml_backend_load_all ищет библиотеки бэкендов и регистрирует те, что нашлись. После этого движок знает, на каких устройствах он может считать, и готов к загрузке модели и построению графа.
Шаг 2а: Загрузка модели из файла (точка входа)
Шаг 2 — загрузка модели из файла. Она начинается с вызова llama_model_load_from_file: приложение передаёт путь к .gguf и параметры, движок возвращает готовый объект модели или nullptr при ошибке.
Для чего эта функция:
- она — единственная точка входа для загрузки модели из файла;
- приложение передаёт путь к файлу и параметры, движок возвращает готовый объект модели или nullptr при ошибке.
Цитата из кода (файл src/llama.cpp):
// Точка входа загрузки модели (шаг 2): путь к .gguf и параметры загрузкиstruct llama_model * llama_model_load_from_file( const char * path_model, struct llama_model_params params) { // splits — пути к частям разбитой модели; здесь пусто, загрузчик выведет их сам std::vector<std::string> splits = {}; // три nullptr и FILE * — это остальные возможные источники модели // (подготовленные в памяти метаданные GGUF и уже открытый файл); задан может быть только один return llama_model_load_from_file_impl(nullptr, nullptr, nullptr, path_model, splits, /*file*/ nullptr, params);}Функция принимает путь к файлу модели (обычно .gguf) и параметры загрузки — приложение указывает, откуда читать модель, как её читать и сколько слоёв положить на видеокарту. splits здесь всегда пустой: если в метаданных сказано, что модель разбита на несколько файлов, пути к частям загрузчик выводит из имени файла сам — поэтому это имя и должно следовать шаблону <name>-00001-of-00003.gguf. Явный список частей передают через отдельную точку входа — llama_model_load_from_splits. Всё остальное — проверка, что бэкенд зарегистрирован, колбэк прогресса по умолчанию, вызов llama_model_load и обработка его результата — происходит в llama_model_load_from_file_impl.
Параметры загрузки (llama_model_params), важные для понимания:
-
load_mode— как читать файл: mmap, mlock, прямой ввод-вывод или обычное чтение в буфер.Для чего: mmap экономит RAM и ускоряет старт загрузки, потому что файл отображается, а не копируется; прямой ввод-вывод на некоторых дисках и системах даёт более предсказуемую скорость чтения. По умолчанию — auto: движок выбирает mmap и откатывается на обычное чтение, если какое-то из устройств не умеет работать с отображённой памятью. Внутри загрузчика это одно значение снова превращается в два флага —
use_mmapиuse_direct_io; именно в таком виде оно встречается дальше. -
n_gpu_layers— сколько слоёв загружать на GPU (остальные на CPU).Для чего: чтобы часть вычислений шла на видеокарте, часть на процессоре.
-
progress_callback— колбэк прогресса загрузки.Для чего: приложение может показывать прогресс-бар или отменять загрузку (вернуть false).
-
vocab_only— загрузить только словарь (без весов).Для чего: когда нужен только токенайзер, без тяжёлых весов. Полный список параметров — в
include/llama.hв структуреllama_model_params.
Шаг 2б: Подготовка к чтению файла
Шаг 2 (продолжение) — внутри точки входа выполняется подготовка к чтению файла. В llama_model_load_from_file_impl выполняется основная подготовка перед чтением файла.
Для чего так:
- перед загрузкой нужно убедиться, что модель берётся ровно из одного источника, что есть бэкенд для вычислений, и настроить отображение прогресса;
- только после этого вызывается внутренняя функция
llama_model_load, которая читает файл по шагам.
Цитата начала функции (файл src/llama.cpp):
static struct llama_model * llama_model_load_from_file_impl( struct gguf_context * metadata, llama_model_set_tensor_data_t set_tensor_data, void * set_tensor_data_ud, const std::string & path_model, std::vector<std::string> & splits, FILE * file, struct llama_model_params params) { // ... опущено: проверка, что задан ровно один источник — metadata, path_model или file ggml_time_init(); // таймер для замеров времени загрузки
// Если загружаем не только словарь — проверяем, что зарегистрирован хотя бы один бэкенд if (!params.vocab_only && ggml_backend_reg_count() == 0) { LLAMA_LOG_ERROR("%s: no backends are loaded. hint: use ggml_backend_load() or ggml_backend_load_all() to load a backend before calling this function\n", __func__); return nullptr; }
unsigned cur_percentage = 0; // Если колбэк прогресса не передан — подставляем свой: выводим точки до 100% if (params.progress_callback == NULL) { params.progress_callback_user_data = &cur_percentage; params.progress_callback = [](float progress, void * ctx) { unsigned * cur_percentage_p = (unsigned *) ctx; unsigned percentage = (unsigned) (100 * progress); while (percentage > *cur_percentage_p) { *cur_percentage_p = percentage; LLAMA_LOG_CONT("."); if (percentage >= 100) { LLAMA_LOG_CONT("\n"); } } return true; // не отменяем загрузку }; }Эта реализация общая для всех публичных точек входа, поэтому она принимает сразу все возможные источники модели — готовые метаданные GGUF, путь (части разбитой на файлы модели передаются в splits) или уже открытый файл — и начинает с проверки, что передан ровно один из них.
Что происходит в этом фрагменте и для чего:
-
ggml_time_init()— включается таймер.Для чего: чтобы потом замерить, сколько заняла загрузка.
-
Проверка
ggml_backend_reg_count() == 0— есть ли хотя бы один бэкенд (CPU или GPU).Для чего: без бэкенда нельзя будет выполнять вычисления модели; при загрузке только словаря (
vocab_only) бэкенд не обязателен. -
Настройка колбэка прогресса — если приложение не передало свой, подставляется колбэк по умолчанию, который выводит точки.
Для чего: пользователь видит, что загрузка идёт; при желании можно передать свой колбэк и показывать прогресс-бар или отменять загрузку (вернуть false).
-
Сам объект модели здесь не создаётся. Он появляется уровнем ниже, внутри
llama_model_load, в вызовеllama_model_create— и только после того, как открыт загрузчик GGUF.Для чего: класс объекта зависит от архитектуры, записанной в файле (LLaMA, Gemma, Qwen и так далее), поэтому сначала нужно прочитать архитектуру из GGUF; гиперпараметры, словарь и тензоры записываются уже в этот объект.
Остальная часть функции — это только вызов внутреннего загрузчика и обработка его результата (тот же файл, конец llama_model_load_from_file_impl):
// Внутренняя загрузка: читает файл и собирает модель (arch, hparams, vocab, tensors) const auto [status, model] = llama_model_load(metadata, set_tensor_data, set_tensor_data_ud, path_model, splits, file, params); GGML_ASSERT(status <= 0); if (status < 0) { if (status == -1) { LLAMA_LOG_ERROR("%s: failed to load model\n", __func__); } else if (status == -2) { LLAMA_LOG_INFO("%s: cancelled model load\n", __func__); }
if (model) { llama_model_free(model); } return nullptr; }
return model;}-
Список устройств тоже строится уровнем ниже, в
llama_prepare_model_devices, которуюllama_model_loadвызывает сразу после создания объекта модели; результат попадает вmodel->devices.Для чего: от него зависит, по каким ускорителям будут распределены слои модели (см.
load_tensors). В этот список попадают только графические устройства — сначала удалённые RPC-серверы, затем дискретные видеокарты, а встроенные — только если дискретной не нашлось; CPU обрабатывается отдельно и в него не входит. -
llama_model_load— читает файл и по шагам заполняет модель: создаёт загрузчик GGUF, определяет архитектуру и создаёт под неё объект модели, выбирает устройства, а затем по очереди вызываетload_hparams,load_vocabиload_tensors.Для чего: вся логика чтения GGUF и заполнения модели сосредоточена в одной функции.
-
При отрицательном статусе в лог пишется причина (-1 — ошибка загрузки, -2 — отмена колбэком прогресса) и возвращается
nullptr. Вызовllama_model_freeв этой ветке — только страховка:llama_model_loadсама уничтожает недостроенную модель и при неудаче никогда не отдаёт указатель наружу.Для чего: приложение по nullptr понимает, что загрузка не удалась, и не использует неполную модель.
Колбэк прогресса вызывается внутри load_tensors при чтении каждого тензора: ему передаётся число от 0.0 до 1.0 (доля загруженных данных). Если колбэк возвращает false, загрузка прерывается и llama_model_load возвращает -2 (отмена).
Для чего:
- приложение может отменить долгую загрузку или показывать прогресс-бар.
Шаги со 2 по 6 вместе: Пошаговая загрузка модели из файла
Шаги 2–6 выполняются внутри одной функции llama_model_load: создаётся загрузчик (шаг 2), затем создаётся объект модели для архитектуры, которую прочитал загрузчик (llama_model_create, шаг 3), а после него по очереди вызываются load_hparams (шаг 4), load_vocab (шаг 5) и load_tensors (шаг 6).
Для чего нужна функция llama_model_load: она выполняет пошаговую загрузку модели из файла: создаёт загрузчик GGUF (открывает файл и строит карту тензоров), создаёт объект модели нужной архитектуры, а затем по очереди загружает гиперпараметры, словарь и тензоры. Порядок важен: архитектура задаёт набор ключей в GGUF; гиперпараметры читаются по этим ключам; словарь загружается с учётом архитектуры; тензоры создаются по известным размерам и заполняются из файла.
Сокращённая цитата функции (файл src/llama.cpp, функция llama_model_load):
// src/llama.cpp — llama_model_load(); пустые строки и две обёртки try/catch убраны для краткости// Возвращает 0 при успехе, -1 при ошибке и -2 при отмене через llama_progress_callbackstatic std::pair<int, llama_model *> llama_model_load(struct gguf_context * metadata, llama_model_set_tensor_data_t set_tensor_data, void * set_tensor_data_ud, const std::string & fname, std::vector<std::string> & splits, FILE * file, llama_model_params & params) { try { // Шаг 2: загрузчик открывает GGUF, читает заголовок и метаданные, строит карту тензоров (weights_map) llama_model_loader ml(metadata, set_tensor_data, set_tensor_data_ud, fname, splits, file, params.load_mode, params.check_tensors, params.no_alloc, params.load_mtp, params.kv_overrides, params.tensor_buft_overrides); ml.lazy.mode = params.lazy_mode; // ленивое чтение тензоров: часть весов остаётся на диске и читается по требованию ml.print_info(); // Шаг 3: архитектура, прочитанная загрузчиком, выбирает C++-класс модели std::unique_ptr<llama_model> model_ptr(llama_model_create(ml, params)); // список устройств (CPU, GPU), по которым будет разложена именно эта модель bool ok = llama_prepare_model_devices(params, model_ptr.get()); if (!ok) { return {-1, nullptr}; } auto * model = dynamic_cast<llama_model_base *>(model_ptr.get()); // ... опущено: проверка на null, прерывающая работу, если модель не реализует llama_model_base model->t_load_us = 0; time_meas tm(model->t_load_us); // время загрузки будет пересчитано после первого eval, чтобы учесть промахи страниц, отложенные mmap model->t_start_us = tm.t_start_us; model->hparams.vocab_only = params.vocab_only; model->hparams.no_alloc = params.no_alloc; // ... опущено: каждый из двух вызовов ниже обёрнут в try/catch, перебрасывающий ошибку с префиксом model->load_hparams(ml); // Шаг 4: размеры модели, длина контекста, число слоёв if (model->arch == LLM_ARCH_CLIP) { throw std::runtime_error("CLIP cannot be used as main model, use it with --mmproj instead"); } model->load_vocab(ml); // Шаг 5: словарь и токенайзер — текст <-> токены model->load_stats(ml); model->print_info(); if (params.vocab_only) { LLAMA_LOG_INFO("%s: vocab only - skipping tensors\n", __func__); return {0, model_ptr.release()}; } // Шаг 6: веса модели из файла в память (или mmap) на CPU/GPU if (!model->load_tensors(ml)) { return {-2, nullptr}; } return {0, model_ptr.release()}; } catch (const std::exception & err) { LLAMA_LOG_ERROR("%s: error loading model: %s\n", __func__, err.what()); return {-1, nullptr}; }}Ошибки и отмена загрузки:
- при ошибке в любом из шагов (создание загрузчика,
llama_model_create,load_hparams,load_vocab,load_tensors) выбрасывается исключение; в блоке catch логируется сообщение и возвращается -1; - при отмене по колбэку прогресса (колбэк возвращает false внутри
load_tensors)load_tensorsвозвращает false, исключение не выбрасывается, ноllama_model_loadвозвращает -2 (отмена); - при любой неудаче недостроенная модель уничтожается тут же: она живёт в
unique_ptr, который отдаётся вызывающему коду только при успехе, поэтомуllama_model_load_from_file_implполучает нулевой указатель, логирует причину и возвращаетnullptr; - таким образом, приложение может отменить долгую загрузку через колбэк и корректно освободить ресурсы.
По шагам (что происходит и для чего):
-
создаётся загрузчик
llama_model_loader ml(...)— он открывает GGUF-файл, читает метаданные и строитweights_map.Для чего:
-
без загрузчика нельзя прочитать архитектуру, гиперпараметры, словарь и тензоры из файла.
-
вызывается
ml.print_info()— выводит в лог формат файла, тип файла (квантование) и размер файла вместе с числом бит на вес.Для чего:
-
чтобы пользователь видел, какой именно файл открывается.
-
вызывается
llama_model_create(ml, params)— архитектура, прочитанная загрузчиком (LLaMA, Gemma, Qwen и т.д.), выбирает C++-класс модели, и объект создаётся.Для чего:
-
от архитектуры зависят имена полей в GGUF и то, какие тензоры создавать; неизвестная архитектура — ошибка уже здесь.
-
вызывается
llama_prepare_model_devices— строится и выводится в лог список устройств (CPU и видеокарты) для этой модели.Для чего:
-
он определяет, по каким устройствам потом будут разложены слои в
load_tensors. -
только теперь сбрасывается время загрузки и запускается таймер.
Для чего:
-
измеренное здесь значение — не окончательное: при первом eval
llama_contextпересчитывает время загрузки от сохранённого t_start_us, поэтому в отсчёт попадают и промахи страниц, отложенные mmap, — они случаются только тогда, когда до весов реально доберутся. -
вызывается
model->load_hparams(ml)— читаем размерности и параметры (длина контекста, число слоёв и т.п.).Для чего:
-
чтобы знать «форму» модели и выделить под неё память.
-
для CLIP выбросится ошибка — его используют отдельно как проектор, не как основную модель.
-
вызывается
model->load_vocab(ml)— загружаем словарь токенов.Для чего:
-
чтобы потом превращать текст в числа (токены) и обратно при генерации.
-
load_stats(ml)копирует из загрузчика в модель число элементов и размер в байтах, аprint_info()выводит в лог архитектуру и все гиперпараметры. Если загружаем только словарь (vocab_only == true), на этом выход. Иначе вызываетсяmodel->load_tensors(ml)— читаются и раскладываются по памяти веса модели. При успехе возвращается 0, при отмене (колбэк прогресса вернул false) — -2, при ошибке — -1.
Порядок вызовов при загрузке модели (сводка):
llama_model_load_from_file→llama_model_load_from_file_impl;- в impl: проверка, что источник модели задан ровно один раз, проверка бэкенда, колбэк прогресса по умолчанию и вызов
llama_model_load(metadata, set_tensor_data, set_tensor_data_ud, path_model, splits, file, params); - внутри
llama_model_load: созданиеllama_model_loader,ml.print_info,llama_model_create,llama_prepare_model_devices,load_hparams,load_vocab,load_stats,print_info, при необходимостиload_tensors. Все эти шаги выполняются последовательно; при ошибке в любом из них загрузка прерывается.
Порядок вызовов llama_model_create → load_hparams → load_vocab важен: архитектура задаёт набор ключей GGUF; гиперпараметры читаются по этим ключам; словарь загружается с учётом архитектуры (например, имена ключей для токенайзера). Поэтому vocab.load(ml, kv) вызывается именно после load_hparams(ml) — к этому моменту и архитектура, и гиперпараметры уже известны, и загрузчик может корректно прочитать тип токенайзера, списки токенов и слияния.
Шаг 2в: Загрузчик GGUF — открытие файла и карта тензоров
Шаг 2 (завершение) (внутри llama_model_load) — создаётся загрузчик GGUF: открывается файл, читаются заголовок и метаданные, строится карта тензоров.
Для чего нужен загрузчик GGUF: чтобы открыть файл модели, прочитать заголовок и метаданные (без самих весов) и построить «карту» — по имени тензора знать, где в файле лежат его данные и какого он размера. Без этой карты нельзя потом загружать веса по одному тензору. Тензоры здесь — многомерные массивы чисел (матрицы и векторы), в которых хранятся веса нейросети; каждый слой модели — это несколько тензоров, и загрузчик должен знать для каждого имя, размер и смещение в файле.
Создание загрузчика — вызов конструктора llama_model_loader. Сам конструктор находится в src/llama-model-loader.cpp; llama_model_load только вызывает его. Сокращённо он выглядит так:
// src/llama-model-loader.cpp - конструктор (шаг 2): открывает GGUF, читает заголовок и метаданные, строит карту тензоровllama_model_loader::llama_model_loader( struct gguf_context * meta, llama_model_set_tensor_data_t set_tensor_data, void * set_tensor_data_ud, const std::string & fname, std::vector<std::string> & splits, FILE * file, llama_load_mode load_mode, bool check_tensors, bool no_alloc, bool load_mtp, const llama_model_kv_override * param_overrides_p, const llama_model_tensor_buft_override * param_tensor_buft_overrides_p) : metadata(meta), set_tensor_data(set_tensor_data), set_tensor_data_ud(set_tensor_data_ud) { // ... переопределения KV и превращение load_mode в use_mmap / use_direct_io ... if (!fname.empty()) { struct ggml_context * ctx = NULL; struct gguf_init_params params = { /*.no_alloc = */ true, // создать только пустые тензоры, не читать блок данных /*.ctx = */ &ctx, }; metadata_ptr.reset(gguf_init_from_file(fname.c_str(), params)); metadata = metadata_ptr.get(); if (metadata == nullptr) { throw std::runtime_error(format("%s: failed to load model from %s", __func__, fname.c_str())); } // Имя архитектуры (llama, qwen2 ...) задаёт имена всех остальных ключей GGUF get_key(llm_kv(LLM_KV_GENERAL_ARCHITECTURE), arch_name, false); llm_kv = LLM_KV(llm_arch_from_string(arch_name)); files.emplace_back(new llama_file(fname.c_str(), "rb", use_direct_io)); contexts.emplace_back(ctx); for (ggml_tensor * cur = ggml_get_first_tensor(ctx); cur; cur = ggml_get_next_tensor(ctx, cur)) { std::string tensor_name = std::string(cur->name); if (weights_map.find(tensor_name) != weights_map.end()) { throw std::runtime_error(format("invalid model: tensor '%s' is duplicated", ggml_get_name(cur))); } n_elements += ggml_nelements(cur); n_bytes += ggml_nbytes(cur); weights_map.emplace(tensor_name, llama_tensor_weight(files.back().get(), 0, metadata, cur)); } // ... тот же цикл по файлам-частям, когда модель разбита на несколько ... } // ... ветки для уже открытого FILE * и для метаданных, переданных извне ... n_kv = gguf_get_n_kv(metadata); n_tensors = weights_map.size(); fver = (enum llama_fver) gguf_get_version(metadata); // ... угадывание типа файла и вывод пар KV в лог ...}В конструкторе gguf_init_from_file читает заголовок GGUF и метаданные (без самих весов) — так получают список тензоров и пары ключ–значение (архитектура, гиперпараметры) без чтения тяжёлых данных в память. Из метаданных берётся имя архитектуры (general.architecture) — от него зависят имена остальных полей в GGUF (у разных моделей разные ключи). Файл открывается для чтения; потом по смещениям из weights_map будут читаться или маппиться данные тензоров. По списку тензоров из GGUF для каждого в weights_map записывается имя, размер и смещение в файле — потом get_weight(name) находит запись в карте, а load_all_data читает байты по этому смещению (или, при mmap, сразу указывает на них).
Структура llama_tensor_weight (элемент weights_map) хранит три вещи: индекс исходного файла (модель может быть разбита на несколько частей GGUF), абсолютное смещение данных тензора в этом файле и указатель на тензор в контексте GGUF. Смещение вычисляется один раз, при создании записи, как начало секции данных плюс смещение тензора внутри неё — и тот же конструктор проверяет, что тензор целиком помещается в файл, так что обрезанная модель отсекается здесь, а не во время генерации. Метод ml.get_key(...) читает из метаданных GGUF значение по ключу (имя ключа зависит от архитектуры — его возвращает kv(...)). Так загрузчик получает, например, имя архитектуры, тип токенайзера, гиперпараметры.
После создания загрузчика в llama_model_load вызывается ml.print_info(). Цитата из кода:
// src/llama.cpp, внутри llama_model_load, сразу после создания загрузчика:ml.print_info(); // пишет в лог версию GGUF, угаданный тип файла и общий размер весовФункция gguf_init_from_file (библиотека GGUF) открывает файл и читает заголовок:
- версию формата;
- число ключей метаданных;
- число тензоров.
Метаданные читаются в контекст gguf_context (пары ключ–значение); сами данные тензоров на этом шаге не загружаются — только имена, типы и смещения в файле. Сам конструктор уже пишет в лог число пар ключ–значение, число тензоров, имя файла и версию GGUF. Метод print_info (src/llama-model-loader.cpp) добавляет ещё три строки:
- версию формата GGUF;
- тип файла — Q4_K - Medium и подобные, угаданный по самому частому типу тензоров, если метаданные не говорят его напрямую;
- общий размер весов в MiB или GiB вместе со средним числом бит на вес.
Формат GGUF (справочно)
GGUF (GPT-Generated Unified Format) — бинарный формат для хранения моделей машинного обучения.
Для чего он нужен:
- чтобы в одном файле хранить и веса модели, и метаданные (размеры, тип архитектуры), и словарь;
- загрузчик по заголовку и метаданным строит «карту» и потом по запросу читает нужные куски файла или маппит их в память (mmap).
Структура заголовка GGUF:
- магическое число, четыре байта GGUF (идентификация формата — по нему понимают, что это GGUF);
- версия формата (для совместимости при изменениях формата);
- число тензоров (n_tensors);
- число ключей метаданных (n_kv).
Метаданные хранятся как массив пар «ключ — значение». Для каждой пары записаны:
- имя ключа (строка с длиной, записанной перед ней);
- тип значения (строка, число, булево, массив и т.д.);
- само значение (имя архитектуры, размерности, параметры RoPE, тип токенайзера, списки токенов и т.д.); для массива сначала идут тип элементов и их количество, потом сами элементы.
Тензоры в файле идут после метаданных. Для каждого тензора записаны:
- имя;
- число размерностей и размер по каждой из них (не больше четырёх; например, [n_embd, n_ff] для весов полносвязного блока одного слоя — каждый слой это отдельный набор тензоров, поэтому номер слоя входит в имя,
blk.0.ffn_up.weight, и никогда не бывает размерностью); - тип элемента (F32, F16, Q8_0, Q4_K и т.д. — от полной точности до квантизованных форматов);
- смещение, по которому начинаются данные, — от начала блока данных тензоров, идущего после метаданных.
Имена вроде Q4_K_M описывают файл целиком, а не отдельный тензор: это рецепт, который кладёт разные тензоры в разные типы.
Зачем это загрузчику: по этой информации он строит weights_map (карту «имя тензора → где в файле лежат данные»), и когда веса действительно понадобятся, load_all_data читает или маппит соответствующий участок файла.
Версия формата и типы элементов:
-
версия формата задаётся в заголовке GGUF.
Для чего:
-
при изменениях формата версия говорит загрузчику, с чем он имеет дело. Текущая версия — 3; файл с более новой или уже не поддерживаемой версией отвергается сразу, и модель не загружается. Неизвестные ключи метаданных, наоборот, ничего не стоят — загрузчик спрашивает только те ключи, которые знает, и на остальные не спотыкается.
-
по типу элемента тензора (F32, F16, Q8_0, Q4_K и т.д.) загрузчик знает, сколько байт занимает блок элементов — квантизованные типы хранят элементы блоками фиксированного размера с общим масштабом, а не по одному, — и как интерпретировать данные при копировании в буфер или при mmap.
Для чего:
-
без этого нельзя корректно прочитать или отобразить в память данные тензора; разные типы имеют разный размер и разную интерпретацию байтов.
Типы данных тензоров в GGUF задают, как интерпретировать байты: F32, F16, Q8_0, Q4_K и т.д. — от полной точности до квантизованных форматов. Квантизация уменьшает размер модели и ускоряет вычисления за счёт приближённого представления весов. Движок при загрузке создаёт тензоры в нужном формате и копирует или маппит данные из файла в буферы на CPU или GPU.
Чтение метаданных GGUF выполняется через функции библиотеки gguf:
gguf_get_n_kv— число ключей;gguf_get_key— имя ключа по индексу;gguf_get_kv_type— тип значения (строка, число, массив и т.д.);gguf_find_key— индекс ключа по его имени или -1, если такого нет;gguf_get_val_*— значение по этому индексу, по функции на каждый тип.
Загрузчик модели оборачивает это в метод get_key(key, value) с учётом архитектуры: ключ преобразуется в имя поля в GGUF (например, llama.embedding_length для LLaMA).
Шаг 3: Определение типа модели (архитектура)
Шаг 3 — определение типа модели (LLaMA, Gemma, Qwen и т.д.) по имени архитектуры из GGUF. Работу делает фабричная функция llama_model_create в src/llama-model.cpp. Для чего это нужно: от архитектуры зависят имена ключей, которые читаются из метаданных GGUF, набор тензоров, создаваемых при загрузке весов, и построение вычислительного графа; без этого нельзя корректно прочитать гиперпараметры и словарь. В llama.cpp это не просто флаг: каждая архитектура — отдельный класс C++, и фабрика выбирает, какой из них создать. Цитата (файл src/llama-model.cpp, функция llama_model_mapping и две перегрузки llama_model_create):
// src/llama-model.cpp — архитектура из GGUF решает, какой класс C++ представляет эту модельstatic llama_model * llama_model_mapping(llm_arch arch, const llama_model_params & params) { switch (arch) { case LLM_ARCH_LLAMA: return new llama_model_llama(params); // ... по одному case на каждую поддерживаемую архитектуру ... default: throw std::runtime_error(std::string("unsupported model architecture: '") + llm_arch_name(arch) + "'"); }}
llama_model * llama_model_create(llm_arch arch, const llama_model_params & params) { llama_model * model = llama_model_mapping(arch, params);
if (model != nullptr) { model->arch = arch; // ... проверка, что запрошенный режим разбиения тензоров реализован для этой архитектуры ... }
return model;}
llama_model * llama_model_create(llama_model_loader & ml, const llama_model_params & params) { llm_arch arch = ml.get_arch(); if (arch == LLM_ARCH_UNKNOWN) { throw std::runtime_error("unknown model architecture: '" + ml.get_arch_name() + "'"); }
return llama_model_create(arch, params);}Что происходит и для чего:
-
ml.get_arch()возвращает enumllm_arch, который загрузчик уже определил по ключуgeneral.architectureиз метаданных GGUF.Для чего: дальше по этому enum выбираются имена полей для гиперпараметров и словаря (у разных архитектур — разные ключи в GGUF).
-
Если тип неизвестен (
LLM_ARCH_UNKNOWN), выбрасывается ошибка.Для чего: движок не умеет работать с неизвестной архитектурой; приложение получит сообщение об ошибке и сможет сообщить пользователю.
Где архитектура читается на самом деле (файл src/llama-model-loader.cpp): конструктор загрузчика делает это один раз, сразу после открытия GGUF и до индексации тензоров, чтобы всё остальное в загрузчике уже пользовалось готовым значением. get_arch() и get_arch_name() после этого — простые геттеры:
// src/llama-model-loader.cpp — конструктор определяет архитектуру один раз, сразу после открытия GGUFllama_model_loader::llama_model_loader(/* ... */) : metadata(meta), /* ... */ { // ... файл GGUF только что открыт в `metadata` ...
get_key(llm_kv(LLM_KV_GENERAL_ARCHITECTURE), arch_name, false); // строка "general.architecture" llm_kv = LLM_KV(llm_arch_from_string(arch_name)); // "llama" -> LLM_ARCH_LLAMA, "qwen2" -> LLM_ARCH_QWEN2
// ... дальше строится индекс тензоров (weights_map) ...}
// к моменту запроса enum уже определён — оба геттера тривиальныstd::string llama_model_loader::get_arch_name() const { return arch_name;}
enum llm_arch llama_model_loader::get_arch() const { return llm_kv.arch;}Что делают эти вызовы:
-
get_key(llm_kv(LLM_KV_GENERAL_ARCHITECTURE), arch_name, false)— из метаданных GGUF читается значение по ключу «общая архитектура» (например,llama,qwen2) и записывается вarch_name.Для чего: без имени архитектуры нельзя выбрать набор ключей для гиперпараметров и словаря.
-
llm_arch_from_string(arch_name)— строка превращается в enumllm_arch(llama→LLM_ARCH_LLAMA,qwen2→LLM_ARCH_QWEN2).Для чего: по enum дальше выбираются имена полей в GGUF для
load_hparamsиload_vocab, а также класс модели вllama_model_create. Чтение необязательное (false), поэтому файл без ключа архитектуры даётLLM_ARCH_UNKNOWN, и ошибка возникает там, где создаётся модель, а не здесь. -
Примеры значений enum:
LLM_ARCH_LLAMA,LLM_ARCH_GEMMA,LLM_ARCH_QWEN,LLM_ARCH_PHI3,LLM_ARCH_MISTRAL3,LLM_ARCH_CLIPи т.д. — сейчас в таблице около 150 именованных архитектур. Сами идентификаторы ключей GGUF (LLM_KV) — один общий набор: те, что относятся к гиперпараметрам, — шаблоны вида%s.context_length, куда вместо %s подставляется имя архитектуры, поэтому один и тот же ключ читается какllama.context_lengthу одной модели и какqwen3.context_lengthу другой; общие ключи, среди нихgeneral.architectureиgeneral.name, — обычные строки, подставлять в них нечего.Для чего: без правильной архитектуры имена ключей получаются неверными, и гиперпараметры со словарём из GGUF не прочитать. По-настоящему своими у архитектуры остаются лишь несколько дополнительных гиперпараметров — их читает её собственный
load_arch_hparamsвsrc/models/. -
Имена тензоров в GGUF зависят от архитектуры: для LLaMA — blk.N.attn_q.weight, blk.N.attn_k.weight и т.д.; для других моделей — свои префиксы и суффиксы. Добавить поддержку новой архитектуры — значит добавить значение в enum и его имя в таблицу имён, написать новый класс в
src/models/, который говорит, какие гиперпараметры читать, какие тензоры создавать и как строить граф, и зарегистрировать его в фабрике; новые ключи GGUF добавляются только тогда, когда архитектуре нужен параметр, которого нет ни в одном из существующих ключей. Гиперпараметры используются при создании тензоров вload_tensors(размеры матриц, число слоёв) и при создании контекста (n_ctx,n_batch, параметры RoPE и т.д.).
Шаг 4: Загрузка гиперпараметров — размеры и контекст
Шаг 4 — загрузка гиперпараметров (размеры модели, длина контекста, число слоёв и т.д.) из метаданных GGUF. Метод llama_model_base::load_hparams заполняет структуру hparams (гиперпараметры) из метаданных GGUF. Для чего это нужно: гиперпараметры задают «форму» модели — длину контекста, число слоёв, размер эмбеддинга, число голов внимания и т.п.; без них нельзя выделить память под тензоры и построить вычислительный граф. Здесь читаются ключи, общие для всех архитектур; затем метод вызывает load_arch_hparams, хук, который каждая архитектура реализует в своём файле в src/models/ и который читает параметры, свойственные только этому семейству. После возврата из хука ставится ещё несколько полей, среди них тип RoPE, — его можно вычислить только тогда, когда архитектура уже определила свои параметры. Цитата с основными ключами (файл src/llama-model.cpp, метод llama_model_base::load_hparams):
// src/llama-model.cpp — llama_model_base::load_hparams(): «форма» модели, прочитанная из метаданных GGUFvoid llama_model_base::load_hparams(llama_model_loader & ml) { const gguf_context * ctx = ml.metadata;
// сохраняем все пары ключ-значение (кроме массивов) как строки, для справки for (int i = 0; i < gguf_get_n_kv(ctx); i++) { gguf_type type = gguf_get_kv_type(ctx, i); if (type == GGUF_TYPE_ARRAY) { continue; } const char * name = gguf_get_key(ctx, i); const std::string value = gguf_kv_to_str(ctx, i); gguf_kv.emplace(name, value); }
ml.get_key(LLM_KV_GENERAL_NAME, name, false);
// всё дальше уже не относится к словарю if (hparams.vocab_only || ml.get_arch() == LLM_ARCH_CLIP) { return; }
// архитектура — префикс каждого ключа ниже: llama.context_length, qwen3.context_length, ... ml.get_key(LLM_KV_CONTEXT_LENGTH, hparams.n_ctx_train); // длина контекста при обучении ml.get_key(LLM_KV_EMBEDDING_LENGTH, hparams.n_embd); // размер эмбеддинга (скрытого состояния) ml.get_key(LLM_KV_BLOCK_COUNT, hparams.n_layer_all); // число слоёв трансформера GGML_ASSERT(hparams.n_layer_all > 0 && hparams.n_layer_all <= LLAMA_MAX_LAYERS); ml.get_key(LLM_KV_EXPERT_COUNT, hparams.n_expert, false); // только для MoE-моделей
// ... здесь массивы по слоям заполняются нулями ...
ml.get_key_or_arr(LLM_KV_FEED_FORWARD_LENGTH, hparams.n_ff_arr, hparams.n_layer(), false); // размер FF по слоям ml.get_key_or_arr(LLM_KV_ATTENTION_HEAD_COUNT, hparams.n_head_arr, hparams.n_layer(), false); // число голов внимания по слоям
// n_head_kv необязателен, по умолчанию равен n_head hparams.n_head_kv_arr = hparams.n_head_arr; ml.get_key_or_arr(LLM_KV_ATTENTION_HEAD_COUNT_KV, hparams.n_head_kv_arr, hparams.n_layer(), false); // число KV-голов (GQA)
// rope_freq_base (необязательный) hparams.rope_freq_base_train = 10000.0f; ml.get_key(LLM_KV_ROPE_FREQ_BASE, hparams.rope_freq_base_train, false);
// ... масштабирование RoPE, размеры голов, варианты со скользящим окном ...
load_arch_hparams(ml); // всё, что нужно только этой архитектуре — src/models/<arch>.cpp
// ... после хука ставится ещё несколько полей, среди них hparams.rope_type ...}Что происходит и для чего:
-
В цикле читаются все пары ключ–значение из GGUF (кроме массивов) и сохраняются в
gguf_kv.Для чего:
-
чтобы потом при необходимости можно было обратиться к любому полю по имени.
-
Затем по ключам, зависящим от архитектуры, заполняются поля
hparams: -
n_ctx_train— длина контекста при обучении.Для чего:
-
от неё зависит размер KV-cache и максимальная длина ввода.
-
n_embd— размер эмбеддинга (скрытого слоя).Для чего:
-
от него зависят размеры матриц весов.
-
n_layer_all— число блоков трансформера, записанное в файле.Для чего:
-
от него зависит, сколько тензоров создавать в
load_tensors; реально строитсяhparams.n_layer()— оно вычитает дополнительные блоки multi-token prediction, которые есть у некоторых моделей. -
n_ff_arr,n_head_arr,n_head_kv_arr— размеры feed-forward и число голов внимания по слоям.Для чего:
-
от них зависят размеры матриц Q, K, V и feed-forward.
-
rope_freq_base_trainи др. — параметры RoPE (позиционного кодирования).Для чего:
-
они задают, как в графе применяются позиционные кодировки. В итоге
hparamsполностью описывает размеры модели — этого достаточно, чтобы выделить память под тензоры и построить вычислительный граф.
Часть ключей GGUF для гиперпараметров (в зависимости от архитектуры):
llama.context_length— длина контекста при обучении;llama.embedding_length— размер скрытого слоя (эмбеддинга);llama.block_count— число слоёв трансформера;llama.attention.head_count— число голов внимания;llama.attention.head_count_kv— число KV-голов (для GQA);llama.feed_forward_length— размер промежуточного слоя feed-forward;llama.rope.freq_base— базовая частота RoPE.
Для MoE-моделей добавляются ключи числа экспертов и т.д. Метод get_key_or_arr читает либо одно значение, либо массив по слоям (когда размеры различаются по слоям). После загрузки гиперпараметров модель знает «форму» всех тензоров — этого достаточно для выделения буферов и построения графа при load_tensors и при создании контекста.
Шаг 5: Загрузка словаря и токенайзера
Шаг 5 — загрузка словаря и токенайзера: по словарю текст превращается в токены при вводе и обратно в текст при выводе. Словарь токенов — таблица соответствия между кусочками текста и целыми числами (токенами); по нему текст режется на токены при вводе и обратно собирается в текст при выводе. Загрузка словаря выполняется после того, как определена архитектура и прочитаны гиперпараметры: в llama_model_load архитектура определяется при создании объекта модели (llama_model_create запрашивает её у загрузчика через ml.get_arch()), затем вызывается load_hparams(ml) и только потом load_vocab(ml). Внутри load_vocab вызывается vocab.load(ml, kv). Он загружается в llama_model_base::load_vocab (src/llama-model.cpp):
void llama_model_base::load_vocab(llama_model_loader & ml) { const auto kv = LLM_KV(arch);
vocab.load(ml, kv);}Для текущей архитектуры берётся набор ключей словаря, после чего вызывается vocab.load(ml, kv). Реализация — метод llama_vocab::impl::load в src/llama-vocab.cpp. Из GGUF читаются:
- тип токенайзера (SPM, BPE, WPM и т.д.);
- списки токенов;
- типы токенов;
- слияния BPE (если есть);
- специальные токены — всё, что нужно, чтобы превращать текст в последовательность чисел (токенов) и обратно.
После этого модель умеет токенизировать промпт и переводить сгенерированные токены обратно в текст.
Вызов vocab.load(ml, kv) выполняется внутри llama_model_base::load_vocab после того, как для текущей архитектуры получен набор ключей kv (через LLM_KV(arch)). Загрузчик ml уже открыл GGUF и прочитал метаданные; архитектура известна из загрузчика (ml.get_arch()), а гиперпараметры прочитаны в load_hparams. Начало реализации llama_vocab::impl::load (файл src/llama-vocab.cpp):
void llama_vocab::impl::load(llama_model_loader & ml, const LLM_KV & kv) { struct gguf_context * ctx = ml.metadata;
// Определяем тип словаря { ml.get_key(LLM_KV_TOKENIZER_MODEL, tokenizer_model); ml.get_key(LLM_KV_TOKENIZER_PRE, tokenizer_pre, false); // ... "no_vocab" / "none" -> LLAMA_VOCAB_TYPE_NONE и ранний выход ... if (tokenizer_model == "llama") { type = LLAMA_VOCAB_TYPE_SPM; // Специальные токены по умолчанию special_bos_id = 1; special_eos_id = 2; special_unk_id = 0; } else if (tokenizer_model == "gpt2" || tokenizer_model == "hybriddna" || tokenizer_model == "whitespace") { type = LLAMA_VOCAB_TYPE_BPE; // Читаем слияния BPE и заполняем ранги BPE const int merges_keyidx = gguf_find_key(ctx, kv(LLM_KV_TOKENIZER_MERGES).c_str()); // ... неверно типизированный массив слияний — ошибка; отсутствующий — тоже, кроме // случая tokenizer_pre == "kimi-k2"; элемент "first second" -> bpe_ranks[{first, second}] = i ... } // ... "bert" -> WPM, "t5" -> UGM, "rwkv", "plamo2", "gemma4"; всё остальное — ошибка ... } const int token_idx = gguf_find_key(ctx, kv(LLM_KV_TOKENIZER_LIST).c_str()); if (token_idx == -1) { throw std::runtime_error("cannot find tokenizer vocab in model file\n"); } // ... здесь находятся массивы оценок и типов токенов и проверяется, что их хватает на все токены ... const uint32_t n_tokens = gguf_get_arr_n(ctx, token_idx); id_to_token.resize(n_tokens); for (uint32_t i = 0; i < n_tokens; i++) { std::string word = gguf_get_arr_str(ctx, token_idx, i); // ... пустая строка заменяется заглушкой "[EMPTY_<i>]" ... token_to_id[word] = i; max_token_len = std::max(max_token_len, (int) word.size()); auto & token_data = id_to_token[i]; token_data.text = std::move(word); token_data.attr = LLAMA_TOKEN_ATTR_NORMAL; // ... score и attr затем берутся из массивов GGUF, если они есть в файле ... } init_tokenizer(type); // ... далее идут токен перевода строки, ID специальных токенов и флаги add_bos / add_eos ...}В коде видно: определение типа словаря по tokenizer_model (llama → SPM, gpt2 → BPE с чтением слияний), поиск массива токенов в GGUF, цикл по всем токенам — заполнение id_to_token и token_to_id, вызов init_tokenizer(type) для инициализации токенайзера (SPM, BPE и т.д.). Всё, что скрыто за строками // ..., — это проверки: массив слияний и массив токенов должны присутствовать и иметь правильный тип GGUF, а массивы оценок и типов токенов должны покрывать все токены — иначе загрузка прерывается исключением. Единственное послабление сделано для пре-токенайзера kimi-k2: он токенизирует без обычных слияний BPE, поэтому отсутствие массива слияний там не ошибка — загрузчик просто пишет об этом в лог. Далее в той же функции читаются специальные токены (BOS, EOS, UNK и т.п.) и настраиваются флаги add_bos, add_eos.
Специальные токены:
- BOS (begin of sequence) — токен начала последовательности, при необходимости добавляется перед промптом.
- EOS (end of sequence) — токен конца вывода, по нему приложение прекращает генерацию.
- UNK (unknown) — токен для неизвестных или не входящих в словарь символов.
Их ID хранятся в special_bos_id, special_eos_id, special_unk_id. В GGUF для словаря могут быть ключи вроде tokenizer.ggml.bos_token_id, tokenizer.ggml.eos_token_id и т.д. — они читаются в конце llama_vocab::impl::load и записываются в соответствующие поля. Флаги add_bos и add_eos задают, добавлять ли эти токены автоматически при токенизации (зависит от модели и формата чата). После init_tokenizer(type) словарь готов к токенизации и обратному переводу токенов в текст; эти операции используются при каждом запросе пользователя.
Шаг 6: Загрузка весов в память или на GPU
Шаг 6 — веса модели (тензоры) загружаются из файла в память или на GPU. Метод llama_model_base::load_tensors (src/llama-model.cpp) создаёт тензоры модели, назначает им буферы — участки памяти на CPU или видеокарте — и заполняет их данными из GGUF-файла. Ниже — начало метода, сокращённо.
// src/llama-model.cpp — llama_model_base::load_tensors()bool llama_model_base::load_tensors(llama_model_loader & ml) { const auto & split_mode = params.split_mode; const bool use_mlock = params.load_mode == LLAMA_LOAD_MODE_MLOCK || params.load_mode == LLAMA_LOAD_MODE_MMAP_MLOCK; const auto & tensor_split = params.tensor_split; const int n_layer_all = hparams.n_layer_all; const int n_gpu_layers = this->n_gpu_layers(); // отрицательное значение в параметрах означает «все слои» // ... при LLAMA_LOAD_MODE_AUTO ml.use_mmap отключается, если какое-то устройство не умеет mmap // строим списки типов буферов для CPU и для устройств GPU pimpl->cpu_buft_list = make_cpu_buft_list(devices, params.use_extra_bufts, params.no_host); for (const auto & dev : devices) { buft_list_t buft_list = make_gpu_buft_list(dev.dev, split_mode, tensor_split); // добавляем типы буферов CPU как запасной вариант buft_list.insert(buft_list.end(), pimpl->cpu_buft_list.begin(), pimpl->cpu_buft_list.end()); pimpl->gpu_buft_list.emplace(dev.dev, std::move(buft_list)); }
ggml_backend_dev_t cpu_dev = ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_CPU); // ... splits[] заполняется из tensor_split или по свободной памяти каждого устройства и нормируется
const int i_gpu_start = std::max(n_layer_all + 1 - n_gpu_layers, 0); const int act_gpu_layers = devices.empty() ? 0 : std::min(n_gpu_layers, n_layer_all + 1); auto get_layer_buft_list = [&](int il) -> llama_model::impl::layer_dev { // ... строка LLAMA_LOG_DEBUG по каждому слою опущена if (il < i_gpu_start || (il - i_gpu_start) >= act_gpu_layers) { return {cpu_dev, &pimpl->cpu_buft_list}; } const int layer_gpu = std::upper_bound(splits.begin(), splits.begin() + n_devices(), float(il - i_gpu_start)/act_gpu_layers) - splits.begin(); auto * dev = devices.at(layer_gpu).dev; return {dev, &pimpl->gpu_buft_list.at(dev)}; };
// от выгрузки входного слоя пользы почти нет, поэтому он всегда остаётся на CPU pimpl->dev_input = { cpu_dev, &pimpl->cpu_buft_list };
// раскладываем повторяющиеся слои по устройствам согласно splits pimpl->dev_layer.resize(n_layer_all); for (int il = 0; il < n_layer_all; ++il) { pimpl->dev_layer[il] = get_layer_buft_list(il); } // назначаем выходной слой pimpl->dev_output = get_layer_buft_list(n_layer_all);Что происходит в начале load_tensors и для чего:
-
формируются списки типов буферов для CPU и для каждой видеокарты
Для чего: от них зависит, на какие устройства будут размещены тензоры модели — входные обычно на CPU, слои по разбиению на CPU/GPU;
-
по настройкам (
tensor_split) или по свободной памяти решается, какие слои считать на CPU, какие на GPUДля чего: чтобы равномерно загрузить устройства и не переполнить память одной видеокарты.
Обратите внимание: mmap и mlock больше не отдельные булевы параметры — в llama_model_params есть одно поле load_mode (auto, none, mmap, mlock, mmap+mlock, dio), а из командной строки оно задаётся ключом -lm / --load-mode; прежние ключи --mmap, --no-mmap и --mlock отображаются на него.
Функция get_layer_buft_list(il) для номера слоя возвращает устройство и список буферов: вход всегда на CPU, повторяющиеся слои могут быть на CPU или GPU, выход — по разбиению. Далее в цикле создаются тензоры: эмбеддинги, выход, для каждого слоя — матрицы внимания (Q/K/V/O), нормализации, feed-forward. Создание тензора на этом этапе только фиксирует его форму и выбирает буфер, в котором он будет жить, — байты пока не двигаются. Данные приходят вторым проходом, когда все тензоры уже объявлены, а буферы бэкендов выделены: load_tensors вызывает ml.load_all_data(...) по одному разу на каждый буферный контекст, и уже внутри этого вызова выбирается mmap или обычное чтение. В итоге веса модели оказываются в памяти (и при необходимости на видеокарте), модель готова к генерации текста.
Сам загрузчик (src/llama-model-loader.cpp) раскладывает это на две половины. create_tensor объявляет один тензор: сверяет форму с метаданными GGUF, выбирает тип буфера по разбиению устройств, посчитанному выше, и создаёт тензор в соответствующем ggml-контексте. load_all_data затем проходит по всем тензорам контекста, по имени находит их запись в weights_map — оттуда берутся индекс файла и смещение в байтах — и либо направляет тензор на отображение файла в память, либо читает байты в его буфер. Для больших моделей используется mmap, чтобы не дублировать данные в оперативной памяти.
Проход объявления тензоров, для семейства llama — метод load_arch_tensors в файле src/models/llama.cpp:
// src/models/llama.cpp — llama_model_llama::load_arch_tensors(), вызывается из load_tensors()void llama_model_llama::load_arch_tensors(llama_model_loader &) { LLAMA_LOAD_LOCALS; // вводит в область видимости n_embd, n_layer, n_ff, n_vocab и остальные
tok_embd = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, 0);
// выход output_norm = create_tensor(tn(LLM_TENSOR_OUTPUT_NORM, "weight"), {n_embd}, 0); output = create_tensor(tn(LLM_TENSOR_OUTPUT, "weight"), {n_embd, n_vocab}, TENSOR_NOT_REQUIRED);
// если output пустой, инициализируем его из входных эмбеддингов токенов if (output == NULL) { output = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, TENSOR_DUPLICATED); }
for (int i = 0; i < n_layer; ++i) { auto & layer = layers[i];
layer.attn_norm = create_tensor(tn(LLM_TENSOR_ATTN_NORM, "weight", i), {n_embd}, 0);
create_tensor_qkv(layer, i, n_embd, n_embd_head_k * n_head, n_embd_k_gqa, n_embd_v_gqa, 0); layer.wo = create_tensor(tn(LLM_TENSOR_ATTN_OUT, "weight", i), {n_embd_head_k * n_head, n_embd}, 0);
// ... необязательные тензоры смещений и тензоры коэффициентов RoPE опущены
layer.ffn_norm = create_tensor(tn(LLM_TENSOR_FFN_NORM, "weight", i), {n_embd}, 0);
if (n_expert == 0) { layer.ffn_gate = create_tensor(tn(LLM_TENSOR_FFN_GATE, "weight", i), {n_embd, n_ff}, 0); layer.ffn_down = create_tensor(tn(LLM_TENSOR_FFN_DOWN, "weight", i), { n_ff, n_embd}, 0); layer.ffn_up = create_tensor(tn(LLM_TENSOR_FFN_UP, "weight", i), {n_embd, n_ff}, 0); // ... необязательные тензоры смещений MLP опущены } // ... ветка mixture-of-experts опущена }}Что здесь происходит и для чего:
-
каждый вес модели именуется через
tn(...), который строит имя тензора в GGUF —token_embd.weight,blk.0.attn_norm.weight,blk.0.ffn_down.weightи так далее.Для чего: имя — это ключ, по которому загрузчик потом найдёт смещение этого тензора в файле.
-
create_tensorсверяет объявленную форму с метаданными, выбирает тип буфера по посчитанному ранее разбиению слоёв и создаёт тензор в ggml-контексте этого типа буфера.Для чего: именно здесь решается, что слой живёт на CPU или на конкретной видеокарте, — ещё до того, как тронуты данные.
-
тензоры с флагом
TENSOR_NOT_REQUIREDмогут законно отсутствовать, аTENSOR_DUPLICATEDпереиспользует уже созданный тензор — так модель без отдельной выходной матрицы связывает свой выход с эмбеддингами токенов.Для чего: одна процедура загрузки должна покрывать много вариантов одной и той же архитектуры.
Сам проход с данными — это цикл по тензорам одного буферного контекста внутри ml.load_all_data (src/llama-model-loader.cpp). Для каждого тензора его запись ищется в weights_map по имени в GGUF — blk.0.attn_q.weight, blk.0.attn_k.weight и так далее, — и оттуда берутся индекс файла и смещение в байтах. Дальше возможны два варианта. При отображении файла в память тензор направляется прямо на отображённые страницы, если над ними удаётся построить буфер, — это и есть случай без копирования, и именно так получается для памяти хоста; иначе байты копируются из отображения в буфер тензора. Без отображения байты читаются из файла в этот буфер, а для буфера на видеокарте — отправляются на устройство. После этого прохода веса модели полностью находятся в памяти (и при необходимости на GPU).
// src/llama-model-loader.cpp — цикл по тензорам в llama_model_loader::load_all_data()for (struct ggml_tensor * cur = ggml_get_first_tensor(ctx); cur != NULL; cur = ggml_get_next_tensor(ctx, cur)) { const auto * weight = get_weight(ggml_get_name(cur)); if (weight == nullptr) { // такое бывает у моделей с разделёнными экспертами continue; } // ... здесь вызывается progress_callback; возврат false прерывает загрузку size_t n_size = ggml_nbytes(cur); const bool from_mapping = use_mmap || lazy.has(cur);
if (from_mapping) { const auto & mapping = mappings.at(weight->idx); ggml_backend_buffer_t buf_mmap = nullptr; if (bufs.count(weight->idx)) { buf_mmap = bufs.at(weight->idx); } uint8_t * data = (uint8_t *) mapping->addr() + weight->offs; // ... необязательная проверка check_tensors опущена GGML_ASSERT(buf_mmap || cur->data); // либо есть буфер под тензор, либо он уже размещён if (buf_mmap && cur->data == nullptr) { // копирования нет вообще: тензор направляется прямо на отображённые страницы ggml_backend_tensor_alloc(buf_mmap, cur, data); // ... расширение mlock и учёт использованного диапазона опущены } else { ggml_backend_tensor_set(cur, data, 0, n_size); } } else { const auto & file = files.at(weight->idx); if (ggml_backend_buffer_is_host(cur->buffer)) { file->seek(weight->offs, SEEK_SET); file->read_raw(cur->data, n_size); } else { // ... в буфер на GPU: асинхронная отправка кусками через закреплённую память хоста, // ... если устройство это умеет, иначе чтение в read_buf + ggml_backend_tensor_set() } } size_done += n_size;}Так два прохода вместе покрывают всё: create_tensor решает, где будет жить каждый вес — эмбеддинги, матрицы Q/K/V/O, нормализации и feed-forward каждого слоя, — а load_all_data кладёт туда байты или подставляет отображение файла.
При использовании mmap данные тензоров не копируются в оперативную память — отображается участок файла, и тензор указывает прямо в него, — но только там, где устройство умеет построить буфер поверх памяти хоста. Процессор умеет всегда; на единой памяти вроде Apple Silicon умеет и графическое устройство. Дискретная карта не умеет: её байты копируются из отображения в видеопамять, и там mmap экономит RAM только на слоях, оставшихся на процессоре. Это уменьшает потребление RAM и ускоряет старт загрузки, но файл должен оставаться на диске на прежнем месте и без изменений всё время, пока модель загружена: отображение живёт дольше загрузчика и освобождается только при уничтожении модели. Режим прямого ввода-вывода (--load-mode dio) открывает файл без буферизации и округляет каждое чтение до размера блока, о котором сообщает файловая система; он реализован только на Linux, а если открыть файл без буферизации не удалось — происходит откат на обычное буферизованное чтение. Разбиение по слоям (tensor_split или по свободной памяти GPU) задаёт, какие слои загружать на какое устройство — так можно распределить большую модель по нескольким видеокартам. После завершения load_tensors модель полностью готова к инференсу: все веса находятся в памяти (и при необходимости на GPU), и можно создавать контекст и вызывать llama_decode.
Шаг 7: Создание контекста инференса
Шаг 7 — создание контекста инференса (KV-cache, планировщик, зарезервированные графы). Загруженная модель хранит только веса (тензоры). Чтобы генерировать текст, нужен контекст инференса — объект llama_context.
Для чего он нужен:
- контекст — это «рабочая среда» одного сеанса генерации: в нём задаётся, сколько токенов модель может «помнить» (размер контекста), как большими порциями подавать данные (размер батча), параметры RoPE и тип внимания;
- без контекста нельзя вызвать
llama_decode— именно контекст хранит KV-cache, планировщик и зарезервированные графы.
Реализация — в файле src/llama-context.cpp.
Что создаётся при создании контекста и для чего:
-
Планировщик (
sched) — решает, на каком устройстве (CPU или видеокарта) выполнять каждый узел вычислительного графа.Для чего: чтобы распределить вычисления по CPU и GPU и выделить под граф буферы на нужных устройствах.
-
Память KV-cache (
memory) — буферы под ключи и значения механизма внимания.Для чего: в них хранятся уже посчитанные ключи и значения по всем предыдущим позициям; без кэша при каждом новом токене пришлось бы заново считать K и V для всей истории, что очень медленно (подробнее в разделе «KV-cache»).
-
Аллокатор батча (
balloc) — заполняет позиции и флаги логитов в батче при необходимости.Для чего: чтобы вызывающему коду не нужно было вручную выставлять позиции и решать, для каких позиций считать логиты.
-
Зарезервированные графы (Prefill и Decode) — временные графы и буферы под них.
Для чего: при первом вызове decode память под граф не выделяется «на лету» — буферы уже зарезервированы под худший случай, и это уменьшает задержку первого ответа (сам граф при первом decode всё равно строится заново).
Начало конструктора llama_context::llama_context в src/llama-context.cpp (сокращённо — пропуски отмечены):
// src/llama-context.cpp — llama_context::llama_context (сокращённо)llama_context::llama_context( const llama_model & model, llama_context_params params) : model(model), cvec(std::make_unique<llama_adapter_cvec>()), loras(std::make_unique<llama_adapter_loras>()), balloc(std::make_unique<llama_batch_allocr>(model.hparams.n_pos_per_embd())) { LLAMA_LOG_INFO("%s: constructing llama_context\n", __func__);
t_start_us = model.t_start_us; t_load_us = model.t_load_us;
const auto & hparams = model.hparams;
cparams.n_seq_max = std::max(1u, params.n_seq_max); if (cparams.n_seq_max > LLAMA_MAX_SEQ) { throw std::runtime_error("n_seq_max must be <= " + std::to_string(LLAMA_MAX_SEQ)); }
// ... n_rs_seq и его сброс в 0 для архитектур без отката recurrent-состояния cparams.n_threads = params.n_threads; cparams.n_threads_batch = params.n_threads_batch; cparams.yarn_ext_factor = params.yarn_ext_factor >= 0.0f ? params.yarn_ext_factor : hparams.yarn_ext_factor; // ... остальные параметры YaRN, пулинга и Flash Attention cparams.n_ctx = params.n_ctx == 0 ? hparams.n_ctx_train : params.n_ctx; cparams.rope_freq_base = params.rope_freq_base == 0.0f ? hparams.rope_freq_base_train : params.rope_freq_base; cparams.rope_freq_scale = params.rope_freq_scale == 0.0f ? hparams.rope_freq_scale_train : params.rope_freq_scale; // ... if (params.attention_type == LLAMA_ATTENTION_TYPE_UNSPECIFIED) { cparams.causal_attn = hparams.causal_attn; } else { cparams.causal_attn = params.attention_type == LLAMA_ATTENTION_TYPE_CAUSAL; }
// при causal attention размер батча ограничен размером контекста cparams.n_batch = cparams.causal_attn ? std::min(cparams.n_ctx, params.n_batch) : params.n_batch; cparams.n_ubatch = std::min(cparams.n_batch, params.n_ubatch == 0 ? params.n_batch : params.n_ubatch); // ... n_ctx дополняется до кратного 256 и, если KV-cache не общий для всех // последовательностей, делится между ними на лимит n_ctx_seq LLAMA_LOG_INFO("%s: n_ctx = %u\n", __func__, cparams.n_ctx); LLAMA_LOG_INFO("%s: n_ctx_seq = %u\n", __func__, cparams.n_ctx_seq); LLAMA_LOG_INFO("%s: n_batch = %u\n", __func__, cparams.n_batch); LLAMA_LOG_INFO("%s: n_ubatch = %u\n", __func__, cparams.n_ubatch); // ... создаются бэкенды (GPU, CPU) и буфер выходов, затем модуль памяти // (KV-cache), и последним sched_reserve() строит планировщик и графы}Что задаётся в конструкторе контекста:
-
сохраняются ссылка на модель и время загрузки;
-
задаются максимальное число последовательностей (
n_seq_max), число потоков, параметры YaRN/RoPE, размер контекста (n_ctx), размер батча (n_batch,n_ubatch), тип внимания (causal).Для чего:
-
от этих параметров зависят размер KV-cache, максимальная длина промпта и то, как большими порциями обрабатывается батч.
Запрошенный n_ctx округляется вверх до кратного 256, и, если KV-cache не общий для всех последовательностей, делится между ними: реальным ограничением одного диалога оказывается лимит на одну последовательность n_ctx_seq — именно его лог печатает рядом с n_ctx.
Далее инициализируются бэкенды (CPU и видеокарты), создаётся объект памяти memory (KV-cache и служебные буферы), планировщик sched (распределяет узлы графа по устройствам), резервируются графы для Prefill и Decode — чтобы к первому decode буферы под них были уже выделены. После этого можно вызывать llama_decode с батчами токенов.
Параметры n_batch и n_ubatch:
n_batch— максимальное число токенов в одном батче (при Prefill в батче может быть доn_batchтокенов промпта);n_ubatch— максимальный размер подбатча, который обрабатывается за один вызовprocess_ubatch;- если промпт длиннее
n_ubatch, он разбивается на подбатчи поn_ubatchтокенов, каждый прогоняется через модель по очереди; - логиты нужны только для последней позиции в батче, поэтому копируются только они;
- уменьшение
n_ubatchснижает пиковое потребление памяти за счёт большего числа проходов.
Контекст создаётся один раз на сеанс (или при смене параметров); один контекст может использоваться для множества запросов подряд без пересоздания.
Память и планировщик настраиваются из конструктора llama_context (файл src/llama-context.cpp), но не прямо в нём. Сначала конструктор собирает собственный список бэкендов — по одному на устройство модели, плюс бэкенды ускорителей и CPU, — затем создаёт объект памяти через model.create_memory и последним вызывает вспомогательную функцию sched_reserve. Именно в ней ggml_backend_sched_new строит планировщик по этому списку бэкендов и резервируются графы, так что при первом реальном decode граф строится заново, но размещается в уже зарезервированной памяти. Сам KV-cache живёт не в src/llama-memory.cpp — в этом файле лежат только две небольшие вспомогательные функции для статуса памяти. Буферы выделяются в конструкторе llama_kv_cache в файле src/llama-kv-cache.cpp: для каждого слоя он создаёт тензор ключей и тензор значений размером на весь контекст, а затем запрашивает у бэкенда по одному буферу на устройство.
Функция graph_reserve (в src/llama-context.cpp) резервирует по одному графу за вызов: она делает фиктивный батч нужной формы, вызывает для него model.build_graph и передаёт результат в ggml_backend_sched_reserve — планировщик разбивает граф по бэкендам и резервирует буферы. sched_reserve вызывает её трижды: сначала для Prefill (целый подбатч токенов за раз), затем для Decode (по одному токену на последовательность), затем снова для Prefill — чтобы буферы остались размером под худший случай и не перевыделялись во время инференса. Позже, при настоящем вызове process_ubatch, предыдущий граф переиспользуется, если это позволяют его параметры (can_reuse), иначе строится заново и размещается через ggml_backend_sched_alloc_graph; в любом случае выделение под большие графы уже сделано при создании контекста — именно это и убирает всплеск задержки на первом decode.
Шаг 8: Приходит текст промпта от пользователя
Шаг 8 — к движку приходит текст промпта от пользователя. После создания контекста пользователь отправляет сообщение (промпт). Модель работает только с числами — токенами. Поэтому текст сначала превращают в токены (шаг 9), упаковывают в батч (шаг 10), прогоняют через модель вызовом llama_decode (шаги 11–14): при первом запросе в батче все токены промпта (Prefill заполняет KV-cache), при генерации — один новый токен (Decode). Из логитов сэмплер выбирает один следующий токен (шаг 15), он переводится в текст и выводится (шаг 16); токен снова добавляется в батч, и цикл повторяется до токена «конец вывода» (EOS) или лимита. Ниже каждый из этих шагов разобран по коду: что вызывается, что происходит и что может быть неочевидно.
Prefill и Decode — в чём разница и для чего:
-
При первом вызове
llama_decodeс полным промптом (Prefill) модель обрабатывает все токены промпта за один или несколько подбатчей; для них считаются K и V и записываются в KV-cache; логиты запрашиваются только для последней позиции — по ним выбирается первый токен ответа.Для чего: один раз «прогнать» весь промпт и заполнить кэш ключей и значений, чтобы дальше генерировать по одному токену, не пересчитывая историю.
-
При следующих вызовах в батче один новый токен (Decode); проходятся по-прежнему все слои модели, но только для этой одной позиции: Q, K и V считаются лишь для неё, а K и V всей предыдущей истории читаются из кэша; логиты снова только для последней позиции.
Для чего: эффективная генерация по одному токену без пересчёта всей истории — старые K и V уже в кэше.
Весь цикл умещается в пару десятков строк. Ниже — цикл генерации из минимального примера examples/simple-chat/simple-chat.cpp (функция generate), сокращённо, с добавленными в комментарии номерами шагов этой статьи.
// Цикл генерации, сокращённо из examples/simple-chat/simple-chat.cpp (лямбда "generate").// Номера шагов — из этой статьи, остальное — код самого примера.// BOS добавляется только для самого первого промпта в диалогеconst bool is_first = llama_memory_seq_pos_max(llama_get_memory(ctx), 0) == -1;// Шаг 9: текст промпта -> массив токенов. При вызове с tokens == NULL// llama_tokenize возвращает минус число токенов — так и узнают размер буфера.const int n_prompt_tokens = -llama_tokenize(vocab, prompt.c_str(), prompt.size(), NULL, 0, is_first, true);std::vector<llama_token> prompt_tokens(n_prompt_tokens);if (llama_tokenize(vocab, prompt.c_str(), prompt.size(), prompt_tokens.data(), prompt_tokens.size(), is_first, true) < 0) { GGML_ABORT("failed to tokenize the prompt\n");}// Шаг 10: токены упаковываются в батч — одна последовательность, позиции ведёт llama_decodellama_batch batch = llama_batch_get_one(prompt_tokens.data(), prompt_tokens.size());llama_token new_token_id;while (true) { // ... здесь пример ещё проверяет, что батч влезает в контекст // Шаги 11–14: decode, подбатчи, process_ubatch; логиты попадают в буфер контекста int ret = llama_decode(ctx, batch); if (ret != 0) { GGML_ABORT("failed to decode, ret = %d\n", ret); } // Шаг 15: сэмплинг; -1 означает «логиты последней позиции» new_token_id = llama_sampler_sample(smpl, ctx, -1); // это конец генерации? if (llama_vocab_is_eog(vocab, new_token_id)) { break; } // Шаг 16: токен -> текст; отрицательное n значит, что буфер оказался мал char buf[256]; int n = llama_token_to_piece(vocab, new_token_id, buf, sizeof(buf), 0, true); if (n < 0) { GGML_ABORT("failed to convert token to piece\n"); } std::string piece(buf, n); printf("%s", piece.c_str()); fflush(stdout); // следующий батч — один засэмплированный токен, дальше проход Decode batch = llama_batch_get_one(&new_token_id, 1);}Шаг 9: Токенизация — от текста к последовательности токенов
Шаг 9 — текст промпта превращается в последовательность токенов (целых чисел). Текст нужно превратить в последовательность целых чисел — токенов.
Для чего это нужно:
- модель (слои трансформера) принимает на вход не строки, а векторы чисел фиксированной длины;
- каждому токену соответствует одна строка в эмбеддинг-таблице модели.
Токенизация — это первый шаг:
- по словарю текст режется на кусочки (токены), каждому кусочку ставится в соответствие число (ID);
- дальше по этим ID берутся эмбеддинги и подаются в модель.
Что такое токен:
- токен — это ID (целое число), который соответствует кусочку текста: целому слову, части слова или служебному символу;
- словарь модели задаёт соответствие «текст ↔ токены»: по тексту можно получить массив токенов (токенизация), по токену — текст (
token_to_piece).
В API (заголовок include/llama.h) объявлена функция llama_tokenize; реализация делегирует вызов словарю модели.
Реализация в src/llama-vocab.cpp:
// Шаг 9: текст промпта превращается в массив токенов (ID); словарь модели задаёт соответствие «текст ↔ токены»int32_t llama_tokenize( const struct llama_vocab * vocab, const char * text, int32_t text_len, llama_token * tokens, int32_t n_tokens_max, bool add_special, bool parse_special) { return vocab->tokenize(text, text_len, tokens, n_tokens_max, add_special, parse_special);}Параметры:
vocab— словарь модели;- text и
text_len— строка промпта; - tokens — массив для записи токенов;
n_tokens_max— его размер;add_special— добавлять ли служебные токены BOS и EOS, и только если сама модель на них настроена;parse_special— обрабатывать ли специальные теги.
Реальная логика (BPE, SentencePiece и т.д.) в методе vocab->tokenize в src/llama-vocab.cpp. Результат — число записанных токенов; если буфер оказался мал, функция возвращает минус нужное число токенов — именно так приложение сначала вызывает её с пустым буфером, чтобы узнать размер, и только потом выделяет массив. Токенизация выполняется при каждом новом сообщении пользователя; словарь при этом не меняется и уже загружен при загрузке модели.
Реализация токенизации — метод llama_vocab::impl::tokenize в src/llama-vocab.cpp. Внутри по типу словаря (SPM, BPE, WPM и т.д.) создаётся сессия токенайзера и вызывается её tokenize; для BPE, например, используется llm_tokenizer_bpe_session, который разбивает текст по регулярным выражениям и собирает токены по слияниям (merges).
Типы токенайзеров в словаре:
LLAMA_VOCAB_TYPE_SPM— SentencePiece-подобный (модели LLaMA и др.), разбиение по правилам и словарю SPM;LLAMA_VOCAB_TYPE_BPE— Byte Pair Encoding (GPT-2 и др.), слияния пар байт/подстрок хранятся в GGUF, токенизация — жадное слияние;LLAMA_VOCAB_TYPE_WPM— WordPiece, токенайзер в стиле BERT;LLAMA_VOCAB_TYPE_UGM— Unigram, токенайзер в стиле T5;LLAMA_VOCAB_TYPE_RWKV— жадная токенизация по префиксному дереву (trie);LLAMA_VOCAB_TYPE_PLAMO2— алгоритм Ахо — Корасик с динамическим программированием;LLAMA_VOCAB_TYPE_NONE— модель опубликована вообще без словаря; токенизировать нечем, и попытка токенизации просто аварийно завершается. Функцияtokenizer_st_partitionразбивает входной текст на фрагменты: обычный текст и специальные токены — специальный токен становится одним токеном на выходе, остальное идёт в токенайзер по типу словаря. Флагparse_specialуправляет только управляющими и служебными токенами; токены, объявленные в модели как пользовательские, выделяются всегда. Фрагментllama_vocab::impl::tokenize— общая часть и ветка BPE; остальные ветки устроены так же:
// Шаг 9: текст разбивается на фрагменты (обычный текст и специальные токены),// и каждый фрагмент токенизируется по типу словаряstd::vector<llama_token> llama_vocab::impl::tokenize( const std::string & raw_text, bool add_special, bool parse_special) const { GGML_ASSERT(tokenizer && "Tokenizer not initialized. Call llama_vocab::init_tokenizer() first."); std::vector<llama_token> output; std::forward_list<fragment_buffer_variant> fragment_buffer; if (!raw_text.empty()) { fragment_buffer.emplace_front(raw_text, 0, raw_text.length()); tokenizer_st_partition(fragment_buffer, parse_special); } switch (get_type()) { // ... ветки SPM, WPM, UGM, RWKV и PLAMO2 устроены так же case LLAMA_VOCAB_TYPE_BPE: { const llm_tokenizer_bpe * tok_bpe = static_cast<const llm_tokenizer_bpe *>(tokenizer.get()); std::unique_ptr<llm_tokenizer_bpe_session> session; // ... для моделей токенайзера "hybriddna" и "whitespace" берётся свой подкласс сессии session = std::make_unique<llm_tokenizer_bpe_session>(vocab, *tok_bpe); if (add_special) { session->append_bos(output); } for (const auto & fragment : fragment_buffer) { if (fragment.type == FRAGMENT_BUFFER_VARIANT_TYPE_RAW_TEXT) { std::string text = fragment.raw_text.substr(fragment.offset, fragment.length); // ... экранирование пробелов, если словарь этого требует session->tokenize(text, output); } else { // FRAGMENT_BUFFER_VARIANT_TYPE_TOKEN session->append(fragment.token, output); } } if (add_special) { session->append_eos(output); session->check_double_bos_eos(output); } } break; case LLAMA_VOCAB_TYPE_NONE: GGML_ABORT("fatal error"); } return output;}Шаг 10: Формирование батча для одного вызова
Шаг 10 — токены упаковываются в батч для одного вызова к модели. Один вызов к модели передаётся через структуру llama_batch (в include/llama.h).
Для чего нужен батч:
- функция
llama_decodeпринимает ровно один батч — набор токенов (и служебных полей: позиции, идентификаторы последовательностей, флаги логитов); - так движок знает, какие токены обработать за один проход, на каких позициях они стоят и для каких позиций нужно вернуть логиты (обычно только для последней — чтобы выбрать следующий токен);
- при первом запросе в батче обычно все токены промпта; при генерации по одному токену — один новый токен.
// Шаг 10: батч — «пакет» токенов для одного вызова llama_decode; token[], pos[], seq_id[], logits[] заполняются приложением или balloc->inittypedef struct llama_batch { int32_t n_tokens; llama_token * token; float * embd; llama_pos * pos; int32_t * n_seq_id; llama_seq_id ** seq_id; int8_t * logits;} llama_batch;Структура батча:
- массивы имеют размер
n_tokens; - либо передаются ID токенов (token), либо готовые эмбеддинги (embd) — векторы чисел, в которые уже превращены токены (обычно передают токены, а эмбеддинги считаются внутри);
pos— позиция каждого токена;seq_idиn_seq_id— к какой последовательности относится токен;- logits[i] != 0 означает, что для позиции i нужно вернуть логиты (обычно только для последней — чтобы выбрать следующий токен). Внутри
llama_decodeбатч сначала обрабатывается классомllama_batch_allocr(файлsrc/llama-batch.cpp): методinitпроверяет батч и при отсутствии полей заполняет их автоматически (позиции из памяти, логиты только для последнего токена).
Поля seq_id и n_seq_id используются при батчинге нескольких последовательностей (например, несколько запросов в одном батче): каждый токен может относиться к одной или нескольким последовательностям; позиции (pos) считаются отдельно для каждой последовательности по memory->seq_pos_max(seq_id). В типичном случае одна последовательность — все токены имеют один и тот же seq_id (например, 0), и позиции идут по порядку 0, 1, 2, ... . Заполнить батч для следующего шага приложение может двумя способами. Минимальный — llama_batch_get_one: он оборачивает один только что выбранный токен и оставляет равными NULL и pos, и seq_id, чтобы llama_decode сам продолжил позицию и отнёс токен к последовательности 0 — так делают examples/simple и examples/simple-chat. Второй — один раз выделить батч через llama_batch_init, а перед каждым вызовом очищать его через common_batch_clear и добавлять токены через common_batch_add, явно передавая позицию и идентификаторы последовательностей — именно так приходится делать любому коду, который обслуживает несколько последовательностей сразу.
Метод init класса llama_batch_allocr (файл src/llama-batch.cpp), который llama_context::decode вызывает для входящего батча раньше всего остального:
// src/llama-batch.cpp, llama_batch_allocr::init(): проверяет входной батч, затем заполняет// все массивы, которые приложение оставило NULL — прежде всего позиции и флаги выводаbool llama_batch_allocr::init( const llama_batch & batch_inp, const llama_vocab & vocab, const llama_memory_i * memory, uint32_t n_embd, uint32_t n_seq_max, bool output_all) { clear(); batch = batch_inp; this->vocab = &vocab; GGML_ASSERT(batch.n_tokens > 0); // ... опущено: n_seq_max должен помещаться в LLAMA_MAX_SEQ, каждый seq_id должен быть меньше n_seq_max, // и каждый batch.token[i] должен быть допустимым id (< vocab.n_tokens()) — иначе return false ... // ... опущено: если n_seq_id или seq_id равны NULL, каждый токен относится к последовательности 0 ... if (!batch.pos) { pos.resize(batch.n_tokens);
// начальная позиция каждой последовательности берётся из позиций в памяти llama_pos p0[LLAMA_MAX_SEQ]; for (uint32_t s = 0; s < n_seq_max; ++s) { if (!memory) { // если памяти нет -> начинаем с 0 p0[s] = 0; } else { p0[s] = memory->seq_pos_max(s) + 1; } }
for (int32_t i = 0; i < batch.n_tokens; i++) { const llama_seq_id seq_id = batch.seq_id[i][0]; pos[i] = p0[seq_id]; // ... опущено: p0 сдвигается за эту позицию для каждой последовательности токена ... } batch.pos = pos.data(); } if (!batch.logits) { if (output_all) { output.resize(batch.n_tokens, true); // вывод для каждого токена } else { output.resize(batch.n_tokens, false); // вывод только для последнего токена output[output.size() - 1] = true; } batch.logits = output.data(); } // ... опущено: если logits заданы, но выставлен output_all, недостающие выводы включаются принудительно; // затем статистика позиций по последовательностям, связанные последовательности и проверки согласованности ... return true;}В коде:
- проверка токенов на допустимость;
- при отсутствии
n_seq_idиseq_idкаждый токен относится ровно к одной последовательности с номером 0; - при отсутствии
posкаждая последовательность продолжается с того места, где она остановилась в памяти (memory->seq_pos_max(s) + 1), и позиции проставляются по порядку; если памяти нет вовсе, отсчёт начинается с 0; - при отсутствии
logitsлогиты запрашиваются только для последнего токена (или для всех, еслиoutput_all).
От токенов к эмбеддингам (происходит внутри шага 13)
Внутри шага 13 токены батча превращаются в эмбеддинги. В батче можно передать либо ID токенов (token), либо готовые эмбеддинги (embd), и в типичном случае приложение передаёт токены. Превращение — это не отдельный проход перед обработкой батча: это самый первый узел графа вычислений, который decode строит для каждого ubatch.
Для чего нужно это превращение:
- модель (слои трансформера) работает не с номерами токенов, а с векторами чисел фиксированной длины — эмбеддингами;
- выборка берёт каждый ID токена и читает соответствующую строку эмбеддинг-таблицы модели, и эта строка становится входом первого слоя;
- без него графу было бы не над чем считать.
Что такое эмбеддинг-таблица:
- это матрица весов модели размером «размер словаря × размер эмбеддинга»; одна строка — вектор чисел для одного токена;
- в коде на C++ это поле модели
tok_embd, а в файле GGUF — тензорtoken_embd.weight; именаembed_tokensиtok_embeddingsпришли из исходных чекпоинтов PyTorch — скрипт конвертации отображает их вtoken_embd, так что искать что-то под этими именами внутриllama.cppбесполезно; - она создаётся при загрузке модели, в списке тензоров конкретной архитектуры (
load_arch_tensors, вызывается изload_tensors); - при выполнении графа для каждого токена батча читается строка с индексом token[i] и попадает на позицию i входа графа;
- после этого граф считает уже по эмбеддингам (слои трансформера, внимание, feed-forward, логиты).
Где это в коде: эмбеддинг-таблица — это поле модели tok_embd, а выборку по ней строит llm_graph_context::build_inp_embd (файл src/llama-graph.cpp), который каждая архитектура вызывает первой же строкой своего графа. Он создаёт два входных тензора — вектор I32 под ID токенов и матрицу F32 под готовые эмбеддинги — и пропускает токенную ветку через ggml_get_rows по таблице; в граф попадают обе ветки, а ggml_build_forward_select помечает к вычислению только ту, что соответствует содержимому ubatch — токенам или готовым векторам. Ради этого он и нужен: топология графа остаётся одинаковой от ubatch к ubatch, и граф можно переиспользовать. Сами ID записывает во входной тензор llm_graph_input_embd::set_input, которую process_ubatch (файл src/llama-context.cpp) вызывает уже после того, как граф построен и размещён, прямо перед его выполнением. То есть строку копирует не decode на CPU: она читается на бэкенде, во время вычисления графа, вместе со всем остальным — а поскольку эмбеддинг-таблица обычно хранится квантизованной, ggml_get_rows попутно распаковывает каждую строку во float. При decode по одному токену читается ровно одна строка таблицы.
Выборка, как она строится в графе (src/llama-graph.cpp, llm_graph_context::build_inp_embd):
// src/llama-graph.cpp, llm_graph_context::build_inp_embd() — сокращённо: убраны отладочные// колбэки и проверки. Превращение токена в вектор — это не memcpy внутри decode, а узел// графа вычислений, который выполняется на бэкенде вместе со всей остальной моделью.ggml_tensor * llm_graph_context::build_inp_embd(ggml_tensor * tok_embd) const { const int64_t n_embd_inp = hparams.n_embd_inp();
auto inp = std::make_unique<llm_graph_input_embd>(n_embd_inp);
inp->tokens = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, ubatch.n_tokens); ggml_set_input(inp->tokens);
inp->embd = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, n_embd_inp, ubatch.n_tokens); ggml_set_input(inp->embd);
// выбираем один из 2 входов, исходя из содержимого батча std::array<ggml_tensor *, 2> inps;
// ветка с эмбеддингами токенов (ubatch.token != nullptr) { auto & cur = inps[0];
cur = ggml_get_rows(ctx0, tok_embd, inp->tokens);
// ... сокращено: необязательная LoRA-поправка к эмбеддинг-таблице // и паддинг, который нужен при n_embd_inp != n_embd }
// ветка с готовыми векторами (ubatch.embd != nullptr) { auto & cur = inps[1];
cur = inp->embd; }
ggml_tensor * cur = ggml_build_forward_select(gf, inps.data(), inps.size(), ubatch.token ? 0 : 1);
// ... сокращено: view при n_embd_inp != n_embd и умножение на f_embedding_scale
res->add_input(std::move(inp));
return cur;}Что происходит и для чего:
-
для каждого индекса i в ubatch ID токена ubatch.token[i] записывается во входной тензор графа
inp_tokens.Для чего: граф выполняется на бэкенде, и ID нужны ему там как собственные данные, а не как указатель в батч, который передало приложение.
-
ggml_get_rowsчитает из таблицыtok_embdстроку с этим индексом — вектор длиныn_embd, потому что сама таблица имеет ширинуn_embd. Отдельным архитектурам нужен более широкий вход графа,n_embd_inp— это модели с deepstack-слоями и gemma4-assistant, которая задаёт своё значение; там строку добивают паддингом до этой ширины, а перед первым слоем снова сужают доn_embd.Для чего: эта строка и есть эмбеддинг токена — вход для первого слоя трансформера.
-
строки для всех токенов ubatch складываются в матрицу, которую получает первый слой.
Для чего: слои обрабатывают весь ubatch разом; без этого узла у графа вообще не было бы входа.
Шаги 11 и 12: Прогон батча через модель (вход в decode, подбатчи)
Шаги 11 и 12 — приложение вызывает llama_decode, передавая ему батч. Внутри батч проверяется и подготавливается (шаг 11), затем разбивается на подбатчи (шаг 12), и каждый подбатч прогоняется через модель (шаг 13). Превращение идентификаторов токенов в векторы-эмбеддинги — не отдельный шаг на стороне хоста: это первый узел вычислительного графа, поэтому оно происходит на шаге 13 вместе со всем остальным. Ниже — точка входа, цикл по подбатчам и разбиение батча.
Для чего нужна функция llama_decode:
- она прогоняет один батч токенов через модель и заполняет внутренний буфер контекста логитами — «сырыми» оценками по каждому возможному следующему токену;
- по этим логитам приложение (через сэмплер) выбирает один следующий токен и при необходимости снова вызывает decode с одним новым токеном в батче;
- так повторяется до конца ответа (EOS) или лимита.
Публичный API — llama_decode (в src/llama-context.cpp):
// Шаг 11: точка входа decode. Публичная C-функция только передаёт батч// в C++-контекст; вся работа происходит в llama_context::decode.int32_t llama_decode( llama_context * ctx, llama_batch batch) { const int ret = ctx->decode(batch); // ret == 1 означает «нет свободного слота в KV-cache под этот батч» — это предупреждение, // а не сбой, поэтому оно не логируется как ошибка if (ret != 0 && ret != 1) { LLAMA_LOG_ERROR("%s: failed to decode, ret = %d\n", __func__, ret); }
return ret;}Вот что происходит внутри llama_context::decode (src/llama-context.cpp). Сначала батч проверяется. Если у этого контекста вообще нет модуля памяти — например, это контекст только под эмбеддинги — decode просто передаёт батч в encode и возвращает результат. Иначе он вызывает balloc->init(...), резервирует планировщик и применяет отложенные обновления памяти (KV-cache). Затем один раз вызывается memory->init_batch: этот единственный вызов разбивает весь батч на подбатчи и возвращает контекст памяти, в котором лежат сразу все они. Дальше цикл идёт по этому контексту: берёт очередной подбатч через get_ubatch(), прогоняет его через process_ubatch, копирует полученные логиты во внутренний буфер контекста и переходит к следующему подбатчу через next().
Цикл по подбатчам внутри decode (сокращённо, из src/llama-context.cpp):
// src/llama-context.cpp, llama_context::decode — сокращённо memory_update(false); // применяем отложенные сдвиги/копирования llama_memory_context_ptr mctx; // разбиваем ВЕСЬ батч на подбатчи не больше n_ubatch токенов, за один вызов // ... при FAILED_PREPARE: один раз оптимизируем кэш, повторяем, иначе возвращаем 1 mctx = memory->init_batch(*balloc, cparams.n_ubatch, output_all); if (!mctx) { return -2; } // ... резервируем выходной буфер под n_outputs_all строк логитов do { const auto & ubatch = mctx->get_ubatch(); // ... n_outputs — сколько строк логитов даёт этот подбатч ggml_status status; // шаг 13: строим (или переиспользуем) граф и выполняем его на CPU/GPU const auto * res = process_ubatch(ubatch, ctx_type_to_graph_type(cparams.ctx_type), mctx.get(), status); if (!res) { // ... откатываем позиции этого подбатча из модуля памяти switch (status) { case GGML_STATUS_ABORTED: return 2; case GGML_STATUS_ALLOC_FAILED: return -2; case GGML_STATUS_FAILED: return -3; case GGML_STATUS_SUCCESS: GGML_ABORT("should not happen"); } } auto * t_logits = res->get_logits(); // шаг 14: забираем логиты этого подбатча с бэкенда в буфер контекста — // пропускается, если у всех выходных последовательностей есть бэкенд-сэмплер if (logits.data && t_logits && n_outputs > 0 && needs_raw_logits(ubatch, sampling.samplers)) { ggml_backend_t backend_res = ggml_backend_sched_get_tensor_backend(sched.get(), t_logits); float * logits_out = logits.data + n_outputs_prev*n_vocab; // ... проверки, что эти строки помещаются в выходной буфер ggml_backend_tensor_get_async(backend_res, t_logits, logits_out, 0, n_outputs*n_vocab*sizeof(float)); } // ... эмбеддинги и выходы бэкенд-сэмплера извлекаются так же n_outputs_prev += n_outputs; } while (mctx->next()); // после цикла приложение читает логиты через llama_get_logits_ithСначала проверяется батч:
- что в нём переданы либо токены, либо готовые эмбеддинги;
- что у этого контекста действительно есть модуль памяти — если его нет, decode сразу передаёт батч в
encode.
Затем батч инициализируется через balloc->init — это нужно, чтобы заполнить позиции токенов и решить, для каких позиций считать логиты (обычно только для последней). Резервируется планировщик и обновляется память (KV-cache).
Дальше memory->init_batch разбивает батч на подбатчи размером не больше n_ubatch токенов каждый и возвращает контекст памяти, в котором лежат все они, — так большой батч разбивается на части, которые по очереди прогоняются через модель. Цикл берёт их по одному и для каждого вызывает process_ubatch(...): внутри строится вычислительный граф (эмбеддинги, слои трансформера, выход в логиты) — или переиспользуется уже построенный, если форма прохода ничем не изменилась, — выполняется вычисление на CPU/GPU и возвращаются тензоры с логитами: «сырыми», ненормированными оценками по словарю.
init_batch — метод модуля памяти, поэтому у каждого типа памяти своя реализация; для обычного KV-cache это llama_kv_cache::init_batch в src/llama-kv-cache.cpp (сам интерфейс объявлен в src/llama-memory.h):
// src/llama-kv-cache.cpp, llama_kv_cache::init_batch — сокращённо// Шаг 12: весь батч режется на подбатчи здесь, за один вызов, до запуска любого из нихllama_memory_context_ptr llama_kv_cache::init_batch( llama_batch_allocr & balloc, uint32_t n_ubatch, bool embd_all) { do { balloc.split_reset();
std::vector<llama_ubatch> ubatches; while (true) { // каждый вызов берёт очередные (не больше) n_ubatch ещё не использованных токенов батча auto ubatch = n_stream == 1 ? balloc.split_simple(n_ubatch) : balloc.split_equal(n_ubatch, true, 0); if (ubatch.n_tokens == 0) { break; // батч исчерпан } ubatches.push_back(std::move(ubatch)); }
if (balloc.get_n_used() < balloc.get_n_tokens()) { break; // часть токенов не удалось разложить ни в один подбатч }
// находим слот в KV-cache под каждый подбатч, прежде чем зафиксировать хоть один auto sinfos = prepare(ubatches); if (sinfos.empty()) { break; }
return std::make_unique<llama_kv_cache_context>( this, std::move(sinfos), std::move(ubatches)); } while (false);
// места под этот батч нет — decode превращает это в код возврата 1 return std::make_unique<llama_kv_cache_context>(LLAMA_MEMORY_STATUS_FAILED_PREPARE);}Что делает init_batch и для чего:
-
за один проход режет весь батч на порции не больше
n_ubatchтокенов каждая.Для чего: один вызов
process_ubatchобрабатывает ограниченное число токенов — так на длинном промпте не разрастается память под промежуточные тензоры графа. -
для каждой порции копирует в
ubatchтокены, позиции и флаги выходов.Для чего:
process_ubatchполучает готовый подбатч и строит граф ровно под него. -
перед возвратом резервирует слот в KV-cache под каждый подбатч и только потом фиксирует результат.
Для чего: если кэш не вмещает весь батч, decode узнаёт об этом до того, как отработал хоть один подбатч, и может сообщить вызывающему вместо того, чтобы оставить в кэше половину батча.
-
возвращённый контекст памяти хранит список подбатчей; цикл в decode идёт по нему через
get_ubatch()иnext().Для чего: длинный промпт обрабатывается за несколько проходов через модель, без единого огромного графа.
Цикл по подбатчам в llama_context::decode устроен так: контекст памяти, возвращённый memory->init_batch, выдаёт по одному подбатчу через get_ubatch(), для каждого вызывается process_ubatch(ubatch, gtype, mctx, status), а next() переводит на следующий, пока подбатчи не кончатся. Тип графа gtype не выбирается для каждого подбатча — он следует из типа контекста и одинаков для всех проходов этого контекста. Между проходом по длинному промпту и проходом с одним токеном различается кое-что попроще: батчевый проход берёт число потоков для батча, а не для генерации, и при создании контекста llama.cpp резервирует буферы вычислений по худшему случаю сразу под обе формы. После каждого process_ubatch логиты для позиций, помеченных как выходные, копируются с бэкенда во внутренний буфер контекста, откуда их читает llama_get_logits_ith, — но только если хотя бы одна из этих позиций принадлежит последовательности без своего бэкенд-сэмплера: когда сэмплеры навешаны на все, сырые логиты не копируются вовсе. Строить граф заново для каждого подбатча было бы расточительно, поэтому llama.cpp переиспользует предыдущий, если параметры, полностью определяющие его топологию, не изменились.
Порядок вызовов при одном decode (сводка):
llama_decode(ctx, batch)→ctx->decode(batch);- в decode: проверка батча (и, если у контекста нет модуля памяти, передача батча в
encodeи возврат),balloc->init(...), резервирование планировщика, применение отложенных обновлений памяти (KV-cache); memory->init_batch(...)— один вызов, который разбивает весь батч на подбатчи и резервирует слот в кэше под каждый; затем резервируется выходной буфер;- цикл:
get_ubatch()выдаёт очереднойubatch, вызываетсяprocess_ubatch(ubatch, ...)— внутриmodel.build_graph(или переиспользование предыдущего графа),res->set_inputs,graph_compute— логиты копируются в буфер контекста, аnext()переходит к следующему подбатчу; - после цикла приложение читает логиты через
llama_get_logits_ith, сэмплер возвращает следующий токен.
Логиты копируются в выходной буфер контекста. Оттуда их читает llama_get_logits_ith — по этим числам сэмплер выбирает следующий токен (например, самый вероятный или по temperature/top_p).
Возвращаемое значение llama_decode: 0 — успех; 1 — в KV-cache не нашлось свободного слота под этот батч (уменьшить батч или увеличить контекст); 2 — вычисление прервано колбэком; -1 — сам батч некорректен; всё, что меньше -1, — фатальная ошибка, чаще всего неудачное выделение буфера. Положительный код — это предупреждение, а не сбой, но 2 тоже положительное, так что проверка только на отрицательное значение пропустит прерывание незамеченным — проверять надо на любое значение, кроме 0. После прерывания или фатальной ошибки уже отработавшие подбатчи остаются в памяти контекста; насколько далеко продвинулся батч, можно узнать через llama_memory_seq_pos_min и llama_memory_seq_pos_max. Минимальные примеры, которые идут в комплекте с llama.cpp, просто считают любой ненулевой код фатальным и останавливаются. Приложение должно проверять возвращаемое значение и при ошибке прекращать генерацию или выводить сообщение об ошибке.
Шаг 13а: Подготовка к прогону подбатча (память, параметры графа)
Шаг 13 разбит в статье на три части: подготовка к прогону подбатча, построение графа и аллокация буферов, запись входов и выполнение графа. Вся работа по одному подбатчу выполняется в методе llama_context::process_ubatch (файл src/llama-context.cpp). Его вызывают из llama_context::decode в цикле. Разбиение на подбатчи происходит раньше и только один раз: memory->init_batch вызывают со всем батчем, и он возвращает объект контекста памяти, в котором уже лежат все подбатчи вместе с нужным им состоянием кэша. Дальше цикл идёт по ним — mctx->get_ubatch() отдаёт текущий, mctx->next() переходит к следующему и возвращает false, когда они закончились, — и на каждый подбатч один раз вызывает process_ubatch. Внутри по шагам происходит: подготовка памяти, решение — переиспользовать ли уже построенный граф или строить заново, при необходимости построение графа и аллокация буферов, запись входных данных в граф, выполнение графа на CPU/GPU. Ниже — первая часть шага 13: точка входа и подготовка.
Для чего нужен вычислительный граф:
- модель (трансформер) — это цепочка операций: эмбеддинги, слои внимания (Q, K, V, softmax, взвешенная сумма), нормализации, feed-forward и т.д.;
- вместо того чтобы вызывать каждую операцию вручную, движок строит граф — список узлов (операций) и связей между ними;
- планировщик потом обходит граф в топологическом порядке и выполняет операции на CPU или GPU;
- так можно автоматически распределять узлы по устройствам и переиспользовать граф при одинаковых размерах батча.
Сигнатура и входные данные process_ubatch (цитата из src/llama-context.cpp):
// src/llama-context.cpp, llama_context::process_ubatch — один подбатч прогоняется через модель;// вызывается из llama_context::decode в цикле по подбатчамllm_graph_result * llama_context::process_ubatch(const llama_ubatch & ubatch, llm_graph_type gtype, llama_memory_context_i * mctx, ggml_status & ret) { // 1) фиксируем состояние памяти, подготовленное для этого подбатча (ячейки KV-cache), до построения графа if (mctx && !mctx->apply()) { LLAMA_LOG_ERROR("%s: failed to apply memory context\n", __func__); ret = GGML_STATUS_FAILED; return nullptr; }
// 2) результат ПРЕДЫДУЩЕГО построения: в нём лежат граф и его входные тензоры auto * res = gf_res_prev.get(); auto * gf = res->get_gf();
// 3) всё, от чего зависит топология графа; граф можно переиспользовать только тогда, // когда эти параметры совпадают с теми, с которыми он был построен const auto gparams = graph_params(res, ubatch, mctx, gtype);
// ... опущено: решение о переиспользовании, build_graph, set_inputs и graph_compute — см. ниже}Что происходит в начале process_ubatch и для чего:
-
Вызывается
mctx->apply(), если передан контекст памяти (mctx).Для чего:
-
контекст памяти обновляет состояние KV-cache и служебных буферов (например, сдвигает указатели на следующие свободные позиции); перед построением графа граф будет обращаться к этим буферам — они должны быть в актуальном состоянии.
-
gf_res_prev— это результат предыдущего построения графа: сам граф, его входные тензоры и параметры, с которыми он был построен. При самом первом вызове он пуст, поэтому граф строится с нуля; дальше он становится кандидатом на переиспользование.Для чего:
-
именно хранение последнего графа и делает переиспользование возможным — если новые параметры совпадают с сохранёнными в нём, тот же граф можно выполнить снова с новыми значениями входов.
-
в
gparamsсобирается всё, от чего зависит форма графа: архитектура модели и её гиперпараметры, параметры контекста, копия текущегоubatch(сколько в нём токенов, как они сгруппированы по последовательностям, лежат в нём ID токенов или готовые эмбеддинги), тип графа, планировщик, загруженные LoRA-адаптеры и control vector, контекст памяти, сэмплеры по последовательностям и число позиций, для которых нужны выходы.Для чего:
-
правило такое: два графа с одинаковыми
gparamsимеют одинаковую топологию; именно это и делает возможной проверку на переиспользование ниже, а те же значения потом задают размеры узлов и входных тензоров при построении графа.
Если mctx->apply() возвращает false, функция сразу возвращает nullptr и в ret записывается GGML_STATUS_FAILED — вызывающий код (decode) обработает ошибку и прекратит цикл по подбатчам.
Цитата по смыслу: что делает mctx->apply() и что входит в gparams (по коду src/llama-context.cpp и src/llama-kv-cache.cpp):
// mctx->apply() — фиксирует состояние памяти, подготовленное для этого подбатча. Для KV-cache это значит// записать позицию и id последовательности каждого токена в отведённые ему ячейки и пересчитать,// сколько ячеек графу придётся читать (src/llama-memory.h, src/llama-kv-cache.cpp)if (mctx && !mctx->apply()) { ret = GGML_STATUS_FAILED; return nullptr; }
// graph_params() собирает всё, от чего зависит топология графа: архитектуру, параметры модели// и контекста, копию подбатча, тип графа, планировщик, адаптеры, контекст памяти,// сэмплеры и n_outputs — проверка на переиспользование сравнивает ровно этот наборconst auto gparams = graph_params(res, ubatch, mctx, gtype);Шаг 13б: Построение вычислительного графа и аллокация буферов
Шаг 13 (продолжение) — построение графа и выделение под него буферов. После подготовки памяти и формирования gparams движок решает: можно ли переиспользовать уже построенный граф (та же форма подбатча и те же параметры построения), или нужно строить граф заново и выделять под него буферы. Если граф переиспользуется — сразу переходят к записи входов и выполнению. Если нет — вызываются res->reset(), ggml_backend_sched_reset(sched.get()), model.build_graph(gparams) и ggml_backend_sched_alloc_graph(sched.get(), gf). Ниже по шагам, что за чем происходит и для чего; несколько цитат из кода.
Цитата: решение о переиспользовании и построение графа (фрагмент process_ubatch, src/llama-context.cpp):
// граф перестраиваем только когда изменились параметры; повторные decode с одним токеном переиспользуют его if (!graph_reuse_disable && res->can_reuse(gparams)) { // ... опущено: при pipeline parallelism здесь синхронизируется предыдущий, ещё идущий расчёт, // иначе set_inputs перезаписал бы тензоры, которые он ещё читает n_reused++; // счётчик переиспользований (профилировщик показывает его как "graphs reused") } else { res->reset(); // выбрасываем старый граф вместе с его входными тензорами
ggml_backend_sched_reset(sched.get()); // сбрасываем разбиение планировщика и его аллокации ggml_backend_sched_set_eval_callback(sched.get(), cparams.cb_eval, cparams.cb_eval_user_data);
gf = model.build_graph(gparams); // эмбеддинги → слои трансформера → логиты
if (!gf) { LLAMA_LOG_ERROR("%s: failed to initialize graph\n", __func__); ret = GGML_STATUS_FAILED; return nullptr; }
// планировщик разбивает граф по устройствам и выделяет память под каждый тензор if (!ggml_backend_sched_alloc_graph(sched.get(), gf)) { LLAMA_LOG_ERROR("%s: failed to allocate graph\n", __func__); ret = GGML_STATUS_ALLOC_FAILED; return nullptr; } }По шагам (что происходит и для чего):
-
Проверка
res->can_reuse(gparams): эквивалентен ли новый набор параметров тому, с которым был построен сохранённый граф. Сравниваются форма подбатча (сколько токенов, как они сгруппированы в последовательности, приходят они как ID токенов или как готовые эмбеддинги), число позиций, для которых запрошены выходы, архитектура и тип графа, загруженные адаптеры и несколько флагов контекста; затем у каждого входного тензора графа отдельно спрашивают, подходит ли он ещё.Для чего:
-
при генерации по одному токену каждый следующий вызов приносит подбатч ровно той же формы — один токен, одна последовательность, один выход, — поэтому вся проверка проходит, граф не перестраивается, и остаётся только записать в его входы новые значения. Именно это и делает генерацию быстрой: иначе построение графа для большой модели стоило бы миллисекунд на каждом токене.
-
Если переиспользование отключено или параметры изменились:
res->reset()— очищаются старые узлы графа и привязанные буферы.Для чего:
-
перед построением нового графа старый нужно сбросить, иначе узлы и тензоры накопятся.
-
ggml_backend_sched_reset(sched.get())— планировщик сбрасывает своё состояние (распределение узлов по бэкендам, зарезервированные буферы для этого графа).Для чего:
-
планировщик будет заново определять, на каком устройстве выполнять каждый узел нового графа и выделять под него память.
-
gf = model.build_graph(gparams)— строится граф GGML: узлы для эмбеддингов и позиций, затем для каждого слоя трансформера — внимание (Q, K, V, softmax, взвешенная сумма), нормализация, feed-forward, снова нормализация; в конце — выходной слой в логиты (размер словаря).Для чего:
-
граф описывает, какие операции выполнить и в каком порядке; без него планировщику нечего выполнять.
llama_model::build_graphвsrc/llama-model.cpp— только тонкая обёртка: она вызывает построитель для конкретной архитектуры, а потом добавляет слой пулинга, слои сэмплинга на устройстве, если они есть, и разметку выходных тензоров. Раскладывает слои именно построитель для архитектуры, и у каждой архитектуры свой файл вsrc/models/—llama.cpp,gemma3.cpp,qwen3.cppи ещё около 150. -
ggml_backend_sched_alloc_graph(sched.get(), gf)— планировщик обходит граф, для каждого тензора определяет бэкенд (CPU или GPU по разбиению слоёв), выделяет буфер на этом бэкенде (или переиспользует зарезервированный при создании контекста).Для чего:
-
без выделения буферов данные графа негде хранить; при первом decode буферы резервируются здесь (или при
graph_reserveпри создании контекста), при последующих переиспользованиях графа повторное выделение не нужно.
Структура графа по слоям (схема):
- вход: эмбеддинги токенов подбатча плюс отдельный тензор с их позициями; позиции не добавляются к эмбеддингам, их используют внутри каждого слоя;
- для каждого слоя трансформера: нормализация входа → блок внимания (Q = вход × W_q, K = вход × W_k, V = вход × W_v; применение RoPE к Q и K; attention scores = Q × K^T; маска (causal); softmax; взвешенная сумма scores × V; линейный слой O) → остаточное соединение → нормализация → feed-forward (два линейных слоя с активацией между ними) → остаточное соединение;
- после всех слоёв: остаются только строки тех позиций, для которых запрошен выход, затем финальная нормализация → выходная матрица («размер скрытого слоя × размер словаря») → логиты:
n_vocabчисел для каждой из этих позиций.
Цитата вызова build_graph и ggml_backend_sched_alloc_graph (по смыслу кода в src/llama-context.cpp и GGML):
// llama_model::build_graph (src/llama-model.cpp) возвращает ggml_cgraph — плоский список узлов// с их зависимостями; gparams уже фиксирует форму подбатча, тип графа и контекст памятиggml_cgraph * gf = model.build_graph(gparams);
// ggml_backend_sched_alloc_graph (ggml/include/ggml-backend.h) режет граф на цепочки узлов,// принадлежащих одному бэкенду, и размещает каждый тензор в вычислительном буфере этого бэкендаbool ok = ggml_backend_sched_alloc_graph(sched.get(), gf);if (!ok) { ret = GGML_STATUS_ALLOC_FAILED; return nullptr; }Резервирование буферов делается один раз, при создании контекста, в graph_reserve (src/llama-context.cpp). Там создаётся фиктивный ubatch заданного размера, для него строится граф, и планировщика просят зарезервировать под него достаточно большие вычислительные буферы. Конструктор делает это трижды — с ubatch размером с целый промпт, затем с ubatch по одному токену на последовательность, затем снова с промптовым, — чтобы буферы получились рассчитанными на худший случай и во время инференса ничего не перевыделялось. Построенный здесь граф для переиспользования не сохраняется: graph_reserve намеренно сбрасывает сохранённый предыдущий результат — именно для того, чтобы первый настоящий decode построил свой собственный граф. Функция ggml_backend_sched_alloc_graph (библиотека GGML) потом, при каждом настоящем построении, режет граф на цепочки узлов, принадлежащих одному бэкенду, и размещает каждый тензор в буфере этого бэкенда.
Шаг 13в: Запись входов в граф и выполнение на CPU/GPU
Шаг 13 (завершение) — запись входов в граф и выполнение. После того как граф построен (или переиспользован), нужно записать во входные тензоры графа данные текущего подбатча — токены или эмбеддинги и позиции — и запустить выполнение графа. Это делают вызовы res->set_inputs(&ubatch) и graph_compute(res->get_gf(), ubatch.n_tokens > 1). Ниже — цитаты и пошаговое объяснение.
Цитата: запись входов и выполнение (конец process_ubatch, src/llama-context.cpp):
res->set_inputs(&ubatch); // заполняем входные тензоры графа этим подбатчем: ID токенов, позиции, индексы записи в KV, маску внимания const auto status = graph_compute(res->get_gf(), ubatch.n_tokens > 1); // обход узлов графа, выполнение на CPU/GPU if (status != GGML_STATUS_SUCCESS) { ret = status; return nullptr; } ret = GGML_STATUS_SUCCESS; return res; // в res — тензоры с логитами; decode потом копирует их в буфер контекста для llama_get_logits_ith}Что происходит при set_inputs и для чего:
-
res->set_inputs(&ubatch)обходит входные тензоры, которые граф зарегистрировал при построении, и заполняет каждый из них из подбатча: ID токенов (или готовые эмбеддинги — в более редком случае, когда вызывающий передал эмбеддинги, а не токены; иначе lookup по таблице эмбеддингов сам является узлом графа), позиции токенов, индексы ячеек KV-cache, в которые этот подбатч должен записать свои ключи и значения, маску внимания, которая говорит, на какие сохранённые позиции каждому токену можно смотреть, и список позиций, для которых нужны выходы.Для чего:
-
граф вычисляет по конкретным данным; без записи входов он работал бы с пустыми или устаревшими тензорами; при Decode в кэш подставляются буферы, куда дописываются новые ключи и значения для текущей позиции.
Цитата graph_compute (метод контекста, который выполняет граф на планировщике, src/llama-context.cpp):
// src/llama-context.cpp, llama_context::graph_compute — отдаёт построенный граф планировщикуggml_status llama_context::graph_compute(ggml_cgraph * gf, bool batched) { // "batched" значит, что в подбатче больше одного токена: обработка промпта берёт батчевое число потоков, // генерация по одному токену — меньшее; на одном токене лишние потоки не окупаются int n_threads = batched ? cparams.n_threads_batch : cparams.n_threads; ggml_threadpool_t tp = batched ? threadpool_batch : threadpool;
// ... опущено: передача выбранного пула потоков CPU-бэкенду
// задаём число потоков для всех бэкендов for (const auto & set_n_threads_fn : set_n_threads_fns) { set_n_threads_fn.second(set_n_threads_fn.first, n_threads); }
// один вызов выполняет весь граф: планировщик идёт по нарезанным им цепочкам и запускает каждую // на своём бэкенде. "_async" — здесь он не ждёт GPU auto status = ggml_backend_sched_graph_compute_async(sched.get(), gf); if (status != GGML_STATUS_SUCCESS) { LLAMA_LOG_ERROR("%s: ggml_backend_sched_graph_compute_async failed with error %d\n", __func__, status); }
return status;}Что делает graph_compute по шагам и для чего:
-
выбирает число потоков:
n_threads_batch, если в подбатче больше одного токена, иначеn_threads, и отдаёт соответствующий пул потоков CPU-бэкенду.Для чего:
-
обработка длинного промпта — это большая матричная работа, которая масштабируется по ядрам; генерация одного токена — нет, и размазывание её по всем ядрам стоит только синхронизации. Две отдельные настройки позволяют пользователю настроить оба случая.
-
вызывает
ggml_backend_sched_graph_compute_async. Планировщик уже, на этапе аллокации, нарезал граф на цепочки идущих подряд узлов, принадлежащих одному бэкенду; здесь он выполняет эти цепочки одну за другой, вставляя копирования между устройствами там, где цепочке нужен тензор, посчитанный другим устройством.Для чего:
-
именно здесь и выполняются все слои трансформера. Результат — выходные тензоры графа, заполненные числами, среди них логиты:
n_vocabзначений для каждой позиции, для которой запрошен выход. -
возвращает статус, не дожидаясь окончания работы, — это и значит
_asyncв имени.Для чего:
-
пока GPU ещё занят, вызывающий код уже может поставить в очередь копирование логитов из графа. Само ожидание происходит позже, в
llama_context::synchronize, который вызывает каждый публичный читатель результатов — в том числеllama_get_logits_ith, — прежде чем отдать вызывающему указатель.
Второй аргумент graph_compute(res->get_gf(), ubatch.n_tokens > 1) называется batched и просто говорит, больше ли одного токена в этом подбатче. Он выбирает число потоков и пул потоков: батчевые настройки для обработки промпта, обычные — для генерации по одному токену. Возвращаемое значение — статус GGML; при успехе process_ubatch возвращает res, а decode копирует логиты из него во внутренний буфер контекста — для тех позиций, у которых выставлен флаг выхода.
При переиспользовании графа (когда размер батча и тип графа совпадают с предыдущим вызовом) граф не перестраивается и буферы не перевыделяются — вызываются только res->set_inputs(&ubatch) и graph_compute. Это ускоряет повторные вызовы decode при генерации по одному токену: граф Decode строится один раз при первом decode с одним токеном и далее переиспользуется. Счётчик n_reused в контексте увеличивается при каждом переиспользовании графа — по нему можно оценить долю переиспользований при профилировании.
Шаги с 14 по 16: Логиты, сэмплинг и токен обратно в текст
Шаги с 14 по 16 — после прогона подбатча логиты для последней позиции оказываются в буфере контекста (шаг 14); по ним сэмплер выбирает один следующий токен (шаг 15). Здесь же, в конце раздела, разбирается шаг 16 — перевод выбранного токена обратно в текст. После llama_decode во внутреннем буфере контекста лежат логиты — по одному числу на каждый токен словаря.
Для чего нужны логиты:
- модель на выходе выдаёт не один токен, а «оценки» по всем возможным следующим токенам (по одному числу — логиту — на каждый токен словаря);
- по этим числам сэмплер решает, какой один токен выбрать: например, с максимальным логитом (жадный выбор) или случайно с учётом temperature/top_p;
- без логитов приложение не смогло бы выбрать следующий токен.
Логиты можно понимать как «сырые» оценки того, насколько подходит каждый следующий токен; из них сэмплер выбирает один токен. Доступ к логитам — через llama_get_logits_ith (файл src/llama-context.cpp):
- возвращается указатель на массив из
n_vocabfloat — по одному числу на каждый токен словаря.
Логиты запрашиваются только для тех позиций в батче, для которых в батче был установлен флаг logits[i] == true (обычно только для последней позиции). После каждого вызова process_ubatch контекст выгружает логиты позиций с этим флагом, дописывая их в буфер, а не перезаписывая его: если decode разбит на несколько подбатчей, строки накапливаются в том порядке, в котором позиции шли в батче. То есть буфер — это таблица: строк столько, сколько запрошено позиций, и в каждой строке n_vocab float (по одному на токен словаря); в контексте он выделяется на максимальное число выходных позиций.
Реализация llama_get_logits_ith в src/llama-context.cpp возвращает указатель на строку логитов для заданной позиции в батче. Индекс может быть отрицательным — -1 означает последнюю позицию, для которой запрашивался вывод, и именно его передаёт цикл генерации. Прежде чем что-то вернуть, функция дожидается бэкендов и сначала проверяет, не отработал ли сэмплер прямо на бэкенде, оставив собственные логиты; обычный путь — второй, «сырые» логиты, выгруженные decode. Индекс, указывающий на позицию, для которой логиты не запрашивались, — это ошибка: в релизной сборке функция вернёт нулевой указатель.
// Шаг 14: src/llama-context.cpp — публичный аксессор.// Возвращает указатель на одну строку логитов: n_vocab float, по одному на токен словаря.// i может быть отрицательным: -1 — последняя позиция, для которой запрашивался вывод.float * llama_get_logits_ith(llama_context * ctx, int32_t i) { ctx->synchronize(); // ждём, пока бэкенды досчитают граф
float * res = nullptr;
res = ctx->get_sampled_logits_ith(i); // не null, только если сработал сэмплер на бэкенде
if (!res) { res = ctx->get_logits_ith(i); // обычный путь: «сырые» логиты, выгруженные decode }
return res;}
// src/llama-context.cpp — сам поиск строкиfloat * llama_context::get_logits_ith(int32_t i) { output_reorder();
try { if (logits.data == nullptr) { throw std::runtime_error("no logits"); }
// output_resolve_row превращает индекс в батче в номер строки: // отрицательный i отсчитывается с конца, иначе output_ids[i]; бросает исключение, // если для этой позиции логиты не запрашивались const int64_t j = output_resolve_row(i);
return logits.data + j*model.vocab.n_tokens(); } catch (const std::exception & err) { LLAMA_LOG_ERROR("%s: invalid logits id %d, reason: %s\n", __func__, i, err.what()); // ... опущено: отладочная сборка здесь падает, релизная возвращает nullptr return nullptr; }}По этим числам выбирается следующий токен с помощью сэмплера — компонента, который по логитам решает, какой токен выдать (например, самый вероятный или случайный с учётом temperature/top_p). В API (include/llama.h) вызов выглядит как llama_sampler_sample(smpl, ctx, idx): ему передают цепочку сэмплеров, контекст и индекс выходной позиции (-1 — последняя), логиты этой позиции он читает сам, применяет к ним цепочку и возвращает один токен. Он же записывает этот токен в собственную историю цепочки, так что вызывающему коду делать это не нужно. При следующем вызове llama_decode токен передаётся в батче как единственный новый; цикл повторяется, пока модель не выдаст токен конца генерации — это проверяется через llama_vocab_is_eog — или пока не сработает ограничение по длине. Перевести токен обратно в текст — через llama_token_to_piece / llama_detokenize (реализация в src/llama-vocab.cpp).
Цепочка сэмплеров:
- сэмплинг в
llama.cppустроен как последовательность шагов; - сначала к логитам может применяться сдвиг по повторениям (repeat penalty) — уменьшение вероятности уже появившихся токенов;
- затем идут отсечения, отбрасывающие кандидатов: top-k оставляет k самых больших логитов, top-p (nucleus) — только токены с накопленной вероятностью до порога p, min-p отбрасывает всё, что сильно ниже лидера;
- temperature идёт ближе к концу — деление логитов на число (temperature > 1 делает распределение мягче, < 1 — острее), то есть она перестраивает то, что осталось после отсечений;
- последним из оставшихся выбирается один токен — например, с максимальным логитом (жадный выбор) или случайно с вероятностями по softmax от логитов;
- сама цепочка — цикл по зарегистрированным сэмплерам, каждый из которых либо модифицирует логиты, либо выбирает токен, — лежит в
src/llama-sampler.cpp; цепочка по умолчанию для консольных утилит собирается по параметрам вcommon/sampling.cpp.
Реализация перевода токена в текст — это уже шаг 16; за него отвечают методы llama_vocab::impl::token_to_piece и llama_vocab::impl::detokenize в src/llama-vocab.cpp. token_to_piece по ID токена выдаёт кусочек текста; для байтовых токенов выполняется преобразование в символ, для обычных — берётся текст из id_to_token. detokenize проходит по массиву токенов, записывая кусочки один за другим в переданный буфер и следя за тем, сколько места осталось, а для некоторых словарей в конце ещё прогоняет чистку пробелов. API llama_token_to_piece (в include/llama.h) принимает словарь — не модель; словарь получают через llama_model_get_vocab — затем токен, выходной буфер и его размер, сколько ведущих пробелов пропустить и нужно ли выводить специальные токены. Возвращает число записанных байт или минус требуемый размер, если буфер оказался мал, и завершающий ноль не пишет — так что результат надо проверять, что минимальные примеры и делают. Для вывода по одному токену обычно используют llama_token_to_piece; для сборки полной строки из массива токенов — llama_detokenize. Фрагмент:
// Шаг 16: src/llama-vocab.cpp — llama_vocab::impl::token_to_piece// По ID токена возвращаем кусочек текста (для вывода пользователю); id_to_token загружен в load_vocabint32_t llama_vocab::impl::token_to_piece(llama_token token, char * buf, int32_t length, int32_t lstrip, bool special) const { static const int attr_special = LLAMA_TOKEN_ATTR_UNKNOWN | LLAMA_TOKEN_ATTR_CONTROL; const llama_token_attr attr = token_get_attr(token); if (!special && (attr & attr_special)) { return 0; // управляющий токен, который не просили выводить }
// копируем символы кусочка в выходной буфер текста // перед копированием пропускаем до 'lstrip' ведущих пробелов auto _try_copy = [=] (const char * token, size_t size) -> int32_t { // ... опущено: цикл, пропускающий до 'lstrip' ведущих пробелов if (length < (int32_t)size) { return -(int32_t) size; // буфер мал: нужный размер со знаком минус } memcpy(buf, token, size); return (int32_t) size; };
// ... опущено: если кэш token_to_piece построен, текст берётся из него
if (0 <= token && token < (int32_t) id_to_token.size()) { const std::string & token_text = id_to_token[token].text; switch (get_type()) { case LLAMA_VOCAB_TYPE_WPM: case LLAMA_VOCAB_TYPE_SPM: case LLAMA_VOCAB_TYPE_UGM: { if (attr & LLAMA_TOKEN_ATTR_NORMAL) { std::string result = token_text; llama_unescape_whitespace(result); return _try_copy(result.data(), result.size()); } // ... опущено: байтовые токены превращаются в один символ через token_to_byte break; } // ... опущено: ветки BPE (llama_decode_text), RWKV и PLAMO2 default: GGML_ABORT("fatal error"); } }
return 0;}В коде видно: управляющий или неизвестный токен просто пропускается, если вывод специальных токенов не запрашивали; для словарей WPM, SPM и UGM обычные токены превращаются в текст через llama_unescape_whitespace, байтовые — через token_to_byte; ветка BPE устроена похоже, но обычно идёт через llama_decode_text (декодирование escape-последовательностей). Всё копирование проходит через один небольшой помощник, и именно там проверяется размер буфера: если кусочек не помещается, не пишется ничего, а возвращается требуемый размер со знаком минус. Функция llama_detokenize в API вызывает vocab.detokenize, которая собирает текст целого массива токенов в переданный буфер.
Параметры сэмплинга в сам вызов не передаются: они фиксируются при сборке цепочки — llama_sampler_chain_init, затем по одному llama_sampler_chain_add на каждый шаг (llama_sampler_init_penalties, llama_sampler_init_top_p, llama_sampler_init_temp и завершающий llama_sampler_init_dist или llama_sampler_init_greedy, который и выбирает токен). Сам вызов принимает только цепочку, контекст и индекс выходной позиции. Внутри он набирает массив кандидатов из логитов этой позиции, прогоняет по нему всю цепочку, берёт выбранный ею токен, записывает его в историю цепочки и возвращает. Если сэмплер отработал на бэкенде и токен уже выбран, функция сразу возвращает его, не трогая цепочку на CPU. Возвращённый токен затем передаётся в следующий вызов llama_decode в батче как единственный новый токен.
KV-cache и этапы Prefill / Decode (справочно)
В механизме внимания каждый токен получает вектор запроса (Q), ключа (K) и значения (V) — это числовые векторы, которые модель считает из своих весов.
Для чего нужны Q, K, V:
- по ним вычисляется «внимание» — насколько каждая предыдущая позиция важна для текущей;
- результат — взвешенная сумма значений V с весами по Q и K.
Логиты для следующего токена зависят от Q текущей позиции и от K, V всех предыдущих позиций — поэтому при генерации по одному токену старые K и V не меняются, их достаточно посчитать один раз и сохранить. KV-cache — это буфер в памяти, в котором для каждого слоя и каждой позиции хранятся уже посчитанные ключи и значения; так не нужно пересчитывать их заново на каждом шаге генерации.
Prefill и Decode:
- Prefill — в первом проходе обрабатываются все токены промпта, для них считаются K и V и записываются в кэш;
- Decode — в последующих шагах обрабатывается только один новый токен: по нему считаются Q, K, V; K и V сначала дописываются в кэш, и уже затем Q умножается на все ключи, лежащие в кэше, — в том числе на ключ текущего токена, — а после маски и softmax на все значения из кэша. В итоге получаем один вектор контекста для этой позиции и дальше feed-forward и т.д. Так мы не пересчитываем внимание по всей истории на каждом шаге, а только по новому токену — это и даёт ускорение генерации. Разделение на Prefill (много токенов за раз, нагрузка на вычислители) и Decode (один токен, нагрузка на память) характерно для
llama.cppи других движков.
Реализация KV-cache — это класс llama_kv_cache в src/llama-kv-cache.cpp; какая ячейка какой позиции и какой последовательности принадлежит, отслеживается в src/llama-kv-cells.h, а общий интерфейс, через который к памяти обращается контекст, — init_batch, seq_pos_max и остальные — объявлен в src/llama-memory.h. Для каждого кэшируемого слоя модели выделяются буферы под ключи и значения (размер зависит от числа голов KV, размера головы и длины контекста). При Prefill в эти буферы записываются K и V для всех позиций промпта; при Decode для нового токена вычисляются только новые K и V и дописываются в конец. Граф модели при выполнении обращается к этим буферам через контекст памяти, переданный ему в параметрах графа, и читает их через get_k и get_v.
Расположение буферов KV-cache: для каждого слоя трансформера создаётся два тензора (или один объединённый):
- один для ключей;
- один для значений.
Размерность каждого из них — [число голов KV × размер головы, число ячеек кэша]: одна строка — это ключи (или значения) одной позиции для всего слоя, а число ячеек — это длина контекста, под которую создан кэш. Тензоры называются cache_k_l0, cache_v_l0 и так далее по номеру слоя — удобная строка для поиска в дампе графа, но в исходниках имя собирается из форматной строки, поэтому там ищут cache_, а не имя целиком. При создании контекста вызывается выделение памяти под эти тензоры на CPU или GPU (по настройкам). При Prefill граф записывает K и V для позиций 0..n-1 (n — число токенов в батче). При Decode граф пишет K и V только для позиции n_cur (текущая позиция) в соответствующее место буфера; чтение K и V для всех позиций 0..n_cur идёт из того же буфера. Так не нужно пересчитывать ключи и значения для уже обработанных токенов.
Объём памяти KV-cache растёт линейно с длиной контекста и числом слоёв: для каждого слоя хранятся ключи и значения для всех позиций. При длине контекста n_ctx и числе слоёв n_layer объём пропорционален n_ctx × n_layer × размер одной головы × число голов KV × 2 (×2 — ключи и значения), умноженному на размер одного элемента. Здесь легко ошибиться в двух вещах. Первая: считать нужно число голов KV, а не число голов внимания — в моделях с grouped-query attention несколько голов запроса делят одну голову ключей и значений, и кэш выходит в несколько раз меньше, чем можно решить по одному только числу голов. Вторая: размер элемента не фиксирован — типы кэшей K и V задаются отдельно (опции -ctk и -ctv), и это и есть та самая «более агрессивная квантизация». Поэтому при ограниченной памяти уменьшают n_ctx или понижают точность KV-cache. В llama.cpp при создании контекста буферы выделяются сразу на всю длину контекста; при генерации заполняются только позиции до текущей. Исключение — слои со скользящим окном внимания: для них кэш выделяется по размеру окна, а не по всему контексту.
Метод памяти seq_pos_max(s) (объявлен в src/llama-memory.h, реализован для KV-cache в src/llama-kv-cache.cpp) возвращает максимальную позицию, до которой заполнен кэш для последовательности s. Если приложение не проставило позиции токенов само, за него их заполняют в src/llama-batch.cpp: первый токен батча получает seq_pos_max(s) + 1, остальные — позиции следом за ним; так батч всегда продолжает последовательность с первой свободной позиции в кэше. Ячейки кэша помечаются занятыми в самом начале process_ubatch, вызовом apply у контекста памяти, ещё до вычисления графа; а если вычисление не удалось, decode убирает эти позиции из кэша обратно.
Резюме: от файла до первого токена
Краткая последовательность шагов от запуска приложения до появления первого токена ответа.
Для чего это полезно:
- при отладке или изучении кода можно сверяться с этим списком и проверять, на каком шаге вы находитесь;
- так проще найти в исходниках место, соответствующее этапу загрузки или генерации.
Подготовка (один раз при старте или смене модели):
-
llama_backend_init()— запуск таймера, а если ни один бэкенд ещё не зарегистрирован — загрузка всех доступных.Для чего: один раз при старте приложения.
-
llama_model_load_from_file(path, params)→llama_model_load_from_file_impl→ проверка бэкенда, колбэк прогресса,llama_model_load(...)— а уже внутри него, среди прочего, собирается список устройств для модели.Для чего: загрузить модель из файла и получить указатель на
llama_model. -
Внутри
llama_model_load:llama_model_loader(открытие GGUF, индекс тензоров), затемllama_model_create— он спрашивает у загрузчика архитектуру (ml.get_arch()) и создаёт объект соответствующего класса модели, — а после него по очередиload_hparams,load_vocab(в т.ч.vocab.load(ml, kv)),load_statsиload_tensors.Для чего: по шагам заполнить модель архитектурой, гиперпараметрами, словарём и весами.
-
llama_init_from_model(model, ctx_params)— создание контекста: параметрыn_ctx,n_batch,n_ubatch, инициализация памяти (KV-cache), планировщика, резерв графов Prefill и Decode (в исходниках и логах они называются pp и tg). Старое имяllama_new_context_with_modelещё работает, но помечено устаревшим.Для чего: контекст нужен для вызова
llama_decode; без него нельзя генерировать текст.
Генерация (на каждое сообщение пользователя и каждый новый токен в ответе):
-
llama_tokenize(vocab, prompt, ...)— промпт в токены; словарь заранее берут у модели черезllama_model_get_vocab(model). При вызове с пустым выходным буфером функция возвращает минус нужное число токенов — так узнают, сколько памяти выделить.Для чего: модель работает только с числами (токенами).
-
Формирование батча. Самый короткий путь —
llama_batch_get_one(tokens, n): батч берёт токены как есть, а логиты только для последней позицииllama.cppзапрашивает сама. Где нужен более тонкий контроль, примеры пользуются помощниками библиотеки common —common_batch_clearи затемcommon_batch_addдля каждого токена, последний аргумент которого говорит, нужны ли логиты для этой позиции. Эти двое не входят в публичный API вinclude/llama.h; они живут в common/common.h.Для чего: один вызов decode принимает один батч; логиты нужны только для последней позиции, чтобы выбрать следующий токен.
-
llama_decode(ctx, batch)→ctx->decode(batch)→balloc->init(проверка батча и заполнение полей, которые не заполнил вызывающий), обновление памяти,memory->init_batch— он разбивает батч на подбатчи — и затем цикл по этим подбатчам:process_ubatch(apply,build_graph,set_inputs,graph_compute), копирование логитов. Если контекст создан вообще без памяти, decode просто передаёт батч вencodeи возвращается.Для чего: прогон батча через модель и получение логитов во внутреннем буфере контекста.
-
llama_get_logits_ith(ctx, i)— указатель на логиты одной строки вывода. Неотрицательный i — это индекс токена внутри батча, который контекст переводит в строку выходного буфера черезoutput_ids, а не номер среди выходов и не позиция в последовательности: после Prefill из n токенов, где флаг стоит только на последнем, его логиты просят под номером n-1, а под номером 0 вернётся нулевой указатель, потому что для того токена логиты не запрашивались. Отрицательный индекс, наоборот, считается среди строк вывода, поэтому надёжный способ попросить последний выход —-1.Для чего: по ним сэмплер выбирает следующий токен.
-
Сэмплер выбирает следующий токен;
llama_token_to_piece(vocab, token, buf, size, ...)превращает его в кусочек текста — возвращённую длину нужно проверить, отрицательное значение означает, что буфер оказался мал; вывод; токен уходит в следующий батч; decode повторяется до токена конца генерации (llama_vocab_is_eog) или лимита.Для чего: цикл генерации по одному токену до конца ответа.
Таким образом, путь от файла модели до первого токена ответа проходит через загрузчик GGUF, загрузку архитектуры и гиперпараметров, словаря и тензоров, создание контекста с KV-cache и планировщиком, токенизацию промпта, батч, decode, построение графа и его выполнение, сэмплинг по логитам.
При первом decode с полным промптом (Prefill) время выполнения зависит от длины промпта и размера подбатча (n_ubatch): чем длиннее промпт, тем больше подбатчей и проходов через модель; логиты нужны только для последней позиции. При последующих decode (по одному токену) каждый вызов обрабатывает один токен; граф Decode переиспользуется, основное время уходит на вычисление одного слоя внимания (Q, K, V для одной позиции, чтение K и V из кэша, softmax, взвешенная сумма) и остальных слоёв. Оптимизация скорости генерации связана с оптимизацией этого пути: эффективное чтение KV-cache, переиспользование графа, распределение по GPU. При профилировании полезно смотреть время первого decode (Prefill) и время последующих decode (по одному токену): первый зависит от длины промпта и n_ubatch, последующие — от эффективности одного прохода через модель и копирования данных между бэкендами.
Примечания по версиям и сборке: структура кода и имена файлов в репозитории llama.cpp меняются от версии к версии; всё в этой статье — имена файлов, сигнатуры функций и фрагменты кода — сверено с веткой master на коммите 8887a48f и у вас может отличаться. При сборке с GPU нужны соответствующие бэкенды (CUDA, Metal, Vulkan и т.д.) и переменные окружения; без них движок работает только на CPU. Примеры вызовов API и имена структур приведены по include/llama.h; при использовании C-обёрток или других языков (Python, Go и т.д.) сигнатуры могут отличаться, но общий сценарий загрузки и decode остаётся тем же.
Рекомендации по изучению кода: для понимания пути загрузки модели удобно начать с llama_model_load_from_file в src/llama.cpp и пройти по вызовам до llama_model_load, затем через llama_model_create (здесь определяется архитектура), load_hparams, load_vocab, load_stats, load_tensors. Для пути decode — с llama_decode в src/llama-context.cpp, затем ctx->decode, balloc->init, memory->init_batch и цикл по подбатчам с process_ubatch. В process_ubatch смотреть build_graph и graph_compute. Словарь и токенизация — src/llama-vocab.cpp (load, tokenize, token_to_piece). Загрузчик GGUF — src/llama-model-loader.cpp (конструктор, get_tensor_meta, create_tensor, load_all_data). Память и KV-cache — src/llama-kv-cache.cpp (seq_pos_max, init_batch, apply_ubatch), общий интерфейс — в src/llama-memory.h. При отладке полезно ставить точки останова на входах в load_hparams, load_vocab, load_tensors и на входах в decode, process_ubatch, build_graph.
Детализация реализации по файлам
Ниже — краткая привязка описанных в статье шагов к конкретным файлам и функциям репозитория llama.cpp.
Для чего это нужно:
- при изучении или отладке можно быстро найти нужный код по имени файла и функции;
- каждый пункт указывает, что искать в файле и зачем это нужно.
src/llama.cpp:
-
llama_backend_init— запуск таймера и загрузка бэкендов ggml, которые поставляются отдельными динамическими библиотеками (бэкенды, вкомпилированные в сборку, регистрируются сами, без этого вызова).Для чего: один раз при старте приложения.
-
llama_model_load_from_file,llama_model_load_from_file_impl— точка входа загрузки модели.Для чего: приложение вызывает их, чтобы загрузить модель из файла.
-
llama_model_load(статическая) — последовательный вызовllama_model_create(который выбирает архитектуру),load_hparams,load_vocab,load_statsиload_tensors.Для чего: здесь создаётся загрузчик и по шагам заполняется модель. В том же файле — проверка бэкенда, колбэк прогресса и список устройств (
llama_prepare_model_devices); сам объект модели создаётllama_model_createвsrc/llama-model.cpp, этот файл его только вызывает.
src/llama-model-loader.cpp:
-
Класс
llama_model_loader: конструктор открывает GGUF черезgguf_init_from_file, строитweights_mapпо списку тензоров из контекста GGUF.Для чего: по имени тензора потом можно прочитать данные из файла.
-
Методы
get_key,get_arch,get_tensor_meta,create_tensor,load_all_data— чтение метаданных, описание тензоров и чтение их данных.Для чего:
load_hparams,load_vocabиload_tensorsвызывают их. -
print_info— вывод информации о файле. Поддержка нескольких файлов (splits) и mmap/direct_io.
src/llama-model.cpp:
-
Класс
llama_model:load_hparams(чтение ключей GGUF в hparams),load_vocab(вызовvocab.load(ml, kv)),load_tensors(формирование списков буферов CPU/GPU, разбиение слоёв, создание тензоров черезcreate_tensorи чтение их данных черезml.load_all_data).Для чего: здесь создаются тензоры модели по архитектуре и назначаются буферы на CPU/GPU. Саму архитектуру выбирают раньше, вне класса:
llama_model_createзапрашивает её у загрузчика черезml.get_arch()и создаёт объект нужного типа. Всё, что относится к одной архитектуре, — её гиперпараметры, список тензоров и граф — лежит в отдельном файле вsrc/models/, за методамиload_arch_hparams,load_arch_tensorsиbuild_arch_graph, которые реализует каждая модель.
src/llama-vocab.cpp:
-
Класс
llama_vocab,llama_vocab::impl::load— чтение типа токенайзера, списков токенов, слияний BPE, специальных токенов из GGUF.Для чего: словарь нужен для токенизации и перевода токенов в текст.
-
init_tokenizer— инициализация токенайзера по типу (SPM, BPE и т.д.).tokenize— разбиение текста на токены;token_to_piece,detokenize— перевод токенов в текст. Функцииllama_tokenize,llama_token_to_piece,llama_detokenizeделегируют вызовы словарю модели.
src/llama-context.cpp:
-
Класс
llama_context: конструктор создаёт balloc, задаёт cparams (n_ctx,n_batch,n_ubatchи т.д.), инициализирует память (KV-cache) и планировщик (ggml_backend_sched), вызываетgraph_reserveдля Prefill и Decode.Для чего: контекст — «рабочая среда» одного сеанса генерации.
-
Метод
decode— проверка батча,balloc->init, обновление памяти,memory->init_batch(весь батч разбивается на подбатчи одним вызовом), затем цикл по этим подбатчам (process_ubatch), копирование логитов. Контексту, созданному без модуля памяти (например, для эмбеддинговой модели), декодировать нечего, и вызов сразу переадресуется вencode.Для чего: один вызов decode прогоняет батч через модель и заполняет буфер логитов.
-
process_ubatch— применение mctx,build_graphили переиспользование графа,set_inputs,graph_compute.get_logits_ith— возврат указателя на логиты для позиции. Публичные функцииllama_decode,llama_get_logits_ithвызывают методы контекста.
src/llama-batch.cpp:
-
Структура
llama_batch(объявлена вinclude/llama.h), классllama_batch_allocr: методinitпроверяет токены, заполняетn_seq_id,seq_id, pos, logits при их отсутствии; позиции берутся изmemory->seq_pos_max.Для чего: чтобы вызывающему коду не нужно было вручную выставлять позиции и флаги логитов. Используется внутри
llama_context::decodeперед циклом по подбатчам.
src/llama-memory.h, src/llama-kv-cache.cpp:
-
Интерфейс
llama_memory_i(объявлен вsrc/llama-memory.h) и его основная реализация — классllama_kv_cacheвsrc/llama-kv-cache.cpp— выделение буферов KV-cache: по одному тензору K и одному тензору V на каждый слой, каждый на всю длину контекста.Для чего: в них хранятся ключи и значения механизма внимания. Модели с другим видом состояния (рекуррентные, гибридные, со скользящим окном) дают собственную реализацию того же интерфейса в соседнем файле src/llama-memory-*.cpp или src/llama-kv-cache-*.cpp.
-
seq_pos_max(s)— максимальная позиция для последовательности s.Для чего: при формировании батча позиции новых токенов берутся как
seq_pos_max(s)+ 1. -
init_batch— разбиение батча на подбатчи (ubatch) размером не большеn_ubatchи резервирование места в KV-cache под каждый из них; занятые позиции обновляются по мере обработки подбатчей. Планировщик и граф обращаются к буферам KV-cache через параметры графа.
src/llama-graph.cpp:
-
Класс
llm_graph_context— общие строительные блоки, из которых файлы отдельных архитектур вsrc/models/собирают свои слои:build_inp_embd(шаг 13, поиск эмбеддингов токенов —ggml_get_rowsпоtok_embd),build_norm,build_attn,build_ffn.Для чего: архитектуры не повторяют одни и те же операции, и первый узел любого графа строится здесь.
-
Классы
llm_graph_input_*и их методыset_input— запись идентификаторов токенов, позиций и масок внимания во входные тензоры уже построенного графа.Для чего:
process_ubatchвызывает их прямо передgraph_compute; именно это позволяет переиспользовать тот же граф для следующего подбатча.
src/llama-sampler.cpp:
-
llama_sampler_chain_init,llama_sampler_chain_add— создание цепочки сэмплеров и добавление в неё шага (штраф за повторы, top-k, top-p, min-p, температура, финальный выбор).Для чего: цепочка собирается один раз, до генерации; для консольных программ её строят по параметрам в
common/sampling.cpp. -
llama_sampler_sample— шаг 15: сам читает логиты нужной позиции, применяет к ним всю цепочку и возвращает один токен;llama_sampler_acceptзаписывает этот токен в собственную историю цепочки.Для чего: здесь числа из буфера контекста превращаются в следующий токен.
GGML/GGUF (каталог ggml/ того же репозитория):
-
ggml_init,ggml_free— контекст графа.gguf_init_from_file,gguf_get_*— чтение GGUF.Для чего: загрузчик и модель используют их для чтения файла и построения графа.
-
ggml_backend_sched,ggml_backend_sched_alloc_graph,ggml_backend_sched_reset— планировщик и выделение буферов под граф.Для чего: планировщик распределяет узлы графа по CPU/GPU и выделяет под них память.
-
Выполнение графа (
graph_compute) — обход узлов в топологическом порядке, копирование между бэкендами при необходимости, запуск операций на CPU/GPU. Модель строит граф черезmodel.build_graphвsrc/llama-model.cpp: этот метод общий — он вызываетbuild_arch_graphконкретной архитектуры, а затем дописывает общий «хвост» (пулинг, выходную проекцию). Сам граф архитектуры лежит в отдельном файле вsrc/models/, по файлу на архитектуру. Поддержка новой модели добавляется таким файлом, а не правкойbuild_graph; общая схема «эмбеддинги → слои → логиты» сохраняется.
При изучении кода удобно искать по имени функции или метода: llama_backend_init, llama_model_load_from_file, llama_model_load, load_arch_hparams, load_arch_tensors, load_hparams, load_vocab, vocab.load, load_tensors, llama_init_from_model, llama_model_get_vocab, llama_decode, decode, process_ubatch, build_graph, build_arch_graph, graph_compute, llama_get_logits_ith, llama_tokenize, llama_token_to_piece. По ним можно проследить весь путь от загрузки модели до вывода токена. Самые короткие полные примеры — examples/simple/simple.cpp и examples/simple-chat/simple-chat.cpp: в них показан типичный цикл — загрузка модели, создание контекста, токенизация, батч, decode, сэмплинг, вывод; полнофункциональные программы (CLI, сервер) лежат в tools/.
Связь компонентов и потоки данных
Кратко — как данные проходят между компонентами от файла модели до вывода токена. Для чего это полезно: при отладке или изучении кода можно проследить, откуда берётся каждое значение и куда оно передаётся.
Загрузка (один раз при старте или смене модели):
-
Файл GGUF →
llama_model_loader(метаданные и индекс тензоровweights_map).Для чего: загрузчик даёт доступ к полям GGUF и к данным тензоров по имени.
-
ml.get_arch()→llama_model_create(тип модели: LLaMA, Gemma и т.д.).Для чего: от типа зависят имена ключей в GGUF.
-
load_hparams(размерности и параметры:n_ctx_train, n_layer,n_embdи т.д.; рабочая длина контекстаn_ctxв файле не хранится — её выбирают позже, при создании контекста, а по умолчанию берут равнойn_ctx_train).Для чего: от них зависит «форма» модели и размеры тензоров.
-
load_vocab→vocab.load(словарь токенов и токенайзер).Для чего: словарь нужен для токенизации и перевода токенов в текст.
-
load_tensors(веса из файла в буферы CPU/GPU).Для чего: модель готова к вычислениям. Модель (
llama_model) хранит hparams, vocab и тензоры; загрузчик после загрузки больше не нужен.
Создание контекста (один раз на сеанс):
-
Модель + параметры контекста →
llama_context: balloc (аллокатор батча), memory (KV-cache иinit_batch), sched (планировщик), зарезервированные графы Prefill/Decode.Для чего: контекст — «рабочая среда» одного сеанса генерации; в нём хранятся KV-cache, планировщик и графы для decode. Контекст ссылается на модель; модель не хранит ссылку на контекст.
Генерация (на каждое сообщение и каждый новый токен):
-
Текст промпта →
llama_tokenize(model.vocab) → массив токенов → батч (token, pos,seq_id, logits).Для чего: один вызов decode принимает один батч.
-
Батч →
llama_decode→balloc->init(позиции из memory) →memory->init_batch(один вызов: весь батч разбивается на подбатчи) → цикл: ubatch →process_ubatch(build_graphпо модели,set_inputs,graph_computeна sched) → логиты копируются в буфер контекста. Поиск эмбеддингов токенов — не отдельный этап, а первый узел графа, который строитbuild_graph.Для чего: прогон батча через модель и получение логитов.
-
Логиты →
llama_get_logits_ith→ сэмплер → следующий токен →llama_token_to_piece(model.vocab) → текст.Для чего: по логитам выбирается один токен и переводится в текст для вывода.
-
Токен добавляется в батч; при следующем decode в батче один новый токен; memory обновляет занятые позиции KV-cache; цикл повторяется до EOS или лимита.
Сводка потоков данных:
- Файл GGUF → загрузчик → модель (hparams, vocab, тензоры). Модель + параметры → контекст (memory, sched, графы).
- Промпт → vocab (токенизация) → батч. Батч →
llama_context::decode→ balloc, memory →process_ubatch(модель:build_graph, веса; memory: KV-cache; sched: выполнение графа) → логиты в контексте. - Логиты → сэмплер → токен → vocab (
token_to_piece) → текст.
Модель и контекст — центральные объекты; загрузчик, balloc, memory и sched — вспомогательные, привязаны к контексту или модели.
Что из этого следует
Всё описанное укладывается в две фазы. Подготовка происходит один раз: движок регистрирует бэкенд, открывает GGUF, читает из него архитектуру, размеры и словарь, раскладывает веса туда, где они будут считаться, и строит вокруг них контекст. Генерация — это цикл: текст превращается в токены, токены — в батч, батч проходит через граф, граф возвращает логиты, сэмплер выбирает один токен, и этот токен уходит в следующий батч. Всё остальное в статье — подробности, навешенные на эти два каркаса.
Четыре настройки меняют поведение сильнее прочих, и теперь понятно почему. n_ctx задаёт, сколько KV-кэша выделяется сразу: память растёт с длиной контекста, числом слоёв и числом KV-голов — и из этих трёх величин вы выбираете только длину контекста. n_gpu_layers решает, какие слои живут в памяти устройства; отображение достаётся им без копирования лишь там, где устройство умеет построить буфер поверх памяти хоста, — процессору всегда, единой памяти вроде Apple Silicon тоже, дискретной карте никогда. -ctk и -ctv задают точность ключей и значений по отдельности: это самый дешёвый рычаг, когда не помещается именно кэш. А --load-mode определяет, как файл вообще читается: отображается, запирается в памяти, и то и другое сразу, читается без буферизации или просто читается — а при значении по умолчанию auto движок выбирает сам.
Если хочется читать не статью, а исходники, самый короткий вход — examples/simple-chat/simple-chat.cpp: двести строк, проходящих весь путь — от загрузки модели до выбора токена. Дальше — llama_context::decode в src/llama-context.cpp, где живёт цикл по подбатчам. Все имена в статье написаны ровно так, как в чекауте, названном в предисловии, — их можно искать как есть.
На этом же сайте: Нейросети простым языком объясняют, что такое веса и внимание, которые здесь считаются; LoRA — про адаптеры, которые вливаются в эти же веса GGUF; RAG и function calling — про то, что обычно строят поверх локального движка; Offline AI Launcher — этот же движок на Android.