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 — допоміжні, прив'язані до контексту або моделі.