➤ Предисловие
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. Примеры кода в статье могут отличаться от вашей версии.
Как читать статью:
— разделы идут в порядке выполнения сценария;
— в каждом разделе для действия даётся цитата из кода и объяснение, что происходит и для чего;
— файлы исходников указаны в тексте;
— статья рассчитана на неподготовленного читателя: все термины объясняются в глоссарии в начале статьи, все шаги снабжены цитатами кода и пояснениями.
➤ Глоссарий терминов
Батч (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) и выделяет под граф буферы на этих устройствах. При выполнении графа планировщик обходит узлы в топологическом порядке и запускает операции на выбранных устройствах.
Содержание:
➤ Предисловие
➤ Глоссарий терминов
➤ Схема процесса: все шаги по порядку
➤ Общий процесс инференса LLM
➤ Структура репозитория и основные файлы
➤ Основные типы и структуры (справочно)
➤ Шаг 1: Инициализация бэкенда
➤ Шаг 2: Загрузка модели из файла (точка входа)
➤ Шаг 2: Подготовка к чтению файла
➤ Шаги 2–6: Пошаговая загрузка модели из файла
➤ Шаг 2: Загрузчик GGUF — открытие файла и карта тензоров
➤ Формат GGUF (справочно)
➤ Шаг 3: Определение типа модели (архитектура)
➤ Шаг 4: Загрузка гиперпараметров — размеры и контекст
➤ Шаг 5: Загрузка словаря и токенайзера
➤ Шаг 6: Загрузка весов в память или на GPU
➤ Шаг 7: Создание контекста инференса
➤ Шаг 8: Приходит текст промпта от пользователя
➤ Шаг 9: Токенизация — от текста к последовательности токенов
➤ Шаг 10: Формирование батча для одного вызова
➤ Шаг 11: Токены превращаются в эмбеддинги
➤ Шаги 11–12: Прогон батча через модель (вход в decode, подбатчи)
➤ Шаг 13: Подготовка к прогону подбатча (память, параметры графа)
➤ Шаг 13: Построение вычислительного графа и аллокация буферов
➤ Шаг 13: Запись входов в граф и выполнение на CPU/GPU
➤ Шаги 14–15: Логиты в буфере и выбор следующего токена (сэмплинг)
➤ KV-cache и этапы Prefill / Decode (справочно)
➤ Резюме: от файла до первого токена
➤ Детализация реализации по файлам
➤ Связь компонентов и потоки данных
➤ Схема процесса: все шаги по порядку
Ниже — вся цепочка от запуска движка до появления очередного токена в ответе. Каждый пункт дальше в статье разбирается подробно, с цитатами кода и пояснениями.
Подготовка (один раз при старте или смене модели):
шаг 1: инициализация бэкенда (llama_backend_init) →
шаг 2: загрузка модели из файла (llama_model_load_from_file → загрузчик GGUF) →
шаг 3: определение типа модели (load_arch) →
шаг 4: загрузка гиперпараметров — размеры, контекст (load_hparams) →
шаг 5: загрузка словаря и токенайзера (load_vocab) →
шаг 6: загрузка весов в память или на GPU (load_tensors) →
шаг 7: создание контекста инференса — KV-cache, планировщик (llama_new_context).
Генерация (для каждого сообщения и каждого нового токена в ответе):
шаг 8: приходит текст промпта от пользователя →
шаг 9: токенизация — текст превращается в последовательность токенов (llama_tokenize) →
шаг 10: формирование батча — токены упаковываются для одного вызова (llama_batch_add, balloc->init) →
шаг 11: llama_decode — при необходимости токены превращаются в эмбеддинги (encode) →
шаг 12: батч при длинном промпте разбивается на подбатчи (memory->init_batch) →
шаг 13: каждый подбатч прогоняется через модель: построение графа → выполнение на CPU/GPU (process_ubatch → build_graph → graph_compute) →
шаг 14: логиты для последней позиции копируются в буфер контекста →
шаг 15: сэмплинг — по логитам выбирается один следующий токен →
шаг 16: токен переводится в текст и выводится (llama_token_to_piece); если это не EOS — новый токен добавляется в батч, переход к шагу 11; иначе цикл завершается.
➤ Общий процесс инференса LLM
Выше дана схема: что за чем происходит. По смыслу процесс делится на два этапа. Первый — подготовка: инициализация бэкенда, загрузка модели из GGUF (архитектура, гиперпараметры, словарь, веса) и создание контекста. Он выполняется один раз при старте или при смене модели. Второй этап — генерация: при каждом сообщении пользователя текст превращается в токены, упаковывается в батч, прогоняется через модель (llama_decode), из логитов выбирается следующий токен, он переводится в текст и выводится; цикл повторяется до токена «конец вывода» (EOS) или лимита. Этот цикл повторяется для каждого нового сообщения и для каждого нового токена в ответе. Ниже каждый шаг из схемы разбирается по отдельности: что именно вызывается в коде, что происходит и что может быть неочевидно неподготовленному читателю.
➤ Структура репозитория и основные файлы
В репозитории llama.cpp основные части движка разнесены по папкам и файлам.
Для чего так сделано:
— чтобы разнести ответственность: загрузка файла, модель, словарь, контекст и батч — в разных файлах;
— так проще искать код и отлаживать.
Ниже перечислены файлы, которые прямо относятся к загрузке модели и инференсу; для каждого указано, что в нём лежит и зачем это нужно.
Точка входа и загрузка модели:
— src/llama.cpp — здесь живут функции llama_backend_init, llama_model_load_from_file, llama_model_load (статическая).
Для чего:
это «входная дверь» в движок: приложение вызывает эти функции, чтобы инициализировать библиотеку и загрузить модель; здесь же создаётся загрузчик и по очереди вызываются load_arch, load_hparams, load_vocab, load_tensors.
— src/llama-model-loader.cpp — класс llama_model_loader: открытие GGUF-файла, построение индекса тензоров (weights_map), методы get_tensor, load_tensor_data.
Для чего:
загрузчик нужен, чтобы по имени тензора знать, где в файле лежат его данные и как их прочитать или отобразить в память (mmap); без него нельзя по шагам загружать архитектуру, гиперпараметры, словарь и веса.
Модель и словарь:
— src/llama-model.cpp — класс llama_model, методы load_arch, load_hparams, load_vocab, load_tensors, создание тензоров и назначение буферов.
Для чего:
объект модели хранит всё, что прочитано из файла: тип архитектуры, гиперпараметры, словарь и сами веса (тензоры); методы load_* по очереди заполняют эти данные из загрузчика.
— 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-batch.cpp — структура батча, класс llama_batch_allocr, метод init (заполнение позиций и флагов логитов).
Для чего:
батч — это «пакет» токенов для одного вызова decode; аллокатор проверяет батч и при отсутствии полей заполняет их (позиции из памяти, логиты только для последнего токена), чтобы вызывающему коду не нужно было вручную всё выставлять.
— src/llama-memory.cpp — выделение и обновление KV-cache, init_batch (разбиение на подбатчи).
Для чего:
KV-cache хранит уже посчитанные ключи и значения по всем предыдущим позициям, чтобы не пересчитывать их на каждом шаге; init_batch разбивает большой батч на подбатчи ограниченного размера, чтобы не переполнить память.
Библиотеки и бэкенды:
— ggml (подмодуль или отдельная папка) — вычислительный граф (GGML), типы тензоров, планировщик (ggml_backend_sched), бэкенды CPU/GPU.
Для чего:
граф описывает, какие операции (умножения матриц, активации и т.д.) выполнить и в каком порядке; планировщик решает, на каком устройстве (CPU или GPU) считать каждый узел графа.
— gguf — чтение и запись формата GGUF (заголовок, метаданные, тензоры).
Для чего:
формат 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. В нём: метаданные (meta), карта тензоров (weights_map), открытые файлы.
Для чего:
загрузчик живёт только во время загрузки модели; по нему по очереди читаются архитектура, гиперпараметры, словарь и тензоры; после загрузки он не нужен.
— ggml_context — контекст графа GGML: в нём создаются узлы и тензоры графа.
Для чего:
граф описывает последовательность операций (умножения, активации и т.д.); все узлы графа создаются в одном таком контексте.
— ggml_backend_sched — планировщик: список бэкендов и логика распределения узлов графа по устройствам.
Для чего:
планировщик решает, на каком устройстве (CPU или GPU) выполнять каждый узел графа, и выделяет под граф буферы на этих устройствах.
Что возвращают основные функции:
— llama_model_load_from_file возвращает llama_model* или nullptr при ошибке.
Для чего:
приложение проверяет указатель: если не nullptr, модель загружена и можно создавать контекст.
— llama_model_load возвращает 0 при успехе, -1 при ошибке, -2 при отмене (например, по колбэку прогресса).
Для чего:
вызывающий код по возврату решает, удалять ли модель и выходить.
— llama_decode возвращает 0 при успехе, 1 если нужно больше входных данных, отрицательное значение при ошибке.
Для чего:
приложение по возврату понимает, удалось ли выполнить decode и можно ли читать логиты.
— llama_tokenize возвращает число записанных токенов.
Для чего:
чтобы знать, сколько элементов массива токенов заполнено.
— llama_get_logits_ith возвращает указатель на массив float размером n_vocab.
Для чего:
по этому массиву сэмплер выбирает следующий токен (по одному числу на каждый токен словаря).
➤ Шаг 1: Инициализация бэкенда
Шаг 1 в схеме процесса — инициализация бэкенда. Перед загрузкой модели и инференсом приложение один раз вызывает llama_backend_init(). Движок опирается на внутренние структуры: таймер для замеров скорости и таблицы для работы с числами в формате f16 (половинная точность). Они создаются при первом вызове; без инициализации загрузка модели или decode могут вести себя некорректно. Ниже — цитата целиком и по фрагментам (файл src/llama.cpp).
// Шаг 1: единственная точка входа инициализации движка; вызывается один раз при старте приложения
void llama_backend_init(void) {
// Включаем точный таймер — потом по нему замеряют время загрузки и decode
ggml_time_init();
// Один раз проинициализировать таблицы формата f16 (половинная точность):
// веса и часть вычислений хранятся в f16 — экономия памяти и ускорение на GPU
{
struct ggml_init_params params = { 0, NULL, false }; // нулевой буфер — только таблицы
struct ggml_context * ctx = ggml_init(params);
ggml_free(ctx); // контекст освобождаем, таблицы остаются инициализированными
}
}Что в коде происходит по шагам:
// Фрагмент 1: таймер нужен, чтобы потом замерить время загрузки модели и время decode
ggml_time_init();
// Фрагмент 2: нулевой буфер — только инициализация таблиц f16, данные тензоров не выделяются
struct ggml_init_params params = { 0, NULL, false };
struct ggml_context * ctx = ggml_init(params);
ggml_free(ctx); // таблицы f16 остаются инициализированными на всё время работы процессаСначала включается таймер для последующих замеров скорости. Затем создаётся временный контекст GGML с нулевым буфером — этого достаточно, чтобы один раз проинициализировать внутренние таблицы формата f16 (преобразование между float32 и float16). Контекст сразу освобождается, но таблицы остаются. После этого движок готов к загрузке модели и построению графа; формат f16 используется для весов и части вычислений — он экономит память и ускоряет работу на GPU.
➤ Шаг 2: Загрузка модели из файла (точка входа)
Шаг 2 — загрузка модели из файла. Она начинается с вызова llama_model_load_from_file: приложение передаёт путь к .gguf и параметры, движок возвращает готовый объект модели или nullptr при ошибке.
Для чего эта функция:
— она — единственная точка входа для загрузки модели из файла;
— приложение передаёт путь к файлу и параметры, движок возвращает готовый объект модели или nullptr при ошибке.
Цитата из кода (файл src/llama.cpp):
// Точка входа загрузки модели (шаг 2): путь к .gguf и параметры (mmap, n_gpu_layers и т.д.)
struct llama_model * llama_model_load_from_file(
const char * path_model,
struct llama_model_params params) {
// splits — пути к частям модели, если она разбита на несколько файлов; для одного файла пусто
std::vector<std::string> splits = {};
return llama_model_load_from_file_impl(path_model, splits, params);
}Функция принимает путь к файлу модели (обычно .gguf) и параметры загрузки — приложение указывает, откуда читать модель и как её загружать (mmap, число слоёв на GPU и т.д.). При одном файле splits пустой; при разбитой модели в splits передают пути к частям. Вся дальнейшая логика (проверка бэкенда, колбэк прогресса, создание модели, вызов llama_model_load) сосредоточена в llama_model_load_from_file_impl.
Параметры загрузки (llama_model_params), важные для понимания:
— use_mmap — использовать отображение файла в память вместо чтения в буфер.
Для чего:
экономит RAM и ускоряет старт загрузки; файл остаётся открытым.
— 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(
const std::string & path_model,
std::vector<std::string> & splits,
struct llama_model_params params) {
ggml_time_init(); // таймер для замеров времени загрузки
// Если загружаем не только словарь — проверяем, что есть бэкенд (CPU или GPU)
if (!params.vocab_only && ggml_backend_reg_count() == 0) {
LLAMA_LOG_ERROR("%s: no backends are loaded...\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; // не отменяем загрузку
};
}
llama_model * model = new llama_model(params); // создаём объект моделиЧто происходит в этом фрагменте и для чего:
— ggml_time_init() — включается таймер.
Для чего:
чтобы потом замерить, сколько заняла загрузка.
— Проверка ggml_backend_reg_count() == 0 — есть ли хотя бы один бэкенд (CPU или GPU).
Для чего:
без бэкенда нельзя будет выполнять вычисления модели; при загрузке только словаря (vocab_only) бэкенд не обязателен.
— Настройка колбэка прогресса — если приложение не передало свой, подставляется колбэк по умолчанию, который выводит точки.
Для чего:
пользователь видит, что загрузка идёт; при желании можно передать свой колбэк и показывать прогресс-бар или отменять загрузку (вернуть false).
— new llama_model(params) — создаётся объект модели с переданными параметрами.
Для чего:
в этот объект потом будут записаны архитектура, гиперпараметры, словарь и тензоры из файла.
Далее в той же функции формируется список устройств и вызывается llama_model_load. Цитата:
// Список устройств: если не задан в params — берём все доступные (CPU + GPU)
std::vector<ggml_backend_dev_t> devs = params.devices.empty() ? ggml_backend_dev_get_all() : params.devices;
model->devices = devs;
// Внутренняя загрузка: читает файл, заполняет модель (arch, hparams, vocab, tensors)
int ret = llama_model_load(path_model, splits, *model, params);
if (ret != 0) {
delete model;
return nullptr;
}
return model;— devs — список устройств (CPU и видеокарты).
Для чего:
от него зависит, на какие устройства будут загружаться слои модели (см. load_tensors).
— llama_model_load(path_model, splits, *model, params) — читает файл и по шагам заполняет модель (загрузчик, load_arch, load_hparams, load_vocab, load_tensors).
Для чего:
вся логика чтения GGUF и заполнения модели сосредоточена в одной функции.
— При ненулевом возврате модель удаляется и возвращается nullptr.
Для чего:
приложение по nullptr понимает, что загрузка не удалась, и не использует неполную модель.
Колбэк прогресса вызывается внутри load_tensors при чтении каждого тензора: ему передаётся число от 0.0 до 1.0 (доля загруженных данных). Если колбэк возвращает false, загрузка прерывается и llama_model_load возвращает -2 (отмена).
Для чего:
— приложение может отменить долгую загрузку или показывать прогресс-бар.
➤ Шаги 2–6: Пошаговая загрузка модели из файла
Шаги 2–6 выполняются внутри одной функции llama_model_load: создаётся загрузчик (шаг 2), затем по очереди вызываются load_arch (шаг 3), load_hparams (шаг 4), load_vocab (шаг 5), load_tensors (шаг 6).
Для чего нужна функция llama_model_load: она выполняет пошаговую загрузку модели из файла: создаёт загрузчик GGUF (открывает файл и строит карту тензоров), затем по очереди загружает архитектуру, гиперпараметры, словарь и тензоры. Порядок важен: архитектура задаёт набор ключей в GGUF; гиперпараметры читаются по этим ключам; словарь загружается с учётом архитектуры; тензоры создаются по известным размерам и заполняются из файла.
Цитата функции целиком (файл src/llama.cpp):
// Возвращает 0 при успехе, -1 при ошибке, -2 при отмене (колбэк прогресса вернул false)
static int llama_model_load(const std::string & fname, std::vector<std::string> & splits, llama_model & model, llama_model_params & params) {
model.t_load_us = 0;
time_meas tm(model.t_load_us); // замер времени загрузки
model.t_start_us = tm.t_start_us;
try {
// Шаг 2 (продолжение): загрузчик открывает GGUF, читает заголовок и метаданные, строит карту тензоров (weights_map)
llama_model_loader ml(fname, splits, params.use_mmap, params.use_direct_io, params.check_tensors, params.no_alloc, params.kv_overrides, params.tensor_buft_overrides);
ml.print_info();
model.hparams.vocab_only = params.vocab_only;
model.hparams.no_alloc = params.no_alloc;
// Шаг 3: тип модели (LLaMA, Gemma и т.д.) — от него зависят имена полей в GGUF
try { model.load_arch(ml); } catch(const std::exception & e) {
throw std::runtime_error("error loading model architecture: " + std::string(e.what()));
}
// Шаг 4: размеры модели, длина контекста, число слоёв — нужны для выделения памяти и построения графа
try { model.load_hparams(ml); } catch(const std::exception & e) {
throw std::runtime_error("error loading model hyperparameters: " + std::string(e.what()));
}
if (model.arch == LLM_ARCH_CLIP) {
throw std::runtime_error("CLIP cannot be used as main model, use it with --mmproj instead");
}
// Шаг 5: словарь и токенайзер — текст ↔ токены при вводе и выводе
try { model.load_vocab(ml); } catch(const std::exception & e) {
throw std::runtime_error("error loading model vocabulary: " + std::string(e.what()));
}
model.load_stats(ml);
model.print_info();
if (params.vocab_only) {
LLAMA_LOG_INFO("%s: vocab only - skipping tensors\n", __func__);
return 0;
}
// Шаг 6: веса модели из файла в память (или mmap) на CPU/GPU
if (!model.load_tensors(ml)) { return -2; }
} catch (const std::exception & err) {
LLAMA_LOG_ERROR("%s: error loading model: %s\n", __func__, err.what());
return -1;
}
return 0;
}Ошибки и отмена загрузки:
— при ошибке в любом из шагов (load_arch, load_hparams, load_vocab, load_tensors) выбрасывается исключение; в блоке catch логируется сообщение и возвращается -1;
— при отмене по колбэку прогресса (колбэк возвращает false внутри load_tensors) load_tensors возвращает false, исключение не выбрасывается, но llama_model_load возвращает -2 (отмена);
— вызывающий код (llama_model_load_from_file_impl) при ненулевом возврате удаляет модель и возвращает nullptr;
— таким образом, приложение может отменить долгую загрузку через колбэк и корректно освободить ресурсы.
По шагам (что происходит и для чего):
— сбрасывается время загрузки и запускается таймер.
Для чего:
— чтобы потом можно было замерить, сколько заняла загрузка.
— создаётся загрузчик llama_model_loader ml(…) — он открывает GGUF-файл и читает метаданные, строит weights_map.
Для чего:
— без загрузчика нельзя прочитать архитектуру, гиперпараметры, словарь и тензоры из файла.
— вызывается ml.print_info() — выводит в лог архитектуру, число тензоров, размер файла.
Для чего:
— чтобы пользователь видел прогресс и информацию о файле.
— вызывается model.load_arch(ml) — определяем тип модели (LLaMA, Gemma, Qwen и т.д.).
Для чего:
— от архитектуры зависят имена полей в GGUF и то, какие тензоры создавать.
— вызывается 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: проверка бэкенда, колбэк прогресса, new llama_model(params), формирование списка устройств, llama_model_load(path_model, splits, *model, params);
— внутри llama_model_load: создание llama_model_loader, load_arch, load_hparams, load_vocab, load_stats, print_info, при необходимости load_tensors. Все эти шаги выполняются последовательно; при ошибке в любом из них загрузка прерывается.
Порядок вызовов load_arch → load_hparams → load_vocab важен: архитектура задаёт набор ключей GGUF; гиперпараметры читаются по этим ключам; словарь загружается с учётом архитектуры (например, имена ключей для токенайзера). Поэтому vocab.load(ml, kv) вызывается именно после load_hparams(ml) — к этому моменту и архитектура, и гиперпараметры уже известны, и загрузчик может корректно прочитать тип токенайзера, списки токенов и слияния.
➤ Шаг 2: Загрузчик GGUF — открытие файла и карта тензоров
Шаг 2 (внутри llama_model_load) — создаётся загрузчик GGUF: открывается файл, читаются заголовок и метаданные, строится карта тензоров.
Для чего нужен загрузчик GGUF: чтобы открыть файл модели, прочитать заголовок и метаданные (без самих весов) и построить «карту» — по имени тензора знать, где в файле лежат его данные и какого он размера. Без этой карты нельзя потом загружать веса по одному тензору. Тензоры здесь — многомерные массивы чисел (матрицы и векторы), в которых хранятся веса нейросети; каждый слой модели — это несколько тензоров, и загрузчик должен знать для каждого имя, размер и смещение в файле.
Создание загрузчика — вызов конструктора llama_model_loader. В коде llama_model_load это выглядит так:
// Конструктор загрузчика (шаг 2): открывает GGUF, читает заголовок и метаданные, строит карту тензоров
llama_model_loader::llama_model_loader(
const std::string & fname,
std::vector<std::string> & splits,
bool use_mmap,
bool use_direct_io,
bool check_tensors,
bool no_alloc,
const llama_model_kv_override * param_overrides_p,
const llama_model_tensor_buft_override * param_tensor_buft_overrides_p) {
// no_alloc = true: читаем только заголовок и метаданные, данные тензоров пока не загружаем
struct ggml_context * ctx = NULL;
struct gguf_init_params params = {
/*.no_alloc = */ true,
/*.ctx = */ &ctx,
};
// Заголовок + метаданные (ключ–значение) в meta; в ctx — список тензоров с именами, типами, смещениями в файле
meta.reset(gguf_init_from_file(fname.c_str(), params));
if (!meta) {
throw std::runtime_error(format("%s: failed to load model from %s", __func__, fname.c_str()));
}
// Имя архитектуры (llama, qwen2 и т.д.) — от него зависят имена полей при load_hparams и load_vocab
get_key(llm_kv(LLM_KV_GENERAL_ARCHITECTURE), arch_name, false);
llm_kv = LLM_KV(llm_arch_from_string(arch_name));
// Файл открыт для чтения — потом по смещениям из weights_map будем читать или маппить данные тензоров
files.emplace_back(new llama_file(fname.c_str(), "rb", use_direct_io));
contexts.emplace_back(ctx);
// Карта: имя тензора → (файл, смещение, метаданные, тензор); при load_tensor_data по имени найдём, откуда читать
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, meta.get(), cur));
}
}В конструкторе gguf_init_from_file читает заголовок GGUF и метаданные (без самих весов) — так получают список тензоров и пары ключ–значение (архитектура, гиперпараметры) без чтения тяжёлых данных в память. Из метаданных берётся имя архитектуры (general.architecture) — от него зависят имена остальных полей в GGUF (у разных моделей разные ключи). Файл открывается для чтения; потом по смещениям из weights_map будут читаться или маппиться данные тензоров. По списку тензоров из GGUF для каждого в weights_map записывается имя, размер и смещение в файле — при вызове load_tensor_data загрузчик по имени найдёт тензор в карте и прочитает данные по смещению.
Структура llama_tensor_weight (элемент weights_map) хранит ссылку на файл, смещение в файле, ссылку на метаданные GGUF и указатель на тензор в контексте GGUF — по ним загрузчик потом читает байты в буфер тензора. Метод ml.get_key(…) читает из метаданных GGUF значение по ключу (имя ключа зависит от архитектуры — его возвращает kv(…)). Так загрузчик получает, например, имя архитектуры, тип токенайзера, гиперпараметры.
После создания загрузчика в llama_model_load вызывается ml.print_info(). Цитата из кода:
// В llama_model_load после создания llama_model_loader: ml.print_info(); // выводит в лог архитектуру, число тензоров, размер файла — для отладки и информации пользователю
Функция gguf_init_from_file (библиотека GGUF) открывает файл и читает заголовок:
— версию формата;
— число ключей метаданных;
— число тензоров.
Метаданные читаются в контекст gguf_context (пары ключ–значение); сами данные тензоров на этом шаге не загружаются — только имена, типы и смещения в файле. Метод print_info (src/llama-model-loader.cpp) выводит в лог информацию о файле:
— архитектуру;
— количество тензоров;
— размер в байтах.
➤ Формат GGUF (справочно)
GGUF (GPT-Generated Unified Format) — бинарный формат для хранения моделей машинного обучения.
Для чего он нужен:
— чтобы в одном файле хранить и веса модели, и метаданные (размеры, тип архитектуры), и словарь;
— загрузчик по заголовку и метаданным строит «карту» и потом по запросу читает нужные куски файла или маппит их в память (mmap).
Структура заголовка GGUF:
— магическое число (идентификация формата — по нему понимают, что это GGUF);
— версия формата (для совместимости при изменениях формата);
— число ключей метаданных (n_kv);
— число тензоров (n_tensors).
Метаданные хранятся как массив пар «ключ — значение». Для каждой пары записаны:
— тип ключа (строка, число, массив и т.д.);
— имя ключа;
— значение (имя архитектуры, размерности, параметры RoPE, тип токенайзера, списки токенов и т.д.).
Тензоры в файле идут после метаданных. Для каждого тензора записаны:
— имя;
— тип элемента (F32, F16, Q8_0, Q4_K_M и т.д. — от полной точности до квантизованных форматов);
— размерности (например, [n_layer, n_embd, n_embd]);
— смещение в файле, по которому начинаются данные.
Зачем это загрузчику: по этой информации он строит weights_map (карту «имя тензора → где в файле лежат данные») и при вызове load_tensor_data читает или маппит соответствующий участок файла.
Версия формата и типы элементов:
— версия формата задаётся в заголовке GGUF.
Для чего:
— при изменениях формата версия позволяет загрузчику понять, с какой версией он работает; при несовместимости загрузчик может выдать ошибку или проигнорировать неизвестные поля.
— по типу элемента тензора (F32, F16, Q8_0, Q4_K_M и т.д.) загрузчик знает, сколько байт занимает один элемент и как интерпретировать данные при копировании в буфер или при mmap.
Для чего:
— без этого нельзя корректно прочитать или отобразить в память данные тензора; разные типы имеют разный размер и разную интерпретацию байтов.
Типы данных тензоров в GGUF задают, как интерпретировать байты: F32, F16, Q8_0, Q4_K_M и т.д. — от полной точности до квантизованных форматов. Квантизация уменьшает размер модели и ускоряет вычисления за счёт приближённого представления весов. Движок при загрузке создаёт тензоры в нужном формате и копирует или маппит данные из файла в буферы на CPU или GPU.
Чтение метаданных GGUF выполняется через функции библиотеки gguf:
— gguf_get_n_kv — число ключей;
— gguf_get_key — имя ключа по индексу;
— gguf_get_kv_type — тип значения (строка, число, массив и т.д.);
— gguf_get_val_* — значение по индексу или по имени.
Загрузчик модели оборачивает это в метод get_key(key, value) с учётом архитектуры: ключ преобразуется в имя поля в GGUF (например, llama.embedding_length для LLaMA).
➤ Шаг 3: Определение типа модели (архитектура)
Шаг 3 — определение типа модели (LLaMA, Gemma, Qwen и т.д.) по имени архитектуры из GGUF. Метод llama_model::load_arch определяет тип модели по имени из GGUF. Для чего это нужно: от типа архитектуры (LLaMA, Gemma, Qwen и т.д.) зависят имена полей в метаданных GGUF и то, какие тензоры создавать при загрузке весов; без этого нельзя корректно прочитать гиперпараметры и словарь. Цитата (файл src/llama-model.cpp):
// Шаг 3: по имени архитектуры из GGUF выбираем тип модели — от него зависят ключи при load_hparams и load_vocab
void llama_model::load_arch(llama_model_loader & ml) {
arch = ml.get_arch(); // читает general.architecture (строка) и превращает в enum: LLM_ARCH_LLAMA, LLM_ARCH_GEMMA и т.д.
if (arch == LLM_ARCH_UNKNOWN) {
throw std::runtime_error("unknown model architecture: '" + ml.get_arch_name() + "'");
}
}Что происходит и для чего:
— ml.get_arch() читает из метаданных GGUF ключ general.architecture (строка) и преобразует его в enum llm_arch.
Для чего:
дальше по этому enum выбираются имена полей для гиперпараметров и словаря (у разных архитектур — разные ключи в GGUF).
— Если тип неизвестен (LLM_ARCH_UNKNOWN), выбрасывается ошибка.
Для чего:
движок не умеет работать с неизвестной архитектурой; приложение получит сообщение об ошибке и сможет сообщить пользователю.
Реализация get_arch() в загрузчике (src/llama-model-loader.cpp):
// Вспомогательный метод загрузчика: читает general.architecture из GGUF и превращает строку в enum
llm_arch llama_model_loader::get_arch() const {
std::string arch_name;
get_key(llm_kv(LLM_KV_GENERAL_ARCHITECTURE), arch_name, false); // ключ из meta (метаданные GGUF)
return llm_arch_from_string(arch_name); // "llama" → LLM_ARCH_LLAMA, "qwen2" → LLM_ARCH_QWEN и т.д.
}Что делают эти вызовы:
— get_key(llm_kv(LLM_KV_GENERAL_ARCHITECTURE), arch_name, false) — из метаданных GGUF читается значение по ключу «общая архитектура» (например, llama, qwen2) и записывается в arch_name.
Для чего:
без имени архитектуры нельзя выбрать набор ключей для гиперпараметров и словаря.
— llm_arch_from_string(arch_name) — строка превращается в enum llm_arch (LLM_ARCH_LLAMA, LLM_ARCH_QWEN и т.д.).
Для чего:
по enum дальше выбираются имена полей в GGUF для load_hparams и load_vocab.
— Примеры значений enum: LLM_ARCH_LLAMA, LLM_ARCH_GEMMA, LLM_ARCH_QWEN, LLM_ARCH_PHI, LLM_ARCH_MISTRAL, LLM_ARCH_CLIP и т.д. Для каждой архитектуры задан свой набор ключей GGUF (LLM_KV), по которым загрузчик читает имена полей (гиперпараметры, словарь, имена тензоров).
Для чего:
без правильного набора ключей нельзя прочитать гиперпараметры и словарь из GGUF.
— Имена тензоров в GGUF зависят от архитектуры: для LLaMA — blk.N.attn_q.weight, blk.N.attn_k.weight и т.д.; для других моделей — свои префиксы и суффиксы. При добавлении поддержки новой архитектуры в код добавляется новый enum и набор ключей LLM_KV. Гиперпараметры используются при создании тензоров в load_tensors (размеры матриц, число слоёв) и при создании контекста (n_ctx, n_batch, параметры RoPE и т.д.).
➤ Шаг 4: Загрузка гиперпараметров — размеры и контекст
Шаг 4 — загрузка гиперпараметров (размеры модели, длина контекста, число слоёв и т.д.) из метаданных GGUF. Метод llama_model::load_hparams заполняет структуру hparams (гиперпараметры) из метаданных GGUF. Для чего это нужно: гиперпараметры задают «форму» модели — длину контекста, число слоёв, размер эмбеддинга, число голов внимания и т.п.; без них нельзя выделить память под тензоры и построить вычислительный граф. Цитата с основными ключами (файл src/llama-model.cpp):
// Шаг 4: читаем гиперпараметры из GGUF — размеры модели, длина контекста, число слоёв; от них зависят выделение памяти и граф
void llama_model::load_hparams(llama_model_loader & ml) {
const gguf_context * ctx = ml.meta.get();
// Сохраняем все пары ключ–значение из GGUF (кроме массивов) для справки
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;
}
// Основные размеры (имена ключей зависят от архитектуры — load_arch уже вызван)
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_EMBEDDING_LENGTH_OUT, hparams.n_embd_out_impl, false);
ml.get_key(LLM_KV_BLOCK_COUNT, hparams.n_layer); // число слоёв трансформера
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); // число голов внимания
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)
ml.get_key(LLM_KV_ROPE_FREQ_BASE, hparams.rope_freq_base_train, false); // базовая частота RoPE
// rope_scaling_type, rope_freq_scale и т.д.
}Что происходит и для чего:
— В цикле читаются все пары ключ–значение из GGUF (кроме массивов) и сохраняются в gguf_kv.
Для чего:
— чтобы потом при необходимости можно было обратиться к любому полю по имени.
— Затем по ключам, зависящим от архитектуры, заполняются поля hparams:
— n_ctx_train — длина контекста при обучении.
Для чего:
— от неё зависит размер KV-cache и максимальная длина ввода.
— n_embd — размер эмбеддинга (скрытого слоя).
Для чего:
— от него зависят размеры матриц весов.
— n_layer — число слоёв трансформера.
Для чего:
— от него зависит, сколько тензоров создавать в load_tensors.
— 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 сначала вызываются load_arch(ml) и load_hparams(ml), затем load_vocab(ml). Внутри load_vocab вызывается vocab.load(ml, kv). Он загружается в llama_model::load_vocab (src/llama-model.cpp):
void llama_model::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::load_vocab после того, как для текущей архитектуры получен набор ключей kv (через LLM_KV(arch)). Загрузчик ml уже открыл GGUF и прочитал метаданные; архитектура и гиперпараметры к этому моменту загружены в load_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.meta.get();
// Тип токенайзера: llama → SPM, gpt2 → BPE и т.д.
ml.get_key(LLM_KV_TOKENIZER_MODEL, tokenizer_model);
ml.get_key(LLM_KV_TOKENIZER_PRE, tokenizer_pre, false);
ml.get_key(LLM_KV_TOKENIZER_TOKEN_TYPE_COUNT, n_token_types, false);
if (tokenizer_model == "no_vocab" || tokenizer_model == "none") {
type = LLAMA_VOCAB_TYPE_NONE;
special_bos_id = LLAMA_TOKEN_NULL;
special_eos_id = LLAMA_TOKEN_NULL;
special_unk_id = LLAMA_TOKEN_NULL;
return;
}
if (tokenizer_model == "llama") {
type = LLAMA_VOCAB_TYPE_SPM; // SentencePiece-подобный
special_bos_id = 1;
special_eos_id = 2;
special_unk_id = 0;
} else if (tokenizer_model == "gpt2") {
type = LLAMA_VOCAB_TYPE_BPE;
// Читаем слияния BPE (пары подстрок для жадного слияния)
const int merges_keyidx = gguf_find_key(ctx, kv(LLM_KV_TOKENIZER_MERGES).c_str());
const int n_merges = gguf_get_arr_n(ctx, merges_keyidx);
for (int i = 0; i < n_merges; i++) {
const std::string word = gguf_get_arr_str(ctx, merges_keyidx, i);
std::string first, second;
const size_t pos = word.find(' ', 1);
if (pos != std::string::npos) {
first = word.substr(0, pos);
second = word.substr(pos + 1);
}
bpe_ranks.emplace(std::make_pair(first, second), i);
}
}
// Массив токенов: для каждого ID — строка (кусочек текста)
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");
}
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);
token_to_id[word] = i; // текст → ID
max_token_len = std::max(max_token_len, (int) word.size());
auto & token_data = id_to_token[i];
token_data.text = std::move(word); // ID → текст
token_data.score = scores ? scores[i] : 0.0f;
token_data.attr = LLAMA_TOKEN_ATTR_NORMAL;
}
init_tokenizer(type); // инициализация токенайзера по типу (SPM, BPE и т.д.)
}В коде видно: определение типа словаря по tokenizer_model (llama → SPM, gpt2 → BPE с чтением слияний), поиск массива токенов в GGUF, цикл по всем токенам — заполнение id_to_token и token_to_id, вызов init_tokenizer(type) для инициализации токенайзера (SPM, 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::load_tensors (src/llama-model.cpp) создаёт в памяти тензоры модели (те самые массивы весов), назначает им буферы — участки памяти на CPU или видеокарте — и заполняет их данными из GGUF-файла. Ниже — цитаты начала метода и ключевых фрагментов.
bool llama_model::load_tensors(llama_model_loader & ml) {
const auto & split_mode = params.split_mode;
const auto & use_mlock = params.use_mlock;
const auto & tensor_split = params.tensor_split;
const int n_layer = hparams.n_layer;
const int n_gpu_layers = this->n_gpu_layers(); // сколько слоёв загружать на GPU
const bool use_mmap_buffer = true;
LLAMA_LOG_INFO("%s: loading model tensors, this can take a while... (mmap = %s, direct_io = %s)\n",
__func__, ml.use_mmap ? "true" : "false", ml.use_direct_io ? "true" : "false");
// Списки типов буферов для CPU и для каждой видеокарты — от них зависит, куда попадут тензоры
pimpl->cpu_buft_list = make_cpu_buft_list(devices, params.use_extra_bufts, params.no_host);
for (auto * dev : devices) {
buft_list_t buft_list = make_gpu_buft_list(dev, split_mode, tensor_split);
buft_list.insert(buft_list.end(), pimpl->cpu_buft_list.begin(), pimpl->cpu_buft_list.end());
pimpl->gpu_buft_list.emplace(dev, std::move(buft_list));
}
ggml_backend_dev_t cpu_dev = ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_CPU);
// Точки разбиения слоёв по устройствам (по свободной памяти или tensor_split)
const int i_gpu_start = std::max(int(hparams.n_layer) + 1 - n_gpu_layers, 0);
const int act_gpu_layers = devices.empty() ? 0 : std::min(n_gpu_layers, int(n_layer) + 1);
// Для номера слоя il возвращаем устройство и список буферов: вход всегда CPU, слои — по разбиению
auto get_layer_buft_list = [&](int il) -> llama_model::impl::layer_dev {
const bool is_swa = il < int(hparams.n_layer) && hparams.is_swa(il);
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);
return {dev, &pimpl->gpu_buft_list.at(dev)};
};
pimpl->dev_input = { cpu_dev, &pimpl->cpu_buft_list }; // входные тензоры всегда на CPU
pimpl->dev_layer.resize(n_layer);
for (int il = 0; il < n_layer; ++il) {
pimpl->dev_layer[il] = get_layer_buft_list(il); // каждый слой — на CPU или на одной из GPU
}
pimpl->dev_output = get_layer_buft_list(n_layer); // выход по тому же разбиениюЧто происходит в начале load_tensors и для чего:
— формируются списки типов буферов для CPU и для каждой видеокарты
Для чего:
от них зависит, на какие устройства будут размещены тензоры модели — входные обычно на CPU, слои по разбиению на CPU/GPU;
— по настройкам (tensor_split) или по свободной памяти решается, какие слои считать на CPU, какие на GPU
Для чего:
чтобы равномерно загрузить устройства и не переполнить память одной видеокарты.
Функция get_layer_buft_list(il) для номера слоя возвращает устройство и список буферов: вход всегда на CPU, повторяющиеся слои могут быть на CPU или GPU, выход — по разбиению. Далее в цикле создаются тензоры: эмбеддинги, выход, для каждого слоя — матрицы внимания (Q/K/V/O), нормализации, feed-forward. Каждый тензор создаётся в нужном буфере, данные читаются из GGUF (ml.load_all_data(…) или mmap). В итоге веса модели оказываются в памяти (и при необходимости на видеокарте), модель готова к генерации текста.
Реализация чтения весов из файла — вызовы ml.get_tensor(name) и ml.load_tensor_data(tensor) (или mmap) в src/llama-model-loader.cpp: по имени тензора из weights_map берётся смещение и размер, данные копируются в буфер тензора на CPU или GPU. Для больших моделей используется mmap, чтобы не дублировать данные в оперативной памяти.
Цитата метода load_tensor_data (загрузчик, src/llama-model-loader.cpp):
// Шаг 6 (деталь): загружает данные одного тензора — по карте weights_map знаем смещение в файле
void llama_model_loader::load_tensor_data(ggml_tensor * tensor) {
auto it = weights_map.find(ggml_get_name(tensor));
if (it == weights_map.end()) return;
llama_tensor_weight & w = it->second;
// mmap: участок файла отображаем в память (данные не копируются — читаются по обращению). Иначе — читаем байты в буфер
if (use_mmap) {
ggml_backend_tensor_set_from_file(tensor, w.file, w.offset);
} else {
w.file->seek(w.offset, SEEK_SET);
w.file->read_raw(tensor->data, ggml_nbytes(tensor));
}
}Что происходит в load_tensor_data и для чего:
— по имени тензора ищется запись в weights_map (карта строится в конструкторе загрузчика).
Для чего:
без карты нельзя узнать смещение и размер данных тензора в файле.
— при use_mmap == true участок файла отображается в память через ggml_backend_tensor_set_from_file — данные не копируются, читаются по мере обращения.
Для чего:
экономия RAM и ускорение старта загрузки.
— при use_mmap == false файл позиционируется на w.offset и байты читаются в tensor->data.
Для чего:
полная копия данных в буфер (нужно, если файл будет закрыт или данные будут меняться).
Типичный фрагмент загрузки тензоров в цикле по слоям (логика из llama_model::load_tensors в src/llama-model.cpp): для каждого тензора модели (эмбеддинги, веса слоёв внимания и feed-forward) вызывается ml.get_tensor(name) — загрузчик возвращает указатель на тензор GGML с привязанным буфером; затем ml.load_tensor_data(tensor) читает данные из файла в этот буфер (или настраивает mmap). Имена тензоров зависят от архитектуры (например, blk.0.attn_q.weight, blk.0.attn_k.weight и т.д.). После прохода по всем тензорам веса модели полностью находятся в памяти (и при необходимости на GPU).
// Упрощённая схема загрузки тензоров в load_tensors (логика)
for (const auto & it : ml.weights_map) {
const std::string & name = it.first;
ggml_tensor * cur = ggml_get_tensor(ml.ctx_gguf, name.c_str());
if (cur == nullptr) continue;
ggml_tensor * tensor = ml.get_tensor(name);
if (tensor == nullptr) continue;
ml.load_tensor_data(tensor);
// тензор уже привязан к буферу на CPU или GPU, данные прочитаны или замаплены
}Функция get_tensor в загрузчике по имени находит запись в weights_map, создаёт тензор GGML в нужном бэкенде (CPU или GPU по разбиению слоёв) и возвращает указатель. load_tensor_data по смещению в файле читает байты в буфер тензора или настраивает отображение файла в память (mmap). Так загружаются все веса: эмбеддинги, матрицы Q/K/V/O, нормализации, feed-forward по каждому слою.
При использовании mmap данные тензоров не копируются в оперативную память — вместо этого отображается участок файла. Это уменьшает потребление RAM и ускоряет старт загрузки, но требует, чтобы файл оставался открытым на время работы с моделью. При use_direct_io чтение идёт с выравниванием и параметрами, подходящими для прямого доступа к диску. Разбиение по слоям (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 граф не строится с нуля и не выделяется память «на лету» — это уменьшает задержку первого ответа.
Начало конструктора:
llama_context::llama_context(
const llama_model & model,
llama_context_params params) :
model(model),
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));
}
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;
cparams.yarn_attn_factor = params.yarn_attn_factor >= 0.0f ? params.yarn_attn_factor : hparams.yarn_attn_factor;
// ...
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;
// ...
cparams.causal_attn = ...;
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);
// ...
LLAMA_LOG_INFO("%s: n_ctx = %u\n", __func__, cparams.n_ctx);
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), memory (KV-cache), sched (планировщик)
}Что задаётся в конструкторе контекста:
— сохраняются ссылка на модель и время загрузки;
— задаются максимальное число последовательностей (n_seq_max), число потоков, параметры YaRN/RoPE, размер контекста (n_ctx), размер батча (n_batch, n_ubatch), тип внимания (causal).
Для чего:
— от этих параметров зависят размер KV-cache, максимальная длина промпта и то, как большими порциями обрабатывается батч.
Далее инициализируются бэкенды (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): создаётся ggml_backend_sched с учётом списка бэкендов модели, затем вызывается graph_reserve — для Prefill и Decode строятся временные графы, планировщик выделяет под них буферы, так что при первом реальном decode граф только подставляется в уже зарезервированную память. Объект memory (реализация в src/llama-memory.cpp) выделяет буферы под KV-cache для каждого слоя и каждой позиции в контексте.
Функция graph_reserve (в src/llama-context.cpp) создаёт два зарезервированных графа: один для Prefill (много токенов за раз), другой для Decode (один токен). Для каждого вызывается model.build_graph с соответствующими параметрами (тип графа, размер батча), затем ggml_backend_sched_alloc_graph — планировщик разбивает граф по бэкендам и резервирует буферы. При первом вызове process_ubatch граф либо переиспользуется (если размеры совпадают), либо строится заново; в любом случае выделение под большие графы уже сделано при создании контекста, что уменьшает задержки при первом 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 уже в кэше.
// Упрощённый цикл генерации (шаги 8–16): как приложение вызывает API (псевдокод по логике examples/)
// Шаг 9: текст промпта → массив токенов
n_tokens = llama_tokenize(model, prompt, tokens, n_max, add_bos, true);
llama_batch_clear(batch);
for (int i = 0; i < n_tokens; i++) llama_batch_add(batch, tokens[i], i, {0}, false);
llama_batch_set_logits(batch, n_tokens - 1, true); // логиты запрашиваем только для последней позиции (шаг 14)
while (true) {
if (llama_decode(ctx, batch) != 0) break; // шаги 11–14: encode, подбатчи, process_ubatch, логиты в буфере
float * logits = llama_get_logits_ith(ctx, batch.n_tokens - 1); // шаг 14: читаем логиты
llama_token next = llama_sampler_sample(sampler, ctx, batch.n_tokens - 1); // шаг 15: сэмплинг
if (next == llama_token_eos(model)) break; // EOS — конец вывода
char buf[256];
int n = llama_token_to_piece(model, next, buf, sizeof(buf)); // шаг 16: токен → текст
printf("%.*s", n, buf);
llama_batch_clear(batch);
llama_batch_add(batch, next, n_cur, {0}, true); // один новый токен — следующий цикл будет Decode
n_cur++;
}➤ Шаг 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);
— 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 и подобные;
— LLAMA_VOCAB_TYPE_NONE — словарь не используется (например, для CLIP). Функция tokenizer_st_partition разбивает входной текст на фрагменты: обычный текст и специальные теги (если parse_special включён) — теги превращаются в один токен, остальное идёт в токенайзер по типу словаря. Фрагмент:
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.");
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()) {
case LLAMA_VOCAB_TYPE_SPM: {
llm_tokenizer_spm_session session(vocab);
if (add_special && add_bos) output.push_back(special_bos_id);
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 {
output.push_back(fragment.token);
}
}
if (add_special && add_eos) output.push_back(special_eos_id);
} break;
case LLAMA_VOCAB_TYPE_BPE: {
llm_tokenizer_bpe_session session(vocab, *static_cast<llm_tokenizer_bpe*>(tokenizer.get()));
if (add_special) session.append_bos(output);
for (const auto & fragment : fragment_buffer) {
if (fragment.type == FRAGMENT_BUFFER_VARIANT_TYPE_RAW_TEXT)
session.tokenize(fragment.raw_text.substr(fragment.offset, fragment.length), output);
else
session.append(fragment.token, output);
}
if (add_special) session.append_eos(output);
} break;
default: break;
}
return output;
}➤ Шаг 10: Формирование батча для одного вызова
Шаг 10 — токены упаковываются в батч для одного вызова к модели. Один вызов к модели передаётся через структуру llama_batch (в include/llama.h).
Для чего нужен батч:
— функция llama_decode принимает ровно один батч — набор токенов (и служебных полей: позиции, идентификаторы последовательностей, флаги логитов);
— так движок знает, какие токены обработать за один проход, на каких позициях они стоят и для каких позиций нужно вернуть логиты (обычно только для последней — чтобы выбрать следующий токен);
— при первом запросе в батче обычно все токены промпта; при генерации по одному токену — один новый токен.
// Шаг 10: батч — «пакет» токенов для одного вызова llama_decode; token[], pos[], seq_id[], logits[] заполняются приложением или balloc->init
typedef 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_decode; при генерации по одному токену в батче один токен с позицией n_cur.
Реализация метода llama_batch_allocr::init (файл src/llama-batch.cpp):
// Шаг 10 (продолжение): balloc->init заполняет позиции и флаги логитов в батче (если приложение не заполнило); логиты обычно только для последней позиции
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);
if (batch.token) {
for (int32_t i = 0; i < batch.n_tokens; ++i) {
if (batch.token[i] < 0 || (uint32_t) batch.token[i] >= vocab.n_tokens()) {
LLAMA_LOG_ERROR("%s: invalid token[%d] = %d\n", __func__, i, batch.token[i]);
return false;
}
}
}
if (!batch.n_seq_id) {
n_seq_id.resize(batch.n_tokens);
for (int32_t i = 0; i < batch.n_tokens; i++) {
n_seq_id[i] = seq_id_0.size();
}
batch.n_seq_id = n_seq_id.data();
}
if (!batch.seq_id) {
seq_id.resize(batch.n_tokens + 1);
seq_id[batch.n_tokens] = NULL;
for (int32_t i = 0; i < batch.n_tokens; i++) {
seq_id[i] = seq_id_0.data();
}
batch.seq_id = seq_id.data();
}
if (!batch.pos) {
pos.resize(batch.n_tokens);
llama_pos p0[LLAMA_MAX_SEQ];
for (uint32_t s = 0; s < n_seq_max; ++s) {
p0[s] = memory ? memory->seq_pos_max(s) + 1 : 0;
}
for (int32_t i = 0; i < batch.n_tokens; i++) {
pos[i] = p0[batch.seq_id[i][0]];
for (int32_t s = 0; s < batch.n_seq_id[i]; ++s) {
p0[batch.seq_id[i][s]] = pos[i] + 1;
}
}
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();
}
return true;
}В коде:
— проверка токенов на допустимость;
— при отсутствии n_seq_id и seq_id заполняются нулевыми последовательностями;
— при отсутствии pos позиции берутся из памяти (memory->seq_pos_max(s) + 1) и проставляются по порядку;
— при отсутствии logits логиты запрашиваются только для последнего токена (или для всех, если output_all).
➤ Шаг 11: Токены превращаются в эмбеддинги
Шаг 11 — внутри decode токены (если в батче переданы именно они, а не готовые эмбеддинги) превращаются в эмбеддинги. В батче можно передать либо ID токенов (token), либо готовые эмбеддинги (embd). В типичном случае приложение передаёт токены — тогда внутри llama_context::decode перед обработкой батча вызывается encode.
Для чего нужен encode:
— модель (слои трансформера) работает не с номерами токенов, а с векторами чисел фиксированной длины — эмбеддингами;
— encode превращает каждый токен в такой вектор, копируя соответствующую строку из эмбеддинг-таблицы модели во входной тензор графа;
— без encode граф не получил бы корректный вход.
Что такое эмбеддинг-таблица:
— это матрица весов модели размером «размер словаря × размер эмбеддинга»; одна строка — вектор чисел для одного токена;
— она загружается в load_tensors (тензор с именем вроде tok_embeddings или embed_tokens);
— при encode для каждого токена из батча берётся строка с индексом token[i] и копируется во входной тензор графа на позицию i;
— после этого граф считает уже по эмбеддингам (слои трансформера, внимание, feed-forward, логиты).
Где это в коде: в llama_context::decode (файл src/llama-context.cpp) проверяется, передан ли батч с токенами или с эмбеддингами. Если с токенами — вызывается внутренняя функция (или метод), которая для каждого элемента батча берёт batch.token[i], находит в модели тензор эмбеддингов и копирует строку с индексом token[i] во входной тензор графа на позицию i. Один вызов encode заполняет входной слой графа эмбеддингами для всех токенов батча. Дальше process_ubatch строит граф и выполняет вычисления уже по этим эмбеддингам. При decode по одному токену копируется одна строка матрицы эмбеддингов во входной буфер графа.
Цитата логики encode (получение эмбеддингов по ID токенов):
// Шаг 11 (encode): для каждого токена в батче берём строку эмбеддинг-таблицы модели (индекс = token[i]) и копируем во входной тензор графа — модель считает по векторам, а не по ID
// В decode перед process_ubatch (если в батче переданы токены, а не эмбеддинги):
for (int32_t i = 0; i < batch.n_tokens; i++) {
llama_token tid = batch.token[i];
// tok_embeddings — тензор модели размером [n_vocab, n_embd]; одна строка — вектор эмбеддинга для одного токена
float * row = (float *) ((char *) model.tok_embeddings->data + tid * ggml_row_size(model.tok_embeddings->type, n_embd));
memcpy(embd_buffer + i * n_embd, row, n_embd * sizeof(float));
}
// Для чего: модель (слои трансформера) принимает на вход векторы фиксированной длины, а не ID токеновЧто происходит при encode и для чего:
— для каждого индекса i в батче берётся ID токена batch.token[i].
Для чего:
по ID нужно получить вектор эмбеддинга из таблицы весов модели.
— из тензора tok_embeddings (или embed_tokens) читается строка с индексом tid — это вектор длины n_embd.
Для чего:
эта строка и есть эмбеддинг токена — вход для первого слоя трансформера.
— строка копируется во входной буфер графа на позицию i.
Для чего:
граф при выполнении будет читать эмбеддинги из этого буфера; без encode буфер был бы пуст или содержал мусор.
➤ Шаги 11–12: Прогон батча через модель (вход в decode, подбатчи)
Шаги 11 и 12 — приложение вызывает llama_decode, передавая батч; внутри выполняются при необходимости превращение токенов в эмбеддинги (шаг 11), затем разбиение батча на подбатчи (шаг 12) и прогон каждого подбатча через модель (шаг 13). Ниже — цитаты точки входа, цикла по подбатчам и разбиения батча.
Для чего нужна функция llama_decode:
— она прогоняет один батч токенов через модель и заполняет внутренний буфер контекста логитами — «сырыми» оценками по каждому возможному следующему токену;
— по этим логитам приложение (через сэмплер) выбирает один следующий токен и при необходимости снова вызывает decode с одним новым токеном в батче;
— так повторяется до конца ответа (EOS) или лимита.
Публичный API — llama_decode (в src/llama-context.cpp):
// Шаг 11: точка входа decode — приложение передаёт батч; внутри: encode (если нужен), подбатчи, process_ubatch, логиты в буфере
int32_t llama_decode(llama_context * ctx, llama_batch batch) {
const int ret = ctx->decode(batch); // 0 — успех, 1 — нужно больше данных, отрицательное — ошибка
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: метод llama_context::decode(const llama_batch & batch) проверяет батч, при необходимости вызывает encode (если переданы токены, а не эмбеддинги), затем вызывает balloc->init(batch, …), резервирует планировщик и обновляет память (KV-cache), после чего в цикле получает подбатчи через memory->init_batch и для каждого вызывает process_ubatch; логиты копируются во внутренний буфер контекста.
Цитата цикла по подбатчам внутри decode (схема, src/llama-context.cpp):
// В decode после balloc->init и обновления памяти: цикл по подбатчам (шаг 12 → шаг 13)
while (memory->init_batch(batch, ubatch)) {
if (ubatch.n_tokens == 0) break;
auto * res = process_ubatch(ubatch, gtype, mctx, status); // шаг 13: граф + выполнение
if (!res) { ...; break; }
// Копирование логитов для позиций с logits[i]==true в буфер контекста (шаг 14)
copy_logits(res, ...);
}
// После цикла приложение читает логиты через llama_get_logits_ithСначала проверяется батч:
— переданы ли токены или готовые эмбеддинги;
— при необходимости вызывается encode.
Затем батч инициализируется через balloc->init — это нужно, чтобы заполнить позиции токенов и решить, для каких позиций считать логиты (обычно только для последней). Резервируется планировщик и обновляется память (KV-cache).
Дальше в цикле memory->init_batch выдаёт подбатчи (ubatch) размером не больше n_ubatch — так большой батч разбивается на части, которые по очереди прогоняются через модель. Для каждого подбатча вызывается process_ubatch(…): внутри строится вычислительный граф (эмбеддинги, слои трансформера, выход в логиты), выполняется вычисление на CPU/GPU, возвращаются тензоры с логитами — вероятностями по словарю.
Цитата логики разбиения батча на подбатчи (init_batch, src/llama-memory.cpp):
// Шаг 12: возвращает очередной подбатч (ubatch) размером не больше n_ubatch; в decode вызывается в цикле
bool llama_memory::init_batch(const llama_batch & batch, llama_ubatch & ubatch) {
if (batch.n_tokens == 0) return false;
const uint32_t n_ubatch = cparams.n_ubatch;
// Из полного батча берём очередные n_ubatch токенов (или остаток) — так длинный промпт не переполняет память
ubatch.n_tokens = std::min((uint32_t) batch.n_tokens, n_ubatch);
// Копируем в ubatch: token[], pos[], seq_id[], logits[] для этой порции
// ... (реализация копирования и сдвига индексов для следующего вызова)
return true; // при следующем вызове вернётся следующий подбатч; когда токены кончились — false
}Что делает init_batch и для чего:
— из полного батча берётся очередная порция токенов размером не больше n_ubatch.
Для чего:
один вызов process_ubatch обрабатывает ограниченное число токенов — так не переполняется память под промежуточные тензоры графа.
— в ubatch копируются токены, позиции и флаги логитов для этой порции.
Для чего:
process_ubatch получает готовый подбатч и строит граф только для него.
— при следующем вызове init_batch возвращается следующий подбатч, пока все токены батча не будут обработаны.
Для чего:
длинный промпт обрабатывается за несколько проходов через модель без единого огромного графа.
Реализация цикла по подбатчам в llama_context::decode (схема): пока memory->init_batch(batch, ubatch) возвращает подбатч с ubatch.n_tokens > 0, вызывается process_ubatch(ubatch, gtype, mctx, status). Тип графа gtype — Prefill, если в подбатче больше одного токена (или первый проход по промпту), иначе Decode. После каждого process_ubatch логиты для позиций с logits[i] == true копируются во внутренний буфер контекста, откуда их читает llama_get_logits_ith. Цикл завершается, когда все токены батча обработаны. При первом decode с полным промптом тип графа — Prefill; при последующих decode с одним токеном — Decode; планировщик и граф переключаются между зарезервированными графами по типу.
Порядок вызовов при одном decode (сводка):
— llama_decode(ctx, batch) → ctx->decode(batch);
— в decode: проверка батча, при необходимости encode, balloc->init(batch, …), резервирование планировщика, обновление памяти (KV-cache);
— цикл: memory->init_batch возвращает очередной ubatch, вызывается process_ubatch(ubatch, …) — внутри model.build_graph, res->set_inputs, graph_compute; логиты копируются в буфер контекста;
— после цикла приложение читает логиты через llama_get_logits_ith, сэмплер возвращает следующий токен.
Логиты копируются в выходной буфер контекста. Оттуда их читает llama_get_logits_ith — по этим числам сэмплер выбирает следующий токен (например, самый вероятный или по temperature/top_p).
Возвращаемое значение llama_decode: 0 — успех; 1 — нужно больше входных данных (в некоторых режимах батчинга); отрицательное значение — ошибка (например, GGML_STATUS_ALLOC_FAILED при нехватке памяти, GGML_STATUS_FAILED при ошибке вычислений). Приложение должно проверять возвращаемое значение и при ошибке прекращать генерацию или выводить сообщение об ошибке.
➤ Шаг 13: Подготовка к прогону подбатча (память, параметры графа)
Шаг 13 разбит в статье на три части: подготовка к прогону подбатча, построение графа и аллокация буферов, запись входов и выполнение графа. Вся работа по одному подбатчу выполняется в методе llama_context::process_ubatch (файл src/llama-context.cpp). Его вызывают из llama_context::decode в цикле: для каждого подбатча, который возвращает memory->init_batch, вызывается один раз process_ubatch. Внутри по шагам происходит: подготовка памяти, решение — переиспользовать ли уже построенный граф или строить заново, при необходимости построение графа и аллокация буферов, запись входных данных в граф, выполнение графа на CPU/GPU. Ниже — первая часть шага 13: точка входа и подготовка.
Для чего нужен вычислительный граф:
— модель (трансформер) — это цепочка операций: эмбеддинги, слои внимания (Q, K, V, softmax, взвешенная сумма), нормализации, feed-forward и т.д.;
— вместо того чтобы вызывать каждую операцию вручную, движок строит граф — список узлов (операций) и связей между ними;
— планировщик потом обходит граф в топологическом порядке и выполняет операции на CPU или GPU;
— так можно автоматически распределять узлы по устройствам и переиспользовать граф при одинаковых размерах батча.
Сигнатура и входные данные process_ubatch (цитата из src/llama-context.cpp):
// Шаг 13: один подбатч прогоняется через модель; вызывается из 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()) {
ret = GGML_STATUS_FAILED;
return nullptr;
}
// 2) Берём зарезервированный результат графа (в нём хранятся граф и буферы)
auto * res = gf_res_prev.get();
auto * gf = res->get_gf();
// 3) Формируем параметры графа: размер батча, тип Prefill или Decode, контекст памяти
const auto gparams = graph_params(res, ubatch, mctx, gtype);
// ... далее решение о переиспользовании, при необходимости build_graph и set_inputs, graph_compute
}Что происходит в начале process_ubatch и для чего:
— Вызывается mctx->apply(), если передан контекст памяти (mctx).
Для чего:
— контекст памяти обновляет состояние KV-cache и служебных буферов (например, сдвигает указатели на следующие свободные позиции); перед построением графа граф будет обращаться к этим буферам — они должны быть в актуальном состоянии.
— Берётся указатель на зарезервированный результат графа: res = gf_res_prev.get(), gf = res->get_gf().
Для чего:
— при создании контекста для Prefill и Decode уже зарезервированы графы и буферы; gf_res_prev указывает на тот из них, который соответствует текущему типу вызова (Prefill или Decode).
— Формируются параметры графа: gparams = graph_params(res, ubatch, mctx, gtype).
Для чего:
— в gparams попадают размер батча (ubatch.n_tokens), тип графа (Prefill — много токенов, или Decode — один токен), ссылки на контекст памяти; по ним дальше решается, переиспользовать ли граф, и при построении задаются размеры узлов и входов.
Если mctx->apply() возвращает false, функция сразу возвращает nullptr и в ret записывается GGML_STATUS_FAILED — вызывающий код (decode) обработает ошибку и прекратит цикл по подбатчам.
Цитата по смыслу: что делает mctx->apply() и что входит в gparams (по коду src/llama-context.cpp, src/llama-memory.cpp):
// mctx->apply() — обновляет KV-cache и служебные буферы: сдвигает указатели на следующие свободные позиции,
// применяет отложенные обновления; возвращает true при успехе, false при ошибке
if (mctx && !mctx->apply()) { ret = GGML_STATUS_FAILED; return nullptr; }
// graph_params(res, ubatch, mctx, gtype) — собирает в структуру: n_tokens, тип графа (Prefill/Decode),
// ссылки на буферы KV-cache и входные данные; по gparams потом решают can_reuse и строят граф
const auto gparams = graph_params(res, ubatch, mctx, gtype);➤ Шаг 13: Построение вычислительного графа и аллокация буферов
Шаг 13 (продолжение) — построение графа и выделение под него буферов. После подготовки памяти и формирования gparams движок решает: можно ли переиспользовать уже построенный граф (те же размеры батча и тип Prefill/Decode), или нужно строить граф заново и выделять под него буферы. Если граф переиспользуется — сразу переходят к записи входов и выполнению. Если нет — вызываются 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)) {
n_reused++; // счётчик переиспользований (для профилирования)
} else {
res->reset(); // сбрасываем результат графа (старые узлы и буферы)
ggml_backend_sched_reset(sched.get()); // сбрасываем планировщик
gf = model.build_graph(gparams); // строим граф: эмбеддинги → слои трансформера → логиты
if (!gf) { ret = GGML_STATUS_FAILED; return nullptr; }
if (!ggml_backend_sched_alloc_graph(sched.get(), gf)) { // планировщик выделяет буферы под все тензоры графа на CPU/GPU
ret = GGML_STATUS_ALLOC_FAILED;
return nullptr;
}
}
По шагам (что происходит и для чего):
— Проверка res->can_reuse(gparams): совпадают ли размер батча и тип графа с теми, для которых граф был построен в прошлый раз.
Для чего:
— при генерации по одному токену (Decode) каждый следующий вызов process_ubatch получает подбатч из одного токена и тот же тип Decode — граф можно не перестраивать, только подставить новые входы и выполнить; это сильно ускоряет генерацию.
— Если переиспользование отключено или параметры изменились: res->reset() — очищаются старые узлы графа и привязанные буферы.
Для чего:
— перед построением нового графа старый нужно сбросить, иначе узлы и тензоры накопятся.
— ggml_backend_sched_reset(sched.get()) — планировщик сбрасывает своё состояние (распределение узлов по бэкендам, зарезервированные буферы для этого графа).
Для чего:
— планировщик будет заново определять, на каком устройстве выполнять каждый узел нового графа и выделять под него память.
— gf = model.build_graph(gparams) — строится граф GGML: узлы для эмбеддингов и позиций, затем для каждого слоя трансформера — внимание (Q, K, V, softmax, взвешенная сумма), нормализация, feed-forward, снова нормализация; в конце — выходной слой в логиты (размер словаря).
Для чего:
— граф описывает, какие операции выполнить и в каком порядке; без него планировщику нечего выполнять. Реализация build_graph зависит от архитектуры (LLaMA, Gemma и т.д.) и находится в src/llama-model.cpp или в специализированных файлах.
— ggml_backend_sched_alloc_graph(sched.get(), gf) — планировщик обходит граф, для каждого тензора определяет бэкенд (CPU или GPU по разбиению слоёв), выделяет буфер на этом бэкенде (или переиспользует зарезервированный при создании контекста).
Для чего:
— без выделения буферов данные графа негде хранить; при первом decode буферы резервируются здесь (или при graph_reserve при создании контекста), при последующих переиспользованиях графа повторное выделение не нужно.
Структура графа по слоям (схема):
— вход: эмбеддинги токенов подбатча + позиционные кодировки (RoPE) по позициям из ubatch;
— для каждого слоя трансформера: нормализация входа → блок внимания (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_tokens, n_vocab]).
Цитата вызова build_graph и ggml_backend_sched_alloc_graph (по смыслу кода в src/llama-context.cpp и GGML):
// model.build_graph(gparams) — возвращает граф GGML (ggml_context с узлами); gparams задаёт n_tokens, тип Prefill/Decode, контекст памяти
ggml_graph * gf = model.build_graph(gparams);
// ggml_backend_sched_alloc_graph — планировщик обходит граф, для каждого тензора выбирает бэкенд (CPU/GPU) и выделяет буфер
bool ok = ggml_backend_sched_alloc_graph(sched.get(), gf);
if (!ok) { ret = GGML_STATUS_ALLOC_FAILED; return nullptr; }Резервирование графа для Prefill и Decode делается в graph_reserve при создании контекста: создаётся временный ubatch, вызывается model.build_graph, планировщик разбивает граф по устройствам и резервирует буферы — чтобы при первом 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); // записываем в входные тензоры графа: эмбеддинги (или токены) и позиции для этого подбатча
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 (куда писать новые K и V и откуда читать уже сохранённые).
Для чего:
— граф вычисляет по конкретным данным; без записи входов он работал бы с пустыми или устаревшими тензорами; при Decode в кэш подставляются буферы, куда дописываются новые ключи и значения для текущей позиции.
Цитата вызова graph_compute (выполнение графа на планировщике, обычно в том же файле или в src/llama-context.cpp):
// Планировщик обходит узлы графа в топологическом порядке и выполняет каждую операцию на назначенном бэкенде (CPU/GPU)
ggml_status graph_compute(llm_graph_result * res, bool capture) {
ggml_backend_sched * sched = ctx->sched.get();
ggml_backend_sched_begin(sched); // подготовка планировщика: резервирование буферов, порядок узлов
ggml_backend_sched_run(sched, res->get_gf()); // обход графа, выполнение каждого узла на своём бэкенде
ggml_backend_sched_end(sched); // завершение и синхронизация бэкендов
return ggml_backend_sched_get_status(sched);
}Что делает graph_compute по шагам и для чего:
— ggml_backend_sched_begin(sched) подготавливает планировщик к выполнению графа: фиксирует порядок обхода узлов (топологическая сортировка), при необходимости резервирует промежуточные буферы.
Для чего:
— узлы графа нужно выполнять в таком порядке, чтобы к моменту выполнения узла все его входы уже вычислены; планировщик этот порядок обеспечивает.
— ggml_backend_sched_run(sched, res->get_gf()) обходит граф: для каждого узла определяет бэкенд (CPU или GPU по разбиению модели), при необходимости копирует входные данные на это устройство, запускает операцию (умножение матриц, softmax, сложение и т.д.).
Для чего:
— так выполняются все слои трансформера; в итоге выходные тензоры графа — логиты — заполняются числами (по одному на каждый токен словаря для каждой позиции в подбатче, для которой запрошены логиты).
— ggml_backend_sched_end(sched) завершает выполнение и синхронизирует бэкенды (например, ждёт завершения операций на GPU).
Для чего:
— после этого выходные тензоры графа (логиты) готовы к чтению; вызывающий код (decode) копирует их в буфер контекста, откуда приложение читает логиты через llama_get_logits_ith.
Параметр capture в graph_compute(res->get_gf(), ubatch.n_tokens > 1) в некоторых сборках используется для отладки или профилирования (например, захват графа для визуализации). Возвращаемое значение graph_compute — статус GGML (успех или код ошибки); при успехе process_ubatch возвращает res, и в decode логиты из res копируются во внутренний буфер контекста для позиций с logits[i] == true.
При переиспользовании графа (когда размер батча и тип графа совпадают с предыдущим вызовом) граф не перестраивается и буферы не перевыделяются — вызываются только res->set_inputs(&ubatch) и graph_compute. Это ускоряет повторные вызовы decode при генерации по одному токену: граф Decode строится один раз при первом decode с одним токеном и далее переиспользуется. Счётчик n_reused в контексте увеличивается при каждом переиспользовании графа — по нему можно оценить долю переиспользований при профилировании.
➤ Шаги 14–15: Логиты в буфере и выбор следующего токена (сэмплинг)
Шаги 14 и 15 — после прогона подбатча логиты для последней позиции оказываются в буфере контекста (шаг 14); по ним сэмплер выбирает один следующий токен (шаг 15). После llama_decode во внутреннем буфере контекста лежат логиты — по одному числу на каждый токен словаря.
Для чего нужны логиты:
— модель на выходе выдаёт не один токен, а «оценки» по всем возможным следующим токенам (по одному числу — логиту — на каждый токен словаря);
— по этим числам сэмплер решает, какой один токен выбрать: например, с максимальным логитом (жадный выбор) или случайно с учётом temperature/top_p;
— без логитов приложение не смогло бы выбрать следующий токен.
Логиты можно понимать как «сырые» оценки того, насколько подходит каждый следующий токен; из них сэмплер выбирает один токен. Доступ к логитам — через llama_get_logits_ith (файл src/llama-context.cpp):
— возвращается указатель на массив из n_vocab float — по одному числу на каждый токен словаря.
Логиты запрашиваются только для тех позиций в батче, для которых в батче был установлен флаг logits[i] == true (обычно только для последней позиции). Контекст копирует логиты во внутренний буфер после каждого вызова process_ubatch для позиций с этим флагом; при нескольких подбатчах в одном decode в буфере остаются логиты для последней такой позиции (обычно последний токен в батче). Размер буфера логитов в контексте — n_vocab (по одному float на каждый токен словаря).
Реализация llama_get_logits_ith в src/llama-context.cpp возвращает указатель на логиты для заданной позиции в батче (обычно запрашивают логиты для последней позиции — по ним выбирается следующий токен):
// Шаг 14: возвращает указатель на логиты для позиции i (обычно i = последняя позиция в батче)
// Массив из n_vocab float — по одному числу на каждый токен словаря; сэмплер по ним выбирает следующий токен
float * llama_get_logits_ith(struct llama_context * ctx, int32_t i) {
return ctx->get_logits_ith(i);
}По этим числам выбирается следующий токен с помощью сэмплера — компонента, который по логитам решает, какой токен выдать (например, самый вероятный или случайный с учётом temperature/top_p). В API (include/llama.h) функция llama_sampler_sample(…) берёт логиты, применяет к ним цепочку сэмплеров и возвращает один токен. Этот токен добавляется в историю и при следующем вызове llama_decode передаётся в батче как единственный новый токен; цикл повторяется до конца ответа (токен EOS — «конец вывода») или лимита. Перевести токен обратно в текст — через llama_token_to_piece / llama_detokenize (реализация в src/llama-vocab.cpp).
Цепочка сэмплеров:
— сэмплинг в llama.cpp устроен как последовательность шагов;
— сначала к логитам может применяться сдвиг по повторениям (repeat penalty) — уменьшение вероятности уже появившихся токенов;
— затем применяется temperature — деление логитов на число (temperature > 1 делает распределение мягче, < 1 — острее);
— дальше может идти top-p (nucleus): оставляются только токены с накопленной вероятностью до порога p;
— после этого из оставшихся выбирается один токен — например, с максимальным логитом (жадный выбор) или случайно с вероятностями по softmax от логитов;
— реализация цепочки — в коде сэмплера (например, common/sampling.cpp или аналог в репозитории): цикл по зарегистрированным сэмплерам, каждый модифицирует логиты или выбирает токен.
Реализация перевода токена в текст — методы llama_vocab::impl::token_to_piece и llama_vocab::impl::detokenize в src/llama-vocab.cpp. token_to_piece по ID токена возвращает строку (кусочек текста); для байтовых токенов выполняется преобразование в символ, для обычных — берётся текст из id_to_token. detokenize проходит по массиву токенов и склеивает результаты token_to_piece в одну строку. API llama_token_to_piece (в include/llama.h) принимает модель, токен, буфер и размер; внутри вызывается vocab.token_to_piece. Для вывода по одному токену обычно используют llama_token_to_piece; для сборки полной строки из массива токенов — llama_detokenize. Фрагмент:
// Шаг 16: по ID токена возвращаем кусочек текста (для вывода пользователю); id_to_token загружен в load_vocab
int32_t llama_vocab::impl::token_to_piece(llama_token token, char * buf, int32_t length, int32_t lstrip, bool special) const {
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_SPM:
case LLAMA_VOCAB_TYPE_UGM:
if (attr & LLAMA_TOKEN_ATTR_NORMAL) {
std::string result = token_text;
llama_unescape_whitespace(result);
memcpy(buf, result.data(), result.size());
return (int32_t) result.size();
}
if (attr & LLAMA_TOKEN_ATTR_BYTE) {
char byte = (char) token_to_byte(token);
buf[0] = byte;
return 1;
}
break;
case LLAMA_VOCAB_TYPE_BPE:
if (attr & LLAMA_TOKEN_ATTR_NORMAL) {
std::string result = llama_decode_text(token_text);
memcpy(buf, result.data(), result.size());
return (int32_t) result.size();
}
break;
default: break;
}
}
return 0;
}В коде видно: для SPM и UGM обычные токены превращаются в текст через llama_unescape_whitespace, байтовые — через token_to_byte; для BPE используется llama_decode_text (декодирование escape-последовательностей). Функция llama_detokenize в API вызывает vocab.detokenize и собирает строку по массиву токенов.
Сэмплер в API (например, llama_sampler_sample) принимает контекст, позицию в батче и опционально цепочку параметров сэмплинга (temperature, top_p, repeat_penalty и т.д.). Внутри читаются логиты для этой позиции через ctx->get_logits_ith(pos), к ним применяется цепочка сэмплеров (repeat penalty, temperature, top_p и т.д.), затем выбирается один токен (жадный или случайный по softmax). Возвращаемый токен добавляется в историю и при следующем вызове llama_decode передаётся в батче как единственный новый токен.
➤ KV-cache и этапы Prefill / Decode (справочно)
В механизме внимания каждый токен получает вектор запроса (Q), ключа (K) и значения (V) — это числовые векторы, которые модель считает из своих весов.
Для чего нужны Q, K, V:
— по ним вычисляется «внимание» — насколько каждая предыдущая позиция важна для текущей;
— результат — взвешенная сумма значений V с весами по Q и K.
Логиты для следующего токена зависят от Q текущей позиции и от K, V всех предыдущих позиций — поэтому при генерации по одному токену старые K и V не меняются, их достаточно посчитать один раз и сохранить. KV-cache — это буфер в памяти, в котором для каждого слоя и каждой позиции хранятся уже посчитанные ключи и значения; так не нужно пересчитывать их заново на каждом шаге генерации.
Prefill:
— в первом проходе обрабатываются все токены промпта, для них считаются K и V и записываются в кэш.
Decode:
— в последующих шагах обрабатывается только один новый токен: по нему считаются Q, K, V; K и V дописываются в кэш; Q умножается на все ключи из кэша (и на себя), после маски и softmax — на все значения из кэша. В итоге получаем один вектор контекста для этой позиции и дальше feed-forward и т.д. Так мы не пересчитываем внимание по всей истории на каждом шаге, а только по новому токену — это и даёт ускорение генерации. Разделение на Prefill (много токенов за раз, нагрузка на вычислители) и Decode (один токен, нагрузка на память) характерно для llama.cpp и других движков.
Реализация KV-cache — в src/llama-memory.cpp: для каждого слоя модели выделяются буферы под ключи и значения (размер зависит от числа голов внимания, размера головы и длины контекста). При Prefill в эти буферы записываются K и V для всех позиций промпта; при Decode для нового токена вычисляются только новые K и V и дописываются в конец. Планировщик и граф модели при выполнении обращаются к этим буферам через указатели, переданные в параметрах графа.
Расположение буферов KV-cache: для каждого слоя трансформера создаётся два тензора (или один объединённый):
— один для ключей;
— один для значений.
Размерность: [число голов KV, размер головы, длина контекста] или [длина контекста, число голов × размер головы] в зависимости от реализации. При создании контекста вызывается выделение памяти под эти тензоры на 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 × (размер одной головы × число голов × 2) (×2 — ключи и значения). Поэтому при ограниченной памяти уменьшают n_ctx или используют более агрессивную квантизацию для KV-cache. В llama.cpp при создании контекста выделяются буферы под максимальную длину контекста (n_ctx); при генерации заполняются только позиции до текущей.
Функция memory->seq_pos_max(s) возвращает максимальную позицию, до которой заполнен KV-cache для последовательности s. При добавлении нового токена в батч позиция для него берётся как seq_pos_max(s) + 1 — так батч всегда ссылается на следующую свободную позицию в кэше. После успешного process_ubatch память обновляется: записанные в кэш позиции считаются занятыми, и при следующем вызове init_batch новые токены получат следующие позиции.
➤ Резюме: от файла до первого токена
Краткая последовательность шагов от запуска приложения до появления первого токена ответа.
Для чего это полезно:
— при отладке или изучении кода можно сверяться с этим списком и проверять, на каком шаге вы находитесь;
— так проще найти в исходниках место, соответствующее этапу загрузки или генерации.
Подготовка (один раз при старте или смене модели):
— llama_backend_init() — инициализация таймера и таблиц f16.
Для чего:
один раз при старте приложения.
— 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, индекс тензоров), load_arch, load_hparams, load_vocab (в т.ч. vocab.load(ml, kv)), load_stats, load_tensors.
Для чего:
по шагам заполнить модель архитектурой, гиперпараметрами, словарём и весами.
— llama_new_context(model, ctx_params) — создание контекста: параметры n_ctx, n_batch, n_ubatch, инициализация памяти (KV-cache), планировщика, резерв графов Prefill/Decode.
Для чего:
контекст нужен для вызова llama_decode; без него нельзя генерировать текст.
Генерация (на каждое сообщение пользователя и каждый новый токен в ответе):
— llama_tokenize(model, prompt, …) — промпт в токены.
Для чего:
модель работает только с числами (токенами).
— Формирование батча: llama_batch_clear, llama_batch_add для каждого токена промпта, llama_batch_set_logits для последней позиции.
Для чего:
один вызов decode принимает один батч; логиты нужны только для последней позиции, чтобы выбрать следующий токен.
— llama_decode(ctx, batch) → ctx->decode(batch) → при необходимости encode (токены → эмбеддинги), balloc->init, обновление памяти, цикл: memory->init_batch → process_ubatch (build_graph, set_inputs, graph_compute), копирование логитов.
Для чего:
прогон батча через модель и получение логитов во внутреннем буфере контекста.
— llama_get_logits_ith(ctx, pos) — указатель на логиты для последней позиции.
Для чего:
по ним сэмплер выбирает следующий токен.
— Сэмплер выбирает следующий токен; llama_token_to_piece — токен в текст; вывод; добавление токена в батч; повтор decode до EOS или лимита.
Для чего:
цикл генерации по одному токену до конца ответа.
Таким образом, путь от файла модели до первого токена ответа проходит через загрузчик GGUF, загрузку архитектуры и гиперпараметров, словаря и тензоров, создание контекста с KV-cache и планировщиком, токенизацию промпта, батч, encode, построение графа и его выполнение, сэмплинг по логитам.
При первом decode с полным промптом (Prefill) время выполнения зависит от длины промпта и размера подбатча (n_ubatch): чем длиннее промпт, тем больше подбатчей и проходов через модель; логиты нужны только для последней позиции. При последующих decode (по одному токену) каждый вызов обрабатывает один токен; граф Decode переиспользуется, основное время уходит на вычисление одного слоя внимания (Q, K, V для одной позиции, чтение K и V из кэша, softmax, взвешенная сумма) и остальных слоёв. Оптимизация скорости генерации связана с оптимизацией этого пути: эффективное чтение KV-cache, переиспользование графа, распределение по GPU. При профилировании полезно смотреть время первого decode (Prefill) и время последующих decode (по одному токену): первый зависит от длины промпта и n_ubatch, последующие — от эффективности одного прохода через модель и копирования данных между бэкендами.
Примечания по версиям и сборке: структура кода и имена файлов в репозитории llama.cpp могут меняться от версии к версии; номера строк и фрагменты кода в статье соответствуют одной из последних версий и могут отличаться у вас. При сборке с GPU нужны соответствующие бэкенды (CUDA, Metal, Vulkan и т.д.) и переменные окружения; без них движок работает только на CPU. Примеры вызовов API и имена структур приведены по include/llama.h; при использовании C-обёрток или других языков (Python, Go и т.д.) сигнатуры могут отличаться, но общий сценарий загрузки и decode остаётся тем же.
Рекомендации по изучению кода: для понимания пути загрузки модели удобно начать с llama_model_load_from_file в src/llama.cpp и пройти по вызовам до llama_model_load, затем по load_arch, load_hparams, load_vocab, load_tensors. Для пути decode — с llama_decode в src/llama-context.cpp, затем ctx->decode, balloc->init, цикл 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, load_tensor_data). Память и KV-cache — src/llama-memory.cpp (seq_pos_max, init_batch). При отладке полезно ставить точки останова на входах в load_arch, load_hparams, load_vocab, load_tensors и на входах в decode, process_ubatch, build_graph.
➤ Детализация реализации по файлам
Ниже — краткая привязка описанных в статье шагов к конкретным файлам и функциям репозитория llama.cpp.
Для чего это нужно:
— при изучении или отладке можно быстро найти нужный код по имени файла и функции;
— каждый пункт указывает, что искать в файле и зачем это нужно.
src/llama.cpp:
— llama_backend_init — инициализация таймера и таблиц f16.
Для чего:
один раз при старте приложения.
— llama_model_load_from_file, llama_model_load_from_file_impl — точка входа загрузки модели.
Для чего:
приложение вызывает их, чтобы загрузить модель из файла.
— llama_model_load (статическая) — последовательный вызов load_arch, load_hparams, load_vocab, load_stats, load_tensors.
Для чего:
здесь создаётся загрузчик и по шагам заполняется модель. В том же файле — проверка бэкенда, колбэк прогресса, создание объекта модели и список устройств.
src/llama-model-loader.cpp:
— Класс llama_model_loader: конструктор открывает GGUF через gguf_init_from_file, строит weights_map по списку тензоров из контекста GGUF.
Для чего:
по имени тензора потом можно прочитать данные из файла.
— Методы get_key, get_arch, get_tensor, load_tensor_data — чтение метаданных и данных тензоров.
Для чего:
load_arch, load_hparams, load_vocab и load_tensors вызывают их.
— print_info — вывод информации о файле. Поддержка нескольких файлов (splits) и mmap/direct_io.
src/llama-model.cpp:
— Класс llama_model: load_arch (вызов ml.get_arch()), load_hparams (чтение ключей GGUF в hparams), load_vocab (вызов vocab.load(ml, kv)), load_tensors (формирование списков буферов CPU/GPU, разбиение слоёв, создание тензоров и вызов ml.get_tensor/ml.load_tensor_data).
Для чего:
здесь создаются тензоры модели по архитектуре и назначаются буферы на CPU/GPU.
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 — проверка батча, encode при необходимости, balloc->init, обновление памяти, цикл по подбатчам (memory->init_batch, process_ubatch), копирование логитов.
Для чего:
один вызов 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.cpp:
— Объект памяти (реализация интерфейса llama_memory_i) — выделение буферов KV-cache для каждого слоя и каждой позиции контекста.
Для чего:
в них хранятся ключи и значения механизма внимания.
— seq_pos_max(s) — максимальная позиция для последовательности s.
Для чего:
при формировании батча позиции новых токенов берутся как seq_pos_max(s) + 1.
— init_batch — разбиение батча на подбатчи (ubatch) размером не больше n_ubatch; обновление занятых позиций после process_ubatch. Планировщик и граф обращаются к буферам KV-cache через параметры графа.
GGML/GGUF (внешние библиотеки или подмодули):
— 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_graph добавляются узлы по специфике модели; общая схема «эмбеддинги → слои → логиты» сохраняется.
При изучении кода удобно искать по имени функции или метода: llama_backend_init, llama_model_load_from_file, llama_model_load, load_arch, load_hparams, load_vocab, vocab.load, load_tensors, llama_new_context, llama_decode, decode, process_ubatch, build_graph, graph_compute, llama_get_logits_ith, llama_tokenize, llama_token_to_piece. По ним можно проследить весь путь от загрузки модели до вывода токена. В репозитории полезно смотреть примеры в examples/ — там показан типичный цикл: загрузка модели, создание контекста, токенизация, батч, decode, сэмплинг, вывод.
➤ Связь компонентов и потоки данных
Кратко — как данные проходят между компонентами от файла модели до вывода токена. Для чего это полезно: при отладке или изучении кода можно проследить, откуда берётся каждое значение и куда оно передаётся.
Загрузка (один раз при старте или смене модели):
— Файл GGUF → llama_model_loader (метаданные и индекс тензоров weights_map).
Для чего:
загрузчик даёт доступ к полям GGUF и к данным тензоров по имени.
— load_arch (тип модели: LLaMA, Gemma и т.д.).
Для чего:
от типа зависят имена ключей в GGUF.
— load_hparams (размерности и параметры: n_ctx, n_layer, n_embd и т.д.).
Для чего:
от них зависит «форма» модели и размеры тензоров.
— 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 (модель.vocab) → массив токенов → батч (token, pos, seq_id, logits).
Для чего:
один вызов decode принимает один батч.
— Батч → llama_decode → при необходимости encode (токены → эмбеддинги из модели) → balloc->init (позиции из memory) → цикл: memory->init_batch → ubatch → process_ubatch (build_graph по модели, set_inputs, graph_compute на sched) → логиты копируются в буфер контекста.
Для чего:
прогон батча через модель и получение логитов.
— Логиты → llama_get_logits_ith → сэмплер → следующий токен → llama_token_to_piece (модель.vocab) → текст.
Для чего:
по логитам выбирается один токен и переводится в текст для вывода.
— Токен добавляется в батч; при следующем decode в батче один новый токен; memory обновляет занятые позиции KV-cache; цикл повторяется до EOS или лимита.
Сводка потоков данных:
— Файл GGUF → загрузчик → модель (hparams, vocab, тензоры). Модель + параметры → контекст (memory, sched, графы).
— Промпт → vocab (токенизация) → батч. Батч → контекст.decode → encode (модель: эмбеддинги) → balloc, memory → process_ubatch (модель: build_graph, веса; memory: KV-cache; sched: выполнение графа) → логиты в контексте.
— Логиты → сэмплер → токен → vocab (token_to_piece) → текст. Модель и контекст — центральные объекты; загрузчик, balloc, memory, sched — вспомогательные, привязаны к контексту или модели.