LLama.cpp AI LLM Engine — Как это работает
Предисловие
Llama.cpp — это программа (движок) для запуска больших языковых моделей (LLM) на компьютере или на мобильных устройствах — на процессоре (CPU) или видеокарте (GPU); я написал приложение Offline AI Launcher, которое позволяет использовать этот же движок на Android.
Зачем это нужно:
- чтобы получать ответы от нейросетевых моделей (чат, дополнение текста, код) без отправки данных в облако;
- выбор языка C++ нужен для быстрой работы с памятью и железом; один и тот же код собирается под Windows, Linux, macOS и Android.
«Веса» модели — огромный набор чисел (миллионы или миллиарды), на которых основаны все вычисления нейросети: матрицы умножения, смещения и т.д. Их получают при обучении модели и сохраняют в файл. При инференсе движок только читает эти числа и применяет их к входным данным, не меняя сами веса. Инференс — это процесс «спроса» к уже обученной модели: вы даёте текст, модель по шагам выдаёт ответ.
Движок работает с форматом файлов GGUF. GGUF — формат, в котором лежит сохранённая модель:
- «веса» (числа нейросети);
- метаданные (размеры, тип архитектуры);
- словарь (соответствие «текст ↔ числа» для токенов).
По сути это один файл-контейнер, из которого движок читает всё нужное в память. Поддерживается квантизация (уменьшение размера модели за счёт более грубого хранения чисел) и гибридные вычисления (часть на CPU, часть на GPU). Модели в формате GGUF можно брать с Hugging Face.
Цель статьи — по шагам разобрать, как в llama.cpp устроен инференс: от загрузки модели до появления очередного токена в ответе.
Токен — это число, соответствующее кусочку текста (слову или части слова); модель работает только с числами, а словарь переводит «текст → токены» и обратно.
Статья выстроена как сценарий:
- сначала подготовка (инициализация, загрузка модели, создание контекста);
- затем генерация по запросу (токенизация, батч, decode, сэмплинг).
С какими моделями работает движок:
- LLaMA (Meta);
- Qwen (Alibaba);
- Gemma (Google);
- Mistral;
- Phi и другие.
Отличия — в размере, длине контекста и деталях; в коде это разные гиперпараметры и тензоры весов. Общий сценарий один и тот же.
В каких проектах используется:
- LM Studio;
- Ollama;
- GPT4All;
- Offline AI Launcher (запуск моделей на смартфоне);
- KoboldCpp;
- Text Generation WebUI.
Репозиторий: github.com/ggml-org/llama.cpp. Примеры кода в статье могут отличаться от вашей версии.
Как читать статью:
- разделы идут в порядке выполнения сценария;
- в каждом разделе для действия даётся цитата из кода и объяснение, что происходит и для чего;
- файлы исходников указаны в тексте;
- статья рассчитана на неподготовленного читателя: все термины объясняются в глоссарии в начале статьи, все шаги снабжены цитатами кода и пояснениями.
Глоссарий терминов
Батч (batch) — набор токенов (или эмбеддингов), позиций и флагов логитов, обрабатываемых за один вызов decode. При Prefill в батче много токенов промпта; при Decode — один новый токен.
Бэкенд (backend) — «движок» вычислений: CPU или видеокарта (GPU). Планировщик распределяет узлы графа по бэкендам.
Decode — этап генерации по одному токену: в батче один новый токен, граф вычисляет логиты для этой позиции, K и V дописываются в KV-cache.
Эмбеддинг (embedding) — вектор чисел, в который превращается токен перед подачей в слои модели; одна строка матрицы эмбеддингов модели.
EOS (end of sequence) — специальный токен «конец вывода»; по нему приложение прекращает генерацию.
GGUF — формат файла модели: заголовок, метаданные (ключ–значение), данные тензоров. Поддерживается квантизация и mmap.
Гиперпараметры (hparams) — числа, задающие размеры модели: длина контекста, число слоёв, размер эмбеддинга, число голов внимания и т.д.
Инференс — процесс получения ответа от модели: подаётся промпт, модель по шагам выдаёт следующий токен.
KV-cache — кэш ключей и значений механизма внимания; хранит уже посчитанные K и V по всем предыдущим позициям, чтобы не пересчитывать их на каждом шаге.
Логиты (logits) — «сырые» оценки модели по каждому токену словаря перед softmax; по ним сэмплер выбирает следующий токен.
Prefill — этап обработки промпта: в батче все (или много) токенов промпта, для них считаются K и V и записываются в KV-cache.
Сэмплинг — выбор одного токена по логитам (жадный, случайный с temperature/top_p и т.д.).
Тензор — многомерный массив чисел (веса модели, эмбеддинги, ключи, значения, логиты и т.д.).
Токен — целое число (ID), соответствующее кусочку текста (слову или части слова); модель работает только с токенами.
Токенайзер — компонент словаря, превращающий текст в токены (SPM, BPE и т.д.) и обратно.
Вычислительный граф — список операций (матрицы, сложения, активации) и связей между ними; по нему движок выполняет все вычисления модели.
Подбатч (ubatch) — часть батча, которая обрабатывается за один вызов process_ubatch. Если промпт длиннее n_ubatch, батч разбивается на подбатчи по n_ubatch токенов; каждый подбатч прогоняется через модель по очереди.
Для чего:
- чтобы ограничить пиковое потребление памяти при длинном промпте.
Планировщик (sched) — компонент GGML, который распределяет узлы вычислительного графа по бэкендам (CPU, GPU) и выделяет под граф буферы на этих устройствах. При выполнении графа планировщик обходит узлы в топологическом порядке и запускает операции на выбранных устройствах.
Схема процесса: все шаги по порядку
Ниже — вся цепочка от запуска движка до появления очередного токена в ответе. Каждый пункт дальше в статье разбирается подробно, с цитатами кода и пояснениями.
Подготовка (один раз при старте или смене модели):
шаг 1: инициализация бэкенда (llama_backend_init) →
шаг 2: загрузка модели из файла (llama_model_load_from_file → загрузчик GGUF) →
шаг 3: определение типа модели (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: таймер нужен, чтобы потом замерить время загрузки модели и время decodeggml_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_vocabvoid 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 и превращает строку в enumllm_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->inittypedef struct llama_batch { int32_t n_tokens; llama_token * token; float * embd; llama_pos * pos; int32_t * n_seq_id; llama_seq_id ** seq_id; int8_t * logits;} llama_batch;Структура батча:
- массивы имеют размер n_tokens;
- либо передаются ID токенов (token), либо готовые эмбеддинги (embd) — векторы чисел, в которые уже превращены токены (обычно передают токены, а эмбеддинги считаются внутри);
- pos — позиция каждого токена; seq_id и n_seq_id — к какой последовательности относится токен;
- logits[i] != 0 означает, что для позиции i нужно вернуть логиты (обычно только для последней — чтобы выбрать следующий токен). Перед llama_decode батч обрабатывается классом llama_batch_allocr (файл src/llama-batch.cpp): метод init проверяет батч и при отсутствии полей заполняет их автоматически (позиции из памяти, логиты только для последнего токена).
Поля seq_id и n_seq_id используются при батчинге нескольких последовательностей (например, несколько запросов в одном батче): каждый токен может относиться к одной или нескольким последовательностям; позиции (pos) считаются отдельно для каждой последовательности по memory->seq_pos_max(seq_id). В типичном случае одна последовательность — все токены имеют один и тот же seq_id (например, 0), и позиции идут по порядку 0, 1, 2, ... . Батч очищается и заполняется заново перед каждым вызовом llama_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_vocabint32_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 — вспомогательные, привязаны к контексту или модели.