Roman Kryvolapov Engineering Blog

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 та інші.

Відмінності — у розмірі, довжині контексту та деталях; у коді це різні гіперпараметри й тензори ваг. Загальний сценарій один і той самий.

У яких проєктах використовується:

Репозиторій: github.com/ggml-org/llama.cpp. Код у статті наведено за гілкою master на коміті 8887a48f, одразу після релізу b10736; в іншій версії деталі можуть відрізнятися.

Як читати статтю:

  • розділи йдуть у порядку виконання сценарію;
  • у кожному розділі для дії наводиться цитата з коду та пояснення, що відбувається і для чого;
  • файли вихідників указані в тексті;
  • стаття розрахована на непідготовленого читача: усі терміни пояснюються в глосарії на початку статті, усі кроки супроводжуються цитатами коду та поясненнями.

Глосарій термінів

Батч (batch) — набір токенів (або ембедингів), позицій і прапорців логітів, що обробляються за один виклик decode. При Prefill у батчі багато токенів промпту; при Decode — один новий токен.

Бекенд (backend) — «рушій» обчислень: CPU або відеокарта (GPU). Планувальник розподіляє вузли графа по бекендах.

Decode — етап генерації по одному токену: у батчі один новий токен, граф обчислює логіти для цієї позиції, K і V дописуються в KV-cache.

Ембединг (embedding) — вектор чисел, у який перетворюється токен перед подачею в шари моделі; один рядок матриці ембедингів моделі.

EOS (end of sequence) — спеціальний токен «кінець виводу»; за ним застосунок припиняє генерацію.

GGUF — формат файлу моделі: заголовок, метадані (ключ–значення), дані тензорів. Підтримується квантизація та mmap.

Гіперпараметри (hparams) — числа, що задають розміри моделі: довжина контексту, кількість шарів, розмір ембедингу, кількість голів уваги тощо.

Інференс — процес отримання відповіді від моделі: подається промпт, модель покроково видає наступний токен.

KV-cache — кеш ключів і значень механізму уваги; зберігає вже пораховані K і V по всіх попередніх позиціях, щоб не перераховувати їх на кожному кроці.

Логіти (logits) — «сирі» оцінки моделі по кожному токену словника перед softmax; за ними семплер обирає наступний токен.

Prefill — етап обробки промпту: у батчі всі (або багато) токенів промпту, для них рахуються K і V та записуються в KV-cache.

Семплінг — вибір одного токена за логітами (жадібний, випадковий з temperature/top_p тощо).

Тензор — багатовимірний масив чисел (ваги моделі, ембединги, ключі, значення, логіти тощо).

Токен — ціле число (ID), що відповідає шматочку тексту (слову або частині слова); модель працює лише з токенами.

Токенайзер — компонент словника, що перетворює текст на токени (SPM, BPE тощо) і навпаки.

Обчислювальний граф — перелік операцій (матриці, додавання, активації) та зв'язків між ними; за ним рушій виконує всі обчислення моделі.

Підбатч (ubatch) — частина батча, яка обробляється за один виклик process_ubatch. Якщо промпт довший за n_ubatch, батч розбивається на підбатчі по n_ubatch токенів; кожен підбатч проганяється через модель по черзі.

Для чого:

  • щоб обмежити пікове споживання пам'яті при довгому промпті.

Планувальник (sched) — компонент GGML, який розподіляє вузли обчислювального графа по бекендах (CPU, GPU) і виділяє під граф буфери на цих пристроях. При виконанні графа планувальник обходить вузли в топологічному порядку і запускає операції на обраних пристроях.

Схема процесу: усі кроки по порядку

Нижче — увесь ланцюжок від запуску рушія до появи чергового токена у відповіді. Кожен пункт далі в статті розбирається докладно, з цитатами коду та поясненнями.

Підготовка (один раз при старті або зміні моделі):

  • крок 1: ініціалізація бекенда (llama_backend_init)
  • крок 2: завантаження моделі з файлу (llama_model_load_from_file → завантажувач GGUF)
  • крок 3: визначення типу моделі — архітектура, вже прочитана завантажувачем з ключа GGUF general.architecture, визначає, який клас моделі створюється (llama_model_create)
  • крок 4: завантаження гіперпараметрів — розміри, контекст (load_hparams)
  • крок 5: завантаження словника й токенайзера (load_vocab)
  • крок 6: завантаження ваг у пам'ять або на GPU (load_tensors)
  • крок 7: створення контексту інференсу — KV-cache, планувальник (llama_init_from_model).
llama_backend_init() реєстр бекендів: CPU, CUDA, Metal, Vulkan крок 1 llama_model_load_from_file() відкриває GGUF, будує карту тензорів крок 2 llama_model_load() llama_model_create · load_hparams · load_vocab · load_tensors архітектура, розміри, словник, ваги кроки 3–6 llama_init_from_model() контекст: KV-cache, планувальник, графи крок 7 model.gguf заголовок, метадані карта тензорів і ваги mmap

Генерація (для кожного повідомлення та кожного нового токена у відповіді):

  • крок 8: надходить текст промпту від користувача
  • крок 9: токенізація — текст перетворюється на послідовність токенів (llama_tokenize)
  • крок 10: формування батча — токени пакуються для одного виклику (llama_batch_get_one або common_batch_add), а всередині виклику батч перевіряється й розкладається (balloc->init)
  • крок 11: llama_decode — точка входу кроку генерації: батч перевіряється й передається у внутрішній шлях декодування (llama_context::decode)
  • крок 12: батч при довгому промпті розбивається на підбатчі (memory->init_batch)
  • крок 13: кожен підбатч проганяється через модель: побудова графа → виконання на CPU/GPU (process_ubatchbuild_graphgraph_compute)
  • крок 14: логіти копіюються з виходу графа в буфер контексту — по одному рядку на кожну позицію, позначену в батчі на вивід; у циклі генерації це лише остання позиція
  • крок 15: семплінг — за логітами обирається один наступний токен
  • крок 16: якщо токен завершує генерацію (llama_vocab_is_eog — сюди входять EOS, EOT та інші токени кінця генерації), цикл завершується; інакше токен перекладається в текст і виводиться (llama_token_to_piece), з цього одного токена збирається новий батч, і керування повертається до кроку 11.
промпт крок 8 текст від користувача llama_tokenize крок 9 текст → токени llama_batch_get_one крок 10 токени → батч llama_decode крок 11 перевірка батча, позиції memory->init_batch крок 12 батч → підбатчі process_ubatch крок 13 граф будується і рахується logits крок 14 рядок на кожен вихід llama_sampler_sample крок 15 один наступний токен llama_token_to_piece крок 16 токен → текст новий токен стає батчем з одного токена — до токена кінця відповіді

Загальний процес інференсу LLM

Вище наведено схему: що за чим відбувається. За змістом процес поділяється на два етапи. Перший — підготовка: ініціалізація бекенда, завантаження моделі з GGUF (архітектура, гіперпараметри, словник, ваги) та створення контексту. Він виконується один раз при старті або при зміні моделі. Другий етап — генерація: при кожному повідомленні користувача текст перетворюється на токени, пакується в батч, проганяється через модель (llama_decode), з логітів обирається наступний токен, він перекладається в текст і виводиться; цикл повторюється до токена «кінець виводу» (EOS) або ліміту. Цей цикл повторюється для кожного нового повідомлення і для кожного нового токена у відповіді. Нижче кожен крок зі схеми розбирається окремо: що саме викликається в коді, що відбувається і що може бути неочевидним непідготовленому читачеві.

Структура репозиторію та основні файли

У репозиторії llama.cpp основні частини рушія рознесені по теках і файлах.

Для чого так зроблено:

  • щоб рознести відповідальність: завантаження файлу, модель, словник, контекст і батч — у різних файлах;
  • так простіше шукати код і налагоджувати.

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

Точка входу та завантаження моделі:

  • src/llama.cpp — тут живуть функції llama_backend_init, llama_model_load_from_file, llama_model_load (статична).

    Для чого: це «вхідні двері» до рушія: застосунок викликає ці функції, щоб ініціалізувати бібліотеку та завантажити модель; тут же створюється завантажувач і запитується об'єкт моделі під архітектуру, прочитану з файлу. Саму фабрику llama_model_create (вона запитує архітектуру в завантажувача через get_arch) цей файл лише викликає — живе вона в src/llama-model.cpp. Потім по черзі викликаються load_hparams, load_vocab та load_tensors.

  • src/llama-model-loader.cpp — клас llama_model_loader: відкриття GGUF-файлу, побудова індексу тензорів (weights_map), методи get_weight та get_tensor_meta для пошуку тензора за іменем і load_all_data для читання самих даних тензорів.

    Для чого: завантажувач потрібен, щоб за іменем тензора знати, де у файлі лежать його дані і як їх прочитати або відобразити в пам'ять (mmap); без нього не можна покроково завантажувати архітектуру, гіперпараметри, словник і ваги.

Модель і словник:

  • src/llama-model.cpp — клас llama_model та його базова реалізація llama_model_base: методи load_hparams, load_vocab, load_tensors, створення тензорів і призначення буферів. Те, що відрізняється від архітектури до архітектури, винесено в load_arch_hparams та load_arch_tensors, які кожна родина моделей реалізує у своєму файлі в src/models/.

    Для чого: об'єкт моделі зберігає все, що прочитано з файлу: тип архітектури, гіперпараметри, словник і самі ваги (тензори); методи load_* по черзі заповнюють ці дані із завантажувача. Саму архітектуру визначають раніше, при створенні об'єкта моделі: llama_model_create запитує її в завантажувача (get_arch) і будує клас відповідної родини.

  • src/llama-vocab.cpp — клас llama_vocab, реалізація llama_vocab::impl::load (завантаження словника з GGUF), токенізація та зворотний переклад токенів у текст (tokenize, token_to_piece, detokenize).

    Для чого: словник потрібен, щоб перетворювати текст на числа (токени) при введенні та числа назад на текст при виведенні; без нього модель не зможе ні прийняти промпт, ні видати читабельну відповідь.

Контекст і decode:

  • src/llama-context.cpp — клас llama_context: створення контексту (пам'ять, планувальник, резерв графів), метод decode, process_ubatch, llama_get_logits_ith.

    Для чого: контекст — це «робоче середовище» одного сеансу генерації: у ньому задається розмір контексту, KV-cache, планувальник обчислень; метод decode проганяє батч через модель і повертає логіти.

  • src/llama-graph.cpp — побудова обчислювального графа: клас llm_graph_context з build_inp_embd (пошук ембедингів за таблицею) та результат графа llm_graph_result з методами set_inputs і can_reuse.

    Для чого: граф — це те, що насправді рахується на decode: тут збираються його вхідні вузли, перед запуском заповнюються даними підбатча і вирішується, чи можна повторно використати попередній граф замість того, щоб будувати його заново.

  • src/llama-batch.cpp — клас llama_batch_allocr та його метод init (заповнення позицій і прапорців логітів); сама структура llama_batch оголошена в include/llama.h.

    Для чого: батч — це «пакет» токенів для одного виклику decode; алокатор перевіряє батч і за відсутності полів заповнює їх (позиції з пам'яті, логіти лише для останнього токена), щоб коду, який викликає, не потрібно було вручну все виставляти.

  • src/llama-kv-cache.cpp — виділення та оновлення KV-cache і його init_batch (розбиття на підбатчі та резервування під них місць у кеші). Сам інтерфейс оголошено окремо, у src/llama-memory.h, бо модель може використовувати пам'ять іншого роду — рекурентний стан або гібрид, — і кожен варіант реалізує той самий init_batch у своєму файлі.

    Для чого: KV-cache зберігає вже пораховані ключі та значення по всіх попередніх позиціях, щоб не перераховувати їх на кожному кроці; init_batch розбиває великий батч на підбатчі обмеженого розміру, щоб не переповнити пам'ять.

  • src/llama-sampler.cpp — семплери та ланцюжок із них: llama_sampler_chain_init, llama_sampler_chain_add, llama_sampler_sample.

    Для чого: після decode логіти ще потрібно перетворити на один токен: ланцюжок по черзі проганяє семплери і повертає обраний токен.

Бібліотеки та бекенди:

  • ggml/ (тека всередині репозиторію — бібліотека лежить прямо тут, а не підключена підмодулем) — обчислювальний граф (GGML), типи тензорів, планувальник (ggml_backend_sched) та бекенди CPU/GPU; публічні заголовки — у ggml/include/, бекенди — у підтеках ggml/src/.

    Для чого: граф описує, які операції (множення матриць, активації тощо) виконати і в якому порядку; планувальник вирішує, на якому пристрої (CPU чи GPU) рахувати кожен вузол графа.

  • gguf (ggml/include/gguf.h та ggml/src/gguf.cpp — частина ggml, а не окрема бібліотека) — читання та запис формату GGUF: заголовок, метадані та тензори.

    Для чого: формат GGUF задає, як у файлі лежать заголовок, метадані та дані тензорів; ці функції читають їх без ручного розбору байтів.

  • include/llama.h — заголовок API для застосунків: оголошення llama_model_load_from_file, llama_context, llama_decode, llama_tokenize, llama_get_logits_ith, семплери тощо.

    Для чого: застосунок підключає цей заголовок і викликає оголошені функції, не заходячи у внутрішні файли рушія.

Основні типи та структури (довідково)

Для орієнтування в коді корисно знати основні типи. Нижче — що зберігає кожен тип і для чого він потрібен.

Основні структури:

  • llama_model — об'єкт завантаженої моделі. У ньому зберігаються: гіперпараметри (hparams), словник (vocab), тензори ваг, список пристроїв (devices), розбиття шарів по CPU/GPU.

    Для чого: модель — це все, що прочитано з файлу і потрібно для обчислень; один об'єкт моделі можна використовувати для кількох контекстів (кількох сеансів генерації).

  • llama_context — контекст інференсу. У ньому: посилання на модель, параметри контексту (cparams: n_ctx, n_batch, n_ubatch, n_threads тощо), алокатор батча (balloc), пам'ять KV-cache (memory), планувальник (sched), останній побудований граф, збережений для повторного використання (gf_res_prev), буфер логітів.

    Для чого: контекст — «робоче середовище» одного сеансу: розмір контексту, кеш ключів і значень, планувальник і графи для decode; при кожному запиті викликається decode саме для цього контексту.

  • llama_batch — масив токенів (або ембедингів), позицій, ідентифікаторів послідовностей і прапорців логітів.

    Для чого: один виклик llama_decode приймає один батч; у ньому передається, які токени обробити, на яких позиціях і для яких позицій повернути логіти (зазвичай лише для останньої).

  • llama_model_loader — завантажувач GGUF. У ньому: метадані, прочитані з файлу (metadata), карта тензорів (weights_map), відкриті файли (files) та їхні відображення в пам'ять (mappings).

    Для чого: завантажувач живе лише під час завантаження моделі; за ним по черзі читаються архітектура, гіперпараметри, словник і тензори; після завантаження він не потрібен.

  • ggml_context — контекст графа GGML: у ньому створюються вузли й тензори графа.

    Для чого: граф описує послідовність операцій (множення, активації тощо); усі вузли графа створюються в одному такому контексті.

  • ggml_backend_sched — планувальник: список бекендів і логіка розподілу вузлів графа по пристроях.

    Для чого: планувальник вирішує, на якому пристрої (CPU чи GPU) виконувати кожен вузол графа, і виділяє під граф буфери на цих пристроях.

Що повертають основні функції:

  • llama_model_load_from_file повертає llama_model* або nullptr при помилці.

    Для чого: застосунок перевіряє вказівник: якщо не nullptr, модель завантажена і можна створювати контекст.

  • llama_model_load — допоміжна функція всередині src/llama.cpp, у публічному заголовку її немає — повертає пару зі статусу та моделі: 0 при успіху, -1 при помилці, -2 при скасуванні за колбеком прогресу.

    Для чого: при від'ємному статусі вказівник на модель повертається вже нульовим, тож код, що викликає, лише пише в лог причину і повертає застосунку nullptr.

  • llama_decode повертає 0 при успіху. Додатне значення — це попередження, а не фатальна помилка: 1 означає, що для цього батча не знайшлося вільного місця в KV-cache (потрібно зменшити батч або збільшити контекст), 2 — що виклик було перервано. -1 означає, що некоректний сам батч, усе, що менше -1, — фатальна помилка.

    Для чого: застосунок за поверненням розуміє, чи вдалося виконати decode і чи можна читати логіти; при 1 можна повторити з меншим батчем, а після 2 або фатальної помилки частина батча вже потрапила в пам'ять контексту, тож докуди він дійшов, доводиться питати в контексту окремо.

  • llama_tokenize повертає кількість записаних токенів, але не більше за розмір переданого буфера. Якщо буфер замалий, повертається від'ємне число, за модулем рівне тому, скільки токенів вийшло б, — тому зазвичай її викликають спершу з порожнім буфером, виділяють стільки елементів і викликають ще раз.

    Для чого: щоб знати, скільки елементів масиву токенів заповнено і якого розміру має бути масив.

  • llama_get_logits_ith повертає вказівник на масив float розміром n_vocab — логіти i-го токена останнього decode; при невід'ємному i аргумент — це індекс токена всередині батча, який контекст переводить у рядок виходів через output_ids, тож для токена, логіти якого не запитували, повернеться NULL; від'ємний індекс відлічується за рядками виходів, тобто -1 — останній. При некоректному індексі повертається NULL.

    Для чого: за цим масивом семплер обирає наступний токен (по одному числу на кожен токен словника); у циклі це майже завжди індекс -1, щойно порахована позиція.

Крок 1: Ініціалізація бекенда

Крок 1 у схемі процесу — ініціалізація бекенда. Перед завантаженням моделі та інференсом застосунок один раз викликає llama_backend_init(). Виклик робить три речі: запускає точний таймер, за яким потім вимірюють час завантаження та decode, створює і одразу звільняє порожній контекст GGML і — це головне — заповнює реєстр бекендів, якщо той ще порожній, щоб рушію було на чому рахувати: процесор, а якщо для відеокарти є бекенд, то і вона. Без зареєстрованого бекенда завантаження не деградує тихо, а падає: рушій повертає nullptr і пише, що бекенди не завантажено. Нижче — цитата цілком і по фрагментах (файл src/llama.cpp).

// Крок 1: єдина точка входу ініціалізації рушія; викликається один раз під час старту застосунку
void llama_backend_init(void) {
ggml_time_init(); // точний таймер; у Windows його потрібно ініціалізувати явно
// потрібно для ініціалізації таблиць f16
{
struct ggml_init_params params = { 0, NULL, false };
struct ggml_context * ctx = ggml_init(params);
ggml_free(ctx);
}
// якщо ще нічого не зареєстровано — підтягнути бекенди (CPU, CUDA, Metal, Vulkan ...);
// саме це дає рушію пристрої, на яких він рахуватиме
if (!ggml_backend_reg_count()) {
ggml_backend_load_all();
}
}

Що в коді відбувається покроково:

// Фрагмент 1: таймер потрібен, щоб потім виміряти час завантаження моделі та час decode
ggml_time_init();
// Фрагмент 2: тимчасовий контекст з нульовим буфером — створюється і одразу звільняється.
// Коментар у вихідниках називає це «ініціалізацією таблиць f16», але в нинішньому ggml перший
// виклик ggml_init() лише запускає таймер — блок лишився як рудимент і нічого не коштує
struct ggml_init_params params = { 0, NULL, false };
struct ggml_context * ctx = ggml_init(params);
ggml_free(ctx);
// Фрагмент 3: змістовна частина — якщо реєстр порожній, завантажити бібліотеки бекендів
// (CPU, CUDA, Metal, Vulkan і так далі); без них немає на чому рахувати
if (!ggml_backend_reg_count()) {
ggml_backend_load_all();
}

Спочатку вмикається таймер — потім за ним вимірюють час завантаження та час decode. Далі створюється і одразу звільняється тимчасовий контекст GGML з нульовим буфером; коментар поряд із ним у вихідниках і досі каже, що так ініціалізуються таблиці f16, але в нинішньому ggml перший виклик ggml_init лише запускає таймер, а таблиці для половинної точності будуються пізніше, коли реєструється бекенд CPU. Справжню роботу робить останній блок: якщо жоден бекенд ще не зареєстровано, ggml_backend_load_all шукає бібліотеки бекендів і реєструє ті, що знайшлися. Після цього рушій знає, на яких пристроях він може рахувати, і готовий до завантаження моделі та побудови графа.

Крок 2а: Завантаження моделі з файлу (точка входу)

Крок 2 — завантаження моделі з файлу. Воно починається з виклику llama_model_load_from_file: застосунок передає шлях до .gguf і параметри, рушій повертає готовий об'єкт моделі або nullptr при помилці.

Для чого ця функція:

  • вона — єдина точка входу для завантаження моделі з файлу;
  • застосунок передає шлях до файлу та параметри, рушій повертає готовий об'єкт моделі або nullptr при помилці.

Цитата з коду (файл src/llama.cpp):

// Точка входу завантаження моделі (крок 2): шлях до .gguf і параметри завантаження
struct llama_model * llama_model_load_from_file(
const char * path_model,
struct llama_model_params params) {
// splits — шляхи до частин розбитої моделі; тут порожньо, завантажувач виведе їх сам
std::vector<std::string> splits = {};
// три nullptr і FILE * — це решта можливих джерел моделі
// (підготовлені в пам'яті метадані GGUF та вже відкритий файл); заданим може бути лише одне
return llama_model_load_from_file_impl(nullptr, nullptr, nullptr, path_model, splits, /*file*/ nullptr, params);
}

Функція приймає шлях до файлу моделі (зазвичай .gguf) і параметри завантаження — застосунок вказує, звідки читати модель, як її читати і скільки шарів покласти на відеокарту. splits тут завжди порожній: якщо в метаданих сказано, що модель розбито на кілька файлів, шляхи до частин завантажувач виводить з імені файлу сам — тому це ім'я й має відповідати шаблону <name>-00001-of-00003.gguf. Явний перелік частин передають через окрему точку входу — llama_model_load_from_splits. Усе інше — перевірка, що бекенд зареєстровано, колбек прогресу за замовчуванням, виклик llama_model_load та обробка його результату — відбувається в llama_model_load_from_file_impl.

Параметри завантаження (llama_model_params), важливі для розуміння:

  • load_mode — як читати файл: mmap, mlock, пряме введення-виведення або звичайне читання в буфер.

    Для чого: mmap заощаджує RAM і пришвидшує старт завантаження, бо файл відображається, а не копіюється; пряме введення-виведення на деяких дисках і системах дає передбачуванішу швидкість читання. Типове значення — auto: рушій обирає mmap і відкочується на звичайне читання, якщо якийсь із пристроїв не вміє працювати з відображеною пам'яттю. Усередині завантажувача це одне значення знову перетворюється на два прапорці — use_mmap і use_direct_io; саме в такому вигляді воно трапляється далі.

  • n_gpu_layers — скільки шарів завантажувати на GPU (решту на CPU).

    Для чого: щоб частина обчислень виконувалася на відеокарті, частина на процесорі.

  • progress_callback — колбек прогресу завантаження.

    Для чого: застосунок може показувати прогрес-бар або скасовувати завантаження (повернути false).

  • vocab_only — завантажити лише словник (без ваг).

    Для чого: коли потрібен лише токенайзер, без важких ваг. Повний перелік параметрів — у include/llama.h у структурі llama_model_params.

Крок 2б: Підготовка до читання файлу

Крок 2 (продовження) — усередині точки входу виконується підготовка до читання файлу. У llama_model_load_from_file_impl виконується основна підготовка перед читанням файлу.

Для чого так:

  • перед завантаженням потрібно переконатися, що модель береться рівно з одного джерела, що є бекенд для обчислень, і налаштувати відображення прогресу;
  • тільки після цього викликається внутрішня функція llama_model_load, яка читає файл покроково.

Цитата початку функції (файл src/llama.cpp):

static struct llama_model * llama_model_load_from_file_impl(
struct gguf_context * metadata,
llama_model_set_tensor_data_t set_tensor_data,
void * set_tensor_data_ud,
const std::string & path_model,
std::vector<std::string> & splits,
FILE * file,
struct llama_model_params params) {
// ... опущено: перевірка, що задано рівно одне джерело — metadata, path_model або file
ggml_time_init(); // таймер для вимірювання часу завантаження
// Якщо завантажуємо не лише словник — перевіряємо, що зареєстровано хоча б один бекенд
if (!params.vocab_only && ggml_backend_reg_count() == 0) {
LLAMA_LOG_ERROR("%s: no backends are loaded. hint: use ggml_backend_load() or ggml_backend_load_all() to load a backend before calling this function\n", __func__);
return nullptr;
}
unsigned cur_percentage = 0;
// Якщо колбек прогресу не передано — підставляємо свій: виводимо крапки до 100%
if (params.progress_callback == NULL) {
params.progress_callback_user_data = &cur_percentage;
params.progress_callback = [](float progress, void * ctx) {
unsigned * cur_percentage_p = (unsigned *) ctx;
unsigned percentage = (unsigned) (100 * progress);
while (percentage > *cur_percentage_p) {
*cur_percentage_p = percentage;
LLAMA_LOG_CONT(".");
if (percentage >= 100) {
LLAMA_LOG_CONT("\n");
}
}
return true; // не скасовуємо завантаження
};
}

Ця реалізація спільна для всіх публічних точок входу, тому вона приймає одразу всі можливі джерела моделі — готові метадані GGUF, шлях (частини розбитої на файли моделі передаються в splits) або вже відкритий файл — і починає з перевірки, що передано рівно одне з них.

Що відбувається в цьому фрагменті і для чого:

  • ggml_time_init() — вмикається таймер.

    Для чого: щоб потім виміряти, скільки зайняло завантаження.

  • Перевірка ggml_backend_reg_count() == 0 — чи є хоча б один бекенд (CPU або GPU).

    Для чого: без бекенда не можна буде виконувати обчислення моделі; при завантаженні лише словника (vocab_only) бекенд не обов'язковий.

  • Налаштування колбека прогресу — якщо застосунок не передав свій, підставляється колбек за замовчуванням, який виводить крапки.

    Для чого: користувач бачить, що завантаження триває; за бажання можна передати свій колбек і показувати прогрес-бар або скасовувати завантаження (повернути false).

  • Сам об'єкт моделі тут не створюється. Він з'являється рівнем нижче, усередині llama_model_load, у виклику llama_model_create — і лише після того, як відкрито завантажувач GGUF.

    Для чого: клас об'єкта залежить від архітектури, записаної у файлі (LLaMA, Gemma, Qwen і так далі), тому спочатку потрібно прочитати архітектуру з GGUF; гіперпараметри, словник і тензори записуються вже в цей об'єкт.

Решта функції — це лише виклик внутрішнього завантажувача та обробка його результату (той самий файл, кінець llama_model_load_from_file_impl):

// Внутрішнє завантаження: читає файл і збирає модель (arch, hparams, vocab, tensors)
const auto [status, model] = llama_model_load(metadata, set_tensor_data, set_tensor_data_ud, path_model, splits, file, params);
GGML_ASSERT(status <= 0);
if (status < 0) {
if (status == -1) {
LLAMA_LOG_ERROR("%s: failed to load model\n", __func__);
} else if (status == -2) {
LLAMA_LOG_INFO("%s: cancelled model load\n", __func__);
}
if (model) {
llama_model_free(model);
}
return nullptr;
}
return model;
}
  • Список пристроїв теж будується рівнем нижче, у llama_prepare_model_devices, яку llama_model_load викликає одразу після створення об'єкта моделі; результат потрапляє в model->devices.

    Для чого: від нього залежить, по яких прискорювачах будуть розподілені шари моделі (див. load_tensors). До цього списку потрапляють лише графічні пристрої — спочатку віддалені RPC-сервери, потім дискретні відеокарти, а вбудовані — тільки якщо дискретної не знайшлося; CPU обробляється окремо і до нього не входить.

  • llama_model_load — читає файл і покроково заповнює модель: створює завантажувач GGUF, визначає архітектуру і створює під неї об'єкт моделі, обирає пристрої, а потім по черзі викликає load_hparams, load_vocab і load_tensors.

    Для чого: уся логіка читання GGUF і заповнення моделі зосереджена в одній функції.

  • При від'ємному статусі в лог пишеться причина (-1 — помилка завантаження, -2 — скасування колбеком прогресу) і повертається nullptr. Виклик llama_model_free у цій гілці — лише підстраховка: llama_model_load сама знищує недобудовану модель і при невдачі ніколи не віддає вказівник назовні.

    Для чого: застосунок за nullptr розуміє, що завантаження не вдалося, і не використовує неповну модель.

Колбек прогресу викликається всередині load_tensors при читанні кожного тензора: йому передається число від 0.0 до 1.0 (частка завантажених даних). Якщо колбек повертає false, завантаження переривається і llama_model_load повертає -2 (скасування).

Для чого:

  • застосунок може скасувати довге завантаження або показувати прогрес-бар.

Кроки з 2 до 6 разом: Покрокове завантаження моделі з файлу

Кроки 2–6 виконуються всередині однієї функції llama_model_load: створюється завантажувач (крок 2), потім створюється об'єкт моделі для архітектури, яку прочитав завантажувач (llama_model_create, крок 3), а після нього по черзі викликаються load_hparams (крок 4), load_vocab (крок 5) і load_tensors (крок 6).

Для чого потрібна функція llama_model_load: вона виконує покрокове завантаження моделі з файлу: створює завантажувач GGUF (відкриває файл і будує карту тензорів), створює об'єкт моделі потрібної архітектури, а потім по черзі завантажує гіперпараметри, словник і тензори. Порядок важливий: архітектура задає набір ключів у GGUF; гіперпараметри читаються за цими ключами; словник завантажується з урахуванням архітектури; тензори створюються за відомими розмірами і заповнюються з файлу.

Скорочена цитата функції (файл src/llama.cpp, функція llama_model_load):

// src/llama.cpp — llama_model_load(); порожні рядки і дві обгортки try/catch прибрано для стислості
// Повертає 0 при успіху, -1 при помилці та -2 при скасуванні через llama_progress_callback
static std::pair<int, llama_model *> llama_model_load(struct gguf_context * metadata, llama_model_set_tensor_data_t set_tensor_data, void * set_tensor_data_ud,
const std::string & fname, std::vector<std::string> & splits, FILE * file, llama_model_params & params) {
try {
// Крок 2: завантажувач відкриває GGUF, читає заголовок і метадані, будує карту тензорів (weights_map)
llama_model_loader ml(metadata, set_tensor_data, set_tensor_data_ud, fname, splits, file, params.load_mode,
params.check_tensors, params.no_alloc, params.load_mtp, params.kv_overrides, params.tensor_buft_overrides);
ml.lazy.mode = params.lazy_mode; // ліниве читання тензорів: частина ваг лишається на диску і читається на вимогу
ml.print_info();
// Крок 3: архітектура, яку прочитав завантажувач, обирає C++-клас моделі
std::unique_ptr<llama_model> model_ptr(llama_model_create(ml, params));
// список пристроїв (CPU, GPU), на які буде розкладена саме ця модель
bool ok = llama_prepare_model_devices(params, model_ptr.get());
if (!ok) {
return {-1, nullptr};
}
auto * model = dynamic_cast<llama_model_base *>(model_ptr.get());
// ... пропущено: перевірка на null, що перериває роботу, якщо модель не реалізує llama_model_base
model->t_load_us = 0;
time_meas tm(model->t_load_us); // час завантаження буде перераховано після першого eval, щоб урахувати промахи сторінок, відкладені mmap
model->t_start_us = tm.t_start_us;
model->hparams.vocab_only = params.vocab_only;
model->hparams.no_alloc = params.no_alloc;
// ... пропущено: кожен із двох викликів нижче обгорнутий у try/catch, що перекидає помилку з префіксом
model->load_hparams(ml); // Крок 4: розміри моделі, довжина контексту, кількість шарів
if (model->arch == LLM_ARCH_CLIP) {
throw std::runtime_error("CLIP cannot be used as main model, use it with --mmproj instead");
}
model->load_vocab(ml); // Крок 5: словник і токенайзер — текст <-> токени
model->load_stats(ml);
model->print_info();
if (params.vocab_only) {
LLAMA_LOG_INFO("%s: vocab only - skipping tensors\n", __func__);
return {0, model_ptr.release()};
}
// Крок 6: ваги моделі з файлу в пам'ять (або mmap) на CPU/GPU
if (!model->load_tensors(ml)) {
return {-2, nullptr};
}
return {0, model_ptr.release()};
} catch (const std::exception & err) {
LLAMA_LOG_ERROR("%s: error loading model: %s\n", __func__, err.what());
return {-1, nullptr};
}
}

Помилки та скасування завантаження:

  • при помилці в будь-якому з кроків (створення завантажувача, llama_model_create, load_hparams, load_vocab, load_tensors) викидається виняток; у блоці catch логується повідомлення і повертається -1;
  • при скасуванні за колбеком прогресу (колбек повертає false усередині load_tensors) load_tensors повертає false, виняток не викидається, але llama_model_load повертає -2 (скасування);
  • при будь-якій невдачі недобудована модель знищується одразу: вона живе в unique_ptr, який передається коду, що викликає, лише при успіху, тому llama_model_load_from_file_impl отримує нульовий вказівник, логує причину і повертає nullptr;
  • таким чином, застосунок може скасувати довге завантаження через колбек і коректно звільнити ресурси.

Покроково (що відбувається і для чого):

  • створюється завантажувач llama_model_loader ml(...) — він відкриває GGUF-файл, читає метадані і будує weights_map.

    Для чого:

  • без завантажувача не можна прочитати архітектуру, гіперпараметри, словник і тензори з файлу.

  • викликається ml.print_info() — виводить у лог формат файлу, тип файлу (квантування) і розмір файлу разом із кількістю біт на вагу.

    Для чого:

  • щоб користувач бачив, який саме файл відкривається.

  • викликається llama_model_create(ml, params) — архітектура, яку прочитав завантажувач (LLaMA, Gemma, Qwen тощо), обирає C++-клас моделі, і об'єкт створюється.

    Для чого:

  • від архітектури залежать імена полів у GGUF і те, які тензори створювати; невідома архітектура — помилка вже тут.

  • викликається llama_prepare_model_devices — будується і виводиться в лог список пристроїв (CPU і відеокарти) для цієї моделі.

    Для чого:

  • він визначає, по яких пристроях потім будуть розкладені шари в load_tensors.

  • лише тепер скидається час завантаження і запускається таймер.

    Для чого:

  • виміряне тут значення — не остаточне: при першому eval llama_context перераховує час завантаження від збереженого t_start_us, тому у відлік потрапляють і промахи сторінок, відкладені mmap, — вони трапляються лише тоді, коли до ваг реально дістануться.

  • викликається model->load_hparams(ml) — читаємо розмірності та параметри (довжина контексту, кількість шарів тощо).

    Для чого:

  • щоб знати «форму» моделі та виділити під неї пам'ять.

  • для CLIP буде викинуто помилку — його використовують окремо як проєктор, а не як основну модель.

  • викликається model->load_vocab(ml) — завантажуємо словник токенів.

    Для чого:

  • щоб потім перетворювати текст на числа (токени) і навпаки при генерації.

  • load_stats(ml) копіює із завантажувача в модель кількість елементів і розмір у байтах, а print_info() виводить у лог архітектуру та всі гіперпараметри. Якщо завантажуємо лише словник (vocab_only == true), на цьому вихід. Інакше викликається model->load_tensors(ml) — читаються і розкладаються по пам'яті ваги моделі. При успіху повертається 0, при скасуванні (колбек прогресу повернув false) — -2, при помилці — -1.

Порядок викликів при завантаженні моделі (зведення):

  • llama_model_load_from_filellama_model_load_from_file_impl;
  • в impl: перевірка, що джерело моделі задано рівно один раз, перевірка бекенда, колбек прогресу за замовчуванням і виклик llama_model_load(metadata, set_tensor_data, set_tensor_data_ud, path_model, splits, file, params);
  • усередині llama_model_load: створення llama_model_loader, ml.print_info, llama_model_create, llama_prepare_model_devices, load_hparams, load_vocab, load_stats, print_info, за потреби load_tensors. Усі ці кроки виконуються послідовно; при помилці в будь-якому з них завантаження переривається.

Порядок викликів llama_model_createload_hparamsload_vocab важливий: архітектура задає набір ключів GGUF; гіперпараметри читаються за цими ключами; словник завантажується з урахуванням архітектури (наприклад, імена ключів для токенайзера). Тому vocab.load(ml, kv) викликається саме після load_hparams(ml) — на цей момент і архітектура, і гіперпараметри вже відомі, і завантажувач може коректно прочитати тип токенайзера, списки токенів і злиття.

Крок 2в: Завантажувач GGUF — відкриття файлу та карта тензорів

Крок 2 (завершення) (усередині llama_model_load) — створюється завантажувач GGUF: відкривається файл, читаються заголовок і метадані, будується карта тензорів.

Для чого потрібен завантажувач GGUF: щоб відкрити файл моделі, прочитати заголовок і метадані (без самих ваг) і побудувати «карту» — за іменем тензора знати, де у файлі лежать його дані і якого він розміру. Без цієї карти не можна потім завантажувати ваги по одному тензору. Тензори тут — багатовимірні масиви чисел (матриці та вектори), у яких зберігаються ваги нейромережі; кожен шар моделі — це кілька тензорів, і завантажувач має знати для кожного ім'я, розмір і зміщення у файлі.

Створення завантажувача — виклик конструктора llama_model_loader. Сам конструктор міститься у src/llama-model-loader.cpp; llama_model_load лише викликає його. Скорочено він має такий вигляд:

// src/llama-model-loader.cpp - конструктор (крок 2): відкриває GGUF, читає заголовок і метадані, будує карту тензорів
llama_model_loader::llama_model_loader(
struct gguf_context * meta,
llama_model_set_tensor_data_t set_tensor_data,
void * set_tensor_data_ud,
const std::string & fname,
std::vector<std::string> & splits,
FILE * file,
llama_load_mode load_mode,
bool check_tensors,
bool no_alloc,
bool load_mtp,
const llama_model_kv_override * param_overrides_p,
const llama_model_tensor_buft_override * param_tensor_buft_overrides_p)
: metadata(meta), set_tensor_data(set_tensor_data), set_tensor_data_ud(set_tensor_data_ud) {
// ... перевизначення KV і перетворення load_mode на use_mmap / use_direct_io ...
if (!fname.empty()) {
struct ggml_context * ctx = NULL;
struct gguf_init_params params = {
/*.no_alloc = */ true, // створити лише порожні тензори, не читати блок даних
/*.ctx = */ &ctx,
};
metadata_ptr.reset(gguf_init_from_file(fname.c_str(), params));
metadata = metadata_ptr.get();
if (metadata == nullptr) {
throw std::runtime_error(format("%s: failed to load model from %s", __func__, fname.c_str()));
}
// Ім'я архітектури (llama, qwen2 ...) задає імена всіх інших ключів GGUF
get_key(llm_kv(LLM_KV_GENERAL_ARCHITECTURE), arch_name, false);
llm_kv = LLM_KV(llm_arch_from_string(arch_name));
files.emplace_back(new llama_file(fname.c_str(), "rb", use_direct_io));
contexts.emplace_back(ctx);
for (ggml_tensor * cur = ggml_get_first_tensor(ctx); cur; cur = ggml_get_next_tensor(ctx, cur)) {
std::string tensor_name = std::string(cur->name);
if (weights_map.find(tensor_name) != weights_map.end()) {
throw std::runtime_error(format("invalid model: tensor '%s' is duplicated", ggml_get_name(cur)));
}
n_elements += ggml_nelements(cur);
n_bytes += ggml_nbytes(cur);
weights_map.emplace(tensor_name, llama_tensor_weight(files.back().get(), 0, metadata, cur));
}
// ... той самий цикл по файлах-частинах, коли модель розбита на кілька ...
}
// ... гілки для вже відкритого FILE * і для метаданих, переданих ззовні ...
n_kv = gguf_get_n_kv(metadata);
n_tensors = weights_map.size();
fver = (enum llama_fver) gguf_get_version(metadata);
// ... вгадування типу файлу та вивід пар KV у лог ...
}

У конструкторі gguf_init_from_file читає заголовок GGUF і метадані (без самих ваг) — так отримують список тензорів і пари ключ–значення (архітектура, гіперпараметри) без читання важких даних у пам'ять. З метаданих береться ім'я архітектури (general.architecture) — від нього залежать імена решти полів у GGUF (у різних моделей різні ключі). Файл відкривається для читання; потім за зміщеннями з weights_map будуть читатися або мапитися дані тензорів. За списком тензорів з GGUF для кожного в weights_map записується ім'я, розмір і зміщення у файлі — потім get_weight(name) знаходить запис у карті, а load_all_data читає байти за цим зміщенням (або, при mmap, одразу вказує на них).

Структура llama_tensor_weight (елемент weights_map) зберігає три речі: індекс вихідного файлу (модель може бути розбита на кілька частин GGUF), абсолютне зміщення даних тензора в цьому файлі та вказівник на тензор у контексті GGUF. Зміщення обчислюється один раз, при створенні запису, як початок секції даних плюс зміщення тензора всередині неї — і той самий конструктор перевіряє, що тензор цілком уміщується у файл, тож обрізана модель відсікається тут, а не під час генерації. Метод ml.get_key(...) читає з метаданих GGUF значення за ключем (ім'я ключа залежить від архітектури — його повертає kv(...)). Так завантажувач отримує, наприклад, ім'я архітектури, тип токенайзера, гіперпараметри.

Після створення завантажувача в llama_model_load викликається ml.print_info(). Цитата з коду:

// src/llama.cpp, усередині llama_model_load, одразу після створення завантажувача:
ml.print_info(); // пише в лог версію GGUF, вгаданий тип файлу та загальний розмір ваг

Функція gguf_init_from_file (бібліотека GGUF) відкриває файл і читає заголовок:

  • версію формату;
  • кількість ключів метаданих;
  • кількість тензорів.

Метадані читаються в контекст gguf_context (пари ключ–значення); самі дані тензорів на цьому кроці не завантажуються — лише імена, типи та зміщення у файлі. Сам конструктор уже пише в лог кількість пар ключ–значення, кількість тензорів, ім'я файлу та версію GGUF. Метод print_info (src/llama-model-loader.cpp) додає ще три рядки:

  • версію формату GGUF;
  • тип файлу — Q4_K - Medium і подібні, вгаданий за найчастішим типом тензорів, якщо метадані не вказують його прямо;
  • загальний розмір ваг у MiB або GiB разом із середньою кількістю бітів на вагу.

Формат GGUF (довідково)

GGUF (GPT-Generated Unified Format) — бінарний формат для зберігання моделей машинного навчання.

Для чого він потрібен:

  • щоб в одному файлі зберігати і ваги моделі, і метадані (розміри, тип архітектури), і словник;
  • завантажувач за заголовком і метаданими будує «карту» і потім на запит читає потрібні шматки файлу або мапить їх у пам'ять (mmap).
заголовок magic · version · tensor_count · metadata_kv_count два лічильники — 64-бітні метадані пари «ключ — значення», за лічильником із заголовка спочатку ключ-рядок, потім тип значення, потім значення описи тензорів ім'я, розмірності, тип елемента, зсув зсув рахується від початку даних, а не файлу вирівнювання добивка до general.alignment дані тензорів самі ваги; читаються або відображаються в пам'ять квантизовані типи зберігають елементи блоками один файл

Структура заголовка GGUF:

  • магічне число, чотири байти GGUF (ідентифікація формату — за ним розуміють, що це GGUF);
  • версія формату (для сумісності при змінах формату);
  • кількість тензорів (n_tensors);
  • кількість ключів метаданих (n_kv).

Метадані зберігаються як масив пар «ключ — значення». Для кожної пари записані:

  • ім'я ключа (рядок із довжиною, записаною перед ним);
  • тип значення (рядок, число, булеве, масив тощо);
  • саме значення (ім'я архітектури, розмірності, параметри RoPE, тип токенайзера, списки токенів тощо); для масиву спочатку йдуть тип елементів і їх кількість, потім самі елементи.

Тензори у файлі ідуть після метаданих. Для кожного тензора записані:

  • ім'я;
  • кількість розмірностей і розмір за кожною з них (не більше чотирьох; наприклад, [n_embd, n_ff] для ваг повнозв'язного блоку одного шару — кожен шар це окремий набір тензорів, тому номер шару входить в ім'я, blk.0.ffn_up.weight, і ніколи не буває розмірністю);
  • тип елемента (F32, F16, Q8_0, Q4_K тощо — від повної точності до квантизованих форматів);
  • зміщення, за яким починаються дані, — від початку блоку даних тензорів, що йде після метаданих.

Імена на кшталт Q4_K_M описують файл цілком, а не окремий тензор: це рецепт, який кладе різні тензори в різні типи.

Навіщо це завантажувачу: за цією інформацією він будує weights_map (карту «ім'я тензора → де у файлі лежать дані»), і коли ваги справді знадобляться, load_all_data читає або мапить відповідну ділянку файлу.

Версія формату і типи елементів:

  • версія формату задається в заголовку GGUF.

    Для чого:

  • при змінах формату версія каже завантажувачу, з чим він має справу. Поточна версія — 3; файл із новішою або вже не підтримуваною версією відкидається одразу, і модель не завантажується. Невідомі ключі метаданих, навпаки, нічого не коштують — завантажувач питає лише ті ключі, які знає, і на решту не спотикається.

  • за типом елемента тензора (F32, F16, Q8_0, Q4_K тощо) завантажувач знає, скільки байтів займає блок елементів — квантизовані типи зберігають елементи блоками фіксованого розміру зі спільним масштабом, а не по одному, — і як інтерпретувати дані при копіюванні в буфер або при mmap.

    Для чого:

  • без цього не можна коректно прочитати або відобразити в пам'ять дані тензора; різні типи мають різний розмір і різну інтерпретацію байтів.

Типи даних тензорів у GGUF задають, як інтерпретувати байти: F32, F16, Q8_0, Q4_K тощо — від повної точності до квантизованих форматів. Квантизація зменшує розмір моделі та пришвидшує обчислення за рахунок наближеного представлення ваг. Рушій при завантаженні створює тензори в потрібному форматі і копіює або мапить дані з файлу в буфери на CPU або GPU.

Читання метаданих GGUF виконується через функції бібліотеки gguf:

  • gguf_get_n_kv — кількість ключів;
  • gguf_get_key — ім'я ключа за індексом;
  • gguf_get_kv_type — тип значення (рядок, число, масив тощо);
  • gguf_find_key — індекс ключа за його іменем або -1, якщо такого немає;
  • gguf_get_val_* — значення за цим індексом, по функції на кожен тип.

Завантажувач моделі загортає це в метод get_key(key, value) з урахуванням архітектури: ключ перетворюється на ім'я поля в GGUF (наприклад, llama.embedding_length для LLaMA).

Крок 3: Визначення типу моделі (архітектура)

Крок 3 — визначення типу моделі (LLaMA, Gemma, Qwen тощо) за іменем архітектури з GGUF. Роботу виконує фабрична функція llama_model_create у src/llama-model.cpp. Для чого це потрібно: від архітектури залежать імена ключів, які читаються з метаданих GGUF, набір тензорів, що створюються під час завантаження ваг, і побудова обчислювального графа; без цього не можна коректно прочитати гіперпараметри та словник. У llama.cpp це не просто прапорець: кожна архітектура — окремий клас C++, і фабрика обирає, який із них створити. Цитата (файл src/llama-model.cpp, функція llama_model_mapping і два перевантаження llama_model_create):

// src/llama-model.cpp — архітектура з GGUF вирішує, який клас C++ представляє цю модель
static llama_model * llama_model_mapping(llm_arch arch, const llama_model_params & params) {
switch (arch) {
case LLM_ARCH_LLAMA:
return new llama_model_llama(params);
// ... по одному case на кожну підтримувану архітектуру ...
default:
throw std::runtime_error(std::string("unsupported model architecture: '") + llm_arch_name(arch) + "'");
}
}
llama_model * llama_model_create(llm_arch arch, const llama_model_params & params) {
llama_model * model = llama_model_mapping(arch, params);
if (model != nullptr) {
model->arch = arch;
// ... перевірка, що запитаний режим розбиття тензорів реалізовано для цієї архітектури ...
}
return model;
}
llama_model * llama_model_create(llama_model_loader & ml, const llama_model_params & params) {
llm_arch arch = ml.get_arch();
if (arch == LLM_ARCH_UNKNOWN) {
throw std::runtime_error("unknown model architecture: '" + ml.get_arch_name() + "'");
}
return llama_model_create(arch, params);
}

Що відбувається і для чого:

  • ml.get_arch() повертає enum llm_arch, який завантажувач уже визначив за ключем general.architecture з метаданих GGUF.

    Для чого: далі за цим enum обираються імена полів для гіперпараметрів і словника (у різних архітектур — різні ключі в GGUF).

  • Якщо тип невідомий (LLM_ARCH_UNKNOWN), викидається помилка.

    Для чого: рушій не вміє працювати з невідомою архітектурою; застосунок отримає повідомлення про помилку і зможе повідомити користувача.

Де архітектура читається насправді (файл src/llama-model-loader.cpp): конструктор завантажувача робить це один раз, одразу після відкриття GGUF і до індексації тензорів, щоб усе інше в завантажувачі вже користувалося готовим значенням. get_arch() і get_arch_name() після цього — прості геттери:

// src/llama-model-loader.cpp — конструктор визначає архітектуру один раз, одразу після відкриття GGUF
llama_model_loader::llama_model_loader(/* ... */) : metadata(meta), /* ... */ {
// ... файл GGUF щойно відкрито в `metadata` ...
get_key(llm_kv(LLM_KV_GENERAL_ARCHITECTURE), arch_name, false); // рядок "general.architecture"
llm_kv = LLM_KV(llm_arch_from_string(arch_name)); // "llama" -> LLM_ARCH_LLAMA, "qwen2" -> LLM_ARCH_QWEN2
// ... далі будується індекс тензорів (weights_map) ...
}
// на момент запиту enum уже визначено — обидва геттери тривіальні
std::string llama_model_loader::get_arch_name() const {
return arch_name;
}
enum llm_arch llama_model_loader::get_arch() const {
return llm_kv.arch;
}

Що роблять ці виклики:

  • get_key(llm_kv(LLM_KV_GENERAL_ARCHITECTURE), arch_name, false) — з метаданих GGUF читається значення за ключем «загальна архітектура» (наприклад, llama, qwen2) і записується в arch_name.

    Для чого: без імені архітектури не можна обрати набір ключів для гіперпараметрів і словника.

  • llm_arch_from_string(arch_name) — рядок перетворюється на enum llm_arch (llamaLLM_ARCH_LLAMA, qwen2LLM_ARCH_QWEN2).

    Для чого: за enum далі обираються імена полів у GGUF для load_hparams і load_vocab, а також клас моделі в llama_model_create. Читання необов'язкове (false), тому файл без ключа архітектури дає LLM_ARCH_UNKNOWN, і помилка виникає там, де створюється модель, а не тут.

  • Приклади значень enum: LLM_ARCH_LLAMA, LLM_ARCH_GEMMA, LLM_ARCH_QWEN, LLM_ARCH_PHI3, LLM_ARCH_MISTRAL3, LLM_ARCH_CLIP тощо — зараз у таблиці близько 150 іменованих архітектур. Самі ідентифікатори ключів GGUF (LLM_KV) — один спільний набір: ті, що стосуються гіперпараметрів, — шаблони на кшталт %s.context_length, куди замість %s підставляється ім'я архітектури, тому той самий ключ читається як llama.context_length в однієї моделі і як qwen3.context_length в іншої; загальні ключі, серед них general.architecture і general.name, — звичайні рядки, підставляти в них нічого.

    Для чого: без правильної архітектури імена ключів виходять хибними, і гіперпараметри зі словником з GGUF не прочитати. По-справжньому власними в архітектури лишаються тільки кілька додаткових гіперпараметрів — їх читає її власний load_arch_hparams у src/models/.

  • Імена тензорів у GGUF залежать від архітектури: для LLaMA — blk.N.attn_q.weight, blk.N.attn_k.weight тощо; для інших моделей — свої префікси та суфікси. Додати підтримку нової архітектури — означає додати значення в enum і його ім'я до таблиці імен, написати новий клас у src/models/, який каже, які гіперпараметри читати, які тензори створювати і як будувати граф, і зареєструвати його у фабриці; нові ключі GGUF додаються лише тоді, коли архітектурі потрібен параметр, якого немає в жодному з наявних ключів. Гіперпараметри використовуються при створенні тензорів у load_tensors (розміри матриць, кількість шарів) і при створенні контексту (n_ctx, n_batch, параметри RoPE тощо).

Крок 4: Завантаження гіперпараметрів — розміри й контекст

Крок 4 — завантаження гіперпараметрів (розміри моделі, довжина контексту, кількість шарів тощо) з метаданих GGUF. Метод llama_model_base::load_hparams заповнює структуру hparams (гіперпараметри) з метаданих GGUF. Для чого це потрібно: гіперпараметри задають «форму» моделі — довжину контексту, кількість шарів, розмір ембедингу, кількість голів уваги тощо; без них не можна виділити пам'ять під тензори і побудувати обчислювальний граф. Тут читаються ключі, спільні для всіх архітектур; потім метод викликає load_arch_hparams, хук, який кожна архітектура реалізує у власному файлі в src/models/ і який читає параметри, властиві лише цій родині. Після повернення з хука встановлюється ще кілька полів, серед них тип RoPE, — його можна обчислити лише тоді, коли архітектура вже визначила власні параметри. Цитата з основними ключами (файл src/llama-model.cpp, метод llama_model_base::load_hparams):

// src/llama-model.cpp — llama_model_base::load_hparams(): «форма» моделі, прочитана з метаданих GGUF
void llama_model_base::load_hparams(llama_model_loader & ml) {
const gguf_context * ctx = ml.metadata;
// зберігаємо всі пари ключ-значення (крім масивів) як рядки, для довідки
for (int i = 0; i < gguf_get_n_kv(ctx); i++) {
gguf_type type = gguf_get_kv_type(ctx, i);
if (type == GGUF_TYPE_ARRAY) {
continue;
}
const char * name = gguf_get_key(ctx, i);
const std::string value = gguf_kv_to_str(ctx, i);
gguf_kv.emplace(name, value);
}
ml.get_key(LLM_KV_GENERAL_NAME, name, false);
// усе далі вже не стосується словника
if (hparams.vocab_only || ml.get_arch() == LLM_ARCH_CLIP) {
return;
}
// архітектура — префікс кожного ключа нижче: llama.context_length, qwen3.context_length, ...
ml.get_key(LLM_KV_CONTEXT_LENGTH, hparams.n_ctx_train); // довжина контексту під час навчання
ml.get_key(LLM_KV_EMBEDDING_LENGTH, hparams.n_embd); // розмір ембедингу (прихованого стану)
ml.get_key(LLM_KV_BLOCK_COUNT, hparams.n_layer_all); // кількість шарів трансформера
GGML_ASSERT(hparams.n_layer_all > 0 && hparams.n_layer_all <= LLAMA_MAX_LAYERS);
ml.get_key(LLM_KV_EXPERT_COUNT, hparams.n_expert, false); // лише для MoE-моделей
// ... тут масиви за шарами заповнюються нулями ...
ml.get_key_or_arr(LLM_KV_FEED_FORWARD_LENGTH, hparams.n_ff_arr, hparams.n_layer(), false); // розмір FF за шарами
ml.get_key_or_arr(LLM_KV_ATTENTION_HEAD_COUNT, hparams.n_head_arr, hparams.n_layer(), false); // кількість голів уваги за шарами
// n_head_kv необов'язковий, за замовчуванням дорівнює n_head
hparams.n_head_kv_arr = hparams.n_head_arr;
ml.get_key_or_arr(LLM_KV_ATTENTION_HEAD_COUNT_KV, hparams.n_head_kv_arr, hparams.n_layer(), false); // кількість KV-голів (GQA)
// rope_freq_base (необов'язковий)
hparams.rope_freq_base_train = 10000.0f;
ml.get_key(LLM_KV_ROPE_FREQ_BASE, hparams.rope_freq_base_train, false);
// ... масштабування RoPE, розміри голів, варіанти з ковзним вікном ...
load_arch_hparams(ml); // усе, що потрібно лише цій архітектурі — src/models/<arch>.cpp
// ... після хука встановлюється ще кілька полів, серед них hparams.rope_type ...
}

Що відбувається і для чого:

  • У циклі читаються всі пари ключ–значення з GGUF (крім масивів) і зберігаються в gguf_kv.

    Для чого:

  • щоб потім за потреби можна було звернутися до будь-якого поля за іменем.

  • Потім за ключами, що залежать від архітектури, заповнюються поля hparams:

  • n_ctx_train — довжина контексту при навчанні.

    Для чого:

  • від неї залежить розмір KV-cache і максимальна довжина введення.

  • n_embd — розмір ембедингу (прихованого шару).

    Для чого:

  • від нього залежать розміри матриць ваг.

  • n_layer_all — кількість блоків трансформера, записана у файлі.

    Для чого:

  • від нього залежить, скільки тензорів створювати в load_tensors; реально будується hparams.n_layer() — воно віднімає додаткові блоки multi-token prediction, які є в деяких моделей.

  • n_ff_arr, n_head_arr, n_head_kv_arr — розміри feed-forward і кількість голів уваги по шарах.

    Для чого:

  • від них залежать розміри матриць Q, K, V і feed-forward.

  • rope_freq_base_train та ін. — параметри RoPE (позиційного кодування).

    Для чого:

  • вони задають, як у графі застосовуються позиційні кодування. У підсумку hparams повністю описує розміри моделі — цього достатньо, щоб виділити пам'ять під тензори і побудувати обчислювальний граф.

Частина ключів GGUF для гіперпараметрів (залежно від архітектури):

  • llama.context_length — довжина контексту при навчанні;
  • llama.embedding_length — розмір прихованого шару (ембедингу);
  • llama.block_count — кількість шарів трансформера;
  • llama.attention.head_count — кількість голів уваги;
  • llama.attention.head_count_kv — кількість KV-голів (для GQA);
  • llama.feed_forward_length — розмір проміжного шару feed-forward;
  • llama.rope.freq_base — базова частота RoPE.

Для MoE-моделей додаються ключі кількості експертів тощо. Метод get_key_or_arr читає або одне значення, або масив по шарах (коли розміри різняться по шарах). Після завантаження гіперпараметрів модель знає «форму» всіх тензорів — цього достатньо для виділення буферів і побудови графа при load_tensors і при створенні контексту.

Крок 5: Завантаження словника й токенайзера

Крок 5 — завантаження словника й токенайзера: за словником текст перетворюється на токени при введенні і назад на текст при виведенні. Словник токенів — таблиця відповідності між шматочками тексту й цілими числами (токенами); за ним текст ріжеться на токени при введенні і назад збирається в текст при виведенні. Завантаження словника виконується після того, як визначено архітектуру та прочитано гіперпараметри: у llama_model_load архітектура визначається при створенні об'єкта моделі (llama_model_create запитує її в завантажувача через ml.get_arch()), потім викликається load_hparams(ml) і лише потім load_vocab(ml). Усередині load_vocab викликається vocab.load(ml, kv). Він завантажується в llama_model_base::load_vocab (src/llama-model.cpp):

void llama_model_base::load_vocab(llama_model_loader & ml) {
const auto kv = LLM_KV(arch);
vocab.load(ml, kv);
}

Для поточної архітектури береться набір ключів словника, після чого викликається vocab.load(ml, kv). Реалізація — метод llama_vocab::impl::load у src/llama-vocab.cpp. З GGUF читаються:

  • тип токенайзера (SPM, BPE, WPM тощо);
  • списки токенів;
  • типи токенів;
  • злиття BPE (якщо є);
  • спеціальні токени — усе, що потрібно, щоб перетворювати текст на послідовність чисел (токенів) і навпаки.

Після цього модель уміє токенізувати промпт і перекладати згенеровані токени назад у текст.

Виклик vocab.load(ml, kv) виконується всередині llama_model_base::load_vocab після того, як для поточної архітектури отримано набір ключів kv (через LLM_KV(arch)). Завантажувач ml уже відкрив GGUF і прочитав метадані; архітектура відома із завантажувача (ml.get_arch()), а гіперпараметри прочитані в load_hparams. Початок реалізації llama_vocab::impl::load (файл src/llama-vocab.cpp):

void llama_vocab::impl::load(llama_model_loader & ml, const LLM_KV & kv) {
struct gguf_context * ctx = ml.metadata;
// Визначаємо тип словника
{
ml.get_key(LLM_KV_TOKENIZER_MODEL, tokenizer_model);
ml.get_key(LLM_KV_TOKENIZER_PRE, tokenizer_pre, false);
// ... "no_vocab" / "none" -> LLAMA_VOCAB_TYPE_NONE і ранній вихід ...
if (tokenizer_model == "llama") {
type = LLAMA_VOCAB_TYPE_SPM;
// Спеціальні токени за замовчуванням
special_bos_id = 1;
special_eos_id = 2;
special_unk_id = 0;
} else if (tokenizer_model == "gpt2" || tokenizer_model == "hybriddna" || tokenizer_model == "whitespace") {
type = LLAMA_VOCAB_TYPE_BPE;
// Читаємо злиття BPE і заповнюємо ранги BPE
const int merges_keyidx = gguf_find_key(ctx, kv(LLM_KV_TOKENIZER_MERGES).c_str());
// ... неправильно типізований масив злиттів — помилка; відсутній — теж, крім
// випадку tokenizer_pre == "kimi-k2"; елемент "first second" -> bpe_ranks[{first, second}] = i ...
}
// ... "bert" -> WPM, "t5" -> UGM, "rwkv", "plamo2", "gemma4"; усе інше — помилка ...
}
const int token_idx = gguf_find_key(ctx, kv(LLM_KV_TOKENIZER_LIST).c_str());
if (token_idx == -1) {
throw std::runtime_error("cannot find tokenizer vocab in model file\n");
}
// ... тут розташовані масиви оцінок і типів токенів, і перевіряється, що їх вистачає на всі токени ...
const uint32_t n_tokens = gguf_get_arr_n(ctx, token_idx);
id_to_token.resize(n_tokens);
for (uint32_t i = 0; i < n_tokens; i++) {
std::string word = gguf_get_arr_str(ctx, token_idx, i);
// ... порожній рядок замінюється заглушкою "[EMPTY_<i>]" ...
token_to_id[word] = i;
max_token_len = std::max(max_token_len, (int) word.size());
auto & token_data = id_to_token[i];
token_data.text = std::move(word);
token_data.attr = LLAMA_TOKEN_ATTR_NORMAL;
// ... score і attr далі беруться з масивів GGUF, якщо вони є у файлі ...
}
init_tokenizer(type);
// ... далі йдуть токен переведення рядка, ID спеціальних токенів і прапорці add_bos / add_eos ...
}

У коді видно: визначення типу словника за tokenizer_model (llama → SPM, gpt2 → BPE з читанням злиттів), пошук масиву токенів у GGUF, цикл по всіх токенах — заповнення id_to_token і token_to_id, виклик init_tokenizer(type) для ініціалізації токенайзера (SPM, BPE тощо). Усе, що сховано за рядками // ..., — це перевірки: масив злиттів і масив токенів мають бути присутні й мати правильний тип GGUF, а масиви оцінок і типів токенів мають покривати всі токени — інакше завантаження переривається винятком. Єдине послаблення зроблено для пре-токенайзера kimi-k2: він токенізує без звичайних злиттів BPE, тому відсутність масиву злиттів там не помилка — завантажувач просто пише про це в лог. Далі в тій самій функції читаються спеціальні токени (BOS, EOS, UNK тощо) і налаштовуються прапорці add_bos, add_eos.

Спеціальні токени:

  • BOS (begin of sequence) — токен початку послідовності, за потреби додається перед промптом.
  • EOS (end of sequence) — токен кінця виводу, за ним застосунок припиняє генерацію.
  • UNK (unknown) — токен для невідомих або таких, що не входять у словник, символів.

Їхні ID зберігаються в special_bos_id, special_eos_id, special_unk_id. У GGUF для словника можуть бути ключі на кшталт tokenizer.ggml.bos_token_id, tokenizer.ggml.eos_token_id тощо — вони читаються наприкінці llama_vocab::impl::load і записуються у відповідні поля. Прапорці add_bos і add_eos задають, чи додавати ці токени автоматично при токенізації (залежить від моделі та формату чату). Після init_tokenizer(type) словник готовий до токенізації та зворотного перекладу токенів у текст; ці операції використовуються при кожному запиті користувача.

Крок 6: Завантаження ваг у пам'ять або на GPU

Крок 6 — ваги моделі (тензори) завантажуються з файлу в пам'ять або на GPU. Метод llama_model_base::load_tensors (src/llama-model.cpp) створює тензори моделі, призначає їм буфери — ділянки пам'яті на CPU або відеокарті — і заповнює їх даними з GGUF-файлу. Нижче — початок методу, скорочено.

// src/llama-model.cpp — llama_model_base::load_tensors()
bool llama_model_base::load_tensors(llama_model_loader & ml) {
const auto & split_mode = params.split_mode;
const bool use_mlock = params.load_mode == LLAMA_LOAD_MODE_MLOCK || params.load_mode == LLAMA_LOAD_MODE_MMAP_MLOCK;
const auto & tensor_split = params.tensor_split;
const int n_layer_all = hparams.n_layer_all;
const int n_gpu_layers = this->n_gpu_layers(); // від'ємне значення в параметрах означає «всі шари»
// ... за LLAMA_LOAD_MODE_AUTO ml.use_mmap вимикається, якщо якийсь пристрій не вміє mmap
// будуємо переліки типів буферів для CPU і для пристроїв GPU
pimpl->cpu_buft_list = make_cpu_buft_list(devices, params.use_extra_bufts, params.no_host);
for (const auto & dev : devices) {
buft_list_t buft_list = make_gpu_buft_list(dev.dev, split_mode, tensor_split);
// додаємо типи буферів CPU як запасний варіант
buft_list.insert(buft_list.end(), pimpl->cpu_buft_list.begin(), pimpl->cpu_buft_list.end());
pimpl->gpu_buft_list.emplace(dev.dev, std::move(buft_list));
}
ggml_backend_dev_t cpu_dev = ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_CPU);
// ... splits[] заповнюється з tensor_split або за вільною пам'яттю кожного пристрою і нормується
const int i_gpu_start = std::max(n_layer_all + 1 - n_gpu_layers, 0);
const int act_gpu_layers = devices.empty() ? 0 : std::min(n_gpu_layers, n_layer_all + 1);
auto get_layer_buft_list = [&](int il) -> llama_model::impl::layer_dev {
// ... рядок LLAMA_LOG_DEBUG по кожному шару опущено
if (il < i_gpu_start || (il - i_gpu_start) >= act_gpu_layers) {
return {cpu_dev, &pimpl->cpu_buft_list};
}
const int layer_gpu = std::upper_bound(splits.begin(), splits.begin() + n_devices(), float(il - i_gpu_start)/act_gpu_layers) - splits.begin();
auto * dev = devices.at(layer_gpu).dev;
return {dev, &pimpl->gpu_buft_list.at(dev)};
};
// від вивантаження вхідного шару користі майже немає, тому він завжди лишається на CPU
pimpl->dev_input = { cpu_dev, &pimpl->cpu_buft_list };
// розкладаємо повторювані шари по пристроях згідно зі splits
pimpl->dev_layer.resize(n_layer_all);
for (int il = 0; il < n_layer_all; ++il) {
pimpl->dev_layer[il] = get_layer_buft_list(il);
}
// призначаємо вихідний шар
pimpl->dev_output = get_layer_buft_list(n_layer_all);

Що відбувається на початку load_tensors і для чого:

  • формуються списки типів буферів для CPU і для кожної відеокарти

    Для чого: від них залежить, на які пристрої будуть розміщені тензори моделі — вхідні зазвичай на CPU, шари за розбиттям на CPU/GPU;

  • за налаштуваннями (tensor_split) або за вільною пам'яттю вирішується, які шари рахувати на CPU, які на GPU

    Для чого: щоб рівномірно завантажити пристрої і не переповнити пам'ять однієї відеокарти.

Зверніть увагу: mmap і mlock більше не окремі булеві параметри — у llama_model_params є одне поле load_mode (auto, none, mmap, mlock, mmap+mlock, dio), а з командного рядка воно задається ключем -lm / --load-mode; попередні ключі --mmap, --no-mmap і --mlock відображаються на нього.

Функція get_layer_buft_list(il) для номера шару повертає пристрій і список буферів: вхід завжди на CPU, повторювані шари можуть бути на CPU або GPU, вихід — за розбиттям. Далі в циклі створюються тензори: ембединги, вихід, для кожного шару — матриці уваги (Q/K/V/O), нормалізації, feed-forward. Створення тензора на цьому етапі лише фіксує його форму й обирає буфер, у якому він житиме, — байти поки не рухаються. Дані приходять другим проходом, коли всі тензори вже оголошені, а буфери бекендів виділені: load_tensors викликає ml.load_all_data(...) по одному разу на кожен буферний контекст, і вже всередині цього виклику обирається mmap або звичайне читання. У підсумку ваги моделі опиняються в пам'яті (і за потреби на відеокарті), модель готова до генерації тексту.

Сам завантажувач (src/llama-model-loader.cpp) розкладає це на дві половини. create_tensor оголошує один тензор: звіряє форму з метаданими GGUF, обирає тип буфера за розбиттям пристроїв, порахованим вище, і створює тензор у відповідному ggml-контексті. load_all_data потім проходить по всіх тензорах контексту, за іменем знаходить їхній запис у weights_map — звідти беруться індекс файлу і зміщення в байтах — і або спрямовує тензор на відображення файлу в пам'ять, або читає байти в його буфер. Для великих моделей використовується mmap, щоб не дублювати дані в оперативній пам'яті.

Прохід оголошення тензорів, для родини llama — метод load_arch_tensors у файлі src/models/llama.cpp:

// src/models/llama.cpp — llama_model_llama::load_arch_tensors(), викликається з load_tensors()
void llama_model_llama::load_arch_tensors(llama_model_loader &) {
LLAMA_LOAD_LOCALS; // вводить в область видимості n_embd, n_layer, n_ff, n_vocab та інші
tok_embd = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, 0);
// вихід
output_norm = create_tensor(tn(LLM_TENSOR_OUTPUT_NORM, "weight"), {n_embd}, 0);
output = create_tensor(tn(LLM_TENSOR_OUTPUT, "weight"), {n_embd, n_vocab}, TENSOR_NOT_REQUIRED);
// якщо output порожній, ініціалізуємо його з вхідних ембедингів токенів
if (output == NULL) {
output = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, TENSOR_DUPLICATED);
}
for (int i = 0; i < n_layer; ++i) {
auto & layer = layers[i];
layer.attn_norm = create_tensor(tn(LLM_TENSOR_ATTN_NORM, "weight", i), {n_embd}, 0);
create_tensor_qkv(layer, i, n_embd, n_embd_head_k * n_head, n_embd_k_gqa, n_embd_v_gqa, 0);
layer.wo = create_tensor(tn(LLM_TENSOR_ATTN_OUT, "weight", i), {n_embd_head_k * n_head, n_embd}, 0);
// ... необов'язкові тензори зсувів і тензори коефіцієнтів RoPE опущено
layer.ffn_norm = create_tensor(tn(LLM_TENSOR_FFN_NORM, "weight", i), {n_embd}, 0);
if (n_expert == 0) {
layer.ffn_gate = create_tensor(tn(LLM_TENSOR_FFN_GATE, "weight", i), {n_embd, n_ff}, 0);
layer.ffn_down = create_tensor(tn(LLM_TENSOR_FFN_DOWN, "weight", i), { n_ff, n_embd}, 0);
layer.ffn_up = create_tensor(tn(LLM_TENSOR_FFN_UP, "weight", i), {n_embd, n_ff}, 0);
// ... необов'язкові тензори зсувів MLP опущено
}
// ... гілка mixture-of-experts опущена
}
}

Що тут відбувається і для чого:

  • кожна вага моделі іменується через tn(...), який будує ім'я тензора в GGUF — token_embd.weight, blk.0.attn_norm.weight, blk.0.ffn_down.weight і так далі.

    Для чого: ім'я — це ключ, за яким завантажувач потім знайде зміщення цього тензора у файлі.

  • create_tensor звіряє оголошену форму з метаданими, обирає тип буфера за порахованим раніше розбиттям шарів і створює тензор у ggml-контексті цього типу буфера.

    Для чого: саме тут вирішується, що шар живе на CPU або на конкретній відеокарті, — ще до того, як зачеплено дані.

  • тензори з прапорцем TENSOR_NOT_REQUIRED можуть законно бути відсутні, а TENSOR_DUPLICATED перевикористовує вже створений тензор — так модель без окремої вихідної матриці зв'язує свій вихід з ембедингами токенів.

    Для чого: одна процедура завантаження має покривати багато варіантів однієї й тієї самої архітектури.

Сам прохід з даними — це цикл по тензорах одного буферного контексту всередині ml.load_all_data (src/llama-model-loader.cpp). Для кожного тензора його запис шукається у weights_map за іменем у GGUF — blk.0.attn_q.weight, blk.0.attn_k.weight і так далі, — і звідти беруться індекс файлу та зміщення в байтах. Далі можливі два варіанти. За наявності відображення файлу в пам'ять тензор спрямовується прямо на відображені сторінки, якщо над ними вдається побудувати буфер, — це і є випадок без копіювання, і саме так виходить для пам'яті хоста; інакше байти копіюються з відображення в буфер тензора. Без відображення байти читаються з файлу в цей буфер, а для буфера на відеокарті — надсилаються на пристрій. Після цього проходу ваги моделі повністю перебувають у пам'яті (і за потреби на GPU).

// src/llama-model-loader.cpp — цикл по тензорах у llama_model_loader::load_all_data()
for (struct ggml_tensor * cur = ggml_get_first_tensor(ctx); cur != NULL; cur = ggml_get_next_tensor(ctx, cur)) {
const auto * weight = get_weight(ggml_get_name(cur));
if (weight == nullptr) {
// таке буває в моделях з розділеними експертами
continue;
}
// ... тут викликається progress_callback; повернення false перериває завантаження
size_t n_size = ggml_nbytes(cur);
const bool from_mapping = use_mmap || lazy.has(cur);
if (from_mapping) {
const auto & mapping = mappings.at(weight->idx);
ggml_backend_buffer_t buf_mmap = nullptr;
if (bufs.count(weight->idx)) {
buf_mmap = bufs.at(weight->idx);
}
uint8_t * data = (uint8_t *) mapping->addr() + weight->offs;
// ... необов'язкова перевірка check_tensors опущена
GGML_ASSERT(buf_mmap || cur->data); // або є буфер під тензор, або він уже розміщений
if (buf_mmap && cur->data == nullptr) {
// копіювання немає взагалі: тензор спрямовується прямо на відображені сторінки
ggml_backend_tensor_alloc(buf_mmap, cur, data);
// ... розширення mlock і облік використаного діапазону опущено
} else {
ggml_backend_tensor_set(cur, data, 0, n_size);
}
} else {
const auto & file = files.at(weight->idx);
if (ggml_backend_buffer_is_host(cur->buffer)) {
file->seek(weight->offs, SEEK_SET);
file->read_raw(cur->data, n_size);
} else {
// ... у буфер на GPU: асинхронна відправка шматками через закріплену пам'ять хоста,
// ... якщо пристрій це вміє, інакше читання в read_buf + ggml_backend_tensor_set()
}
}
size_done += n_size;
}

Так два проходи разом покривають усе: create_tensor вирішує, де житиме кожна вага — ембединги, матриці Q/K/V/O, нормалізації та feed-forward кожного шару, — а load_all_data кладе туди байти або підставляє відображення файлу.

При використанні mmap дані тензорів не копіюються в оперативну пам'ять — відображається ділянка файлу, і тензор вказує прямо в неї, — але лише там, де пристрій уміє побудувати буфер поверх пам'яті хоста. Процесор уміє завжди; на єдиній пам'яті на кшталт Apple Silicon уміє і графічний пристрій. Дискретна карта не вміє: її байти копіюються з відображення у відеопам'ять, і там mmap заощаджує RAM лише на шарах, що лишилися на процесорі. Це зменшує споживання RAM і пришвидшує старт завантаження, але файл має залишатися на диску на тому самому місці й без змін увесь час, поки модель завантажена: відображення живе довше за завантажувач і звільняється лише при знищенні моделі. Режим прямого вводу-виводу (--load-mode dio) відкриває файл без буферизації та округлює кожне читання до розміру блоку, про який повідомляє файлова система; він реалізований лише на Linux, а якщо відкрити файл без буферизації не вдалося — відбувається відкат на звичайне буферизоване читання. Розбиття по шарах (tensor_split або за вільною пам'яттю GPU) задає, які шари завантажувати на який пристрій — так можна розподілити велику модель по кількох відеокартах. Після завершення load_tensors модель повністю готова до інференсу: усі ваги перебувають у пам'яті (і за потреби на GPU), і можна створювати контекст та викликати llama_decode.

Крок 7: Створення контексту інференсу

Крок 7 — створення контексту інференсу (KV-cache, планувальник, зарезервовані графи). Завантажена модель зберігає лише ваги (тензори). Щоб генерувати текст, потрібен контекст інференсу — об'єкт llama_context.

Для чого він потрібен:

  • контекст — це «робоче середовище» одного сеансу генерації: у ньому задається, скільки токенів модель може «пам'ятати» (розмір контексту), якими великими порціями подавати дані (розмір батча), параметри RoPE і тип уваги;
  • без контексту не можна викликати llama_decode — саме контекст зберігає KV-cache, планувальник і зарезервовані графи.

Реалізація — у файлі src/llama-context.cpp.

Що створюється при створенні контексту і для чого:

  • Планувальник (sched) — вирішує, на якому пристрої (CPU чи відеокарта) виконувати кожен вузол обчислювального графа.

    Для чого: щоб розподілити обчислення по CPU і GPU та виділити під граф буфери на потрібних пристроях.

  • Пам'ять KV-cache (memory) — буфери під ключі та значення механізму уваги.

    Для чого: у них зберігаються вже пораховані ключі та значення по всіх попередніх позиціях; без кешу при кожному новому токені довелося б наново рахувати K і V для всієї історії, що дуже повільно (докладніше в розділі «KV-cache»).

  • Алокатор батча (balloc) — заповнює позиції та прапорці логітів у батчі за потреби.

    Для чого: щоб коду, який викликає, не потрібно було вручну виставляти позиції і вирішувати, для яких позицій рахувати логіти.

  • Зарезервовані графи (Prefill і Decode) — тимчасові графи та буфери під них.

    Для чого: при першому виклику decode пам'ять під граф не виділяється «на льоту» — буфери вже зарезервовані під найгірший випадок, і це зменшує затримку першої відповіді (сам граф при першому decode все одно будується наново).

Початок конструктора llama_context::llama_context у src/llama-context.cpp (скорочено — пропуски позначені):

// src/llama-context.cpp — llama_context::llama_context (скорочено)
llama_context::llama_context(
const llama_model & model,
llama_context_params params) :
model(model),
cvec(std::make_unique<llama_adapter_cvec>()),
loras(std::make_unique<llama_adapter_loras>()),
balloc(std::make_unique<llama_batch_allocr>(model.hparams.n_pos_per_embd())) {
LLAMA_LOG_INFO("%s: constructing llama_context\n", __func__);
t_start_us = model.t_start_us;
t_load_us = model.t_load_us;
const auto & hparams = model.hparams;
cparams.n_seq_max = std::max(1u, params.n_seq_max);
if (cparams.n_seq_max > LLAMA_MAX_SEQ) {
throw std::runtime_error("n_seq_max must be <= " + std::to_string(LLAMA_MAX_SEQ));
}
// ... n_rs_seq і його скидання в 0 для архітектур без відкату recurrent-стану
cparams.n_threads = params.n_threads;
cparams.n_threads_batch = params.n_threads_batch;
cparams.yarn_ext_factor = params.yarn_ext_factor >= 0.0f ? params.yarn_ext_factor : hparams.yarn_ext_factor;
// ... решта параметрів YaRN, пулінгу і Flash Attention
cparams.n_ctx = params.n_ctx == 0 ? hparams.n_ctx_train : params.n_ctx;
cparams.rope_freq_base = params.rope_freq_base == 0.0f ? hparams.rope_freq_base_train : params.rope_freq_base;
cparams.rope_freq_scale = params.rope_freq_scale == 0.0f ? hparams.rope_freq_scale_train : params.rope_freq_scale;
// ...
if (params.attention_type == LLAMA_ATTENTION_TYPE_UNSPECIFIED) {
cparams.causal_attn = hparams.causal_attn;
} else {
cparams.causal_attn = params.attention_type == LLAMA_ATTENTION_TYPE_CAUSAL;
}
// при causal attention розмір батча обмежений розміром контексту
cparams.n_batch = cparams.causal_attn ? std::min(cparams.n_ctx, params.n_batch) : params.n_batch;
cparams.n_ubatch = std::min(cparams.n_batch, params.n_ubatch == 0 ? params.n_batch : params.n_ubatch);
// ... n_ctx доповнюється до кратного 256 і, якщо KV-cache не спільний для всіх
// послідовностей, ділиться між ними на ліміт n_ctx_seq
LLAMA_LOG_INFO("%s: n_ctx = %u\n", __func__, cparams.n_ctx);
LLAMA_LOG_INFO("%s: n_ctx_seq = %u\n", __func__, cparams.n_ctx_seq);
LLAMA_LOG_INFO("%s: n_batch = %u\n", __func__, cparams.n_batch);
LLAMA_LOG_INFO("%s: n_ubatch = %u\n", __func__, cparams.n_ubatch);
// ... створюються бекенди (GPU, CPU) і буфер виходів, потім модуль пам'яті
// (KV-cache), і останнім sched_reserve() будує планувальник і графи
}

Що задається в конструкторі контексту:

  • зберігаються посилання на модель і час завантаження;

  • задаються максимальна кількість послідовностей (n_seq_max), кількість потоків, параметри YaRN/RoPE, розмір контексту (n_ctx), розмір батча (n_batch, n_ubatch), тип уваги (causal).

    Для чого:

  • від цих параметрів залежать розмір KV-cache, максимальна довжина промпту і те, якими великими порціями обробляється батч.

Запитаний n_ctx округлюється вгору до кратного 256, і, якщо KV-cache не спільний для всіх послідовностей, ділиться між ними: реальним обмеженням одного діалогу виявляється ліміт на одну послідовність n_ctx_seq — саме його лог друкує поряд з n_ctx.

Далі ініціалізуються бекенди (CPU і відеокарти), створюється об'єкт пам'яті memory (KV-cache і службові буфери), планувальник sched (розподіляє вузли графа по пристроях), резервуються графи для Prefill і Decode — щоб до першого decode буфери під них були вже виділені. Після цього можна викликати llama_decode з батчами токенів.

Параметри n_batch та n_ubatch:

  • n_batch — максимальна кількість токенів в одному батчі (при Prefill у батчі може бути до n_batch токенів промпту);
  • n_ubatch — максимальний розмір підбатча, який обробляється за один виклик process_ubatch;
  • якщо промпт довший за n_ubatch, він розбивається на підбатчі по n_ubatch токенів, кожен проганяється через модель по черзі;
  • логіти потрібні лише для останньої позиції в батчі, тому копіюються тільки вони;
  • зменшення n_ubatch знижує пікове споживання пам'яті за рахунок більшої кількості проходів.

Контекст створюється один раз на сеанс (або при зміні параметрів); один контекст може використовуватися для багатьох запитів поспіль без перестворення.

Пам'ять і планувальник налаштовуються з конструктора llama_context (файл src/llama-context.cpp), але не прямо в ньому. Спочатку конструктор збирає власний список бекендів — по одному на пристрій моделі, плюс бекенди прискорювачів і CPU, — потім створює об'єкт пам'яті через model.create_memory і останнім викликає допоміжну функцію sched_reserve. Саме в ній ggml_backend_sched_new будує планувальник за цим списком бекендів і резервуються графи, тож при першому реальному decode граф будується наново, але розміщується в уже зарезервованій пам'яті. Сам KV-cache живе не в src/llama-memory.cpp — у цьому файлі лежать лише дві невеликі допоміжні функції для статусу пам'яті. Буфери виділяються в конструкторі llama_kv_cache у файлі src/llama-kv-cache.cpp: для кожного шару він створює тензор ключів і тензор значень розміром на весь контекст, а потім запитує у бекенда по одному буферу на пристрій.

Функція graph_reservesrc/llama-context.cpp) резервує по одному графу за виклик: вона робить фіктивний батч потрібної форми, викликає для нього model.build_graph і передає результат у ggml_backend_sched_reserve — планувальник розбиває граф по бекендах і резервує буфери. sched_reserve викликає її тричі: спочатку для Prefill (цілий підбатч токенів за раз), потім для Decode (по одному токену на послідовність), потім знову для Prefill — щоб буфери залишилися розміром під найгірший випадок і не перевиділялися під час інференсу. Пізніше, при справжньому виклику process_ubatch, попередній граф перевикористовується, якщо це дозволяють його параметри (can_reuse), інакше будується наново і розміщується через ggml_backend_sched_alloc_graph; у будь-якому разі виділення під великі графи вже зроблено при створенні контексту — саме це й прибирає сплеск затримки на першому decode.

Крок 8: Надходить текст промпту від користувача

Крок 8 — до рушія надходить текст промпту від користувача. Після створення контексту користувач надсилає повідомлення (промпт). Модель працює лише з числами — токенами. Тому текст спочатку перетворюють на токени (крок 9), пакують у батч (крок 10), проганяють через модель викликом llama_decode (кроки 11–14): при першому запиті в батчі всі токени промпту (Prefill заповнює KV-cache), при генерації — один новий токен (Decode). З логітів семплер обирає один наступний токен (крок 15), він перетворюється на текст і виводиться (крок 16); токен знову додається в батч, і цикл повторюється до токена «кінець виводу» (EOS) або ліміту. Нижче кожен із цих кроків розібрано за кодом: що викликається, що відбувається і що може бути неочевидним.

Prefill і Decode — у чому різниця і для чого:

  • При першому виклику llama_decode з повним промптом (Prefill) модель обробляє всі токени промпту за один або кілька підбатчів; для них рахуються K і V та записуються в KV-cache; логіти запитуються лише для останньої позиції — за ними обирається перший токен відповіді.

    Для чого: один раз «прогнати» весь промпт і заповнити кеш ключів і значень, щоб далі генерувати по одному токену, не перераховуючи історію.

  • При наступних викликах у батчі один новий токен (Decode); проходяться, як і раніше, усі шари моделі, але тільки для цієї однієї позиції: Q, K і V рахуються лише для неї, а K і V усієї попередньої історії читаються з кешу; логіти знову лише для останньої позиції.

    Для чого: ефективна генерація по одному токену без перерахунку всієї історії — старі K і V уже в кеші.

Увесь цикл уміщається в пару десятків рядків. Нижче — цикл генерації з мінімального прикладу examples/simple-chat/simple-chat.cpp (функція generate), скорочено, з доданими в коментарі номерами кроків цієї статті.

// Цикл генерації, скорочено з examples/simple-chat/simple-chat.cpp (лямбда "generate").
// Номери кроків — із цієї статті, решта — код самого прикладу.
// BOS додається лише для найпершого промпту в діалозі
const bool is_first = llama_memory_seq_pos_max(llama_get_memory(ctx), 0) == -1;
// Крок 9: текст промпту -> масив токенів. При виклику з tokens == NULL
// llama_tokenize повертає мінус кількість токенів — так і дізнаються розмір буфера.
const int n_prompt_tokens = -llama_tokenize(vocab, prompt.c_str(), prompt.size(), NULL, 0, is_first, true);
std::vector<llama_token> prompt_tokens(n_prompt_tokens);
if (llama_tokenize(vocab, prompt.c_str(), prompt.size(), prompt_tokens.data(), prompt_tokens.size(), is_first, true) < 0) {
GGML_ABORT("failed to tokenize the prompt\n");
}
// Крок 10: токени пакуються в батч — одна послідовність, позиції веде llama_decode
llama_batch batch = llama_batch_get_one(prompt_tokens.data(), prompt_tokens.size());
llama_token new_token_id;
while (true) {
// ... тут приклад ще перевіряє, що батч уміщається в контекст
// Кроки 11–14: decode, підбатчі, process_ubatch; логіти потрапляють у буфер контексту
int ret = llama_decode(ctx, batch);
if (ret != 0) {
GGML_ABORT("failed to decode, ret = %d\n", ret);
}
// Крок 15: семплінг; -1 означає «логіти останньої позиції»
new_token_id = llama_sampler_sample(smpl, ctx, -1);
// це кінець генерації?
if (llama_vocab_is_eog(vocab, new_token_id)) {
break;
}
// Крок 16: токен -> текст; від'ємне n означає, що буфер виявився замалим
char buf[256];
int n = llama_token_to_piece(vocab, new_token_id, buf, sizeof(buf), 0, true);
if (n < 0) {
GGML_ABORT("failed to convert token to piece\n");
}
std::string piece(buf, n);
printf("%s", piece.c_str());
fflush(stdout);
// наступний батч — один засемплений токен, далі прохід Decode
batch = llama_batch_get_one(&new_token_id, 1);
}

Крок 9: Токенізація — від тексту до послідовності токенів

Крок 9 — текст промпту перетворюється на послідовність токенів (цілих чисел). Текст потрібно перетворити на послідовність цілих чисел — токенів.

Для чого це потрібно:

  • модель (шари трансформера) приймає на вхід не рядки, а вектори чисел фіксованої довжини;
  • кожному токену відповідає один рядок в ембединг-таблиці моделі.

Токенізація — це перший крок:

  • за словником текст ріжеться на шматочки (токени), кожному шматочку ставиться у відповідність число (ID);
  • далі за цими ID беруться ембединги і подаються в модель.

Що таке токен:

  • токен — це ID (ціле число), що відповідає шматочку тексту: цілому слову, частині слова або службовому символу;
  • словник моделі задає відповідність «текст ↔ токени»: за текстом можна отримати масив токенів (токенізація), за токеном — текст (token_to_piece).

В API (заголовок include/llama.h) оголошено функцію llama_tokenize; реалізація делегує виклик словнику моделі.

Реалізація в src/llama-vocab.cpp:

// Крок 9: текст промпту перетворюється на масив токенів (ID); словник моделі задає відповідність «текст ↔ токени»
int32_t llama_tokenize(
const struct llama_vocab * vocab,
const char * text,
int32_t text_len,
llama_token * tokens,
int32_t n_tokens_max,
bool add_special,
bool parse_special) {
return vocab->tokenize(text, text_len, tokens, n_tokens_max, add_special, parse_special);
}

Параметри:

  • vocab — словник моделі;
  • text і text_len — рядок промпту;
  • tokens — масив для запису токенів;
  • n_tokens_max — його розмір;
  • add_special — чи додавати службові токени BOS і EOS, і лише якщо сама модель на них налаштована;
  • parse_special — чи обробляти спеціальні теги.

Реальна логіка (BPE, SentencePiece тощо) — у методі vocab->tokenize у src/llama-vocab.cpp. Результат — кількість записаних токенів; якщо буфер виявився замалим, функція повертає мінус потрібну кількість токенів — саме так застосунок спочатку викликає її з порожнім буфером, щоб дізнатися розмір, і лише потім виділяє масив. Токенізація виконується при кожному новому повідомленні користувача; словник при цьому не змінюється і вже завантажений при завантаженні моделі.

Реалізація токенізації — метод llama_vocab::impl::tokenize у src/llama-vocab.cpp. Усередині за типом словника (SPM, BPE, WPM тощо) створюється сесія токенайзера і викликається її tokenize; для BPE, наприклад, використовується llm_tokenizer_bpe_session, який розбиває текст за регулярними виразами і збирає токени за злиттями (merges).

Типи токенайзерів у словнику:

  • LLAMA_VOCAB_TYPE_SPM — SentencePiece-подібний (моделі LLaMA та ін.), розбиття за правилами і словником SPM;
  • LLAMA_VOCAB_TYPE_BPE — Byte Pair Encoding (GPT-2 та ін.), злиття пар байтів/підрядків зберігаються в GGUF, токенізація — жадібне злиття;
  • LLAMA_VOCAB_TYPE_WPM — WordPiece, токенайзер у стилі BERT;
  • LLAMA_VOCAB_TYPE_UGM — Unigram, токенайзер у стилі T5;
  • LLAMA_VOCAB_TYPE_RWKV — жадібна токенізація за префіксним деревом (trie);
  • LLAMA_VOCAB_TYPE_PLAMO2 — алгоритм Ахо — Корасік з динамічним програмуванням;
  • LLAMA_VOCAB_TYPE_NONE — модель опублікована взагалі без словника; токенізувати нічим, і спроба токенізації просто аварійно завершується. Функція tokenizer_st_partition розбиває вхідний текст на фрагменти: звичайний текст і спеціальні токени — спеціальний токен стає одним токеном на виході, решта йде в токенайзер за типом словника. Прапорець parse_special керує лише керуючими і службовими токенами; токени, оголошені в моделі як користувацькі, виділяються завжди. Фрагмент llama_vocab::impl::tokenize — спільна частина і гілка BPE; решта гілок влаштована так само:
// Крок 9: текст розбивається на фрагменти (звичайний текст і спеціальні токени),
// і кожен фрагмент токенізується за типом словника
std::vector<llama_token> llama_vocab::impl::tokenize(
const std::string & raw_text,
bool add_special,
bool parse_special) const {
GGML_ASSERT(tokenizer && "Tokenizer not initialized. Call llama_vocab::init_tokenizer() first.");
std::vector<llama_token> output;
std::forward_list<fragment_buffer_variant> fragment_buffer;
if (!raw_text.empty()) {
fragment_buffer.emplace_front(raw_text, 0, raw_text.length());
tokenizer_st_partition(fragment_buffer, parse_special);
}
switch (get_type()) {
// ... гілки SPM, WPM, UGM, RWKV і PLAMO2 влаштовані так само
case LLAMA_VOCAB_TYPE_BPE:
{
const llm_tokenizer_bpe * tok_bpe = static_cast<const llm_tokenizer_bpe *>(tokenizer.get());
std::unique_ptr<llm_tokenizer_bpe_session> session;
// ... для моделей токенайзера "hybriddna" і "whitespace" береться свій підклас сесії
session = std::make_unique<llm_tokenizer_bpe_session>(vocab, *tok_bpe);
if (add_special) {
session->append_bos(output);
}
for (const auto & fragment : fragment_buffer) {
if (fragment.type == FRAGMENT_BUFFER_VARIANT_TYPE_RAW_TEXT) {
std::string text = fragment.raw_text.substr(fragment.offset, fragment.length);
// ... екранування пробілів, якщо словник цього вимагає
session->tokenize(text, output);
} else { // FRAGMENT_BUFFER_VARIANT_TYPE_TOKEN
session->append(fragment.token, output);
}
}
if (add_special) {
session->append_eos(output);
session->check_double_bos_eos(output);
}
} break;
case LLAMA_VOCAB_TYPE_NONE:
GGML_ABORT("fatal error");
}
return output;
}

Крок 10: Формування батча для одного виклику

Крок 10 — токени пакуються в батч для одного виклику до моделі. Один виклик до моделі передається через структуру llama_batchinclude/llama.h).

Для чого потрібен батч:

  • функція llama_decode приймає рівно один батч — набір токенів (і службових полів: позиції, ідентифікатори послідовностей, прапорці логітів);
  • так рушій знає, які токени обробити за один прохід, на яких позиціях вони стоять і для яких позицій потрібно повернути логіти (зазвичай лише для останньої — щоб обрати наступний токен);
  • при першому запиті в батчі зазвичай усі токени промпту; при генерації по одному токену — один новий токен.
// Крок 10: батч — «пакет» токенів для одного виклику llama_decode; token[], pos[], seq_id[], logits[] заповнюються застосунком або balloc->init
typedef struct llama_batch {
int32_t n_tokens;
llama_token * token;
float * embd;
llama_pos * pos;
int32_t * n_seq_id;
llama_seq_id ** seq_id;
int8_t * logits;
} llama_batch;

Структура батча:

  • масиви мають розмір n_tokens;
  • або передаються ID токенів (token), або готові ембединги (embd) — вектори чисел, на які вже перетворені токени (зазвичай передають токени, а ембединги рахуються всередині);
  • pos — позиція кожного токена; seq_id і n_seq_id — до якої послідовності належить токен;
  • logits[i] != 0 означає, що для позиції i потрібно повернути логіти (зазвичай лише для останньої — щоб обрати наступний токен). Усередині llama_decode батч спочатку обробляється класом llama_batch_allocr (файл src/llama-batch.cpp): метод init перевіряє батч і за відсутності полів заповнює їх автоматично (позиції з пам'яті, логіти лише для останнього токена).

Поля seq_id і n_seq_id використовуються при батчингу кількох послідовностей (наприклад, кілька запитів в одному батчі): кожен токен може належати до однієї або кількох послідовностей; позиції (pos) рахуються окремо для кожної послідовності за memory->seq_pos_max(seq_id). У типовому випадку одна послідовність — усі токени мають один і той самий seq_id (наприклад, 0), і позиції йдуть по порядку 0, 1, 2, ... . Заповнити батч для наступного кроку застосунок може двома способами. Мінімальний — llama_batch_get_one: він огортає один щойно вибраний токен і залишає рівними NULL і pos, і seq_id, щоб llama_decode сам продовжив позицію та відніс токен до послідовності 0 — так роблять examples/simple і examples/simple-chat. Другий — один раз виділити батч через llama_batch_init, а перед кожним викликом очищати його через common_batch_clear і додавати токени через common_batch_add, явно передаючи позицію та ідентифікатори послідовностей — саме так доводиться робити будь-якому коду, який обслуговує кілька послідовностей одразу.

Метод init класу llama_batch_allocr (файл src/llama-batch.cpp), який llama_context::decode викликає для вхідного батча раніше за все інше:

// src/llama-batch.cpp, llama_batch_allocr::init(): перевіряє вхідний батч, потім заповнює
// всі масиви, які застосунок залишив NULL — насамперед позиції та прапорці виводу
bool llama_batch_allocr::init(
const llama_batch & batch_inp,
const llama_vocab & vocab,
const llama_memory_i * memory,
uint32_t n_embd,
uint32_t n_seq_max,
bool output_all) {
clear();
batch = batch_inp;
this->vocab = &vocab;
GGML_ASSERT(batch.n_tokens > 0);
// ... опущено: n_seq_max має вміщатися в LLAMA_MAX_SEQ, кожен seq_id має бути меншим за n_seq_max,
// і кожен batch.token[i] має бути допустимим id (< vocab.n_tokens()) — інакше return false ...
// ... опущено: якщо n_seq_id або seq_id дорівнюють NULL, кожен токен належить до послідовності 0 ...
if (!batch.pos) {
pos.resize(batch.n_tokens);
// початкова позиція кожної послідовності береться з позицій у пам'яті
llama_pos p0[LLAMA_MAX_SEQ];
for (uint32_t s = 0; s < n_seq_max; ++s) {
if (!memory) {
// якщо пам'яті немає -> починаємо з 0
p0[s] = 0;
} else {
p0[s] = memory->seq_pos_max(s) + 1;
}
}
for (int32_t i = 0; i < batch.n_tokens; i++) {
const llama_seq_id seq_id = batch.seq_id[i][0];
pos[i] = p0[seq_id];
// ... опущено: p0 зсувається за цю позицію для кожної послідовності токена ...
}
batch.pos = pos.data();
}
if (!batch.logits) {
if (output_all) {
output.resize(batch.n_tokens, true); // вивід для кожного токена
} else {
output.resize(batch.n_tokens, false); // вивід тільки для останнього токена
output[output.size() - 1] = true;
}
batch.logits = output.data();
}
// ... опущено: якщо logits задані, але виставлено output_all, відсутні виводи вмикаються примусово;
// потім статистика позицій за послідовностями, пов'язані послідовності та перевірки узгодженості ...
return true;
}

У коді:

  • перевірка токенів на допустимість;
  • за відсутності n_seq_id і seq_id кожен токен належить рівно до однієї послідовності з номером 0;
  • за відсутності pos кожна послідовність продовжується з того місця, де вона зупинилася в пам'яті (memory->seq_pos_max(s) + 1), і позиції проставляються по порядку; якщо пам'яті немає зовсім, відлік починається з 0;
  • за відсутності logits логіти запитуються лише для останнього токена (або для всіх, якщо output_all).

Від токенів до ембедингів (відбувається всередині кроку 13)

Усередині кроку 13 токени батча перетворюються на ембединги. У батчі можна передати або ID токенів (token), або готові ембединги (embd), і в типовому випадку застосунок передає токени. Перетворення — це не окремий прохід перед обробкою батча: це найперший вузол графа обчислень, який decode будує для кожного ubatch.

Для чого потрібне це перетворення:

  • модель (шари трансформера) працює не з номерами токенів, а з векторами чисел фіксованої довжини — ембедингами;
  • вибірка бере кожен ID токена і читає відповідний рядок ембединг-таблиці моделі, і цей рядок стає входом першого шару;
  • без нього графу не було б над чим рахувати.

Що таке ембединг-таблиця:

  • це матриця ваг моделі розміром «розмір словника × розмір ембедингу»; один рядок — вектор чисел для одного токена;
  • у коді на C++ це поле моделі tok_embd, а у файлі GGUF — тензор token_embd.weight; імена embed_tokens і tok_embeddings прийшли з вихідних чекпоінтів PyTorch — скрипт конвертації відображає їх у token_embd, тож шукати щось під цими іменами всередині llama.cpp марно;
  • вона створюється під час завантаження моделі, у списку тензорів конкретної архітектури (load_arch_tensors, викликається з load_tensors);
  • при виконанні графа для кожного токена батча читається рядок з індексом token[i] і потрапляє на позицію i входу графа;
  • після цього граф рахує вже за ембедингами (шари трансформера, увага, feed-forward, логіти).

Де це в коді: ембединг-таблиця — це поле моделі tok_embd, а вибірку по ній будує llm_graph_context::build_inp_embd (файл src/llama-graph.cpp), який кожна архітектура викликає першим же рядком свого графа. Він створює два вхідні тензори — вектор I32 під ID токенів і матрицю F32 під готові ембединги — і пропускає токенну гілку через ggml_get_rows по таблиці; у граф потрапляють обидві гілки, а ggml_build_forward_select позначає до обчислення лише ту, що відповідає вмісту ubatch — токенам чи готовим векторам. Заради цього він і потрібен: топологія графа лишається однаковою від ubatch до ubatch, і граф можна перевикористати. Самі ID записує у вхідний тензор llm_graph_input_embd::set_input, яку process_ubatch (файл src/llama-context.cpp) викликає вже після того, як граф побудований і розміщений, прямо перед його виконанням. Тобто рядок копіює не decode на CPU: він читається на бекенді, під час обчислення графа, разом з усім іншим — а оскільки ембединг-таблиця зазвичай зберігається квантизованою, ggml_get_rows принагідно розпаковує кожен рядок у float. При decode по одному токену читається рівно один рядок таблиці.

Вибірка, як вона будується в графі (src/llama-graph.cpp, llm_graph_context::build_inp_embd):

// src/llama-graph.cpp, llm_graph_context::build_inp_embd() — скорочено: прибрано налагоджувальні
// колбеки та перевірки. Перетворення токена на вектор — це не memcpy усередині decode, а вузол
// графа обчислень, який виконується на бекенді разом з усією рештою моделі.
ggml_tensor * llm_graph_context::build_inp_embd(ggml_tensor * tok_embd) const {
const int64_t n_embd_inp = hparams.n_embd_inp();
auto inp = std::make_unique<llm_graph_input_embd>(n_embd_inp);
inp->tokens = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, ubatch.n_tokens);
ggml_set_input(inp->tokens);
inp->embd = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, n_embd_inp, ubatch.n_tokens);
ggml_set_input(inp->embd);
// обираємо один з 2 входів, виходячи зі вмісту батча
std::array<ggml_tensor *, 2> inps;
// гілка з ембедингами токенів (ubatch.token != nullptr)
{
auto & cur = inps[0];
cur = ggml_get_rows(ctx0, tok_embd, inp->tokens);
// ... скорочено: необов'язкова LoRA-поправка до ембединг-таблиці
// і паддинг, потрібний при n_embd_inp != n_embd
}
// гілка з готовими векторами (ubatch.embd != nullptr)
{
auto & cur = inps[1];
cur = inp->embd;
}
ggml_tensor * cur = ggml_build_forward_select(gf, inps.data(), inps.size(), ubatch.token ? 0 : 1);
// ... скорочено: view при n_embd_inp != n_embd і множення на f_embedding_scale
res->add_input(std::move(inp));
return cur;
}

Що відбувається і для чого:

  • для кожного індексу i в ubatch ID токена ubatch.token[i] записується у вхідний тензор графа inp_tokens.

    Для чого: граф виконується на бекенді, і ID потрібні йому там як власні дані, а не як вказівник у батч, який передав застосунок.

  • ggml_get_rows читає з таблиці tok_embd рядок з цим індексом — вектор довжини n_embd, бо сама таблиця має ширину n_embd. Окремим архітектурам потрібен ширший вхід графа, n_embd_inp — це моделі з deepstack-шарами і gemma4-assistant, яка задає власне значення; там рядок доповнюють паддингом до цієї ширини, а перед першим шаром знову звужують до n_embd.

    Для чого: цей рядок і є ембедингом токена — вхід для першого шару трансформера.

  • рядки для всіх токенів ubatch складаються в матрицю, яку отримує перший шар.

    Для чого: шари обробляють увесь ubatch разом; без цього вузла граф взагалі не мав би входу.

Кроки 11 і 12: Проганяння батча через модель (вхід у decode, підбатчі)

Кроки 11 і 12 — застосунок викликає llama_decode, передаючи йому батч. Усередині батч перевіряється і готується (крок 11), потім розбивається на підбатчі (крок 12), і кожен підбатч проганяється через модель (крок 13). Перетворення ідентифікаторів токенів на вектори-ембединги — не окремий крок на боці хоста: це перший вузол обчислювального графа, тому воно відбувається на кроці 13 разом з усім іншим. Нижче — точка входу, цикл по підбатчах і розбиття батча.

Для чого потрібна функція llama_decode:

  • вона проганяє один батч токенів через модель і заповнює внутрішній буфер контексту логітами — «сирими» оцінками по кожному можливому наступному токену;
  • за цими логітами застосунок (через семплер) обирає один наступний токен і за потреби знову викликає decode з одним новим токеном у батчі;
  • так повторюється до кінця відповіді (EOS) або ліміту.

Публічний API — llama_decodesrc/llama-context.cpp):

src/llama-context.cpp
// Крок 11: точка входу decode. Публічна C-функція лише передає батч
// у C++-контекст; уся робота відбувається в llama_context::decode.
int32_t llama_decode(
llama_context * ctx,
llama_batch batch) {
const int ret = ctx->decode(batch);
// ret == 1 означає «немає вільного слота в KV-cache під цей батч» — це попередження,
// а не збій, тому воно не логується як помилка
if (ret != 0 && ret != 1) {
LLAMA_LOG_ERROR("%s: failed to decode, ret = %d\n", __func__, ret);
}
return ret;
}

Ось що відбувається всередині llama_context::decode (src/llama-context.cpp). Спочатку батч перевіряється. Якщо цей контекст узагалі не має модуля пам'яті — наприклад, це контекст лише під ембединги — decode просто передає батч у encode і повертає результат. Інакше він викликає balloc->init(...), резервує планувальник і застосовує відкладені оновлення пам'яті (KV-cache). Потім один раз викликається memory->init_batch: цей єдиний виклик розбиває весь батч на підбатчі й повертає контекст пам'яті, у якому лежать одразу всі вони. Далі цикл іде цим контекстом: бере черговий підбатч через get_ubatch(), проганяє його через process_ubatch, копіює отримані логіти у внутрішній буфер контексту і переходить до наступного підбатча через next().

Цикл по підбатчах усередині decode (скорочено, з src/llama-context.cpp):

// src/llama-context.cpp, llama_context::decode — скорочено
memory_update(false); // застосовуємо відкладені зсуви/копіювання
llama_memory_context_ptr mctx;
// розбиваємо ВЕСЬ батч на підбатчі не більше n_ubatch токенів, за один виклик
// ... при FAILED_PREPARE: один раз оптимізуємо кеш, повторюємо, інакше повертаємо 1
mctx = memory->init_batch(*balloc, cparams.n_ubatch, output_all);
if (!mctx) {
return -2;
}
// ... резервуємо вихідний буфер під n_outputs_all рядків логітів
do {
const auto & ubatch = mctx->get_ubatch();
// ... n_outputs — скільки рядків логітів дає цей підбатч
ggml_status status;
// крок 13: будуємо (або перевикористовуємо) граф і виконуємо його на CPU/GPU
const auto * res = process_ubatch(ubatch, ctx_type_to_graph_type(cparams.ctx_type), mctx.get(), status);
if (!res) {
// ... відкочуємо позиції цього підбатча з модуля пам'яті
switch (status) {
case GGML_STATUS_ABORTED: return 2;
case GGML_STATUS_ALLOC_FAILED: return -2;
case GGML_STATUS_FAILED: return -3;
case GGML_STATUS_SUCCESS: GGML_ABORT("should not happen");
}
}
auto * t_logits = res->get_logits();
// крок 14: забираємо логіти цього підбатча з бекенда в буфер контексту —
// пропускається, якщо в усіх вихідних послідовностей є бекенд-семплер
if (logits.data && t_logits && n_outputs > 0 && needs_raw_logits(ubatch, sampling.samplers)) {
ggml_backend_t backend_res = ggml_backend_sched_get_tensor_backend(sched.get(), t_logits);
float * logits_out = logits.data + n_outputs_prev*n_vocab;
// ... перевірки, що ці рядки вміщаються у вихідний буфер
ggml_backend_tensor_get_async(backend_res, t_logits, logits_out, 0, n_outputs*n_vocab*sizeof(float));
}
// ... ембединги та виходи бекенд-семплера дістаються так само
n_outputs_prev += n_outputs;
} while (mctx->next());
// після циклу застосунок читає логіти через llama_get_logits_ith

Спочатку перевіряється батч:

  • що в ньому передані або токени, або готові ембединги;
  • що цей контекст справді має модуль пам'яті — якщо його немає, decode одразу передає батч у encode.

Потім батч ініціалізується через balloc->init — це потрібно, щоб заповнити позиції токенів і вирішити, для яких позицій рахувати логіти (зазвичай лише для останньої). Резервується планувальник і оновлюється пам'ять (KV-cache).

Далі memory->init_batch розбиває батч на підбатчі розміром не більше n_ubatch токенів кожен і повертає контекст пам'яті, у якому лежать усі вони, — так великий батч розбивається на частини, які по черзі проганяються через модель. Цикл бере їх по одному і для кожного викликає process_ubatch(...): усередині будується обчислювальний граф (ембединги, шари трансформера, вихід у логіти) — або перевикористовується вже побудований, якщо форма проходу нічим не змінилася, — виконується обчислення на CPU/GPU і повертаються тензори з логітами: «сирими», ненормованими оцінками по словнику.

init_batch — метод модуля пам'яті, тому в кожного типу пам'яті своя реалізація; для звичайного KV-cache це llama_kv_cache::init_batch у src/llama-kv-cache.cpp (сам інтерфейс оголошено в src/llama-memory.h):

// src/llama-kv-cache.cpp, llama_kv_cache::init_batch — скорочено
// Крок 12: весь батч ріжеться на підбатчі тут, за один виклик, до запуску будь-якого з них
llama_memory_context_ptr llama_kv_cache::init_batch(
llama_batch_allocr & balloc,
uint32_t n_ubatch,
bool embd_all) {
do {
balloc.split_reset();
std::vector<llama_ubatch> ubatches;
while (true) {
// кожен виклик бере чергові (не більше) n_ubatch ще не використаних токенів батча
auto ubatch = n_stream == 1 ? balloc.split_simple(n_ubatch) : balloc.split_equal(n_ubatch, true, 0);
if (ubatch.n_tokens == 0) {
break; // батч вичерпано
}
ubatches.push_back(std::move(ubatch));
}
if (balloc.get_n_used() < balloc.get_n_tokens()) {
break; // частину токенів не вдалося розкласти в жоден підбатч
}
// знаходимо слот у KV-cache під кожен підбатч, перш ніж зафіксувати хоч один
auto sinfos = prepare(ubatches);
if (sinfos.empty()) {
break;
}
return std::make_unique<llama_kv_cache_context>(
this, std::move(sinfos), std::move(ubatches));
} while (false);
// місця під цей батч немає — decode перетворює це на код повернення 1
return std::make_unique<llama_kv_cache_context>(LLAMA_MEMORY_STATUS_FAILED_PREPARE);
}

Що робить init_batch і для чого:

  • за один прохід ріже весь батч на порції не більше n_ubatch токенів кожна.

    Для чого: один виклик process_ubatch обробляє обмежену кількість токенів — так на довгому промпті не розростається пам'ять під проміжні тензори графа.

  • для кожної порції копіює в ubatch токени, позиції та прапорці виходів.

    Для чого: process_ubatch отримує готовий підбатч і будує граф саме під нього.

  • перед поверненням резервує слот у KV-cache під кожен підбатч і лише потім фіксує результат.

    Для чого: якщо кеш не вміщає весь батч, decode дізнається про це до того, як відпрацював хоч один підбатч, і може повідомити про це тому, хто викликав, замість того щоб залишити в кеші половину батча.

  • повернутий контекст пам'яті зберігає список підбатчів; цикл у decode іде по ньому через get_ubatch() і next().

    Для чого: довгий промпт обробляється за кілька проходів через модель, без єдиного величезного графа.

Цикл по підбатчах у llama_context::decode влаштований так: контекст пам'яті, повернутий memory->init_batch, видає по одному підбатчу через get_ubatch(), для кожного викликається process_ubatch(ubatch, gtype, mctx, status), а next() переводить на наступний, доки підбатчі не скінчаться. Тип графа gtype не обирається для кожного підбатча — він випливає з типу контексту й однаковий для всіх проходів цього контексту. Між проходом по довгому промпту і проходом з одним токеном різниться дещо простіше: батчевий прохід бере число потоків для батча, а не для генерації, і при створенні контексту llama.cpp резервує буфери обчислень за найгіршим випадком одразу під обидві форми. Після кожного process_ubatch логіти для позицій, позначених як вихідні, копіюються з бекенда у внутрішній буфер контексту, звідки їх читає llama_get_logits_ith, — але лише якщо хоча б одна з цих позицій належить послідовності без власного бекенд-семплера: коли семплери навішані на всі, сирі логіти не копіюються взагалі. Будувати граф наново для кожного підбатча було б марнотратно, тому llama.cpp перевикористовує попередній, якщо параметри, які повністю визначають його топологію, не змінилися.

Порядок викликів при одному decode (зведення):

  • llama_decode(ctx, batch)ctx->decode(batch);
  • у decode: перевірка батча (і, якщо контекст не має модуля пам'яті, передача батча в encode та повернення), balloc->init(...), резервування планувальника, застосування відкладених оновлень пам'яті (KV-cache);
  • memory->init_batch(...) — один виклик, який розбиває весь батч на підбатчі й резервує слот у кеші під кожен; потім резервується вихідний буфер;
  • цикл: get_ubatch() видає черговий ubatch, викликається process_ubatch(ubatch, ...) — усередині model.build_graph (або перевикористання попереднього графа), res->set_inputs, graph_compute — логіти копіюються в буфер контексту, а next() переходить до наступного підбатча;
  • після циклу застосунок читає логіти через llama_get_logits_ith, семплер повертає наступний токен.

Логіти копіюються у вихідний буфер контексту. Звідти їх читає llama_get_logits_ith — за цими числами семплер обирає наступний токен (наприклад, найімовірніший або за temperature/top_p).

Значення, що повертає llama_decode: 0 — успіх; 1 — у KV-cache не знайшлося вільного слота під цей батч (зменшити батч або збільшити контекст); 2 — обчислення перерване колбеком; -1 — сам батч некоректний; усе, що менше -1, — фатальна помилка, найчастіше невдале виділення буфера. Додатний код — це попередження, а не збій, але 2 теж додатне, тож перевірка лише на від'ємне значення пропустить переривання непоміченим — перевіряти треба на будь-яке значення, крім 0. Після переривання або фатальної помилки вже відпрацьовані підбатчі залишаються в пам'яті контексту; наскільки далеко просунувся батч, можна дізнатися через llama_memory_seq_pos_min і llama_memory_seq_pos_max. Мінімальні приклади, що йдуть у комплекті з llama.cpp, просто вважають будь-який ненульовий код фатальним і зупиняються. Застосунок має перевіряти значення, що повертається, і при помилці припиняти генерацію або виводити повідомлення про помилку.

Крок 13а: Підготовка до проганяння підбатча (пам'ять, параметри графа)

Крок 13 розбито в статті на три частини: підготовка до проганяння підбатча, побудова графа та алокація буферів, запис входів і виконання графа. Уся робота по одному підбатчу виконується в методі llama_context::process_ubatch (файл src/llama-context.cpp). Його викликають з llama_context::decode у циклі. Розбиття на підбатчі відбувається раніше і лише один раз: memory->init_batch викликають з усім батчем, і він повертає об'єкт контексту пам'яті, у якому вже лежать усі підбатчі разом з потрібним їм станом кешу. Далі цикл іде по них — mctx->get_ubatch() віддає поточний, mctx->next() переходить до наступного і повертає false, коли вони закінчилися, — і на кожен підбатч один раз викликає process_ubatch. Усередині покроково відбувається: підготовка пам'яті, рішення — чи перевикористовувати вже побудований граф, чи будувати наново, за потреби побудова графа та алокація буферів, запис вхідних даних у граф, виконання графа на CPU/GPU. Нижче — перша частина кроку 13: точка входу і підготовка.

Для чого потрібен обчислювальний граф:

  • модель (трансформер) — це ланцюжок операцій: ембединги, шари уваги (Q, K, V, softmax, зважена сума), нормалізації, feed-forward тощо;
  • замість того щоб викликати кожну операцію вручну, рушій будує граф — перелік вузлів (операцій) і зв'язків між ними;
  • планувальник потім обходить граф у топологічному порядку і виконує операції на CPU або GPU;
  • так можна автоматично розподіляти вузли по пристроях і перевикористовувати граф при однакових розмірах батча.

Сигнатура і вхідні дані process_ubatch (цитата з src/llama-context.cpp):

// src/llama-context.cpp, llama_context::process_ubatch — один підбатч проганяється через модель;
// викликається з llama_context::decode у циклі за підбатчами
llm_graph_result * llama_context::process_ubatch(const llama_ubatch & ubatch, llm_graph_type gtype, llama_memory_context_i * mctx, ggml_status & ret) {
// 1) фіксуємо стан пам'яті, підготовлений для цього підбатча (комірки KV-cache), до побудови графа
if (mctx && !mctx->apply()) {
LLAMA_LOG_ERROR("%s: failed to apply memory context\n", __func__);
ret = GGML_STATUS_FAILED;
return nullptr;
}
// 2) результат ПОПЕРЕДНЬОЇ побудови: у ньому лежать граф і його вхідні тензори
auto * res = gf_res_prev.get();
auto * gf = res->get_gf();
// 3) усе, від чого залежить топологія графа; граф можна перевикористати лише тоді,
// коли ці параметри збігаються з тими, з якими його було побудовано
const auto gparams = graph_params(res, ubatch, mctx, gtype);
// ... опущено: рішення про перевикористання, build_graph, set_inputs і graph_compute — див. нижче
}

Що відбувається на початку process_ubatch і для чого:

  • Викликається mctx->apply(), якщо переданий контекст пам'яті (mctx).

    Для чого:

  • контекст пам'яті оновлює стан KV-cache і службових буферів (наприклад, зсуває вказівники на наступні вільні позиції); перед побудовою графа граф звертатиметься до цих буферів — вони мають бути в актуальному стані.

  • gf_res_prev — це результат попередньої побудови графа: сам граф, його вхідні тензори та параметри, з якими його було побудовано. При найпершому виклику він порожній, тому граф будується з нуля; далі він стає кандидатом на перевикористання.

    Для чого:

  • саме зберігання останнього графа й робить перевикористання можливим — якщо нові параметри збігаються зі збереженими в ньому, той самий граф можна виконати знову з новими значеннями входів.

  • у gparams збирається все, від чого залежить форма графа: архітектура моделі та її гіперпараметри, параметри контексту, копія поточного ubatch (скільки в ньому токенів, як вони згруповані за послідовностями, лежать у ньому ID токенів чи готові ембединги), тип графа, планувальник, завантажені LoRA-адаптери і control vector, контекст пам'яті, семплери за послідовностями та кількість позицій, для яких потрібні виходи.

    Для чого:

  • правило таке: два графи з однаковими gparams мають однакову топологію; саме це й робить можливою перевірку на перевикористання нижче, а ті самі значення потім задають розміри вузлів і вхідних тензорів під час побудови графа.

Якщо mctx->apply() повертає false, функція одразу повертає nullptr і в ret записується GGML_STATUS_FAILED — код, що викликає (decode), обробить помилку і припинить цикл по підбатчах.

Цитата за змістом: що робить mctx->apply() і що входить у gparams (за кодом src/llama-context.cpp і src/llama-kv-cache.cpp):

// mctx->apply() — фіксує стан пам'яті, підготовлений для цього підбатча. Для KV-cache це означає
// записати позицію та id послідовності кожного токена у відведені йому комірки й перерахувати,
// скільки комірок графу доведеться читати (src/llama-memory.h, src/llama-kv-cache.cpp)
if (mctx && !mctx->apply()) { ret = GGML_STATUS_FAILED; return nullptr; }
// graph_params() збирає все, від чого залежить топологія графа: архітектуру, параметри моделі
// та контексту, копію підбатча, тип графа, планувальник, адаптери, контекст пам'яті,
// семплери і n_outputs — перевірка на перевикористання порівнює саме цей набір
const auto gparams = graph_params(res, ubatch, mctx, gtype);

Крок 13б: Побудова обчислювального графа та алокація буферів

Крок 13 (продовження) — побудова графа й виділення під нього буферів. Після підготовки пам'яті та формування gparams рушій вирішує: чи можна перевикористати вже побудований граф (та сама форма підбатча і ті самі параметри побудови), чи потрібно будувати граф наново і виділяти під нього буфери. Якщо граф перевикористовується — одразу переходять до запису входів і виконання. Якщо ні — викликаються res->reset(), ggml_backend_sched_reset(sched.get()), model.build_graph(gparams) і ggml_backend_sched_alloc_graph(sched.get(), gf). Нижче покроково, що за чим відбувається і для чого; кілька цитат з коду.

Цитата: рішення про перевикористання і побудова графа (фрагмент process_ubatch, src/llama-context.cpp):

// граф перебудовуємо лише коли змінилися параметри; повторні decode з одним токеном перевикористовують його
if (!graph_reuse_disable && res->can_reuse(gparams)) {
// ... опущено: при pipeline parallelism тут синхронізується попередній, ще незавершений розрахунок,
// інакше set_inputs перезаписав би тензори, які він ще читає
n_reused++; // лічильник перевикористань (профілювальник показує його як "graphs reused")
} else {
res->reset(); // викидаємо старий граф разом з його вхідними тензорами
ggml_backend_sched_reset(sched.get()); // скидаємо розбиття планувальника та його алокації
ggml_backend_sched_set_eval_callback(sched.get(), cparams.cb_eval, cparams.cb_eval_user_data);
gf = model.build_graph(gparams); // ембединги → шари трансформера → логіти
if (!gf) {
LLAMA_LOG_ERROR("%s: failed to initialize graph\n", __func__);
ret = GGML_STATUS_FAILED;
return nullptr;
}
// планувальник розбиває граф по пристроях і виділяє пам'ять під кожен тензор
if (!ggml_backend_sched_alloc_graph(sched.get(), gf)) {
LLAMA_LOG_ERROR("%s: failed to allocate graph\n", __func__);
ret = GGML_STATUS_ALLOC_FAILED;
return nullptr;
}
}

Покроково (що відбувається і для чого):

  • Перевірка res->can_reuse(gparams): чи еквівалентний новий набір параметрів тому, з яким було побудовано збережений граф. Порівнюються форма підбатча (скільки токенів, як вони згруповані в послідовності, приходять вони як ID токенів чи як готові ембединги), кількість позицій, для яких запитано виходи, архітектура і тип графа, завантажені адаптери та кілька прапорців контексту; потім у кожного вхідного тензора графа окремо запитують, чи він ще підходить.

    Для чого:

  • при генерації по одному токену кожен наступний виклик приносить підбатч рівно тієї самої форми — один токен, одна послідовність, один вихід, — тому вся перевірка проходить, граф не перебудовується, і лишається тільки записати в його входи нові значення. Саме це й робить генерацію швидкою: інакше побудова графа для великої моделі коштувала б мілісекунд на кожному токені.

  • Якщо перевикористання вимкнено або параметри змінилися: res->reset() — очищуються старі вузли графа і прив'язані буфери.

    Для чого:

  • перед побудовою нового графа старий потрібно скинути, інакше вузли й тензори накопичаться.

  • ggml_backend_sched_reset(sched.get()) — планувальник скидає свій стан (розподіл вузлів по бекендах, зарезервовані буфери для цього графа).

    Для чого:

  • планувальник наново визначатиме, на якому пристрої виконувати кожен вузол нового графа, і виділятиме під нього пам'ять.

  • gf = model.build_graph(gparams) — будується граф GGML: вузли для ембедингів і позицій, потім для кожного шару трансформера — увага (Q, K, V, softmax, зважена сума), нормалізація, feed-forward, знову нормалізація; наприкінці — вихідний шар у логіти (розмір словника).

    Для чого:

  • граф описує, які операції виконати і в якому порядку; без нього планувальнику нічого виконувати. llama_model::build_graph у src/llama-model.cpp — лише тонка обгортка: вона викликає будівник для конкретної архітектури, а потім додає шар пулінгу, шари семплінгу на пристрої, якщо вони є, і розмітку вихідних тензорів. Розкладає шари саме будівник для архітектури, і в кожної архітектури свій файл у src/models/llama.cpp, gemma3.cpp, qwen3.cpp і ще близько 150.

  • ggml_backend_sched_alloc_graph(sched.get(), gf) — планувальник обходить граф, для кожного тензора визначає бекенд (CPU або GPU за розбиттям шарів), виділяє буфер на цьому бекенді (або перевикористовує зарезервований при створенні контексту).

    Для чого:

  • без виділення буферів дані графа нема де зберігати; при першому decode буфери резервуються тут (або при graph_reserve при створенні контексту), при наступних перевикористаннях графа повторне виділення не потрібне.

Структура графа по шарах (схема):

  • вхід: ембединги токенів підбатча плюс окремий тензор з їхніми позиціями; позиції не додаються до ембедингів, їх використовують усередині кожного шару;
  • для кожного шару трансформера: нормалізація входу → блок уваги (Q = вхід × W_q, K = вхід × W_k, V = вхід × W_v; застосування RoPE до Q і K; attention scores = Q × K^T; маска (causal); softmax; зважена сума scores × V; лінійний шар O) → залишкове з'єднання → нормалізація → feed-forward (два лінійні шари з активацією між ними) → залишкове з'єднання;
  • після всіх шарів: залишаються тільки рядки тих позицій, для яких запитано вихід, потім фінальна нормалізація → вихідна матриця («розмір прихованого шару × розмір словника») → логіти: n_vocab чисел для кожної з цих позицій.

Цитата виклику build_graph і ggml_backend_sched_alloc_graph (за змістом коду в src/llama-context.cpp і GGML):

// llama_model::build_graph (src/llama-model.cpp) повертає ggml_cgraph — плоский список вузлів
// з їхніми залежностями; gparams уже фіксує форму підбатча, тип графа і контекст пам'яті
ggml_cgraph * gf = model.build_graph(gparams);
// ggml_backend_sched_alloc_graph (ggml/include/ggml-backend.h) ріже граф на ланцюжки вузлів,
// що належать одному бекенду, і розміщує кожен тензор в обчислювальному буфері цього бекенду
bool ok = ggml_backend_sched_alloc_graph(sched.get(), gf);
if (!ok) { ret = GGML_STATUS_ALLOC_FAILED; return nullptr; }

Резервування буферів робиться один раз, при створенні контексту, у graph_reserve (src/llama-context.cpp). Там створюється фіктивний ubatch заданого розміру, для нього будується граф, і планувальника просять зарезервувати під нього достатньо великі обчислювальні буфери. Конструктор робить це тричі — з ubatch завбільшки з цілий промпт, потім з ubatch по одному токену на послідовність, потім знову з промптовим, — щоб буфери вийшли розрахованими на найгірший випадок і під час інференсу нічого не перевиділялося. Побудований тут граф для перевикористання не зберігається: graph_reserve навмисно скидає збережений попередній результат — саме для того, щоб перший справжній decode побудував свій власний граф. Функція ggml_backend_sched_alloc_graph (бібліотека GGML) потім, при кожній справжній побудові, ріже граф на ланцюжки вузлів, що належать одному бекенду, і розміщує кожен тензор у буфері цього бекенду.

Крок 13в: Запис входів у граф і виконання на CPU/GPU

Крок 13 (завершення) — запис входів у граф і виконання. Після того як граф побудовано (або перевикористано), потрібно записати у вхідні тензори графа дані поточного підбатча — токени або ембединги і позиції — і запустити виконання графа. Це роблять виклики res->set_inputs(&ubatch) і graph_compute(res->get_gf(), ubatch.n_tokens > 1). Нижче — цитати й покрокове пояснення.

Цитата: запис входів і виконання (кінець process_ubatch, src/llama-context.cpp):

res->set_inputs(&ubatch); // заповнюємо вхідні тензори графа цим підбатчем: ID токенів, позиції, індекси запису в KV, маску уваги
const auto status = graph_compute(res->get_gf(), ubatch.n_tokens > 1); // обхід вузлів графа, виконання на CPU/GPU
if (status != GGML_STATUS_SUCCESS) {
ret = status;
return nullptr;
}
ret = GGML_STATUS_SUCCESS;
return res; // у res — тензори з логітами; decode потім копіює їх у буфер контексту для llama_get_logits_ith
}

Що відбувається при set_inputs і для чого:

  • res->set_inputs(&ubatch) обходить вхідні тензори, які граф зареєстрував під час побудови, і заповнює кожен з них із підбатча: ID токенів (або готові ембединги — у рідшому випадку, коли той, хто викликає, передав ембединги, а не токени; інакше lookup по таблиці ембедингів сам є вузлом графа), позиції токенів, індекси комірок KV-cache, у які цей підбатч має записати свої ключі та значення, маску уваги, яка каже, на які збережені позиції кожному токену можна дивитися, і список позицій, для яких потрібні виходи.

    Для чого:

  • граф обчислює за конкретними даними; без запису входів він працював би з порожніми або застарілими тензорами; при Decode у кеш підставляються буфери, куди дописуються нові ключі та значення для поточної позиції.

Цитата graph_compute (метод контексту, який виконує граф на планувальнику, src/llama-context.cpp):

// src/llama-context.cpp, llama_context::graph_compute — віддає побудований граф планувальнику
ggml_status llama_context::graph_compute(ggml_cgraph * gf, bool batched) {
// "batched" означає, що в підбатчі більше одного токена: обробка промпта бере батчеву кількість потоків,
// генерація по одному токену — меншу; на одному токені зайві потоки не окупаються
int n_threads = batched ? cparams.n_threads_batch : cparams.n_threads;
ggml_threadpool_t tp = batched ? threadpool_batch : threadpool;
// ... опущено: передача обраного пулу потоків CPU-бекенду
// задаємо кількість потоків для всіх бекендів
for (const auto & set_n_threads_fn : set_n_threads_fns) {
set_n_threads_fn.second(set_n_threads_fn.first, n_threads);
}
// один виклик виконує весь граф: планувальник іде по нарізаних ним ланцюжках і запускає кожен
// на своєму бекенді. "_async" — тут він не чекає на GPU
auto status = ggml_backend_sched_graph_compute_async(sched.get(), gf);
if (status != GGML_STATUS_SUCCESS) {
LLAMA_LOG_ERROR("%s: ggml_backend_sched_graph_compute_async failed with error %d\n", __func__, status);
}
return status;
}

Що робить graph_compute покроково і для чого:

  • обирає кількість потоків: n_threads_batch, якщо в підбатчі більше одного токена, інакше n_threads, і віддає відповідний пул потоків CPU-бекенду.

    Для чого:

  • обробка довгого промпта — це велика матрична робота, яка масштабується по ядрах; генерація одного токена — ні, і розмазування її по всіх ядрах коштує лише синхронізації. Два окремі налаштування дозволяють користувачеві налаштувати обидва випадки.

  • викликає ggml_backend_sched_graph_compute_async. Планувальник уже, на етапі алокації, нарізав граф на ланцюжки послідовних вузлів, що належать одному бекенду; тут він виконує ці ланцюжки один за одним, вставляючи копіювання між пристроями там, де ланцюжку потрібен тензор, порахований іншим пристроєм.

    Для чого:

  • саме тут і виконуються всі шари трансформера. Результат — вихідні тензори графа, заповнені числами, серед них логіти: n_vocab значень для кожної позиції, для якої запитано вихід.

  • повертає статус, не чекаючи на завершення роботи, — це і означає _async в імені.

    Для чого:

  • поки GPU ще зайнятий, код, що викликає, уже може поставити в чергу копіювання логітів із графа. Саме очікування відбувається пізніше, у llama_context::synchronize, який викликає кожен публічний читач результатів — зокрема llama_get_logits_ith, — перш ніж віддати тому, хто викликає, вказівник.

Другий аргумент graph_compute(res->get_gf(), ubatch.n_tokens > 1) називається batched і просто каже, чи більше одного токена в цьому підбатчі. Він обирає кількість потоків і пул потоків: батчеві налаштування для обробки промпта, звичайні — для генерації по одному токену. Значення, що повертається, — статус GGML; при успіху process_ubatch повертає res, а decode копіює логіти з нього у внутрішній буфер контексту — для тих позицій, у яких виставлено прапорець виходу.

При перевикористанні графа (коли розмір батча і тип графа збігаються з попереднім викликом) граф не перебудовується і буфери не перевиділяються — викликаються лише res->set_inputs(&ubatch) і graph_compute. Це пришвидшує повторні виклики decode при генерації по одному токену: граф Decode будується один раз при першому decode з одним токеном і далі перевикористовується. Лічильник n_reused у контексті збільшується при кожному перевикористанні графа — за ним можна оцінити частку перевикористань при профілюванні.

Кроки з 14 до 16: Логіти, семплінг і токен назад у текст

Кроки з 14 до 16 — після проганяння підбатча логіти для останньої позиції опиняються в буфері контексту (крок 14); за ними семплер обирає один наступний токен (крок 15). Тут же, наприкінці розділу, розібрано крок 16 — переклад обраного токена назад у текст. Після llama_decode у внутрішньому буфері контексту лежать логіти — по одному числу на кожен токен словника.

Для чого потрібні логіти:

  • модель на виході видає не один токен, а «оцінки» по всіх можливих наступних токенах (по одному числу — логіту — на кожен токен словника);
  • за цими числами семплер вирішує, який один токен обрати: наприклад, з максимальним логітом (жадібний вибір) або випадково з урахуванням temperature/top_p;
  • без логітів застосунок не зміг би обрати наступний токен.

Логіти можна розуміти як «сирі» оцінки того, наскільки підходить кожен наступний токен; з них семплер обирає один токен. Доступ до логітів — через llama_get_logits_ith (файл src/llama-context.cpp):

  • повертається вказівник на масив з n_vocab float — по одному числу на кожен токен словника.

Логіти запитуються лише для тих позицій у батчі, для яких у батчі було встановлено прапорець logits[i] == true (зазвичай лише для останньої позиції). Після кожного виклику process_ubatch контекст вивантажує логіти позицій із цим прапорцем, дописуючи їх у буфер, а не перезаписуючи його: якщо decode розбито на кілька підбатчів, рядки накопичуються в тому порядку, у якому позиції йшли в батчі. Тобто буфер — це таблиця: рядків стільки, скільки запитано позицій, і в кожному рядку n_vocab float (по одному на токен словника); у контексті він виділяється на максимальну кількість вихідних позицій.

Реалізація llama_get_logits_ith у src/llama-context.cpp повертає вказівник на рядок логітів для заданої позиції в батчі. Індекс може бути від'ємним — -1 означає останню позицію, для якої запитували вивід, і саме його передає цикл генерації. Перш ніж щось повернути, функція дочікується бекендів і спершу перевіряє, чи не відпрацював семплер просто на бекенді, залишивши власні логіти; звичайний шлях — другий, «сирі» логіти, вивантажені decode. Індекс, що вказує на позицію, для якої логіти не запитували, — це помилка: у релізній збірці функція поверне нульовий вказівник.

// Крок 14: src/llama-context.cpp — публічний аксесор.
// Повертає вказівник на один рядок логітів: n_vocab float, по одному на токен словника.
// i може бути від'ємним: -1 — остання позиція, для якої запитували вивід.
float * llama_get_logits_ith(llama_context * ctx, int32_t i) {
ctx->synchronize(); // чекаємо, доки бекенди дорахують граф
float * res = nullptr;
res = ctx->get_sampled_logits_ith(i); // не null, лише якщо спрацював семплер на бекенді
if (!res) {
res = ctx->get_logits_ith(i); // звичайний шлях: «сирі» логіти, вивантажені decode
}
return res;
}
// src/llama-context.cpp — сам пошук рядка
float * llama_context::get_logits_ith(int32_t i) {
output_reorder();
try {
if (logits.data == nullptr) {
throw std::runtime_error("no logits");
}
// output_resolve_row перетворює індекс у батчі на номер рядка:
// від'ємний i відлічується з кінця, інакше output_ids[i]; кидає виняток,
// якщо для цієї позиції логіти не запитували
const int64_t j = output_resolve_row(i);
return logits.data + j*model.vocab.n_tokens();
} catch (const std::exception & err) {
LLAMA_LOG_ERROR("%s: invalid logits id %d, reason: %s\n", __func__, i, err.what());
// ... опущено: налагоджувальна збірка тут падає, релізна повертає nullptr
return nullptr;
}
}

За цими числами обирається наступний токен за допомогою семплера — компонента, який за логітами вирішує, який токен видати (наприклад, найімовірніший або випадковий з урахуванням temperature/top_p). В API (include/llama.h) виклик має вигляд llama_sampler_sample(smpl, ctx, idx): йому передають ланцюжок семплерів, контекст та індекс вихідної позиції (-1 — остання), логіти цієї позиції він читає сам, застосовує до них ланцюжок і повертає один токен. Він же записує цей токен у власну історію ланцюжка, тож коду, що викликає, робити це не потрібно. При наступному виклику llama_decode токен передається в батчі як єдиний новий; цикл повторюється, доки модель не видасть токен кінця генерації — це перевіряють через llama_vocab_is_eog — або доки не спрацює обмеження за довжиною. Перекласти токен назад у текст — через llama_token_to_piece / llama_detokenize (реалізація в src/llama-vocab.cpp).

Ланцюжок семплерів:

  • семплінг у llama.cpp влаштовано як послідовність кроків;
  • спочатку до логітів може застосовуватися зсув за повтореннями (repeat penalty) — зменшення ймовірності токенів, що вже з'явилися;
  • потім ідуть відсікання, які відкидають кандидатів: top-k залишає k найбільших логітів, top-p (nucleus) — лише токени з накопиченою ймовірністю до порога p, min-p відкидає все, що значно нижче за лідера;
  • temperature іде ближче до кінця — ділення логітів на число (temperature > 1 робить розподіл м'якшим, < 1 — гострішим), тобто вона перебудовує те, що залишилося після відсікань;
  • останнім з тих, що залишилися, обирається один токен — наприклад, з максимальним логітом (жадібний вибір) або випадково з імовірностями за softmax від логітів;
  • сам ланцюжок — цикл по зареєстрованих семплерах, кожен з яких або модифікує логіти, або обирає токен, — лежить у src/llama-sampler.cpp; ланцюжок за замовчуванням для консольних утиліт збирається за параметрами в common/sampling.cpp.

Реалізація перекладу токена в текст — це вже крок 16; за нього відповідають методи llama_vocab::impl::token_to_piece і llama_vocab::impl::detokenize у src/llama-vocab.cpp. token_to_piece за ID токена видає шматочок тексту; для байтових токенів виконується перетворення на символ, для звичайних — береться текст з id_to_token. detokenize проходить по масиву токенів, записуючи шматочки один за одним у переданий буфер і стежачи за тим, скільки місця лишилося, а для деяких словників наприкінці ще проганяє чистку пробілів. API llama_token_to_pieceinclude/llama.h) приймає словник — не модель; словник отримують через llama_model_get_vocab — далі токен, вихідний буфер і його розмір, скільки провідних пробілів пропустити та чи потрібно виводити спеціальні токени. Повертає кількість записаних байтів або мінус потрібний розмір, якщо буфер виявився замалим, і завершального нуля не пише — тож результат треба перевіряти, що мінімальні приклади й роблять. Для виведення по одному токену зазвичай використовують llama_token_to_piece; для збирання повного рядка з масиву токенів — llama_detokenize. Фрагмент:

// Крок 16: src/llama-vocab.cpp — llama_vocab::impl::token_to_piece
// За ID токена повертаємо шматочок тексту (для виведення користувачеві); id_to_token завантажено в load_vocab
int32_t llama_vocab::impl::token_to_piece(llama_token token, char * buf, int32_t length, int32_t lstrip, bool special) const {
static const int attr_special = LLAMA_TOKEN_ATTR_UNKNOWN | LLAMA_TOKEN_ATTR_CONTROL;
const llama_token_attr attr = token_get_attr(token);
if (!special && (attr & attr_special)) {
return 0; // керуючий токен, який не просили виводити
}
// копіюємо символи шматочка у вихідний буфер тексту
// перед копіюванням пропускаємо до 'lstrip' провідних пробілів
auto _try_copy = [=] (const char * token, size_t size) -> int32_t {
// ... опущено: цикл, що пропускає до 'lstrip' провідних пробілів
if (length < (int32_t)size) {
return -(int32_t) size; // буфер замалий: потрібний розмір зі знаком мінус
}
memcpy(buf, token, size);
return (int32_t) size;
};
// ... опущено: якщо кеш token_to_piece побудовано, текст береться з нього
if (0 <= token && token < (int32_t) id_to_token.size()) {
const std::string & token_text = id_to_token[token].text;
switch (get_type()) {
case LLAMA_VOCAB_TYPE_WPM:
case LLAMA_VOCAB_TYPE_SPM:
case LLAMA_VOCAB_TYPE_UGM: {
if (attr & LLAMA_TOKEN_ATTR_NORMAL) {
std::string result = token_text;
llama_unescape_whitespace(result);
return _try_copy(result.data(), result.size());
}
// ... опущено: байтові токени перетворюються на один символ через token_to_byte
break;
}
// ... опущено: гілки BPE (llama_decode_text), RWKV і PLAMO2
default:
GGML_ABORT("fatal error");
}
}
return 0;
}

У коді видно: керуючий або невідомий токен просто пропускається, якщо виведення спеціальних токенів не запитували; для словників WPM, SPM і UGM звичайні токени перетворюються на текст через llama_unescape_whitespace, байтові — через token_to_byte; гілка BPE влаштована схоже, але зазвичай іде через llama_decode_text (декодування escape-послідовностей). Усе копіювання проходить через один невеликий помічник, і саме там перевіряється розмір буфера: якщо шматочок не вміщується, не пишеться нічого, а повертається потрібний розмір зі знаком мінус. Функція llama_detokenize в API викликає vocab.detokenize, яка збирає текст цілого масиву токенів у переданий буфер.

Параметри семплінгу в сам виклик не передаються: вони фіксуються під час збирання ланцюжка — llama_sampler_chain_init, далі по одному llama_sampler_chain_add на кожен крок (llama_sampler_init_penalties, llama_sampler_init_top_p, llama_sampler_init_temp і завершальний llama_sampler_init_dist або llama_sampler_init_greedy, який і обирає токен). Сам виклик приймає лише ланцюжок, контекст та індекс вихідної позиції. Усередині він набирає масив кандидатів з логітів цієї позиції, проганяє по ньому весь ланцюжок, бере обраний ним токен, записує його в історію ланцюжка і повертає. Якщо семплер відпрацював на бекенді й токен уже обрано, функція одразу повертає його, не чіпаючи ланцюжок на CPU. Повернений токен потім передається в наступний виклик llama_decode у батчі як єдиний новий токен.

KV-cache та етапи Prefill / Decode (довідково)

У механізмі уваги кожен токен отримує вектор запиту (Q), ключа (K) і значення (V) — це числові вектори, які модель рахує зі своїх ваг.

Для чого потрібні Q, K, V:

  • за ними обчислюється «увага» — наскільки кожна попередня позиція важлива для поточної;
  • результат — зважена сума значень V з вагами за Q і K.

Логіти для наступного токена залежать від Q поточної позиції і від K, V усіх попередніх позицій — тому при генерації по одному токену старі K і V не змінюються, їх достатньо порахувати один раз і зберегти. KV-cache — це буфер у пам'яті, у якому для кожного шару і кожної позиції зберігаються вже пораховані ключі та значення; так не потрібно перераховувати їх наново на кожному кроці генерації.

Prefill і Decode:

  • Prefill — у першому проході обробляються всі токени промпту, для них рахуються K і V та записуються в кеш;
  • Decode — у наступних кроках обробляється лише один новий токен: за ним рахуються Q, K, V; K і V спочатку дописуються в кеш, і вже потім Q множиться на всі ключі, що лежать у кеші, — зокрема на ключ поточного токена, — а після маски та softmax на всі значення з кешу. У підсумку отримуємо один вектор контексту для цієї позиції і далі feed-forward тощо. Так ми не перераховуємо увагу по всій історії на кожному кроці, а лише по новому токену — це і дає пришвидшення генерації. Поділ на Prefill (багато токенів за раз, навантаження на обчислювачі) і Decode (один токен, навантаження на пам'ять) характерний для llama.cpp та інших рушіїв.

Реалізація KV-cache — це клас llama_kv_cache у src/llama-kv-cache.cpp; яка комірка якій позиції та якій послідовності належить, відстежується у src/llama-kv-cells.h, а спільний інтерфейс, через який до пам'яті звертається контекст, — init_batch, seq_pos_max і решта — оголошений у src/llama-memory.h. Для кожного кешованого шару моделі виділяються буфери під ключі та значення (розмір залежить від кількості голів KV, розміру голови та довжини контексту). При Prefill у ці буфери записуються K і V для всіх позицій промпту; при Decode для нового токена обчислюються лише нові K і V та дописуються в кінець. Граф моделі при виконанні звертається до цих буферів через контекст пам'яті, переданий йому в параметрах графа, і читає їх через get_k і get_v.

Розташування буферів KV-cache: для кожного шару трансформера створюється два тензори (або один об'єднаний):

  • один для ключів;
  • один для значень.

Розмірність кожного з них — [кількість голів KV × розмір голови, кількість комірок кешу]: один рядок — це ключі (або значення) однієї позиції для всього шару, а кількість комірок — це довжина контексту, під яку створено кеш. Тензори називаються cache_k_l0, cache_v_l0 і так далі за номером шару — зручний рядок для пошуку в дампі графа, але у вихідниках ім'я збирається з форматного рядка, тому там шукають cache_, а не ім'я цілком. При створенні контексту викликається виділення пам'яті під ці тензори на CPU або GPU (за налаштуваннями). При Prefill граф записує K і V для позицій 0..n-1 (n — кількість токенів у батчі). При Decode граф пише K і V лише для позиції n_cur (поточна позиція) у відповідне місце буфера; читання K і V для всіх позицій 0..n_cur іде з того самого буфера. Так не потрібно перераховувати ключі та значення для вже оброблених токенів.

Обсяг пам'яті KV-cache зростає лінійно з довжиною контексту й кількістю шарів: для кожного шару зберігаються ключі та значення для всіх позицій. При довжині контексту n_ctx і кількості шарів n_layer обсяг пропорційний n_ctx × n_layer × розмір однієї голови × кількість голів KV × 2 (×2 — ключі та значення), помноженому на розмір одного елемента. Тут легко помилитися у двох речах. Перша: рахувати треба кількість голів KV, а не кількість голів уваги — у моделях з grouped-query attention кілька голів запиту ділять одну голову ключів і значень, і кеш виходить у кілька разів меншим, ніж можна вирішити за самою лише кількістю голів. Друга: розмір елемента не фіксований — типи кешів K і V задаються окремо (опції -ctk і -ctv), і це і є та сама «агресивніша квантизація». Тому при обмеженій пам'яті зменшують n_ctx або знижують точність KV-cache. У llama.cpp при створенні контексту буфери виділяються одразу на всю довжину контексту; при генерації заповнюються лише позиції до поточної. Виняток — шари з ковзним вікном уваги: для них кеш виділяється за розміром вікна, а не за всім контекстом.

Метод пам'яті seq_pos_max(s) (оголошений у src/llama-memory.h, реалізований для KV-cache у src/llama-kv-cache.cpp) повертає максимальну позицію, до якої заповнений кеш для послідовності s. Якщо застосунок не проставив позиції токенів сам, за нього їх заповнюють у src/llama-batch.cpp: перший токен батча отримує seq_pos_max(s) + 1, решта — позиції слідом за ним; так батч завжди продовжує послідовність з першої вільної позиції в кеші. Комірки кешу позначаються зайнятими на самому початку process_ubatch, викликом apply у контексту пам'яті, ще до обчислення графа; а якщо обчислення не вдалося, decode прибирає ці позиції з кешу назад.

Резюме: від файлу до першого токена

Коротка послідовність кроків від запуску застосунку до появи першого токена відповіді.

Для чого це корисно:

  • при налагодженні або вивченні коду можна звірятися з цим переліком і перевіряти, на якому кроці ви перебуваєте;
  • так простіше знайти у вихідниках місце, що відповідає етапу завантаження або генерації.

Підготовка (один раз при старті або зміні моделі):

  • llama_backend_init() — запуск таймера, а якщо жоден бекенд ще не зареєстрований — завантаження всіх доступних.

    Для чого: один раз при старті застосунку.

  • llama_model_load_from_file(path, params)llama_model_load_from_file_impl → перевірка бекенда, колбек прогресу, llama_model_load(...) — а вже всередині нього, серед іншого, збирається список пристроїв для моделі.

    Для чого: завантажити модель з файлу і отримати вказівник на llama_model.

  • Усередині llama_model_load: llama_model_loader (відкриття GGUF, індекс тензорів), потім llama_model_create — він запитує у завантажувача архітектуру (ml.get_arch()) і створює об'єкт відповідного класу моделі, — а після нього по черзі load_hparams, load_vocab (у т.ч. vocab.load(ml, kv)), load_stats і load_tensors.

    Для чого: покроково заповнити модель архітектурою, гіперпараметрами, словником і вагами.

  • llama_init_from_model(model, ctx_params) — створення контексту: параметри n_ctx, n_batch, n_ubatch, ініціалізація пам'яті (KV-cache), планувальника, резерв графів Prefill і Decode (у вихідниках і логах вони називаються pp і tg). Стара назва llama_new_context_with_model ще працює, але позначена застарілою.

    Для чого: контекст потрібен для виклику llama_decode; без нього не можна генерувати текст.

Генерація (на кожне повідомлення користувача і кожен новий токен у відповіді):

  • llama_tokenize(vocab, prompt, ...) — промпт у токени; словник заздалегідь беруть у моделі через llama_model_get_vocab(model). При виклику з порожнім вихідним буфером функція повертає мінус потрібну кількість токенів — так дізнаються, скільки пам'яті виділити.

    Для чого: модель працює лише з числами (токенами).

  • Формування батча. Найкоротший шлях — llama_batch_get_one(tokens, n): батч бере токени як є, а логіти лише для останньої позиції llama.cpp запитує сама. Де потрібен тонший контроль, приклади користуються помічниками бібліотеки commoncommon_batch_clear і потім common_batch_add для кожного токена, останній аргумент якого каже, чи потрібні логіти для цієї позиції. Ці двоє не входять до публічного API в include/llama.h; вони живуть у common/common.h.

    Для чого: один виклик decode приймає один батч; логіти потрібні лише для останньої позиції, щоб обрати наступний токен.

  • llama_decode(ctx, batch)ctx->decode(batch)balloc->init (перевірка батча і заповнення полів, які не заповнив той, хто викликав), оновлення пам'яті, memory->init_batch — він розбиває батч на підбатчі — і потім цикл по цих підбатчах: process_ubatch (apply, build_graph, set_inputs, graph_compute), копіювання логітів. Якщо контекст створено взагалі без пам'яті, decode просто передає батч у encode і повертається.

    Для чого: проганяння батча через модель і отримання логітів у внутрішньому буфері контексту.

  • llama_get_logits_ith(ctx, i) — вказівник на логіти одного рядка виводу. Невід'ємний i — це індекс токена всередині батча, який контекст переводить у рядок вихідного буфера через output_ids, а не номер серед виходів і не позиція в послідовності: після Prefill з n токенів, де прапорець стоїть лише на останньому, його логіти просять під номером n-1, а під номером 0 повернеться нульовий вказівник, бо для того токена логіти не запитували. Від'ємний індекс, навпаки, рахується серед рядків виводу, тому надійний спосіб попросити останній вихід — -1.

    Для чого: за ними семплер обирає наступний токен.

  • Семплер обирає наступний токен; llama_token_to_piece(vocab, token, buf, size, ...) перетворює його на шматочок тексту — повернуту довжину потрібно перевірити, від'ємне значення означає, що буфер виявився замалий; виведення; токен іде в наступний батч; decode повторюється до токена кінця генерації (llama_vocab_is_eog) або ліміту.

    Для чого: цикл генерації по одному токену до кінця відповіді.

Таким чином, шлях від файлу моделі до першого токена відповіді проходить через завантажувач GGUF, завантаження архітектури та гіперпараметрів, словника і тензорів, створення контексту з KV-cache і планувальником, токенізацію промпту, батч, decode, побудову графа та його виконання, семплінг за логітами.

При першому decode з повним промптом (Prefill) час виконання залежить від довжини промпту і розміру підбатча (n_ubatch): що довший промпт, то більше підбатчів і проходів через модель; логіти потрібні лише для останньої позиції. При наступних decode (по одному токену) кожен виклик обробляє один токен; граф Decode перевикористовується, основний час іде на обчислення одного шару уваги (Q, K, V для однієї позиції, читання K і V з кешу, softmax, зважена сума) та решти шарів. Оптимізація швидкості генерації пов'язана з оптимізацією цього шляху: ефективне читання KV-cache, перевикористання графа, розподіл по GPU. При профілюванні корисно дивитися час першого decode (Prefill) і час наступних decode (по одному токену): перший залежить від довжини промпту і n_ubatch, наступні — від ефективності одного проходу через модель і копіювання даних між бекендами.

Примітки щодо версій і збірки: структура коду та імена файлів у репозиторії llama.cpp змінюються від версії до версії; усе в цій статті — імена файлів, сигнатури функцій і фрагменти коду — звірено з гілкою master на коміті 8887a48f і у вас може відрізнятися. При збірці з GPU потрібні відповідні бекенди (CUDA, Metal, Vulkan тощо) і змінні середовища; без них рушій працює лише на CPU. Приклади викликів API та імена структур наведені за include/llama.h; при використанні C-обгорток або інших мов (Python, Go тощо) сигнатури можуть відрізнятися, але загальний сценарій завантаження і decode залишається тим самим.

Рекомендації щодо вивчення коду: для розуміння шляху завантаження моделі зручно почати з llama_model_load_from_file у src/llama.cpp і пройти по викликах до llama_model_load, потім через llama_model_create (тут визначається архітектура), load_hparams, load_vocab, load_stats, load_tensors. Для шляху decode — з llama_decode у src/llama-context.cpp, потім ctx->decode, balloc->init, memory->init_batch і цикл по підбатчах з process_ubatch. У process_ubatch дивитися build_graph і graph_compute. Словник і токенізація — src/llama-vocab.cpp (load, tokenize, token_to_piece). Завантажувач GGUF — src/llama-model-loader.cpp (конструктор, get_tensor_meta, create_tensor, load_all_data). Пам'ять і KV-cache — src/llama-kv-cache.cpp (seq_pos_max, init_batch, apply_ubatch), спільний інтерфейс — у src/llama-memory.h. При налагодженні корисно ставити точки зупину на входах у load_hparams, load_vocab, load_tensors і на входах у decode, process_ubatch, build_graph.

Деталізація реалізації за файлами

Нижче — коротка прив'язка описаних у статті кроків до конкретних файлів і функцій репозиторію llama.cpp.

Для чого це потрібно:

  • при вивченні або налагодженні можна швидко знайти потрібний код за іменем файлу і функції;
  • кожен пункт указує, що шукати у файлі і навіщо це потрібно.

src/llama.cpp:

  • llama_backend_init — запуск таймера і завантаження бекендів ggml, які постачаються окремими динамічними бібліотеками (бекенди, вкомпільовані у збірку, реєструються самі, без цього виклику).

    Для чого: один раз при старті застосунку.

  • llama_model_load_from_file, llama_model_load_from_file_impl — точка входу завантаження моделі.

    Для чого: застосунок викликає їх, щоб завантажити модель з файлу.

  • llama_model_load (статична) — послідовний виклик llama_model_create (який обирає архітектуру), load_hparams, load_vocab, load_stats і load_tensors.

    Для чого: тут створюється завантажувач і покроково заповнюється модель. У тому самому файлі — перевірка бекенда, колбек прогресу та список пристроїв (llama_prepare_model_devices); сам об'єкт моделі створює llama_model_create у src/llama-model.cpp, цей файл його лише викликає.

src/llama-model-loader.cpp:

  • Клас llama_model_loader: конструктор відкриває GGUF через gguf_init_from_file, будує weights_map за списком тензорів з контексту GGUF.

    Для чого: за іменем тензора потім можна прочитати дані з файлу.

  • Методи get_key, get_arch, get_tensor_meta, create_tensor, load_all_data — читання метаданих, опис тензорів і читання їхніх даних.

    Для чого: load_hparams, load_vocab і load_tensors викликають їх.

  • print_info — виведення інформації про файл. Підтримка кількох файлів (splits) і mmap/direct_io.

src/llama-model.cpp:

  • Клас llama_model: load_hparams (читання ключів GGUF у hparams), load_vocab (виклик vocab.load(ml, kv)), load_tensors (формування списків буферів CPU/GPU, розбиття шарів, створення тензорів через create_tensor і читання їхніх даних через ml.load_all_data).

    Для чого: тут створюються тензори моделі за архітектурою і призначаються буфери на CPU/GPU. Саму архітектуру обирають раніше, поза класом: llama_model_create запитує її в завантажувача через ml.get_arch() і створює об'єкт потрібного типу. Усе, що стосується однієї архітектури, — її гіперпараметри, список тензорів і граф — лежить в окремому файлі в src/models/, за методами load_arch_hparams, load_arch_tensors і build_arch_graph, які реалізує кожна модель.

src/llama-vocab.cpp:

  • Клас llama_vocab, llama_vocab::impl::load — читання типу токенайзера, списків токенів, злиттів BPE, спеціальних токенів з GGUF.

    Для чого: словник потрібен для токенізації та перекладу токенів у текст.

  • init_tokenizer — ініціалізація токенайзера за типом (SPM, BPE тощо). tokenize — розбиття тексту на токени; token_to_piece, detokenize — переклад токенів у текст. Функції llama_tokenize, llama_token_to_piece, llama_detokenize делегують виклики словнику моделі.

src/llama-context.cpp:

  • Клас llama_context: конструктор створює balloc, задає cparams (n_ctx, n_batch, n_ubatch тощо), ініціалізує пам'ять (KV-cache) і планувальник (ggml_backend_sched), викликає graph_reserve для Prefill і Decode.

    Для чого: контекст — «робоче середовище» одного сеансу генерації.

  • Метод decode — перевірка батча, balloc->init, оновлення пам'яті, memory->init_batch (весь батч розбивається на підбатчі одним викликом), потім цикл по цих підбатчах (process_ubatch), копіювання логітів. Контексту, створеному без модуля пам'яті (наприклад, для ембедингової моделі), декодувати нічого, і виклик одразу переадресовується в encode.

    Для чого: один виклик decode проганяє батч через модель і заповнює буфер логітів.

  • process_ubatch — застосування mctx, build_graph або перевикористання графа, set_inputs, graph_compute. get_logits_ith — повернення вказівника на логіти для позиції. Публічні функції llama_decode, llama_get_logits_ith викликають методи контексту.

src/llama-batch.cpp:

  • Структура llama_batch (оголошена в include/llama.h), клас llama_batch_allocr: метод init перевіряє токени, заповнює n_seq_id, seq_id, pos, logits за їх відсутності; позиції беруться з memory->seq_pos_max.

    Для чого: щоб коду, який викликає, не потрібно було вручну виставляти позиції та прапорці логітів. Використовується всередині llama_context::decode перед циклом по підбатчах.

src/llama-memory.h, src/llama-kv-cache.cpp:

  • Інтерфейс llama_memory_i (оголошений у src/llama-memory.h) і його основна реалізація — клас llama_kv_cache у src/llama-kv-cache.cpp — виділення буферів KV-cache: по одному тензору K і одному тензору V на кожен шар, кожен на всю довжину контексту.

    Для чого: у них зберігаються ключі та значення механізму уваги. Моделі з іншим видом стану (рекурентні, гібридні, з ковзним вікном) дають власну реалізацію того самого інтерфейсу в сусідньому файлі src/llama-memory-*.cpp або src/llama-kv-cache-*.cpp.

  • seq_pos_max(s) — максимальна позиція для послідовності s.

    Для чого: при формуванні батча позиції нових токенів беруться як seq_pos_max(s) + 1.

  • init_batch — розбиття батча на підбатчі (ubatch) розміром не більше n_ubatch і резервування місця в KV-cache під кожен із них; зайняті позиції оновлюються в міру обробки підбатчів. Планувальник і граф звертаються до буферів KV-cache через параметри графа.

src/llama-graph.cpp:

  • Клас llm_graph_context — спільні будівельні блоки, з яких файли окремих архітектур у src/models/ збирають свої шари: build_inp_embd (крок 13, пошук ембедингів токенів — ggml_get_rows по tok_embd), build_norm, build_attn, build_ffn.

    Для чого: архітектури не повторюють ті самі операції, і перший вузол будь-якого графа будується тут.

  • Класи llm_graph_input_* і їхні методи set_input — запис ідентифікаторів токенів, позицій і масок уваги у вхідні тензори вже побудованого графа.

    Для чого: process_ubatch викликає їх прямо перед graph_compute; саме це дозволяє перевикористати той самий граф для наступного підбатча.

src/llama-sampler.cpp:

  • llama_sampler_chain_init, llama_sampler_chain_add — створення ланцюжка семплерів і додавання до нього кроку (штраф за повтори, top-k, top-p, min-p, температура, фінальний вибір).

    Для чого: ланцюжок збирається один раз, до генерації; для консольних програм його будують за параметрами в common/sampling.cpp.

  • llama_sampler_sample — крок 15: сам читає логіти потрібної позиції, застосовує до них увесь ланцюжок і повертає один токен; llama_sampler_accept записує цей токен у власну історію ланцюжка.

    Для чого: тут числа з буфера контексту перетворюються на наступний токен.

GGML/GGUF (каталог ggml/ того самого репозиторію):

  • ggml_init, ggml_free — контекст графа. gguf_init_from_file, gguf_get_* — читання GGUF.

    Для чого: завантажувач і модель використовують їх для читання файлу та побудови графа.

  • ggml_backend_sched, ggml_backend_sched_alloc_graph, ggml_backend_sched_reset — планувальник і виділення буферів під граф.

    Для чого: планувальник розподіляє вузли графа по CPU/GPU і виділяє під них пам'ять.

  • Виконання графа (graph_compute) — обхід вузлів у топологічному порядку, копіювання між бекендами за потреби, запуск операцій на CPU/GPU. Модель будує граф через model.build_graph у src/llama-model.cpp: цей метод загальний — він викликає build_arch_graph конкретної архітектури, а потім дописує спільний «хвіст» (пулінг, вихідну проєкцію). Сам граф архітектури лежить в окремому файлі в src/models/, по файлу на архітектуру. Підтримка нової моделі додається таким файлом, а не правкою build_graph; загальна схема «ембединги → шари → логіти» зберігається.

При вивченні коду зручно шукати за іменем функції або методу: llama_backend_init, llama_model_load_from_file, llama_model_load, load_arch_hparams, load_arch_tensors, load_hparams, load_vocab, vocab.load, load_tensors, llama_init_from_model, llama_model_get_vocab, llama_decode, decode, process_ubatch, build_graph, build_arch_graph, graph_compute, llama_get_logits_ith, llama_tokenize, llama_token_to_piece. За ними можна простежити весь шлях від завантаження моделі до виведення токена. Найкоротші повні приклади — examples/simple/simple.cpp і examples/simple-chat/simple-chat.cpp: у них показано типовий цикл — завантаження моделі, створення контексту, токенізація, батч, decode, семплінг, виведення; повнофункціональні програми (CLI, сервер) лежать у tools/.

Зв'язок компонентів і потоки даних

Коротко — як дані проходять між компонентами від файлу моделі до виведення токена. Для чого це корисно: при налагодженні або вивченні коду можна простежити, звідки береться кожне значення і куди воно передається.

застосунок промпт і параметри include/llama.h публічний API рушія llama_context balloc · memory · sched буфер логітів process_ubatch будує і запускає граф llama_model hparams · vocab тензори ваг llama_kv_cache K і V за шарами й позиціями llm_graph_context вузли графа, src/models/ ggml CPU · GPU читання і запис K, V посилається володіє

Завантаження (один раз при старті або зміні моделі):

  • Файл GGUF → llama_model_loader (метадані та індекс тензорів weights_map).

    Для чого: завантажувач дає доступ до полів GGUF і до даних тензорів за іменем.

  • ml.get_arch()llama_model_create (тип моделі: LLaMA, Gemma тощо).

    Для чого: від типу залежать імена ключів у GGUF.

  • load_hparams (розмірності та параметри: n_ctx_train, n_layer, n_embd тощо; робоча довжина контексту n_ctx у файлі не зберігається — її обирають пізніше, при створенні контексту, а за замовчуванням беруть рівною n_ctx_train).

    Для чого: від них залежить «форма» моделі та розміри тензорів.

  • load_vocabvocab.load (словник токенів і токенайзер).

    Для чого: словник потрібен для токенізації та перекладу токенів у текст.

  • load_tensors (ваги з файлу в буфери CPU/GPU).

    Для чого: модель готова до обчислень. Модель (llama_model) зберігає hparams, vocab і тензори; завантажувач після завантаження більше не потрібен.

Створення контексту (один раз на сеанс):

  • Модель + параметри контексту → llama_context: balloc (алокатор батча), memory (KV-cache і init_batch), sched (планувальник), зарезервовані графи Prefill/Decode.

    Для чого: контекст — «робоче середовище» одного сеансу генерації; у ньому зберігаються KV-cache, планувальник і графи для decode. Контекст посилається на модель; модель не зберігає посилання на контекст.

Генерація (на кожне повідомлення і кожен новий токен):

  • Текст промпту → llama_tokenize (model.vocab) → масив токенів → батч (token, pos, seq_id, logits).

    Для чого: один виклик decode приймає один батч.

  • Батч → llama_decodeballoc->init (позиції з memory) → memory->init_batch (один виклик: весь батч розбивається на підбатчі) → цикл: ubatch → process_ubatch (build_graph за моделлю, set_inputs, graph_compute на sched) → логіти копіюються в буфер контексту. Пошук ембедингів токенів — не окремий етап, а перший вузол графа, який будує build_graph.

    Для чого: проганяння батча через модель і отримання логітів.

  • Логіти → llama_get_logits_ith → семплер → наступний токен → llama_token_to_piece (model.vocab) → текст.

    Для чого: за логітами обирається один токен і перекладається в текст для виведення.

  • Токен додається в батч; при наступному decode у батчі один новий токен; memory оновлює зайняті позиції KV-cache; цикл повторюється до EOS або ліміту.

Зведення потоків даних:

  • Файл GGUF → завантажувач → модель (hparams, vocab, тензори). Модель + параметри → контекст (memory, sched, графи).
  • Промпт → vocab (токенізація) → батч. Батч → llama_context::decode → balloc, memory → process_ubatch (модель: build_graph, ваги; memory: KV-cache; sched: виконання графа) → логіти в контексті.
  • Логіти → семплер → токен → vocab (token_to_piece) → текст.

Модель і контекст — центральні об'єкти; завантажувач, balloc, memory і sched — допоміжні, прив'язані до контексту або моделі.

Що з цього випливає

Усе описане вкладається у дві фази. Підготовка відбувається один раз: рушій реєструє бекенд, відкриває GGUF, читає з нього архітектуру, розміри та словник, розкладає ваги туди, де вони рахуватимуться, і будує навколо них контекст. Генерація — це цикл: текст перетворюється на токени, токени — на батч, батч проходить через граф, граф повертає логіти, семплер обирає один токен, і цей токен іде в наступний батч. Усе інше в статті — подробиці, навішені на ці два каркаси.

Чотири налаштування змінюють поведінку сильніше за решту, і тепер зрозуміло чому. n_ctx задає, скільки KV-кешу виділяється одразу: пам'ять зростає з довжиною контексту, кількістю шарів і кількістю KV-голів — і з цих трьох величин ви обираєте лише довжину контексту. n_gpu_layers вирішує, які шари живуть у пам'яті пристрою; відображення дістається їм без копіювання лише там, де пристрій уміє побудувати буфер поверх пам'яті хоста, — процесору завжди, єдиній пам'яті на кшталт Apple Silicon теж, дискретній карті ніколи. -ctk і -ctv задають точність ключів і значень окремо: це найдешевший важіль, коли не вміщується саме кеш. А --load-mode визначає, як файл узагалі читається: відображається, замикається в пам'яті, і те й те одразу, читається без буферизації або просто читається — а при типовому значенні auto рушій обирає сам.

Якщо хочеться читати не статтю, а вихідники, найкоротший вхід — examples/simple-chat/simple-chat.cpp: двісті рядків, що проходять увесь шлях — від завантаження моделі до вибору токена. Далі — llama_context::decode у src/llama-context.cpp, де живе цикл по підбатчах. Усі імена в статті написані рівно так, як у чекауті, названому в передмові, — їх можна шукати як є.

На цьому ж сайті: Нейромережі простими словами пояснюють, що таке ваги й увага, які тут рахуються; LoRA — про адаптери, що вливаються в ті самі ваги GGUF; RAG і function calling — про те, що зазвичай будують поверх локального рушія; Offline AI Launcher — цей самий рушій на Android.

Copyright: Roman Kryvolapov