llama.cpp AI LLM Engine — Як це працює
Передмова
llama.cpp — це програма (рушій) для запуску великих мовних моделей (LLM) на комп'ютері або на мобільних пристроях — на процесорі (CPU) чи відеокарті (GPU); я написав застосунок Offline AI Launcher, який дозволяє використовувати цей самий рушій на Android.
Навіщо це потрібно:
- щоб отримувати відповіді від нейромережевих моделей (чат, доповнення тексту, код) без надсилання даних у хмару;
- вибір мови C++ потрібен для швидкої роботи з пам'яттю та залізом; один і той самий код збирається під Windows, Linux, macOS та Android.
«Ваги» моделі — величезний набір чисел (мільйони або мільярди), на яких ґрунтуються всі обчислення нейромережі: матриці множення, зміщення тощо. Їх отримують під час навчання моделі та зберігають у файл. Під час інференсу рушій лише читає ці числа і застосовує їх до вхідних даних, не змінюючи самі ваги. Інференс — це процес «запиту» до вже навченої моделі: ви даєте текст, модель покроково видає відповідь.
Рушій працює з форматом файлів GGUF. GGUF — формат, у якому лежить збережена модель:
- «ваги» (числа нейромережі);
- метадані (розміри, тип архітектури);
- словник (відповідність «текст ↔ числа» для токенів).
По суті це один файл-контейнер, з якого рушій читає все потрібне в пам'ять. Підтримується квантизація (зменшення розміру моделі за рахунок грубішого зберігання чисел) та гібридні обчислення (частина на CPU, частина на GPU). Моделі у форматі GGUF можна брати з Hugging Face.
Мета статті — покроково розібрати, як у llama.cpp влаштовано інференс: від завантаження моделі до появи чергового токена у відповіді.
Токен — це число, що відповідає шматочку тексту (слову або частині слова); модель працює лише з числами, а словник перекладає «текст → токени» і навпаки.
Стаття вибудувана як сценарій:
- спочатку підготовка (ініціалізація, завантаження моделі, створення контексту);
- потім генерація за запитом (токенізація, батч, decode, семплінг).
З якими моделями працює рушій:
- LLaMA (Meta);
- Qwen (Alibaba);
- Gemma (Google);
- Mistral;
- Phi та інші.
Відмінності — у розмірі, довжині контексту та деталях; у коді це різні гіперпараметри й тензори ваг. Загальний сценарій один і той самий.
У яких проєктах використовується:
- LM Studio;
- Ollama;
- GPT4All;
- Offline AI Launcher (запуск моделей на смартфоні);
- KoboldCpp;
- Text Generation WebUI.
Репозиторій: github.com/ggml-org/llama.cpp. Код у статті наведено за гілкою master на коміті 8887a48f, одразу після релізу b10736; в іншій версії деталі можуть відрізнятися.
Як читати статтю:
- розділи йдуть у порядку виконання сценарію;
- у кожному розділі для дії наводиться цитата з коду та пояснення, що відбувається і для чого;
- файли вихідників указані в тексті;
- стаття розрахована на непідготовленого читача: усі терміни пояснюються в глосарії на початку статті, усі кроки супроводжуються цитатами коду та поясненнями.
Глосарій термінів
Батч (batch) — набір токенів (або ембедингів), позицій і прапорців логітів, що обробляються за один виклик decode. При Prefill у батчі багато токенів промпту; при Decode — один новий токен.
Бекенд (backend) — «рушій» обчислень: CPU або відеокарта (GPU). Планувальник розподіляє вузли графа по бекендах.
Decode — етап генерації по одному токену: у батчі один новий токен, граф обчислює логіти для цієї позиції, K і V дописуються в KV-cache.
Ембединг (embedding) — вектор чисел, у який перетворюється токен перед подачею в шари моделі; один рядок матриці ембедингів моделі.
EOS (end of sequence) — спеціальний токен «кінець виводу»; за ним застосунок припиняє генерацію.
GGUF — формат файлу моделі: заголовок, метадані (ключ–значення), дані тензорів. Підтримується квантизація та mmap.
Гіперпараметри (hparams) — числа, що задають розміри моделі: довжина контексту, кількість шарів, розмір ембедингу, кількість голів уваги тощо.
Інференс — процес отримання відповіді від моделі: подається промпт, модель покроково видає наступний токен.
KV-cache — кеш ключів і значень механізму уваги; зберігає вже пораховані K і V по всіх попередніх позиціях, щоб не перераховувати їх на кожному кроці.
Логіти (logits) — «сирі» оцінки моделі по кожному токену словника перед softmax; за ними семплер обирає наступний токен.
Prefill — етап обробки промпту: у батчі всі (або багато) токенів промпту, для них рахуються K і V та записуються в KV-cache.
Семплінг — вибір одного токена за логітами (жадібний, випадковий з temperature/top_p тощо).
Тензор — багатовимірний масив чисел (ваги моделі, ембединги, ключі, значення, логіти тощо).
Токен — ціле число (ID), що відповідає шматочку тексту (слову або частині слова); модель працює лише з токенами.
Токенайзер — компонент словника, що перетворює текст на токени (SPM, BPE тощо) і навпаки.
Обчислювальний граф — перелік операцій (матриці, додавання, активації) та зв'язків між ними; за ним рушій виконує всі обчислення моделі.
Підбатч (ubatch) — частина батча, яка обробляється за один виклик process_ubatch. Якщо промпт довший за n_ubatch, батч розбивається на підбатчі по n_ubatch токенів; кожен підбатч проганяється через модель по черзі.
Для чого:
- щоб обмежити пікове споживання пам'яті при довгому промпті.
Планувальник (sched) — компонент GGML, який розподіляє вузли обчислювального графа по бекендах (CPU, GPU) і виділяє під граф буфери на цих пристроях. При виконанні графа планувальник обходить вузли в топологічному порядку і запускає операції на обраних пристроях.
Схема процесу: усі кроки по порядку
Нижче — увесь ланцюжок від запуску рушія до появи чергового токена у відповіді. Кожен пункт далі в статті розбирається докладно, з цитатами коду та поясненнями.
Підготовка (один раз при старті або зміні моделі):
- крок 1: ініціалізація бекенда (
llama_backend_init) - крок 2: завантаження моделі з файлу (
llama_model_load_from_file→ завантажувач GGUF) - крок 3: визначення типу моделі — архітектура, вже прочитана завантажувачем з ключа GGUF general.architecture, визначає, який клас моделі створюється (
llama_model_create) - крок 4: завантаження гіперпараметрів — розміри, контекст (
load_hparams) - крок 5: завантаження словника й токенайзера (
load_vocab) - крок 6: завантаження ваг у пам'ять або на GPU (
load_tensors) - крок 7: створення контексту інференсу — KV-cache, планувальник (
llama_init_from_model).
Генерація (для кожного повідомлення та кожного нового токена у відповіді):
- крок 8: надходить текст промпту від користувача
- крок 9: токенізація — текст перетворюється на послідовність токенів (
llama_tokenize) - крок 10: формування батча — токени пакуються для одного виклику (
llama_batch_get_oneабоcommon_batch_add), а всередині виклику батч перевіряється й розкладається (balloc->init) - крок 11:
llama_decode— точка входу кроку генерації: батч перевіряється й передається у внутрішній шлях декодування (llama_context::decode) - крок 12: батч при довгому промпті розбивається на підбатчі (
memory->init_batch) - крок 13: кожен підбатч проганяється через модель: побудова графа → виконання на CPU/GPU (
process_ubatch→build_graph→graph_compute) - крок 14: логіти копіюються з виходу графа в буфер контексту — по одному рядку на кожну позицію, позначену в батчі на вивід; у циклі генерації це лише остання позиція
- крок 15: семплінг — за логітами обирається один наступний токен
- крок 16: якщо токен завершує генерацію (
llama_vocab_is_eog— сюди входять EOS, EOT та інші токени кінця генерації), цикл завершується; інакше токен перекладається в текст і виводиться (llama_token_to_piece), з цього одного токена збирається новий батч, і керування повертається до кроку 11.
Загальний процес інференсу LLM
Вище наведено схему: що за чим відбувається. За змістом процес поділяється на два етапи. Перший — підготовка: ініціалізація бекенда, завантаження моделі з GGUF (архітектура, гіперпараметри, словник, ваги) та створення контексту. Він виконується один раз при старті або при зміні моделі. Другий етап — генерація: при кожному повідомленні користувача текст перетворюється на токени, пакується в батч, проганяється через модель (llama_decode), з логітів обирається наступний токен, він перекладається в текст і виводиться; цикл повторюється до токена «кінець виводу» (EOS) або ліміту. Цей цикл повторюється для кожного нового повідомлення і для кожного нового токена у відповіді. Нижче кожен крок зі схеми розбирається окремо: що саме викликається в коді, що відбувається і що може бути неочевидним непідготовленому читачеві.
Структура репозиторію та основні файли
У репозиторії llama.cpp основні частини рушія рознесені по теках і файлах.
Для чого так зроблено:
- щоб рознести відповідальність: завантаження файлу, модель, словник, контекст і батч — у різних файлах;
- так простіше шукати код і налагоджувати.
Нижче перелічені файли, які прямо стосуються завантаження моделі та інференсу; для кожного вказано, що в ньому лежить і навіщо це потрібно.
Точка входу та завантаження моделі:
-
src/llama.cpp— тут живуть функціїllama_backend_init,llama_model_load_from_file,llama_model_load(статична).Для чого: це «вхідні двері» до рушія: застосунок викликає ці функції, щоб ініціалізувати бібліотеку та завантажити модель; тут же створюється завантажувач і запитується об'єкт моделі під архітектуру, прочитану з файлу. Саму фабрику
llama_model_create(вона запитує архітектуру в завантажувача черезget_arch) цей файл лише викликає — живе вона вsrc/llama-model.cpp. Потім по черзі викликаютьсяload_hparams,load_vocabтаload_tensors. -
src/llama-model-loader.cpp— класllama_model_loader: відкриття GGUF-файлу, побудова індексу тензорів (weights_map), методиget_weightтаget_tensor_metaдля пошуку тензора за іменем іload_all_dataдля читання самих даних тензорів.Для чого: завантажувач потрібен, щоб за іменем тензора знати, де у файлі лежать його дані і як їх прочитати або відобразити в пам'ять (mmap); без нього не можна покроково завантажувати архітектуру, гіперпараметри, словник і ваги.
Модель і словник:
-
src/llama-model.cpp— класllama_modelта його базова реалізаціяllama_model_base: методиload_hparams,load_vocab,load_tensors, створення тензорів і призначення буферів. Те, що відрізняється від архітектури до архітектури, винесено вload_arch_hparamsтаload_arch_tensors, які кожна родина моделей реалізує у своєму файлі вsrc/models/.Для чого: об'єкт моделі зберігає все, що прочитано з файлу: тип архітектури, гіперпараметри, словник і самі ваги (тензори); методи load_* по черзі заповнюють ці дані із завантажувача. Саму архітектуру визначають раніше, при створенні об'єкта моделі:
llama_model_createзапитує її в завантажувача (get_arch) і будує клас відповідної родини. -
src/llama-vocab.cpp— класllama_vocab, реалізаціяllama_vocab::impl::load(завантаження словника з GGUF), токенізація та зворотний переклад токенів у текст (tokenize,token_to_piece,detokenize).Для чого: словник потрібен, щоб перетворювати текст на числа (токени) при введенні та числа назад на текст при виведенні; без нього модель не зможе ні прийняти промпт, ні видати читабельну відповідь.
Контекст і decode:
-
src/llama-context.cpp— класllama_context: створення контексту (пам'ять, планувальник, резерв графів), методdecode,process_ubatch,llama_get_logits_ith.Для чого: контекст — це «робоче середовище» одного сеансу генерації: у ньому задається розмір контексту, KV-cache, планувальник обчислень; метод decode проганяє батч через модель і повертає логіти.
-
src/llama-graph.cpp— побудова обчислювального графа: класllm_graph_contextзbuild_inp_embd(пошук ембедингів за таблицею) та результат графаllm_graph_resultз методамиset_inputsіcan_reuse.Для чого: граф — це те, що насправді рахується на decode: тут збираються його вхідні вузли, перед запуском заповнюються даними підбатча і вирішується, чи можна повторно використати попередній граф замість того, щоб будувати його заново.
-
src/llama-batch.cpp— класllama_batch_allocrта його методinit(заповнення позицій і прапорців логітів); сама структураllama_batchоголошена вinclude/llama.h.Для чого: батч — це «пакет» токенів для одного виклику decode; алокатор перевіряє батч і за відсутності полів заповнює їх (позиції з пам'яті, логіти лише для останнього токена), щоб коду, який викликає, не потрібно було вручну все виставляти.
-
src/llama-kv-cache.cpp— виділення та оновлення KV-cache і йогоinit_batch(розбиття на підбатчі та резервування під них місць у кеші). Сам інтерфейс оголошено окремо, уsrc/llama-memory.h, бо модель може використовувати пам'ять іншого роду — рекурентний стан або гібрид, — і кожен варіант реалізує той самийinit_batchу своєму файлі.Для чого: KV-cache зберігає вже пораховані ключі та значення по всіх попередніх позиціях, щоб не перераховувати їх на кожному кроці;
init_batchрозбиває великий батч на підбатчі обмеженого розміру, щоб не переповнити пам'ять. -
src/llama-sampler.cpp— семплери та ланцюжок із них:llama_sampler_chain_init,llama_sampler_chain_add,llama_sampler_sample.Для чого: після decode логіти ще потрібно перетворити на один токен: ланцюжок по черзі проганяє семплери і повертає обраний токен.
Бібліотеки та бекенди:
-
ggml/(тека всередині репозиторію — бібліотека лежить прямо тут, а не підключена підмодулем) — обчислювальний граф (GGML), типи тензорів, планувальник (ggml_backend_sched) та бекенди CPU/GPU; публічні заголовки — уggml/include/, бекенди — у підтекахggml/src/.Для чого: граф описує, які операції (множення матриць, активації тощо) виконати і в якому порядку; планувальник вирішує, на якому пристрої (CPU чи GPU) рахувати кожен вузол графа.
-
gguf (
ggml/include/gguf.hтаggml/src/gguf.cpp— частина ggml, а не окрема бібліотека) — читання та запис формату GGUF: заголовок, метадані та тензори.Для чого: формат GGUF задає, як у файлі лежать заголовок, метадані та дані тензорів; ці функції читають їх без ручного розбору байтів.
-
include/llama.h— заголовок API для застосунків: оголошенняllama_model_load_from_file,llama_context,llama_decode,llama_tokenize,llama_get_logits_ith, семплери тощо.Для чого: застосунок підключає цей заголовок і викликає оголошені функції, не заходячи у внутрішні файли рушія.
Основні типи та структури (довідково)
Для орієнтування в коді корисно знати основні типи. Нижче — що зберігає кожен тип і для чого він потрібен.
Основні структури:
-
llama_model— об'єкт завантаженої моделі. У ньому зберігаються: гіперпараметри (hparams), словник (vocab), тензори ваг, список пристроїв (devices), розбиття шарів по CPU/GPU.Для чого: модель — це все, що прочитано з файлу і потрібно для обчислень; один об'єкт моделі можна використовувати для кількох контекстів (кількох сеансів генерації).
-
llama_context— контекст інференсу. У ньому: посилання на модель, параметри контексту (cparams:n_ctx,n_batch,n_ubatch,n_threadsтощо), алокатор батча (balloc), пам'ять KV-cache (memory), планувальник (sched), останній побудований граф, збережений для повторного використання (gf_res_prev), буфер логітів.Для чого: контекст — «робоче середовище» одного сеансу: розмір контексту, кеш ключів і значень, планувальник і графи для decode; при кожному запиті викликається decode саме для цього контексту.
-
llama_batch— масив токенів (або ембедингів), позицій, ідентифікаторів послідовностей і прапорців логітів.Для чого: один виклик
llama_decodeприймає один батч; у ньому передається, які токени обробити, на яких позиціях і для яких позицій повернути логіти (зазвичай лише для останньої). -
llama_model_loader— завантажувач GGUF. У ньому: метадані, прочитані з файлу (metadata), карта тензорів (weights_map), відкриті файли (files) та їхні відображення в пам'ять (mappings).Для чого: завантажувач живе лише під час завантаження моделі; за ним по черзі читаються архітектура, гіперпараметри, словник і тензори; після завантаження він не потрібен.
-
ggml_context— контекст графа GGML: у ньому створюються вузли й тензори графа.Для чого: граф описує послідовність операцій (множення, активації тощо); усі вузли графа створюються в одному такому контексті.
-
ggml_backend_sched— планувальник: список бекендів і логіка розподілу вузлів графа по пристроях.Для чого: планувальник вирішує, на якому пристрої (CPU чи GPU) виконувати кожен вузол графа, і виділяє під граф буфери на цих пристроях.
Що повертають основні функції:
-
llama_model_load_from_fileповертаєllama_model*абоnullptrпри помилці.Для чого: застосунок перевіряє вказівник: якщо не nullptr, модель завантажена і можна створювати контекст.
-
llama_model_load— допоміжна функція всерединіsrc/llama.cpp, у публічному заголовку її немає — повертає пару зі статусу та моделі: 0 при успіху, -1 при помилці, -2 при скасуванні за колбеком прогресу.Для чого: при від'ємному статусі вказівник на модель повертається вже нульовим, тож код, що викликає, лише пише в лог причину і повертає застосунку nullptr.
-
llama_decodeповертає 0 при успіху. Додатне значення — це попередження, а не фатальна помилка: 1 означає, що для цього батча не знайшлося вільного місця в KV-cache (потрібно зменшити батч або збільшити контекст), 2 — що виклик було перервано. -1 означає, що некоректний сам батч, усе, що менше -1, — фатальна помилка.Для чого: застосунок за поверненням розуміє, чи вдалося виконати decode і чи можна читати логіти; при 1 можна повторити з меншим батчем, а після 2 або фатальної помилки частина батча вже потрапила в пам'ять контексту, тож докуди він дійшов, доводиться питати в контексту окремо.
-
llama_tokenizeповертає кількість записаних токенів, але не більше за розмір переданого буфера. Якщо буфер замалий, повертається від'ємне число, за модулем рівне тому, скільки токенів вийшло б, — тому зазвичай її викликають спершу з порожнім буфером, виділяють стільки елементів і викликають ще раз.Для чого: щоб знати, скільки елементів масиву токенів заповнено і якого розміру має бути масив.
-
llama_get_logits_ithповертає вказівник на масив float розміромn_vocab— логіти i-го токена останнього decode; при невід'ємному i аргумент — це індекс токена всередині батча, який контекст переводить у рядок виходів черезoutput_ids, тож для токена, логіти якого не запитували, повернеться NULL; від'ємний індекс відлічується за рядками виходів, тобто -1 — останній. При некоректному індексі повертається NULL.Для чого: за цим масивом семплер обирає наступний токен (по одному числу на кожен токен словника); у циклі це майже завжди індекс -1, щойно порахована позиція.
Крок 1: Ініціалізація бекенда
Крок 1 у схемі процесу — ініціалізація бекенда. Перед завантаженням моделі та інференсом застосунок один раз викликає llama_backend_init(). Виклик робить три речі: запускає точний таймер, за яким потім вимірюють час завантаження та decode, створює і одразу звільняє порожній контекст GGML і — це головне — заповнює реєстр бекендів, якщо той ще порожній, щоб рушію було на чому рахувати: процесор, а якщо для відеокарти є бекенд, то і вона. Без зареєстрованого бекенда завантаження не деградує тихо, а падає: рушій повертає nullptr і пише, що бекенди не завантажено. Нижче — цитата цілком і по фрагментах (файл src/llama.cpp).
// Крок 1: єдина точка входу ініціалізації рушія; викликається один раз під час старту застосункуvoid llama_backend_init(void) { ggml_time_init(); // точний таймер; у Windows його потрібно ініціалізувати явно
// потрібно для ініціалізації таблиць f16 { struct ggml_init_params params = { 0, NULL, false }; struct ggml_context * ctx = ggml_init(params); ggml_free(ctx); }
// якщо ще нічого не зареєстровано — підтягнути бекенди (CPU, CUDA, Metal, Vulkan ...); // саме це дає рушію пристрої, на яких він рахуватиме if (!ggml_backend_reg_count()) { ggml_backend_load_all(); }}Що в коді відбувається покроково:
// Фрагмент 1: таймер потрібен, щоб потім виміряти час завантаження моделі та час decodeggml_time_init();
// Фрагмент 2: тимчасовий контекст з нульовим буфером — створюється і одразу звільняється.// Коментар у вихідниках називає це «ініціалізацією таблиць f16», але в нинішньому ggml перший// виклик ggml_init() лише запускає таймер — блок лишився як рудимент і нічого не коштуєstruct ggml_init_params params = { 0, NULL, false };struct ggml_context * ctx = ggml_init(params);ggml_free(ctx);
// Фрагмент 3: змістовна частина — якщо реєстр порожній, завантажити бібліотеки бекендів// (CPU, CUDA, Metal, Vulkan і так далі); без них немає на чому рахуватиif (!ggml_backend_reg_count()) { ggml_backend_load_all();}Спочатку вмикається таймер — потім за ним вимірюють час завантаження та час decode. Далі створюється і одразу звільняється тимчасовий контекст GGML з нульовим буфером; коментар поряд із ним у вихідниках і досі каже, що так ініціалізуються таблиці f16, але в нинішньому ggml перший виклик ggml_init лише запускає таймер, а таблиці для половинної точності будуються пізніше, коли реєструється бекенд CPU. Справжню роботу робить останній блок: якщо жоден бекенд ще не зареєстровано, ggml_backend_load_all шукає бібліотеки бекендів і реєструє ті, що знайшлися. Після цього рушій знає, на яких пристроях він може рахувати, і готовий до завантаження моделі та побудови графа.
Крок 2а: Завантаження моделі з файлу (точка входу)
Крок 2 — завантаження моделі з файлу. Воно починається з виклику llama_model_load_from_file: застосунок передає шлях до .gguf і параметри, рушій повертає готовий об'єкт моделі або nullptr при помилці.
Для чого ця функція:
- вона — єдина точка входу для завантаження моделі з файлу;
- застосунок передає шлях до файлу та параметри, рушій повертає готовий об'єкт моделі або nullptr при помилці.
Цитата з коду (файл src/llama.cpp):
// Точка входу завантаження моделі (крок 2): шлях до .gguf і параметри завантаженняstruct llama_model * llama_model_load_from_file( const char * path_model, struct llama_model_params params) { // splits — шляхи до частин розбитої моделі; тут порожньо, завантажувач виведе їх сам std::vector<std::string> splits = {}; // три nullptr і FILE * — це решта можливих джерел моделі // (підготовлені в пам'яті метадані GGUF та вже відкритий файл); заданим може бути лише одне return llama_model_load_from_file_impl(nullptr, nullptr, nullptr, path_model, splits, /*file*/ nullptr, params);}Функція приймає шлях до файлу моделі (зазвичай .gguf) і параметри завантаження — застосунок вказує, звідки читати модель, як її читати і скільки шарів покласти на відеокарту. splits тут завжди порожній: якщо в метаданих сказано, що модель розбито на кілька файлів, шляхи до частин завантажувач виводить з імені файлу сам — тому це ім'я й має відповідати шаблону <name>-00001-of-00003.gguf. Явний перелік частин передають через окрему точку входу — llama_model_load_from_splits. Усе інше — перевірка, що бекенд зареєстровано, колбек прогресу за замовчуванням, виклик llama_model_load та обробка його результату — відбувається в llama_model_load_from_file_impl.
Параметри завантаження (llama_model_params), важливі для розуміння:
-
load_mode— як читати файл: mmap, mlock, пряме введення-виведення або звичайне читання в буфер.Для чого: mmap заощаджує RAM і пришвидшує старт завантаження, бо файл відображається, а не копіюється; пряме введення-виведення на деяких дисках і системах дає передбачуванішу швидкість читання. Типове значення — auto: рушій обирає mmap і відкочується на звичайне читання, якщо якийсь із пристроїв не вміє працювати з відображеною пам'яттю. Усередині завантажувача це одне значення знову перетворюється на два прапорці —
use_mmapіuse_direct_io; саме в такому вигляді воно трапляється далі. -
n_gpu_layers— скільки шарів завантажувати на GPU (решту на CPU).Для чого: щоб частина обчислень виконувалася на відеокарті, частина на процесорі.
-
progress_callback— колбек прогресу завантаження.Для чого: застосунок може показувати прогрес-бар або скасовувати завантаження (повернути false).
-
vocab_only— завантажити лише словник (без ваг).Для чого: коли потрібен лише токенайзер, без важких ваг. Повний перелік параметрів — у
include/llama.hу структуріllama_model_params.
Крок 2б: Підготовка до читання файлу
Крок 2 (продовження) — усередині точки входу виконується підготовка до читання файлу. У llama_model_load_from_file_impl виконується основна підготовка перед читанням файлу.
Для чого так:
- перед завантаженням потрібно переконатися, що модель береться рівно з одного джерела, що є бекенд для обчислень, і налаштувати відображення прогресу;
- тільки після цього викликається внутрішня функція
llama_model_load, яка читає файл покроково.
Цитата початку функції (файл src/llama.cpp):
static struct llama_model * llama_model_load_from_file_impl( struct gguf_context * metadata, llama_model_set_tensor_data_t set_tensor_data, void * set_tensor_data_ud, const std::string & path_model, std::vector<std::string> & splits, FILE * file, struct llama_model_params params) { // ... опущено: перевірка, що задано рівно одне джерело — metadata, path_model або file ggml_time_init(); // таймер для вимірювання часу завантаження
// Якщо завантажуємо не лише словник — перевіряємо, що зареєстровано хоча б один бекенд if (!params.vocab_only && ggml_backend_reg_count() == 0) { LLAMA_LOG_ERROR("%s: no backends are loaded. hint: use ggml_backend_load() or ggml_backend_load_all() to load a backend before calling this function\n", __func__); return nullptr; }
unsigned cur_percentage = 0; // Якщо колбек прогресу не передано — підставляємо свій: виводимо крапки до 100% if (params.progress_callback == NULL) { params.progress_callback_user_data = &cur_percentage; params.progress_callback = [](float progress, void * ctx) { unsigned * cur_percentage_p = (unsigned *) ctx; unsigned percentage = (unsigned) (100 * progress); while (percentage > *cur_percentage_p) { *cur_percentage_p = percentage; LLAMA_LOG_CONT("."); if (percentage >= 100) { LLAMA_LOG_CONT("\n"); } } return true; // не скасовуємо завантаження }; }Ця реалізація спільна для всіх публічних точок входу, тому вона приймає одразу всі можливі джерела моделі — готові метадані GGUF, шлях (частини розбитої на файли моделі передаються в splits) або вже відкритий файл — і починає з перевірки, що передано рівно одне з них.
Що відбувається в цьому фрагменті і для чого:
-
ggml_time_init()— вмикається таймер.Для чого: щоб потім виміряти, скільки зайняло завантаження.
-
Перевірка
ggml_backend_reg_count() == 0— чи є хоча б один бекенд (CPU або GPU).Для чого: без бекенда не можна буде виконувати обчислення моделі; при завантаженні лише словника (
vocab_only) бекенд не обов'язковий. -
Налаштування колбека прогресу — якщо застосунок не передав свій, підставляється колбек за замовчуванням, який виводить крапки.
Для чого: користувач бачить, що завантаження триває; за бажання можна передати свій колбек і показувати прогрес-бар або скасовувати завантаження (повернути false).
-
Сам об'єкт моделі тут не створюється. Він з'являється рівнем нижче, усередині
llama_model_load, у викликуllama_model_create— і лише після того, як відкрито завантажувач GGUF.Для чого: клас об'єкта залежить від архітектури, записаної у файлі (LLaMA, Gemma, Qwen і так далі), тому спочатку потрібно прочитати архітектуру з GGUF; гіперпараметри, словник і тензори записуються вже в цей об'єкт.
Решта функції — це лише виклик внутрішнього завантажувача та обробка його результату (той самий файл, кінець llama_model_load_from_file_impl):
// Внутрішнє завантаження: читає файл і збирає модель (arch, hparams, vocab, tensors) const auto [status, model] = llama_model_load(metadata, set_tensor_data, set_tensor_data_ud, path_model, splits, file, params); GGML_ASSERT(status <= 0); if (status < 0) { if (status == -1) { LLAMA_LOG_ERROR("%s: failed to load model\n", __func__); } else if (status == -2) { LLAMA_LOG_INFO("%s: cancelled model load\n", __func__); }
if (model) { llama_model_free(model); } return nullptr; }
return model;}-
Список пристроїв теж будується рівнем нижче, у
llama_prepare_model_devices, якуllama_model_loadвикликає одразу після створення об'єкта моделі; результат потрапляє вmodel->devices.Для чого: від нього залежить, по яких прискорювачах будуть розподілені шари моделі (див.
load_tensors). До цього списку потрапляють лише графічні пристрої — спочатку віддалені RPC-сервери, потім дискретні відеокарти, а вбудовані — тільки якщо дискретної не знайшлося; CPU обробляється окремо і до нього не входить. -
llama_model_load— читає файл і покроково заповнює модель: створює завантажувач GGUF, визначає архітектуру і створює під неї об'єкт моделі, обирає пристрої, а потім по черзі викликаєload_hparams,load_vocabіload_tensors.Для чого: уся логіка читання GGUF і заповнення моделі зосереджена в одній функції.
-
При від'ємному статусі в лог пишеться причина (-1 — помилка завантаження, -2 — скасування колбеком прогресу) і повертається
nullptr. Викликllama_model_freeу цій гілці — лише підстраховка:llama_model_loadсама знищує недобудовану модель і при невдачі ніколи не віддає вказівник назовні.Для чого: застосунок за nullptr розуміє, що завантаження не вдалося, і не використовує неповну модель.
Колбек прогресу викликається всередині load_tensors при читанні кожного тензора: йому передається число від 0.0 до 1.0 (частка завантажених даних). Якщо колбек повертає false, завантаження переривається і llama_model_load повертає -2 (скасування).
Для чого:
- застосунок може скасувати довге завантаження або показувати прогрес-бар.
Кроки з 2 до 6 разом: Покрокове завантаження моделі з файлу
Кроки 2–6 виконуються всередині однієї функції llama_model_load: створюється завантажувач (крок 2), потім створюється об'єкт моделі для архітектури, яку прочитав завантажувач (llama_model_create, крок 3), а після нього по черзі викликаються load_hparams (крок 4), load_vocab (крок 5) і load_tensors (крок 6).
Для чого потрібна функція llama_model_load: вона виконує покрокове завантаження моделі з файлу: створює завантажувач GGUF (відкриває файл і будує карту тензорів), створює об'єкт моделі потрібної архітектури, а потім по черзі завантажує гіперпараметри, словник і тензори. Порядок важливий: архітектура задає набір ключів у GGUF; гіперпараметри читаються за цими ключами; словник завантажується з урахуванням архітектури; тензори створюються за відомими розмірами і заповнюються з файлу.
Скорочена цитата функції (файл src/llama.cpp, функція llama_model_load):
// src/llama.cpp — llama_model_load(); порожні рядки і дві обгортки try/catch прибрано для стислості// Повертає 0 при успіху, -1 при помилці та -2 при скасуванні через llama_progress_callbackstatic std::pair<int, llama_model *> llama_model_load(struct gguf_context * metadata, llama_model_set_tensor_data_t set_tensor_data, void * set_tensor_data_ud, const std::string & fname, std::vector<std::string> & splits, FILE * file, llama_model_params & params) { try { // Крок 2: завантажувач відкриває GGUF, читає заголовок і метадані, будує карту тензорів (weights_map) llama_model_loader ml(metadata, set_tensor_data, set_tensor_data_ud, fname, splits, file, params.load_mode, params.check_tensors, params.no_alloc, params.load_mtp, params.kv_overrides, params.tensor_buft_overrides); ml.lazy.mode = params.lazy_mode; // ліниве читання тензорів: частина ваг лишається на диску і читається на вимогу ml.print_info(); // Крок 3: архітектура, яку прочитав завантажувач, обирає C++-клас моделі std::unique_ptr<llama_model> model_ptr(llama_model_create(ml, params)); // список пристроїв (CPU, GPU), на які буде розкладена саме ця модель bool ok = llama_prepare_model_devices(params, model_ptr.get()); if (!ok) { return {-1, nullptr}; } auto * model = dynamic_cast<llama_model_base *>(model_ptr.get()); // ... пропущено: перевірка на null, що перериває роботу, якщо модель не реалізує llama_model_base model->t_load_us = 0; time_meas tm(model->t_load_us); // час завантаження буде перераховано після першого eval, щоб урахувати промахи сторінок, відкладені mmap model->t_start_us = tm.t_start_us; model->hparams.vocab_only = params.vocab_only; model->hparams.no_alloc = params.no_alloc; // ... пропущено: кожен із двох викликів нижче обгорнутий у try/catch, що перекидає помилку з префіксом model->load_hparams(ml); // Крок 4: розміри моделі, довжина контексту, кількість шарів if (model->arch == LLM_ARCH_CLIP) { throw std::runtime_error("CLIP cannot be used as main model, use it with --mmproj instead"); } model->load_vocab(ml); // Крок 5: словник і токенайзер — текст <-> токени model->load_stats(ml); model->print_info(); if (params.vocab_only) { LLAMA_LOG_INFO("%s: vocab only - skipping tensors\n", __func__); return {0, model_ptr.release()}; } // Крок 6: ваги моделі з файлу в пам'ять (або mmap) на CPU/GPU if (!model->load_tensors(ml)) { return {-2, nullptr}; } return {0, model_ptr.release()}; } catch (const std::exception & err) { LLAMA_LOG_ERROR("%s: error loading model: %s\n", __func__, err.what()); return {-1, nullptr}; }}Помилки та скасування завантаження:
- при помилці в будь-якому з кроків (створення завантажувача,
llama_model_create,load_hparams,load_vocab,load_tensors) викидається виняток; у блоці catch логується повідомлення і повертається -1; - при скасуванні за колбеком прогресу (колбек повертає false усередині
load_tensors)load_tensorsповертає false, виняток не викидається, алеllama_model_loadповертає -2 (скасування); - при будь-якій невдачі недобудована модель знищується одразу: вона живе в
unique_ptr, який передається коду, що викликає, лише при успіху, томуllama_model_load_from_file_implотримує нульовий вказівник, логує причину і повертаєnullptr; - таким чином, застосунок може скасувати довге завантаження через колбек і коректно звільнити ресурси.
Покроково (що відбувається і для чого):
-
створюється завантажувач
llama_model_loader ml(...)— він відкриває GGUF-файл, читає метадані і будуєweights_map.Для чого:
-
без завантажувача не можна прочитати архітектуру, гіперпараметри, словник і тензори з файлу.
-
викликається
ml.print_info()— виводить у лог формат файлу, тип файлу (квантування) і розмір файлу разом із кількістю біт на вагу.Для чого:
-
щоб користувач бачив, який саме файл відкривається.
-
викликається
llama_model_create(ml, params)— архітектура, яку прочитав завантажувач (LLaMA, Gemma, Qwen тощо), обирає C++-клас моделі, і об'єкт створюється.Для чого:
-
від архітектури залежать імена полів у GGUF і те, які тензори створювати; невідома архітектура — помилка вже тут.
-
викликається
llama_prepare_model_devices— будується і виводиться в лог список пристроїв (CPU і відеокарти) для цієї моделі.Для чого:
-
він визначає, по яких пристроях потім будуть розкладені шари в
load_tensors. -
лише тепер скидається час завантаження і запускається таймер.
Для чого:
-
виміряне тут значення — не остаточне: при першому eval
llama_contextперераховує час завантаження від збереженого t_start_us, тому у відлік потрапляють і промахи сторінок, відкладені mmap, — вони трапляються лише тоді, коли до ваг реально дістануться. -
викликається
model->load_hparams(ml)— читаємо розмірності та параметри (довжина контексту, кількість шарів тощо).Для чого:
-
щоб знати «форму» моделі та виділити під неї пам'ять.
-
для CLIP буде викинуто помилку — його використовують окремо як проєктор, а не як основну модель.
-
викликається
model->load_vocab(ml)— завантажуємо словник токенів.Для чого:
-
щоб потім перетворювати текст на числа (токени) і навпаки при генерації.
-
load_stats(ml)копіює із завантажувача в модель кількість елементів і розмір у байтах, аprint_info()виводить у лог архітектуру та всі гіперпараметри. Якщо завантажуємо лише словник (vocab_only == true), на цьому вихід. Інакше викликаєтьсяmodel->load_tensors(ml)— читаються і розкладаються по пам'яті ваги моделі. При успіху повертається 0, при скасуванні (колбек прогресу повернув false) — -2, при помилці — -1.
Порядок викликів при завантаженні моделі (зведення):
llama_model_load_from_file→llama_model_load_from_file_impl;- в impl: перевірка, що джерело моделі задано рівно один раз, перевірка бекенда, колбек прогресу за замовчуванням і виклик
llama_model_load(metadata, set_tensor_data, set_tensor_data_ud, path_model, splits, file, params); - усередині
llama_model_load: створенняllama_model_loader,ml.print_info,llama_model_create,llama_prepare_model_devices,load_hparams,load_vocab,load_stats,print_info, за потребиload_tensors. Усі ці кроки виконуються послідовно; при помилці в будь-якому з них завантаження переривається.
Порядок викликів llama_model_create → load_hparams → load_vocab важливий: архітектура задає набір ключів GGUF; гіперпараметри читаються за цими ключами; словник завантажується з урахуванням архітектури (наприклад, імена ключів для токенайзера). Тому vocab.load(ml, kv) викликається саме після load_hparams(ml) — на цей момент і архітектура, і гіперпараметри вже відомі, і завантажувач може коректно прочитати тип токенайзера, списки токенів і злиття.
Крок 2в: Завантажувач GGUF — відкриття файлу та карта тензорів
Крок 2 (завершення) (усередині llama_model_load) — створюється завантажувач GGUF: відкривається файл, читаються заголовок і метадані, будується карта тензорів.
Для чого потрібен завантажувач GGUF: щоб відкрити файл моделі, прочитати заголовок і метадані (без самих ваг) і побудувати «карту» — за іменем тензора знати, де у файлі лежать його дані і якого він розміру. Без цієї карти не можна потім завантажувати ваги по одному тензору. Тензори тут — багатовимірні масиви чисел (матриці та вектори), у яких зберігаються ваги нейромережі; кожен шар моделі — це кілька тензорів, і завантажувач має знати для кожного ім'я, розмір і зміщення у файлі.
Створення завантажувача — виклик конструктора llama_model_loader. Сам конструктор міститься у src/llama-model-loader.cpp; llama_model_load лише викликає його. Скорочено він має такий вигляд:
// src/llama-model-loader.cpp - конструктор (крок 2): відкриває GGUF, читає заголовок і метадані, будує карту тензорівllama_model_loader::llama_model_loader( struct gguf_context * meta, llama_model_set_tensor_data_t set_tensor_data, void * set_tensor_data_ud, const std::string & fname, std::vector<std::string> & splits, FILE * file, llama_load_mode load_mode, bool check_tensors, bool no_alloc, bool load_mtp, const llama_model_kv_override * param_overrides_p, const llama_model_tensor_buft_override * param_tensor_buft_overrides_p) : metadata(meta), set_tensor_data(set_tensor_data), set_tensor_data_ud(set_tensor_data_ud) { // ... перевизначення KV і перетворення load_mode на use_mmap / use_direct_io ... if (!fname.empty()) { struct ggml_context * ctx = NULL; struct gguf_init_params params = { /*.no_alloc = */ true, // створити лише порожні тензори, не читати блок даних /*.ctx = */ &ctx, }; metadata_ptr.reset(gguf_init_from_file(fname.c_str(), params)); metadata = metadata_ptr.get(); if (metadata == nullptr) { throw std::runtime_error(format("%s: failed to load model from %s", __func__, fname.c_str())); } // Ім'я архітектури (llama, qwen2 ...) задає імена всіх інших ключів GGUF get_key(llm_kv(LLM_KV_GENERAL_ARCHITECTURE), arch_name, false); llm_kv = LLM_KV(llm_arch_from_string(arch_name)); files.emplace_back(new llama_file(fname.c_str(), "rb", use_direct_io)); contexts.emplace_back(ctx); for (ggml_tensor * cur = ggml_get_first_tensor(ctx); cur; cur = ggml_get_next_tensor(ctx, cur)) { std::string tensor_name = std::string(cur->name); if (weights_map.find(tensor_name) != weights_map.end()) { throw std::runtime_error(format("invalid model: tensor '%s' is duplicated", ggml_get_name(cur))); } n_elements += ggml_nelements(cur); n_bytes += ggml_nbytes(cur); weights_map.emplace(tensor_name, llama_tensor_weight(files.back().get(), 0, metadata, cur)); } // ... той самий цикл по файлах-частинах, коли модель розбита на кілька ... } // ... гілки для вже відкритого FILE * і для метаданих, переданих ззовні ... n_kv = gguf_get_n_kv(metadata); n_tensors = weights_map.size(); fver = (enum llama_fver) gguf_get_version(metadata); // ... вгадування типу файлу та вивід пар KV у лог ...}У конструкторі gguf_init_from_file читає заголовок GGUF і метадані (без самих ваг) — так отримують список тензорів і пари ключ–значення (архітектура, гіперпараметри) без читання важких даних у пам'ять. З метаданих береться ім'я архітектури (general.architecture) — від нього залежать імена решти полів у GGUF (у різних моделей різні ключі). Файл відкривається для читання; потім за зміщеннями з weights_map будуть читатися або мапитися дані тензорів. За списком тензорів з GGUF для кожного в weights_map записується ім'я, розмір і зміщення у файлі — потім get_weight(name) знаходить запис у карті, а load_all_data читає байти за цим зміщенням (або, при mmap, одразу вказує на них).
Структура llama_tensor_weight (елемент weights_map) зберігає три речі: індекс вихідного файлу (модель може бути розбита на кілька частин GGUF), абсолютне зміщення даних тензора в цьому файлі та вказівник на тензор у контексті GGUF. Зміщення обчислюється один раз, при створенні запису, як початок секції даних плюс зміщення тензора всередині неї — і той самий конструктор перевіряє, що тензор цілком уміщується у файл, тож обрізана модель відсікається тут, а не під час генерації. Метод ml.get_key(...) читає з метаданих GGUF значення за ключем (ім'я ключа залежить від архітектури — його повертає kv(...)). Так завантажувач отримує, наприклад, ім'я архітектури, тип токенайзера, гіперпараметри.
Після створення завантажувача в llama_model_load викликається ml.print_info(). Цитата з коду:
// src/llama.cpp, усередині llama_model_load, одразу після створення завантажувача:ml.print_info(); // пише в лог версію GGUF, вгаданий тип файлу та загальний розмір вагФункція gguf_init_from_file (бібліотека GGUF) відкриває файл і читає заголовок:
- версію формату;
- кількість ключів метаданих;
- кількість тензорів.
Метадані читаються в контекст gguf_context (пари ключ–значення); самі дані тензорів на цьому кроці не завантажуються — лише імена, типи та зміщення у файлі. Сам конструктор уже пише в лог кількість пар ключ–значення, кількість тензорів, ім'я файлу та версію GGUF. Метод print_info (src/llama-model-loader.cpp) додає ще три рядки:
- версію формату GGUF;
- тип файлу — Q4_K - Medium і подібні, вгаданий за найчастішим типом тензорів, якщо метадані не вказують його прямо;
- загальний розмір ваг у MiB або GiB разом із середньою кількістю бітів на вагу.
Формат GGUF (довідково)
GGUF (GPT-Generated Unified Format) — бінарний формат для зберігання моделей машинного навчання.
Для чого він потрібен:
- щоб в одному файлі зберігати і ваги моделі, і метадані (розміри, тип архітектури), і словник;
- завантажувач за заголовком і метаданими будує «карту» і потім на запит читає потрібні шматки файлу або мапить їх у пам'ять (mmap).
Структура заголовка GGUF:
- магічне число, чотири байти GGUF (ідентифікація формату — за ним розуміють, що це GGUF);
- версія формату (для сумісності при змінах формату);
- кількість тензорів (n_tensors);
- кількість ключів метаданих (n_kv).
Метадані зберігаються як масив пар «ключ — значення». Для кожної пари записані:
- ім'я ключа (рядок із довжиною, записаною перед ним);
- тип значення (рядок, число, булеве, масив тощо);
- саме значення (ім'я архітектури, розмірності, параметри RoPE, тип токенайзера, списки токенів тощо); для масиву спочатку йдуть тип елементів і їх кількість, потім самі елементи.
Тензори у файлі ідуть після метаданих. Для кожного тензора записані:
- ім'я;
- кількість розмірностей і розмір за кожною з них (не більше чотирьох; наприклад, [n_embd, n_ff] для ваг повнозв'язного блоку одного шару — кожен шар це окремий набір тензорів, тому номер шару входить в ім'я,
blk.0.ffn_up.weight, і ніколи не буває розмірністю); - тип елемента (F32, F16, Q8_0, Q4_K тощо — від повної точності до квантизованих форматів);
- зміщення, за яким починаються дані, — від початку блоку даних тензорів, що йде після метаданих.
Імена на кшталт Q4_K_M описують файл цілком, а не окремий тензор: це рецепт, який кладе різні тензори в різні типи.
Навіщо це завантажувачу: за цією інформацією він будує weights_map (карту «ім'я тензора → де у файлі лежать дані»), і коли ваги справді знадобляться, load_all_data читає або мапить відповідну ділянку файлу.
Версія формату і типи елементів:
-
версія формату задається в заголовку GGUF.
Для чого:
-
при змінах формату версія каже завантажувачу, з чим він має справу. Поточна версія — 3; файл із новішою або вже не підтримуваною версією відкидається одразу, і модель не завантажується. Невідомі ключі метаданих, навпаки, нічого не коштують — завантажувач питає лише ті ключі, які знає, і на решту не спотикається.
-
за типом елемента тензора (F32, F16, Q8_0, Q4_K тощо) завантажувач знає, скільки байтів займає блок елементів — квантизовані типи зберігають елементи блоками фіксованого розміру зі спільним масштабом, а не по одному, — і як інтерпретувати дані при копіюванні в буфер або при mmap.
Для чого:
-
без цього не можна коректно прочитати або відобразити в пам'ять дані тензора; різні типи мають різний розмір і різну інтерпретацію байтів.
Типи даних тензорів у GGUF задають, як інтерпретувати байти: F32, F16, Q8_0, Q4_K тощо — від повної точності до квантизованих форматів. Квантизація зменшує розмір моделі та пришвидшує обчислення за рахунок наближеного представлення ваг. Рушій при завантаженні створює тензори в потрібному форматі і копіює або мапить дані з файлу в буфери на CPU або GPU.
Читання метаданих GGUF виконується через функції бібліотеки gguf:
gguf_get_n_kv— кількість ключів;gguf_get_key— ім'я ключа за індексом;gguf_get_kv_type— тип значення (рядок, число, масив тощо);gguf_find_key— індекс ключа за його іменем або -1, якщо такого немає;gguf_get_val_*— значення за цим індексом, по функції на кожен тип.
Завантажувач моделі загортає це в метод get_key(key, value) з урахуванням архітектури: ключ перетворюється на ім'я поля в GGUF (наприклад, llama.embedding_length для LLaMA).
Крок 3: Визначення типу моделі (архітектура)
Крок 3 — визначення типу моделі (LLaMA, Gemma, Qwen тощо) за іменем архітектури з GGUF. Роботу виконує фабрична функція llama_model_create у src/llama-model.cpp. Для чого це потрібно: від архітектури залежать імена ключів, які читаються з метаданих GGUF, набір тензорів, що створюються під час завантаження ваг, і побудова обчислювального графа; без цього не можна коректно прочитати гіперпараметри та словник. У llama.cpp це не просто прапорець: кожна архітектура — окремий клас C++, і фабрика обирає, який із них створити. Цитата (файл src/llama-model.cpp, функція llama_model_mapping і два перевантаження llama_model_create):
// src/llama-model.cpp — архітектура з GGUF вирішує, який клас C++ представляє цю модельstatic llama_model * llama_model_mapping(llm_arch arch, const llama_model_params & params) { switch (arch) { case LLM_ARCH_LLAMA: return new llama_model_llama(params); // ... по одному case на кожну підтримувану архітектуру ... default: throw std::runtime_error(std::string("unsupported model architecture: '") + llm_arch_name(arch) + "'"); }}
llama_model * llama_model_create(llm_arch arch, const llama_model_params & params) { llama_model * model = llama_model_mapping(arch, params);
if (model != nullptr) { model->arch = arch; // ... перевірка, що запитаний режим розбиття тензорів реалізовано для цієї архітектури ... }
return model;}
llama_model * llama_model_create(llama_model_loader & ml, const llama_model_params & params) { llm_arch arch = ml.get_arch(); if (arch == LLM_ARCH_UNKNOWN) { throw std::runtime_error("unknown model architecture: '" + ml.get_arch_name() + "'"); }
return llama_model_create(arch, params);}Що відбувається і для чого:
-
ml.get_arch()повертає enumllm_arch, який завантажувач уже визначив за ключемgeneral.architectureз метаданих GGUF.Для чого: далі за цим enum обираються імена полів для гіперпараметрів і словника (у різних архітектур — різні ключі в GGUF).
-
Якщо тип невідомий (
LLM_ARCH_UNKNOWN), викидається помилка.Для чого: рушій не вміє працювати з невідомою архітектурою; застосунок отримає повідомлення про помилку і зможе повідомити користувача.
Де архітектура читається насправді (файл src/llama-model-loader.cpp): конструктор завантажувача робить це один раз, одразу після відкриття GGUF і до індексації тензорів, щоб усе інше в завантажувачі вже користувалося готовим значенням. get_arch() і get_arch_name() після цього — прості геттери:
// src/llama-model-loader.cpp — конструктор визначає архітектуру один раз, одразу після відкриття GGUFllama_model_loader::llama_model_loader(/* ... */) : metadata(meta), /* ... */ { // ... файл GGUF щойно відкрито в `metadata` ...
get_key(llm_kv(LLM_KV_GENERAL_ARCHITECTURE), arch_name, false); // рядок "general.architecture" llm_kv = LLM_KV(llm_arch_from_string(arch_name)); // "llama" -> LLM_ARCH_LLAMA, "qwen2" -> LLM_ARCH_QWEN2
// ... далі будується індекс тензорів (weights_map) ...}
// на момент запиту enum уже визначено — обидва геттери тривіальніstd::string llama_model_loader::get_arch_name() const { return arch_name;}
enum llm_arch llama_model_loader::get_arch() const { return llm_kv.arch;}Що роблять ці виклики:
-
get_key(llm_kv(LLM_KV_GENERAL_ARCHITECTURE), arch_name, false)— з метаданих GGUF читається значення за ключем «загальна архітектура» (наприклад,llama,qwen2) і записується вarch_name.Для чого: без імені архітектури не можна обрати набір ключів для гіперпараметрів і словника.
-
llm_arch_from_string(arch_name)— рядок перетворюється на enumllm_arch(llama→LLM_ARCH_LLAMA,qwen2→LLM_ARCH_QWEN2).Для чого: за enum далі обираються імена полів у GGUF для
load_hparamsіload_vocab, а також клас моделі вllama_model_create. Читання необов'язкове (false), тому файл без ключа архітектури даєLLM_ARCH_UNKNOWN, і помилка виникає там, де створюється модель, а не тут. -
Приклади значень enum:
LLM_ARCH_LLAMA,LLM_ARCH_GEMMA,LLM_ARCH_QWEN,LLM_ARCH_PHI3,LLM_ARCH_MISTRAL3,LLM_ARCH_CLIPтощо — зараз у таблиці близько 150 іменованих архітектур. Самі ідентифікатори ключів GGUF (LLM_KV) — один спільний набір: ті, що стосуються гіперпараметрів, — шаблони на кшталт%s.context_length, куди замість %s підставляється ім'я архітектури, тому той самий ключ читається якllama.context_lengthв однієї моделі і якqwen3.context_lengthв іншої; загальні ключі, серед нихgeneral.architectureіgeneral.name, — звичайні рядки, підставляти в них нічого.Для чого: без правильної архітектури імена ключів виходять хибними, і гіперпараметри зі словником з GGUF не прочитати. По-справжньому власними в архітектури лишаються тільки кілька додаткових гіперпараметрів — їх читає її власний
load_arch_hparamsуsrc/models/. -
Імена тензорів у GGUF залежать від архітектури: для LLaMA — blk.N.attn_q.weight, blk.N.attn_k.weight тощо; для інших моделей — свої префікси та суфікси. Додати підтримку нової архітектури — означає додати значення в enum і його ім'я до таблиці імен, написати новий клас у
src/models/, який каже, які гіперпараметри читати, які тензори створювати і як будувати граф, і зареєструвати його у фабриці; нові ключі GGUF додаються лише тоді, коли архітектурі потрібен параметр, якого немає в жодному з наявних ключів. Гіперпараметри використовуються при створенні тензорів уload_tensors(розміри матриць, кількість шарів) і при створенні контексту (n_ctx,n_batch, параметри RoPE тощо).
Крок 4: Завантаження гіперпараметрів — розміри й контекст
Крок 4 — завантаження гіперпараметрів (розміри моделі, довжина контексту, кількість шарів тощо) з метаданих GGUF. Метод llama_model_base::load_hparams заповнює структуру hparams (гіперпараметри) з метаданих GGUF. Для чого це потрібно: гіперпараметри задають «форму» моделі — довжину контексту, кількість шарів, розмір ембедингу, кількість голів уваги тощо; без них не можна виділити пам'ять під тензори і побудувати обчислювальний граф. Тут читаються ключі, спільні для всіх архітектур; потім метод викликає load_arch_hparams, хук, який кожна архітектура реалізує у власному файлі в src/models/ і який читає параметри, властиві лише цій родині. Після повернення з хука встановлюється ще кілька полів, серед них тип RoPE, — його можна обчислити лише тоді, коли архітектура вже визначила власні параметри. Цитата з основними ключами (файл src/llama-model.cpp, метод llama_model_base::load_hparams):
// src/llama-model.cpp — llama_model_base::load_hparams(): «форма» моделі, прочитана з метаданих GGUFvoid llama_model_base::load_hparams(llama_model_loader & ml) { const gguf_context * ctx = ml.metadata;
// зберігаємо всі пари ключ-значення (крім масивів) як рядки, для довідки for (int i = 0; i < gguf_get_n_kv(ctx); i++) { gguf_type type = gguf_get_kv_type(ctx, i); if (type == GGUF_TYPE_ARRAY) { continue; } const char * name = gguf_get_key(ctx, i); const std::string value = gguf_kv_to_str(ctx, i); gguf_kv.emplace(name, value); }
ml.get_key(LLM_KV_GENERAL_NAME, name, false);
// усе далі вже не стосується словника if (hparams.vocab_only || ml.get_arch() == LLM_ARCH_CLIP) { return; }
// архітектура — префікс кожного ключа нижче: llama.context_length, qwen3.context_length, ... ml.get_key(LLM_KV_CONTEXT_LENGTH, hparams.n_ctx_train); // довжина контексту під час навчання ml.get_key(LLM_KV_EMBEDDING_LENGTH, hparams.n_embd); // розмір ембедингу (прихованого стану) ml.get_key(LLM_KV_BLOCK_COUNT, hparams.n_layer_all); // кількість шарів трансформера GGML_ASSERT(hparams.n_layer_all > 0 && hparams.n_layer_all <= LLAMA_MAX_LAYERS); ml.get_key(LLM_KV_EXPERT_COUNT, hparams.n_expert, false); // лише для MoE-моделей
// ... тут масиви за шарами заповнюються нулями ...
ml.get_key_or_arr(LLM_KV_FEED_FORWARD_LENGTH, hparams.n_ff_arr, hparams.n_layer(), false); // розмір FF за шарами ml.get_key_or_arr(LLM_KV_ATTENTION_HEAD_COUNT, hparams.n_head_arr, hparams.n_layer(), false); // кількість голів уваги за шарами
// n_head_kv необов'язковий, за замовчуванням дорівнює n_head hparams.n_head_kv_arr = hparams.n_head_arr; ml.get_key_or_arr(LLM_KV_ATTENTION_HEAD_COUNT_KV, hparams.n_head_kv_arr, hparams.n_layer(), false); // кількість KV-голів (GQA)
// rope_freq_base (необов'язковий) hparams.rope_freq_base_train = 10000.0f; ml.get_key(LLM_KV_ROPE_FREQ_BASE, hparams.rope_freq_base_train, false);
// ... масштабування RoPE, розміри голів, варіанти з ковзним вікном ...
load_arch_hparams(ml); // усе, що потрібно лише цій архітектурі — src/models/<arch>.cpp
// ... після хука встановлюється ще кілька полів, серед них hparams.rope_type ...}Що відбувається і для чого:
-
У циклі читаються всі пари ключ–значення з GGUF (крім масивів) і зберігаються в
gguf_kv.Для чого:
-
щоб потім за потреби можна було звернутися до будь-якого поля за іменем.
-
Потім за ключами, що залежать від архітектури, заповнюються поля
hparams: -
n_ctx_train— довжина контексту при навчанні.Для чого:
-
від неї залежить розмір KV-cache і максимальна довжина введення.
-
n_embd— розмір ембедингу (прихованого шару).Для чого:
-
від нього залежать розміри матриць ваг.
-
n_layer_all— кількість блоків трансформера, записана у файлі.Для чого:
-
від нього залежить, скільки тензорів створювати в
load_tensors; реально будуєтьсяhparams.n_layer()— воно віднімає додаткові блоки multi-token prediction, які є в деяких моделей. -
n_ff_arr,n_head_arr,n_head_kv_arr— розміри feed-forward і кількість голів уваги по шарах.Для чого:
-
від них залежать розміри матриць Q, K, V і feed-forward.
-
rope_freq_base_trainта ін. — параметри RoPE (позиційного кодування).Для чого:
-
вони задають, як у графі застосовуються позиційні кодування. У підсумку
hparamsповністю описує розміри моделі — цього достатньо, щоб виділити пам'ять під тензори і побудувати обчислювальний граф.
Частина ключів GGUF для гіперпараметрів (залежно від архітектури):
llama.context_length— довжина контексту при навчанні;llama.embedding_length— розмір прихованого шару (ембедингу);llama.block_count— кількість шарів трансформера;llama.attention.head_count— кількість голів уваги;llama.attention.head_count_kv— кількість KV-голів (для GQA);llama.feed_forward_length— розмір проміжного шару feed-forward;llama.rope.freq_base— базова частота RoPE.
Для MoE-моделей додаються ключі кількості експертів тощо. Метод get_key_or_arr читає або одне значення, або масив по шарах (коли розміри різняться по шарах). Після завантаження гіперпараметрів модель знає «форму» всіх тензорів — цього достатньо для виділення буферів і побудови графа при load_tensors і при створенні контексту.
Крок 5: Завантаження словника й токенайзера
Крок 5 — завантаження словника й токенайзера: за словником текст перетворюється на токени при введенні і назад на текст при виведенні. Словник токенів — таблиця відповідності між шматочками тексту й цілими числами (токенами); за ним текст ріжеться на токени при введенні і назад збирається в текст при виведенні. Завантаження словника виконується після того, як визначено архітектуру та прочитано гіперпараметри: у llama_model_load архітектура визначається при створенні об'єкта моделі (llama_model_create запитує її в завантажувача через ml.get_arch()), потім викликається load_hparams(ml) і лише потім load_vocab(ml). Усередині load_vocab викликається vocab.load(ml, kv). Він завантажується в llama_model_base::load_vocab (src/llama-model.cpp):
void llama_model_base::load_vocab(llama_model_loader & ml) { const auto kv = LLM_KV(arch);
vocab.load(ml, kv);}Для поточної архітектури береться набір ключів словника, після чого викликається vocab.load(ml, kv). Реалізація — метод llama_vocab::impl::load у src/llama-vocab.cpp. З GGUF читаються:
- тип токенайзера (SPM, BPE, WPM тощо);
- списки токенів;
- типи токенів;
- злиття BPE (якщо є);
- спеціальні токени — усе, що потрібно, щоб перетворювати текст на послідовність чисел (токенів) і навпаки.
Після цього модель уміє токенізувати промпт і перекладати згенеровані токени назад у текст.
Виклик vocab.load(ml, kv) виконується всередині llama_model_base::load_vocab після того, як для поточної архітектури отримано набір ключів kv (через LLM_KV(arch)). Завантажувач ml уже відкрив GGUF і прочитав метадані; архітектура відома із завантажувача (ml.get_arch()), а гіперпараметри прочитані в load_hparams. Початок реалізації llama_vocab::impl::load (файл src/llama-vocab.cpp):
void llama_vocab::impl::load(llama_model_loader & ml, const LLM_KV & kv) { struct gguf_context * ctx = ml.metadata;
// Визначаємо тип словника { ml.get_key(LLM_KV_TOKENIZER_MODEL, tokenizer_model); ml.get_key(LLM_KV_TOKENIZER_PRE, tokenizer_pre, false); // ... "no_vocab" / "none" -> LLAMA_VOCAB_TYPE_NONE і ранній вихід ... if (tokenizer_model == "llama") { type = LLAMA_VOCAB_TYPE_SPM; // Спеціальні токени за замовчуванням special_bos_id = 1; special_eos_id = 2; special_unk_id = 0; } else if (tokenizer_model == "gpt2" || tokenizer_model == "hybriddna" || tokenizer_model == "whitespace") { type = LLAMA_VOCAB_TYPE_BPE; // Читаємо злиття BPE і заповнюємо ранги BPE const int merges_keyidx = gguf_find_key(ctx, kv(LLM_KV_TOKENIZER_MERGES).c_str()); // ... неправильно типізований масив злиттів — помилка; відсутній — теж, крім // випадку tokenizer_pre == "kimi-k2"; елемент "first second" -> bpe_ranks[{first, second}] = i ... } // ... "bert" -> WPM, "t5" -> UGM, "rwkv", "plamo2", "gemma4"; усе інше — помилка ... } const int token_idx = gguf_find_key(ctx, kv(LLM_KV_TOKENIZER_LIST).c_str()); if (token_idx == -1) { throw std::runtime_error("cannot find tokenizer vocab in model file\n"); } // ... тут розташовані масиви оцінок і типів токенів, і перевіряється, що їх вистачає на всі токени ... const uint32_t n_tokens = gguf_get_arr_n(ctx, token_idx); id_to_token.resize(n_tokens); for (uint32_t i = 0; i < n_tokens; i++) { std::string word = gguf_get_arr_str(ctx, token_idx, i); // ... порожній рядок замінюється заглушкою "[EMPTY_<i>]" ... token_to_id[word] = i; max_token_len = std::max(max_token_len, (int) word.size()); auto & token_data = id_to_token[i]; token_data.text = std::move(word); token_data.attr = LLAMA_TOKEN_ATTR_NORMAL; // ... score і attr далі беруться з масивів GGUF, якщо вони є у файлі ... } init_tokenizer(type); // ... далі йдуть токен переведення рядка, ID спеціальних токенів і прапорці add_bos / add_eos ...}У коді видно: визначення типу словника за tokenizer_model (llama → SPM, gpt2 → BPE з читанням злиттів), пошук масиву токенів у GGUF, цикл по всіх токенах — заповнення id_to_token і token_to_id, виклик init_tokenizer(type) для ініціалізації токенайзера (SPM, BPE тощо). Усе, що сховано за рядками // ..., — це перевірки: масив злиттів і масив токенів мають бути присутні й мати правильний тип GGUF, а масиви оцінок і типів токенів мають покривати всі токени — інакше завантаження переривається винятком. Єдине послаблення зроблено для пре-токенайзера kimi-k2: він токенізує без звичайних злиттів BPE, тому відсутність масиву злиттів там не помилка — завантажувач просто пише про це в лог. Далі в тій самій функції читаються спеціальні токени (BOS, EOS, UNK тощо) і налаштовуються прапорці add_bos, add_eos.
Спеціальні токени:
- BOS (begin of sequence) — токен початку послідовності, за потреби додається перед промптом.
- EOS (end of sequence) — токен кінця виводу, за ним застосунок припиняє генерацію.
- UNK (unknown) — токен для невідомих або таких, що не входять у словник, символів.
Їхні ID зберігаються в special_bos_id, special_eos_id, special_unk_id. У GGUF для словника можуть бути ключі на кшталт tokenizer.ggml.bos_token_id, tokenizer.ggml.eos_token_id тощо — вони читаються наприкінці llama_vocab::impl::load і записуються у відповідні поля. Прапорці add_bos і add_eos задають, чи додавати ці токени автоматично при токенізації (залежить від моделі та формату чату). Після init_tokenizer(type) словник готовий до токенізації та зворотного перекладу токенів у текст; ці операції використовуються при кожному запиті користувача.
Крок 6: Завантаження ваг у пам'ять або на GPU
Крок 6 — ваги моделі (тензори) завантажуються з файлу в пам'ять або на GPU. Метод llama_model_base::load_tensors (src/llama-model.cpp) створює тензори моделі, призначає їм буфери — ділянки пам'яті на CPU або відеокарті — і заповнює їх даними з GGUF-файлу. Нижче — початок методу, скорочено.
// src/llama-model.cpp — llama_model_base::load_tensors()bool llama_model_base::load_tensors(llama_model_loader & ml) { const auto & split_mode = params.split_mode; const bool use_mlock = params.load_mode == LLAMA_LOAD_MODE_MLOCK || params.load_mode == LLAMA_LOAD_MODE_MMAP_MLOCK; const auto & tensor_split = params.tensor_split; const int n_layer_all = hparams.n_layer_all; const int n_gpu_layers = this->n_gpu_layers(); // від'ємне значення в параметрах означає «всі шари» // ... за LLAMA_LOAD_MODE_AUTO ml.use_mmap вимикається, якщо якийсь пристрій не вміє mmap // будуємо переліки типів буферів для CPU і для пристроїв GPU pimpl->cpu_buft_list = make_cpu_buft_list(devices, params.use_extra_bufts, params.no_host); for (const auto & dev : devices) { buft_list_t buft_list = make_gpu_buft_list(dev.dev, split_mode, tensor_split); // додаємо типи буферів CPU як запасний варіант buft_list.insert(buft_list.end(), pimpl->cpu_buft_list.begin(), pimpl->cpu_buft_list.end()); pimpl->gpu_buft_list.emplace(dev.dev, std::move(buft_list)); }
ggml_backend_dev_t cpu_dev = ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_CPU); // ... splits[] заповнюється з tensor_split або за вільною пам'яттю кожного пристрою і нормується
const int i_gpu_start = std::max(n_layer_all + 1 - n_gpu_layers, 0); const int act_gpu_layers = devices.empty() ? 0 : std::min(n_gpu_layers, n_layer_all + 1); auto get_layer_buft_list = [&](int il) -> llama_model::impl::layer_dev { // ... рядок LLAMA_LOG_DEBUG по кожному шару опущено if (il < i_gpu_start || (il - i_gpu_start) >= act_gpu_layers) { return {cpu_dev, &pimpl->cpu_buft_list}; } const int layer_gpu = std::upper_bound(splits.begin(), splits.begin() + n_devices(), float(il - i_gpu_start)/act_gpu_layers) - splits.begin(); auto * dev = devices.at(layer_gpu).dev; return {dev, &pimpl->gpu_buft_list.at(dev)}; };
// від вивантаження вхідного шару користі майже немає, тому він завжди лишається на CPU pimpl->dev_input = { cpu_dev, &pimpl->cpu_buft_list };
// розкладаємо повторювані шари по пристроях згідно зі splits pimpl->dev_layer.resize(n_layer_all); for (int il = 0; il < n_layer_all; ++il) { pimpl->dev_layer[il] = get_layer_buft_list(il); } // призначаємо вихідний шар pimpl->dev_output = get_layer_buft_list(n_layer_all);Що відбувається на початку load_tensors і для чого:
-
формуються списки типів буферів для CPU і для кожної відеокарти
Для чого: від них залежить, на які пристрої будуть розміщені тензори моделі — вхідні зазвичай на CPU, шари за розбиттям на CPU/GPU;
-
за налаштуваннями (
tensor_split) або за вільною пам'яттю вирішується, які шари рахувати на CPU, які на GPUДля чого: щоб рівномірно завантажити пристрої і не переповнити пам'ять однієї відеокарти.
Зверніть увагу: mmap і mlock більше не окремі булеві параметри — у llama_model_params є одне поле load_mode (auto, none, mmap, mlock, mmap+mlock, dio), а з командного рядка воно задається ключем -lm / --load-mode; попередні ключі --mmap, --no-mmap і --mlock відображаються на нього.
Функція get_layer_buft_list(il) для номера шару повертає пристрій і список буферів: вхід завжди на CPU, повторювані шари можуть бути на CPU або GPU, вихід — за розбиттям. Далі в циклі створюються тензори: ембединги, вихід, для кожного шару — матриці уваги (Q/K/V/O), нормалізації, feed-forward. Створення тензора на цьому етапі лише фіксує його форму й обирає буфер, у якому він житиме, — байти поки не рухаються. Дані приходять другим проходом, коли всі тензори вже оголошені, а буфери бекендів виділені: load_tensors викликає ml.load_all_data(...) по одному разу на кожен буферний контекст, і вже всередині цього виклику обирається mmap або звичайне читання. У підсумку ваги моделі опиняються в пам'яті (і за потреби на відеокарті), модель готова до генерації тексту.
Сам завантажувач (src/llama-model-loader.cpp) розкладає це на дві половини. create_tensor оголошує один тензор: звіряє форму з метаданими GGUF, обирає тип буфера за розбиттям пристроїв, порахованим вище, і створює тензор у відповідному ggml-контексті. load_all_data потім проходить по всіх тензорах контексту, за іменем знаходить їхній запис у weights_map — звідти беруться індекс файлу і зміщення в байтах — і або спрямовує тензор на відображення файлу в пам'ять, або читає байти в його буфер. Для великих моделей використовується mmap, щоб не дублювати дані в оперативній пам'яті.
Прохід оголошення тензорів, для родини llama — метод load_arch_tensors у файлі src/models/llama.cpp:
// src/models/llama.cpp — llama_model_llama::load_arch_tensors(), викликається з load_tensors()void llama_model_llama::load_arch_tensors(llama_model_loader &) { LLAMA_LOAD_LOCALS; // вводить в область видимості n_embd, n_layer, n_ff, n_vocab та інші
tok_embd = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, 0);
// вихід output_norm = create_tensor(tn(LLM_TENSOR_OUTPUT_NORM, "weight"), {n_embd}, 0); output = create_tensor(tn(LLM_TENSOR_OUTPUT, "weight"), {n_embd, n_vocab}, TENSOR_NOT_REQUIRED);
// якщо output порожній, ініціалізуємо його з вхідних ембедингів токенів if (output == NULL) { output = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, TENSOR_DUPLICATED); }
for (int i = 0; i < n_layer; ++i) { auto & layer = layers[i];
layer.attn_norm = create_tensor(tn(LLM_TENSOR_ATTN_NORM, "weight", i), {n_embd}, 0);
create_tensor_qkv(layer, i, n_embd, n_embd_head_k * n_head, n_embd_k_gqa, n_embd_v_gqa, 0); layer.wo = create_tensor(tn(LLM_TENSOR_ATTN_OUT, "weight", i), {n_embd_head_k * n_head, n_embd}, 0);
// ... необов'язкові тензори зсувів і тензори коефіцієнтів RoPE опущено
layer.ffn_norm = create_tensor(tn(LLM_TENSOR_FFN_NORM, "weight", i), {n_embd}, 0);
if (n_expert == 0) { layer.ffn_gate = create_tensor(tn(LLM_TENSOR_FFN_GATE, "weight", i), {n_embd, n_ff}, 0); layer.ffn_down = create_tensor(tn(LLM_TENSOR_FFN_DOWN, "weight", i), { n_ff, n_embd}, 0); layer.ffn_up = create_tensor(tn(LLM_TENSOR_FFN_UP, "weight", i), {n_embd, n_ff}, 0); // ... необов'язкові тензори зсувів MLP опущено } // ... гілка mixture-of-experts опущена }}Що тут відбувається і для чого:
-
кожна вага моделі іменується через
tn(...), який будує ім'я тензора в GGUF —token_embd.weight,blk.0.attn_norm.weight,blk.0.ffn_down.weightі так далі.Для чого: ім'я — це ключ, за яким завантажувач потім знайде зміщення цього тензора у файлі.
-
create_tensorзвіряє оголошену форму з метаданими, обирає тип буфера за порахованим раніше розбиттям шарів і створює тензор у ggml-контексті цього типу буфера.Для чого: саме тут вирішується, що шар живе на CPU або на конкретній відеокарті, — ще до того, як зачеплено дані.
-
тензори з прапорцем
TENSOR_NOT_REQUIREDможуть законно бути відсутні, аTENSOR_DUPLICATEDперевикористовує вже створений тензор — так модель без окремої вихідної матриці зв'язує свій вихід з ембедингами токенів.Для чого: одна процедура завантаження має покривати багато варіантів однієї й тієї самої архітектури.
Сам прохід з даними — це цикл по тензорах одного буферного контексту всередині ml.load_all_data (src/llama-model-loader.cpp). Для кожного тензора його запис шукається у weights_map за іменем у GGUF — blk.0.attn_q.weight, blk.0.attn_k.weight і так далі, — і звідти беруться індекс файлу та зміщення в байтах. Далі можливі два варіанти. За наявності відображення файлу в пам'ять тензор спрямовується прямо на відображені сторінки, якщо над ними вдається побудувати буфер, — це і є випадок без копіювання, і саме так виходить для пам'яті хоста; інакше байти копіюються з відображення в буфер тензора. Без відображення байти читаються з файлу в цей буфер, а для буфера на відеокарті — надсилаються на пристрій. Після цього проходу ваги моделі повністю перебувають у пам'яті (і за потреби на GPU).
// src/llama-model-loader.cpp — цикл по тензорах у llama_model_loader::load_all_data()for (struct ggml_tensor * cur = ggml_get_first_tensor(ctx); cur != NULL; cur = ggml_get_next_tensor(ctx, cur)) { const auto * weight = get_weight(ggml_get_name(cur)); if (weight == nullptr) { // таке буває в моделях з розділеними експертами continue; } // ... тут викликається progress_callback; повернення false перериває завантаження size_t n_size = ggml_nbytes(cur); const bool from_mapping = use_mmap || lazy.has(cur);
if (from_mapping) { const auto & mapping = mappings.at(weight->idx); ggml_backend_buffer_t buf_mmap = nullptr; if (bufs.count(weight->idx)) { buf_mmap = bufs.at(weight->idx); } uint8_t * data = (uint8_t *) mapping->addr() + weight->offs; // ... необов'язкова перевірка check_tensors опущена GGML_ASSERT(buf_mmap || cur->data); // або є буфер під тензор, або він уже розміщений if (buf_mmap && cur->data == nullptr) { // копіювання немає взагалі: тензор спрямовується прямо на відображені сторінки ggml_backend_tensor_alloc(buf_mmap, cur, data); // ... розширення mlock і облік використаного діапазону опущено } else { ggml_backend_tensor_set(cur, data, 0, n_size); } } else { const auto & file = files.at(weight->idx); if (ggml_backend_buffer_is_host(cur->buffer)) { file->seek(weight->offs, SEEK_SET); file->read_raw(cur->data, n_size); } else { // ... у буфер на GPU: асинхронна відправка шматками через закріплену пам'ять хоста, // ... якщо пристрій це вміє, інакше читання в read_buf + ggml_backend_tensor_set() } } size_done += n_size;}Так два проходи разом покривають усе: create_tensor вирішує, де житиме кожна вага — ембединги, матриці Q/K/V/O, нормалізації та feed-forward кожного шару, — а load_all_data кладе туди байти або підставляє відображення файлу.
При використанні mmap дані тензорів не копіюються в оперативну пам'ять — відображається ділянка файлу, і тензор вказує прямо в неї, — але лише там, де пристрій уміє побудувати буфер поверх пам'яті хоста. Процесор уміє завжди; на єдиній пам'яті на кшталт Apple Silicon уміє і графічний пристрій. Дискретна карта не вміє: її байти копіюються з відображення у відеопам'ять, і там mmap заощаджує RAM лише на шарах, що лишилися на процесорі. Це зменшує споживання RAM і пришвидшує старт завантаження, але файл має залишатися на диску на тому самому місці й без змін увесь час, поки модель завантажена: відображення живе довше за завантажувач і звільняється лише при знищенні моделі. Режим прямого вводу-виводу (--load-mode dio) відкриває файл без буферизації та округлює кожне читання до розміру блоку, про який повідомляє файлова система; він реалізований лише на Linux, а якщо відкрити файл без буферизації не вдалося — відбувається відкат на звичайне буферизоване читання. Розбиття по шарах (tensor_split або за вільною пам'яттю GPU) задає, які шари завантажувати на який пристрій — так можна розподілити велику модель по кількох відеокартах. Після завершення load_tensors модель повністю готова до інференсу: усі ваги перебувають у пам'яті (і за потреби на GPU), і можна створювати контекст та викликати llama_decode.
Крок 7: Створення контексту інференсу
Крок 7 — створення контексту інференсу (KV-cache, планувальник, зарезервовані графи). Завантажена модель зберігає лише ваги (тензори). Щоб генерувати текст, потрібен контекст інференсу — об'єкт llama_context.
Для чого він потрібен:
- контекст — це «робоче середовище» одного сеансу генерації: у ньому задається, скільки токенів модель може «пам'ятати» (розмір контексту), якими великими порціями подавати дані (розмір батча), параметри RoPE і тип уваги;
- без контексту не можна викликати
llama_decode— саме контекст зберігає KV-cache, планувальник і зарезервовані графи.
Реалізація — у файлі src/llama-context.cpp.
Що створюється при створенні контексту і для чого:
-
Планувальник (
sched) — вирішує, на якому пристрої (CPU чи відеокарта) виконувати кожен вузол обчислювального графа.Для чого: щоб розподілити обчислення по CPU і GPU та виділити під граф буфери на потрібних пристроях.
-
Пам'ять KV-cache (
memory) — буфери під ключі та значення механізму уваги.Для чого: у них зберігаються вже пораховані ключі та значення по всіх попередніх позиціях; без кешу при кожному новому токені довелося б наново рахувати K і V для всієї історії, що дуже повільно (докладніше в розділі «KV-cache»).
-
Алокатор батча (
balloc) — заповнює позиції та прапорці логітів у батчі за потреби.Для чого: щоб коду, який викликає, не потрібно було вручну виставляти позиції і вирішувати, для яких позицій рахувати логіти.
-
Зарезервовані графи (Prefill і Decode) — тимчасові графи та буфери під них.
Для чого: при першому виклику decode пам'ять під граф не виділяється «на льоту» — буфери вже зарезервовані під найгірший випадок, і це зменшує затримку першої відповіді (сам граф при першому decode все одно будується наново).
Початок конструктора llama_context::llama_context у src/llama-context.cpp (скорочено — пропуски позначені):
// src/llama-context.cpp — llama_context::llama_context (скорочено)llama_context::llama_context( const llama_model & model, llama_context_params params) : model(model), cvec(std::make_unique<llama_adapter_cvec>()), loras(std::make_unique<llama_adapter_loras>()), balloc(std::make_unique<llama_batch_allocr>(model.hparams.n_pos_per_embd())) { LLAMA_LOG_INFO("%s: constructing llama_context\n", __func__);
t_start_us = model.t_start_us; t_load_us = model.t_load_us;
const auto & hparams = model.hparams;
cparams.n_seq_max = std::max(1u, params.n_seq_max); if (cparams.n_seq_max > LLAMA_MAX_SEQ) { throw std::runtime_error("n_seq_max must be <= " + std::to_string(LLAMA_MAX_SEQ)); }
// ... n_rs_seq і його скидання в 0 для архітектур без відкату recurrent-стану cparams.n_threads = params.n_threads; cparams.n_threads_batch = params.n_threads_batch; cparams.yarn_ext_factor = params.yarn_ext_factor >= 0.0f ? params.yarn_ext_factor : hparams.yarn_ext_factor; // ... решта параметрів YaRN, пулінгу і Flash Attention cparams.n_ctx = params.n_ctx == 0 ? hparams.n_ctx_train : params.n_ctx; cparams.rope_freq_base = params.rope_freq_base == 0.0f ? hparams.rope_freq_base_train : params.rope_freq_base; cparams.rope_freq_scale = params.rope_freq_scale == 0.0f ? hparams.rope_freq_scale_train : params.rope_freq_scale; // ... if (params.attention_type == LLAMA_ATTENTION_TYPE_UNSPECIFIED) { cparams.causal_attn = hparams.causal_attn; } else { cparams.causal_attn = params.attention_type == LLAMA_ATTENTION_TYPE_CAUSAL; }
// при causal attention розмір батча обмежений розміром контексту cparams.n_batch = cparams.causal_attn ? std::min(cparams.n_ctx, params.n_batch) : params.n_batch; cparams.n_ubatch = std::min(cparams.n_batch, params.n_ubatch == 0 ? params.n_batch : params.n_ubatch); // ... n_ctx доповнюється до кратного 256 і, якщо KV-cache не спільний для всіх // послідовностей, ділиться між ними на ліміт n_ctx_seq LLAMA_LOG_INFO("%s: n_ctx = %u\n", __func__, cparams.n_ctx); LLAMA_LOG_INFO("%s: n_ctx_seq = %u\n", __func__, cparams.n_ctx_seq); LLAMA_LOG_INFO("%s: n_batch = %u\n", __func__, cparams.n_batch); LLAMA_LOG_INFO("%s: n_ubatch = %u\n", __func__, cparams.n_ubatch); // ... створюються бекенди (GPU, CPU) і буфер виходів, потім модуль пам'яті // (KV-cache), і останнім sched_reserve() будує планувальник і графи}Що задається в конструкторі контексту:
-
зберігаються посилання на модель і час завантаження;
-
задаються максимальна кількість послідовностей (
n_seq_max), кількість потоків, параметри YaRN/RoPE, розмір контексту (n_ctx), розмір батча (n_batch,n_ubatch), тип уваги (causal).Для чого:
-
від цих параметрів залежать розмір KV-cache, максимальна довжина промпту і те, якими великими порціями обробляється батч.
Запитаний n_ctx округлюється вгору до кратного 256, і, якщо KV-cache не спільний для всіх послідовностей, ділиться між ними: реальним обмеженням одного діалогу виявляється ліміт на одну послідовність n_ctx_seq — саме його лог друкує поряд з n_ctx.
Далі ініціалізуються бекенди (CPU і відеокарти), створюється об'єкт пам'яті memory (KV-cache і службові буфери), планувальник sched (розподіляє вузли графа по пристроях), резервуються графи для Prefill і Decode — щоб до першого decode буфери під них були вже виділені. Після цього можна викликати llama_decode з батчами токенів.
Параметри n_batch та n_ubatch:
n_batch— максимальна кількість токенів в одному батчі (при Prefill у батчі може бути доn_batchтокенів промпту);n_ubatch— максимальний розмір підбатча, який обробляється за один викликprocess_ubatch;- якщо промпт довший за
n_ubatch, він розбивається на підбатчі поn_ubatchтокенів, кожен проганяється через модель по черзі; - логіти потрібні лише для останньої позиції в батчі, тому копіюються тільки вони;
- зменшення
n_ubatchзнижує пікове споживання пам'яті за рахунок більшої кількості проходів.
Контекст створюється один раз на сеанс (або при зміні параметрів); один контекст може використовуватися для багатьох запитів поспіль без перестворення.
Пам'ять і планувальник налаштовуються з конструктора llama_context (файл src/llama-context.cpp), але не прямо в ньому. Спочатку конструктор збирає власний список бекендів — по одному на пристрій моделі, плюс бекенди прискорювачів і CPU, — потім створює об'єкт пам'яті через model.create_memory і останнім викликає допоміжну функцію sched_reserve. Саме в ній ggml_backend_sched_new будує планувальник за цим списком бекендів і резервуються графи, тож при першому реальному decode граф будується наново, але розміщується в уже зарезервованій пам'яті. Сам KV-cache живе не в src/llama-memory.cpp — у цьому файлі лежать лише дві невеликі допоміжні функції для статусу пам'яті. Буфери виділяються в конструкторі llama_kv_cache у файлі src/llama-kv-cache.cpp: для кожного шару він створює тензор ключів і тензор значень розміром на весь контекст, а потім запитує у бекенда по одному буферу на пристрій.
Функція graph_reserve (у src/llama-context.cpp) резервує по одному графу за виклик: вона робить фіктивний батч потрібної форми, викликає для нього model.build_graph і передає результат у ggml_backend_sched_reserve — планувальник розбиває граф по бекендах і резервує буфери. sched_reserve викликає її тричі: спочатку для Prefill (цілий підбатч токенів за раз), потім для Decode (по одному токену на послідовність), потім знову для Prefill — щоб буфери залишилися розміром під найгірший випадок і не перевиділялися під час інференсу. Пізніше, при справжньому виклику process_ubatch, попередній граф перевикористовується, якщо це дозволяють його параметри (can_reuse), інакше будується наново і розміщується через ggml_backend_sched_alloc_graph; у будь-якому разі виділення під великі графи вже зроблено при створенні контексту — саме це й прибирає сплеск затримки на першому decode.
Крок 8: Надходить текст промпту від користувача
Крок 8 — до рушія надходить текст промпту від користувача. Після створення контексту користувач надсилає повідомлення (промпт). Модель працює лише з числами — токенами. Тому текст спочатку перетворюють на токени (крок 9), пакують у батч (крок 10), проганяють через модель викликом llama_decode (кроки 11–14): при першому запиті в батчі всі токени промпту (Prefill заповнює KV-cache), при генерації — один новий токен (Decode). З логітів семплер обирає один наступний токен (крок 15), він перетворюється на текст і виводиться (крок 16); токен знову додається в батч, і цикл повторюється до токена «кінець виводу» (EOS) або ліміту. Нижче кожен із цих кроків розібрано за кодом: що викликається, що відбувається і що може бути неочевидним.
Prefill і Decode — у чому різниця і для чого:
-
При першому виклику
llama_decodeз повним промптом (Prefill) модель обробляє всі токени промпту за один або кілька підбатчів; для них рахуються K і V та записуються в KV-cache; логіти запитуються лише для останньої позиції — за ними обирається перший токен відповіді.Для чого: один раз «прогнати» весь промпт і заповнити кеш ключів і значень, щоб далі генерувати по одному токену, не перераховуючи історію.
-
При наступних викликах у батчі один новий токен (Decode); проходяться, як і раніше, усі шари моделі, але тільки для цієї однієї позиції: Q, K і V рахуються лише для неї, а K і V усієї попередньої історії читаються з кешу; логіти знову лише для останньої позиції.
Для чого: ефективна генерація по одному токену без перерахунку всієї історії — старі K і V уже в кеші.
Увесь цикл уміщається в пару десятків рядків. Нижче — цикл генерації з мінімального прикладу examples/simple-chat/simple-chat.cpp (функція generate), скорочено, з доданими в коментарі номерами кроків цієї статті.
// Цикл генерації, скорочено з examples/simple-chat/simple-chat.cpp (лямбда "generate").// Номери кроків — із цієї статті, решта — код самого прикладу.// BOS додається лише для найпершого промпту в діалозіconst bool is_first = llama_memory_seq_pos_max(llama_get_memory(ctx), 0) == -1;// Крок 9: текст промпту -> масив токенів. При виклику з tokens == NULL// llama_tokenize повертає мінус кількість токенів — так і дізнаються розмір буфера.const int n_prompt_tokens = -llama_tokenize(vocab, prompt.c_str(), prompt.size(), NULL, 0, is_first, true);std::vector<llama_token> prompt_tokens(n_prompt_tokens);if (llama_tokenize(vocab, prompt.c_str(), prompt.size(), prompt_tokens.data(), prompt_tokens.size(), is_first, true) < 0) { GGML_ABORT("failed to tokenize the prompt\n");}// Крок 10: токени пакуються в батч — одна послідовність, позиції веде llama_decodellama_batch batch = llama_batch_get_one(prompt_tokens.data(), prompt_tokens.size());llama_token new_token_id;while (true) { // ... тут приклад ще перевіряє, що батч уміщається в контекст // Кроки 11–14: decode, підбатчі, process_ubatch; логіти потрапляють у буфер контексту int ret = llama_decode(ctx, batch); if (ret != 0) { GGML_ABORT("failed to decode, ret = %d\n", ret); } // Крок 15: семплінг; -1 означає «логіти останньої позиції» new_token_id = llama_sampler_sample(smpl, ctx, -1); // це кінець генерації? if (llama_vocab_is_eog(vocab, new_token_id)) { break; } // Крок 16: токен -> текст; від'ємне n означає, що буфер виявився замалим char buf[256]; int n = llama_token_to_piece(vocab, new_token_id, buf, sizeof(buf), 0, true); if (n < 0) { GGML_ABORT("failed to convert token to piece\n"); } std::string piece(buf, n); printf("%s", piece.c_str()); fflush(stdout); // наступний батч — один засемплений токен, далі прохід Decode batch = llama_batch_get_one(&new_token_id, 1);}Крок 9: Токенізація — від тексту до послідовності токенів
Крок 9 — текст промпту перетворюється на послідовність токенів (цілих чисел). Текст потрібно перетворити на послідовність цілих чисел — токенів.
Для чого це потрібно:
- модель (шари трансформера) приймає на вхід не рядки, а вектори чисел фіксованої довжини;
- кожному токену відповідає один рядок в ембединг-таблиці моделі.
Токенізація — це перший крок:
- за словником текст ріжеться на шматочки (токени), кожному шматочку ставиться у відповідність число (ID);
- далі за цими ID беруться ембединги і подаються в модель.
Що таке токен:
- токен — це ID (ціле число), що відповідає шматочку тексту: цілому слову, частині слова або службовому символу;
- словник моделі задає відповідність «текст ↔ токени»: за текстом можна отримати масив токенів (токенізація), за токеном — текст (
token_to_piece).
В API (заголовок include/llama.h) оголошено функцію llama_tokenize; реалізація делегує виклик словнику моделі.
Реалізація в src/llama-vocab.cpp:
// Крок 9: текст промпту перетворюється на масив токенів (ID); словник моделі задає відповідність «текст ↔ токени»int32_t llama_tokenize( const struct llama_vocab * vocab, const char * text, int32_t text_len, llama_token * tokens, int32_t n_tokens_max, bool add_special, bool parse_special) { return vocab->tokenize(text, text_len, tokens, n_tokens_max, add_special, parse_special);}Параметри:
vocab— словник моделі;- text і
text_len— рядок промпту; - tokens — масив для запису токенів;
n_tokens_max— його розмір;add_special— чи додавати службові токени BOS і EOS, і лише якщо сама модель на них налаштована;parse_special— чи обробляти спеціальні теги.
Реальна логіка (BPE, SentencePiece тощо) — у методі vocab->tokenize у src/llama-vocab.cpp. Результат — кількість записаних токенів; якщо буфер виявився замалим, функція повертає мінус потрібну кількість токенів — саме так застосунок спочатку викликає її з порожнім буфером, щоб дізнатися розмір, і лише потім виділяє масив. Токенізація виконується при кожному новому повідомленні користувача; словник при цьому не змінюється і вже завантажений при завантаженні моделі.
Реалізація токенізації — метод llama_vocab::impl::tokenize у src/llama-vocab.cpp. Усередині за типом словника (SPM, BPE, WPM тощо) створюється сесія токенайзера і викликається її tokenize; для BPE, наприклад, використовується llm_tokenizer_bpe_session, який розбиває текст за регулярними виразами і збирає токени за злиттями (merges).
Типи токенайзерів у словнику:
LLAMA_VOCAB_TYPE_SPM— SentencePiece-подібний (моделі LLaMA та ін.), розбиття за правилами і словником SPM;LLAMA_VOCAB_TYPE_BPE— Byte Pair Encoding (GPT-2 та ін.), злиття пар байтів/підрядків зберігаються в GGUF, токенізація — жадібне злиття;LLAMA_VOCAB_TYPE_WPM— WordPiece, токенайзер у стилі BERT;LLAMA_VOCAB_TYPE_UGM— Unigram, токенайзер у стилі T5;LLAMA_VOCAB_TYPE_RWKV— жадібна токенізація за префіксним деревом (trie);LLAMA_VOCAB_TYPE_PLAMO2— алгоритм Ахо — Корасік з динамічним програмуванням;LLAMA_VOCAB_TYPE_NONE— модель опублікована взагалі без словника; токенізувати нічим, і спроба токенізації просто аварійно завершується. Функціяtokenizer_st_partitionрозбиває вхідний текст на фрагменти: звичайний текст і спеціальні токени — спеціальний токен стає одним токеном на виході, решта йде в токенайзер за типом словника. Прапорецьparse_specialкерує лише керуючими і службовими токенами; токени, оголошені в моделі як користувацькі, виділяються завжди. Фрагментllama_vocab::impl::tokenize— спільна частина і гілка BPE; решта гілок влаштована так само:
// Крок 9: текст розбивається на фрагменти (звичайний текст і спеціальні токени),// і кожен фрагмент токенізується за типом словникаstd::vector<llama_token> llama_vocab::impl::tokenize( const std::string & raw_text, bool add_special, bool parse_special) const { GGML_ASSERT(tokenizer && "Tokenizer not initialized. Call llama_vocab::init_tokenizer() first."); std::vector<llama_token> output; std::forward_list<fragment_buffer_variant> fragment_buffer; if (!raw_text.empty()) { fragment_buffer.emplace_front(raw_text, 0, raw_text.length()); tokenizer_st_partition(fragment_buffer, parse_special); } switch (get_type()) { // ... гілки SPM, WPM, UGM, RWKV і PLAMO2 влаштовані так само case LLAMA_VOCAB_TYPE_BPE: { const llm_tokenizer_bpe * tok_bpe = static_cast<const llm_tokenizer_bpe *>(tokenizer.get()); std::unique_ptr<llm_tokenizer_bpe_session> session; // ... для моделей токенайзера "hybriddna" і "whitespace" береться свій підклас сесії session = std::make_unique<llm_tokenizer_bpe_session>(vocab, *tok_bpe); if (add_special) { session->append_bos(output); } for (const auto & fragment : fragment_buffer) { if (fragment.type == FRAGMENT_BUFFER_VARIANT_TYPE_RAW_TEXT) { std::string text = fragment.raw_text.substr(fragment.offset, fragment.length); // ... екранування пробілів, якщо словник цього вимагає session->tokenize(text, output); } else { // FRAGMENT_BUFFER_VARIANT_TYPE_TOKEN session->append(fragment.token, output); } } if (add_special) { session->append_eos(output); session->check_double_bos_eos(output); } } break; case LLAMA_VOCAB_TYPE_NONE: GGML_ABORT("fatal error"); } return output;}Крок 10: Формування батча для одного виклику
Крок 10 — токени пакуються в батч для одного виклику до моделі. Один виклик до моделі передається через структуру llama_batch (в include/llama.h).
Для чого потрібен батч:
- функція
llama_decodeприймає рівно один батч — набір токенів (і службових полів: позиції, ідентифікатори послідовностей, прапорці логітів); - так рушій знає, які токени обробити за один прохід, на яких позиціях вони стоять і для яких позицій потрібно повернути логіти (зазвичай лише для останньої — щоб обрати наступний токен);
- при першому запиті в батчі зазвичай усі токени промпту; при генерації по одному токену — один новий токен.
// Крок 10: батч — «пакет» токенів для одного виклику llama_decode; token[], pos[], seq_id[], logits[] заповнюються застосунком або balloc->inittypedef struct llama_batch { int32_t n_tokens; llama_token * token; float * embd; llama_pos * pos; int32_t * n_seq_id; llama_seq_id ** seq_id; int8_t * logits;} llama_batch;Структура батча:
- масиви мають розмір
n_tokens; - або передаються ID токенів (token), або готові ембединги (embd) — вектори чисел, на які вже перетворені токени (зазвичай передають токени, а ембединги рахуються всередині);
pos— позиція кожного токена;seq_idіn_seq_id— до якої послідовності належить токен;- logits[i] != 0 означає, що для позиції i потрібно повернути логіти (зазвичай лише для останньої — щоб обрати наступний токен). Усередині
llama_decodeбатч спочатку обробляється класомllama_batch_allocr(файлsrc/llama-batch.cpp): методinitперевіряє батч і за відсутності полів заповнює їх автоматично (позиції з пам'яті, логіти лише для останнього токена).
Поля seq_id і n_seq_id використовуються при батчингу кількох послідовностей (наприклад, кілька запитів в одному батчі): кожен токен може належати до однієї або кількох послідовностей; позиції (pos) рахуються окремо для кожної послідовності за memory->seq_pos_max(seq_id). У типовому випадку одна послідовність — усі токени мають один і той самий seq_id (наприклад, 0), і позиції йдуть по порядку 0, 1, 2, ... . Заповнити батч для наступного кроку застосунок може двома способами. Мінімальний — llama_batch_get_one: він огортає один щойно вибраний токен і залишає рівними NULL і pos, і seq_id, щоб llama_decode сам продовжив позицію та відніс токен до послідовності 0 — так роблять examples/simple і examples/simple-chat. Другий — один раз виділити батч через llama_batch_init, а перед кожним викликом очищати його через common_batch_clear і додавати токени через common_batch_add, явно передаючи позицію та ідентифікатори послідовностей — саме так доводиться робити будь-якому коду, який обслуговує кілька послідовностей одразу.
Метод init класу llama_batch_allocr (файл src/llama-batch.cpp), який llama_context::decode викликає для вхідного батча раніше за все інше:
// src/llama-batch.cpp, llama_batch_allocr::init(): перевіряє вхідний батч, потім заповнює// всі масиви, які застосунок залишив NULL — насамперед позиції та прапорці виводуbool llama_batch_allocr::init( const llama_batch & batch_inp, const llama_vocab & vocab, const llama_memory_i * memory, uint32_t n_embd, uint32_t n_seq_max, bool output_all) { clear(); batch = batch_inp; this->vocab = &vocab; GGML_ASSERT(batch.n_tokens > 0); // ... опущено: n_seq_max має вміщатися в LLAMA_MAX_SEQ, кожен seq_id має бути меншим за n_seq_max, // і кожен batch.token[i] має бути допустимим id (< vocab.n_tokens()) — інакше return false ... // ... опущено: якщо n_seq_id або seq_id дорівнюють NULL, кожен токен належить до послідовності 0 ... if (!batch.pos) { pos.resize(batch.n_tokens);
// початкова позиція кожної послідовності береться з позицій у пам'яті llama_pos p0[LLAMA_MAX_SEQ]; for (uint32_t s = 0; s < n_seq_max; ++s) { if (!memory) { // якщо пам'яті немає -> починаємо з 0 p0[s] = 0; } else { p0[s] = memory->seq_pos_max(s) + 1; } }
for (int32_t i = 0; i < batch.n_tokens; i++) { const llama_seq_id seq_id = batch.seq_id[i][0]; pos[i] = p0[seq_id]; // ... опущено: p0 зсувається за цю позицію для кожної послідовності токена ... } batch.pos = pos.data(); } if (!batch.logits) { if (output_all) { output.resize(batch.n_tokens, true); // вивід для кожного токена } else { output.resize(batch.n_tokens, false); // вивід тільки для останнього токена output[output.size() - 1] = true; } batch.logits = output.data(); } // ... опущено: якщо logits задані, але виставлено output_all, відсутні виводи вмикаються примусово; // потім статистика позицій за послідовностями, пов'язані послідовності та перевірки узгодженості ... return true;}У коді:
- перевірка токенів на допустимість;
- за відсутності
n_seq_idіseq_idкожен токен належить рівно до однієї послідовності з номером 0; - за відсутності
posкожна послідовність продовжується з того місця, де вона зупинилася в пам'яті (memory->seq_pos_max(s) + 1), і позиції проставляються по порядку; якщо пам'яті немає зовсім, відлік починається з 0; - за відсутності
logitsлогіти запитуються лише для останнього токена (або для всіх, якщоoutput_all).
Від токенів до ембедингів (відбувається всередині кроку 13)
Усередині кроку 13 токени батча перетворюються на ембединги. У батчі можна передати або ID токенів (token), або готові ембединги (embd), і в типовому випадку застосунок передає токени. Перетворення — це не окремий прохід перед обробкою батча: це найперший вузол графа обчислень, який decode будує для кожного ubatch.
Для чого потрібне це перетворення:
- модель (шари трансформера) працює не з номерами токенів, а з векторами чисел фіксованої довжини — ембедингами;
- вибірка бере кожен ID токена і читає відповідний рядок ембединг-таблиці моделі, і цей рядок стає входом першого шару;
- без нього графу не було б над чим рахувати.
Що таке ембединг-таблиця:
- це матриця ваг моделі розміром «розмір словника × розмір ембедингу»; один рядок — вектор чисел для одного токена;
- у коді на C++ це поле моделі
tok_embd, а у файлі GGUF — тензорtoken_embd.weight; іменаembed_tokensіtok_embeddingsприйшли з вихідних чекпоінтів PyTorch — скрипт конвертації відображає їх уtoken_embd, тож шукати щось під цими іменами всерединіllama.cppмарно; - вона створюється під час завантаження моделі, у списку тензорів конкретної архітектури (
load_arch_tensors, викликається зload_tensors); - при виконанні графа для кожного токена батча читається рядок з індексом token[i] і потрапляє на позицію i входу графа;
- після цього граф рахує вже за ембедингами (шари трансформера, увага, feed-forward, логіти).
Де це в коді: ембединг-таблиця — це поле моделі tok_embd, а вибірку по ній будує llm_graph_context::build_inp_embd (файл src/llama-graph.cpp), який кожна архітектура викликає першим же рядком свого графа. Він створює два вхідні тензори — вектор I32 під ID токенів і матрицю F32 під готові ембединги — і пропускає токенну гілку через ggml_get_rows по таблиці; у граф потрапляють обидві гілки, а ggml_build_forward_select позначає до обчислення лише ту, що відповідає вмісту ubatch — токенам чи готовим векторам. Заради цього він і потрібен: топологія графа лишається однаковою від ubatch до ubatch, і граф можна перевикористати. Самі ID записує у вхідний тензор llm_graph_input_embd::set_input, яку process_ubatch (файл src/llama-context.cpp) викликає вже після того, як граф побудований і розміщений, прямо перед його виконанням. Тобто рядок копіює не decode на CPU: він читається на бекенді, під час обчислення графа, разом з усім іншим — а оскільки ембединг-таблиця зазвичай зберігається квантизованою, ggml_get_rows принагідно розпаковує кожен рядок у float. При decode по одному токену читається рівно один рядок таблиці.
Вибірка, як вона будується в графі (src/llama-graph.cpp, llm_graph_context::build_inp_embd):
// src/llama-graph.cpp, llm_graph_context::build_inp_embd() — скорочено: прибрано налагоджувальні// колбеки та перевірки. Перетворення токена на вектор — це не memcpy усередині decode, а вузол// графа обчислень, який виконується на бекенді разом з усією рештою моделі.ggml_tensor * llm_graph_context::build_inp_embd(ggml_tensor * tok_embd) const { const int64_t n_embd_inp = hparams.n_embd_inp();
auto inp = std::make_unique<llm_graph_input_embd>(n_embd_inp);
inp->tokens = ggml_new_tensor_1d(ctx0, GGML_TYPE_I32, ubatch.n_tokens); ggml_set_input(inp->tokens);
inp->embd = ggml_new_tensor_2d(ctx0, GGML_TYPE_F32, n_embd_inp, ubatch.n_tokens); ggml_set_input(inp->embd);
// обираємо один з 2 входів, виходячи зі вмісту батча std::array<ggml_tensor *, 2> inps;
// гілка з ембедингами токенів (ubatch.token != nullptr) { auto & cur = inps[0];
cur = ggml_get_rows(ctx0, tok_embd, inp->tokens);
// ... скорочено: необов'язкова LoRA-поправка до ембединг-таблиці // і паддинг, потрібний при n_embd_inp != n_embd }
// гілка з готовими векторами (ubatch.embd != nullptr) { auto & cur = inps[1];
cur = inp->embd; }
ggml_tensor * cur = ggml_build_forward_select(gf, inps.data(), inps.size(), ubatch.token ? 0 : 1);
// ... скорочено: view при n_embd_inp != n_embd і множення на f_embedding_scale
res->add_input(std::move(inp));
return cur;}Що відбувається і для чого:
-
для кожного індексу i в ubatch ID токена ubatch.token[i] записується у вхідний тензор графа
inp_tokens.Для чого: граф виконується на бекенді, і ID потрібні йому там як власні дані, а не як вказівник у батч, який передав застосунок.
-
ggml_get_rowsчитає з таблиціtok_embdрядок з цим індексом — вектор довжиниn_embd, бо сама таблиця має ширинуn_embd. Окремим архітектурам потрібен ширший вхід графа,n_embd_inp— це моделі з deepstack-шарами і gemma4-assistant, яка задає власне значення; там рядок доповнюють паддингом до цієї ширини, а перед першим шаром знову звужують доn_embd.Для чого: цей рядок і є ембедингом токена — вхід для першого шару трансформера.
-
рядки для всіх токенів ubatch складаються в матрицю, яку отримує перший шар.
Для чого: шари обробляють увесь ubatch разом; без цього вузла граф взагалі не мав би входу.
Кроки 11 і 12: Проганяння батча через модель (вхід у decode, підбатчі)
Кроки 11 і 12 — застосунок викликає llama_decode, передаючи йому батч. Усередині батч перевіряється і готується (крок 11), потім розбивається на підбатчі (крок 12), і кожен підбатч проганяється через модель (крок 13). Перетворення ідентифікаторів токенів на вектори-ембединги — не окремий крок на боці хоста: це перший вузол обчислювального графа, тому воно відбувається на кроці 13 разом з усім іншим. Нижче — точка входу, цикл по підбатчах і розбиття батча.
Для чого потрібна функція llama_decode:
- вона проганяє один батч токенів через модель і заповнює внутрішній буфер контексту логітами — «сирими» оцінками по кожному можливому наступному токену;
- за цими логітами застосунок (через семплер) обирає один наступний токен і за потреби знову викликає decode з одним новим токеном у батчі;
- так повторюється до кінця відповіді (EOS) або ліміту.
Публічний API — llama_decode (в src/llama-context.cpp):
// Крок 11: точка входу decode. Публічна C-функція лише передає батч// у C++-контекст; уся робота відбувається в llama_context::decode.int32_t llama_decode( llama_context * ctx, llama_batch batch) { const int ret = ctx->decode(batch); // ret == 1 означає «немає вільного слота в KV-cache під цей батч» — це попередження, // а не збій, тому воно не логується як помилка if (ret != 0 && ret != 1) { LLAMA_LOG_ERROR("%s: failed to decode, ret = %d\n", __func__, ret); }
return ret;}Ось що відбувається всередині llama_context::decode (src/llama-context.cpp). Спочатку батч перевіряється. Якщо цей контекст узагалі не має модуля пам'яті — наприклад, це контекст лише під ембединги — decode просто передає батч у encode і повертає результат. Інакше він викликає balloc->init(...), резервує планувальник і застосовує відкладені оновлення пам'яті (KV-cache). Потім один раз викликається memory->init_batch: цей єдиний виклик розбиває весь батч на підбатчі й повертає контекст пам'яті, у якому лежать одразу всі вони. Далі цикл іде цим контекстом: бере черговий підбатч через get_ubatch(), проганяє його через process_ubatch, копіює отримані логіти у внутрішній буфер контексту і переходить до наступного підбатча через next().
Цикл по підбатчах усередині decode (скорочено, з src/llama-context.cpp):
// src/llama-context.cpp, llama_context::decode — скорочено memory_update(false); // застосовуємо відкладені зсуви/копіювання llama_memory_context_ptr mctx; // розбиваємо ВЕСЬ батч на підбатчі не більше n_ubatch токенів, за один виклик // ... при FAILED_PREPARE: один раз оптимізуємо кеш, повторюємо, інакше повертаємо 1 mctx = memory->init_batch(*balloc, cparams.n_ubatch, output_all); if (!mctx) { return -2; } // ... резервуємо вихідний буфер під n_outputs_all рядків логітів do { const auto & ubatch = mctx->get_ubatch(); // ... n_outputs — скільки рядків логітів дає цей підбатч ggml_status status; // крок 13: будуємо (або перевикористовуємо) граф і виконуємо його на CPU/GPU const auto * res = process_ubatch(ubatch, ctx_type_to_graph_type(cparams.ctx_type), mctx.get(), status); if (!res) { // ... відкочуємо позиції цього підбатча з модуля пам'яті switch (status) { case GGML_STATUS_ABORTED: return 2; case GGML_STATUS_ALLOC_FAILED: return -2; case GGML_STATUS_FAILED: return -3; case GGML_STATUS_SUCCESS: GGML_ABORT("should not happen"); } } auto * t_logits = res->get_logits(); // крок 14: забираємо логіти цього підбатча з бекенда в буфер контексту — // пропускається, якщо в усіх вихідних послідовностей є бекенд-семплер if (logits.data && t_logits && n_outputs > 0 && needs_raw_logits(ubatch, sampling.samplers)) { ggml_backend_t backend_res = ggml_backend_sched_get_tensor_backend(sched.get(), t_logits); float * logits_out = logits.data + n_outputs_prev*n_vocab; // ... перевірки, що ці рядки вміщаються у вихідний буфер ggml_backend_tensor_get_async(backend_res, t_logits, logits_out, 0, n_outputs*n_vocab*sizeof(float)); } // ... ембединги та виходи бекенд-семплера дістаються так само n_outputs_prev += n_outputs; } while (mctx->next()); // після циклу застосунок читає логіти через llama_get_logits_ithСпочатку перевіряється батч:
- що в ньому передані або токени, або готові ембединги;
- що цей контекст справді має модуль пам'яті — якщо його немає, decode одразу передає батч у
encode.
Потім батч ініціалізується через balloc->init — це потрібно, щоб заповнити позиції токенів і вирішити, для яких позицій рахувати логіти (зазвичай лише для останньої). Резервується планувальник і оновлюється пам'ять (KV-cache).
Далі memory->init_batch розбиває батч на підбатчі розміром не більше n_ubatch токенів кожен і повертає контекст пам'яті, у якому лежать усі вони, — так великий батч розбивається на частини, які по черзі проганяються через модель. Цикл бере їх по одному і для кожного викликає process_ubatch(...): усередині будується обчислювальний граф (ембединги, шари трансформера, вихід у логіти) — або перевикористовується вже побудований, якщо форма проходу нічим не змінилася, — виконується обчислення на CPU/GPU і повертаються тензори з логітами: «сирими», ненормованими оцінками по словнику.
init_batch — метод модуля пам'яті, тому в кожного типу пам'яті своя реалізація; для звичайного KV-cache це llama_kv_cache::init_batch у src/llama-kv-cache.cpp (сам інтерфейс оголошено в src/llama-memory.h):
// src/llama-kv-cache.cpp, llama_kv_cache::init_batch — скорочено// Крок 12: весь батч ріжеться на підбатчі тут, за один виклик, до запуску будь-якого з нихllama_memory_context_ptr llama_kv_cache::init_batch( llama_batch_allocr & balloc, uint32_t n_ubatch, bool embd_all) { do { balloc.split_reset();
std::vector<llama_ubatch> ubatches; while (true) { // кожен виклик бере чергові (не більше) n_ubatch ще не використаних токенів батча auto ubatch = n_stream == 1 ? balloc.split_simple(n_ubatch) : balloc.split_equal(n_ubatch, true, 0); if (ubatch.n_tokens == 0) { break; // батч вичерпано } ubatches.push_back(std::move(ubatch)); }
if (balloc.get_n_used() < balloc.get_n_tokens()) { break; // частину токенів не вдалося розкласти в жоден підбатч }
// знаходимо слот у KV-cache під кожен підбатч, перш ніж зафіксувати хоч один auto sinfos = prepare(ubatches); if (sinfos.empty()) { break; }
return std::make_unique<llama_kv_cache_context>( this, std::move(sinfos), std::move(ubatches)); } while (false);
// місця під цей батч немає — decode перетворює це на код повернення 1 return std::make_unique<llama_kv_cache_context>(LLAMA_MEMORY_STATUS_FAILED_PREPARE);}Що робить init_batch і для чого:
-
за один прохід ріже весь батч на порції не більше
n_ubatchтокенів кожна.Для чого: один виклик
process_ubatchобробляє обмежену кількість токенів — так на довгому промпті не розростається пам'ять під проміжні тензори графа. -
для кожної порції копіює в
ubatchтокени, позиції та прапорці виходів.Для чого:
process_ubatchотримує готовий підбатч і будує граф саме під нього. -
перед поверненням резервує слот у KV-cache під кожен підбатч і лише потім фіксує результат.
Для чого: якщо кеш не вміщає весь батч, decode дізнається про це до того, як відпрацював хоч один підбатч, і може повідомити про це тому, хто викликав, замість того щоб залишити в кеші половину батча.
-
повернутий контекст пам'яті зберігає список підбатчів; цикл у decode іде по ньому через
get_ubatch()іnext().Для чого: довгий промпт обробляється за кілька проходів через модель, без єдиного величезного графа.
Цикл по підбатчах у llama_context::decode влаштований так: контекст пам'яті, повернутий memory->init_batch, видає по одному підбатчу через get_ubatch(), для кожного викликається process_ubatch(ubatch, gtype, mctx, status), а next() переводить на наступний, доки підбатчі не скінчаться. Тип графа gtype не обирається для кожного підбатча — він випливає з типу контексту й однаковий для всіх проходів цього контексту. Між проходом по довгому промпту і проходом з одним токеном різниться дещо простіше: батчевий прохід бере число потоків для батча, а не для генерації, і при створенні контексту llama.cpp резервує буфери обчислень за найгіршим випадком одразу під обидві форми. Після кожного process_ubatch логіти для позицій, позначених як вихідні, копіюються з бекенда у внутрішній буфер контексту, звідки їх читає llama_get_logits_ith, — але лише якщо хоча б одна з цих позицій належить послідовності без власного бекенд-семплера: коли семплери навішані на всі, сирі логіти не копіюються взагалі. Будувати граф наново для кожного підбатча було б марнотратно, тому llama.cpp перевикористовує попередній, якщо параметри, які повністю визначають його топологію, не змінилися.
Порядок викликів при одному decode (зведення):
llama_decode(ctx, batch)→ctx->decode(batch);- у decode: перевірка батча (і, якщо контекст не має модуля пам'яті, передача батча в
encodeта повернення),balloc->init(...), резервування планувальника, застосування відкладених оновлень пам'яті (KV-cache); memory->init_batch(...)— один виклик, який розбиває весь батч на підбатчі й резервує слот у кеші під кожен; потім резервується вихідний буфер;- цикл:
get_ubatch()видає черговийubatch, викликаєтьсяprocess_ubatch(ubatch, ...)— усерединіmodel.build_graph(або перевикористання попереднього графа),res->set_inputs,graph_compute— логіти копіюються в буфер контексту, аnext()переходить до наступного підбатча; - після циклу застосунок читає логіти через
llama_get_logits_ith, семплер повертає наступний токен.
Логіти копіюються у вихідний буфер контексту. Звідти їх читає llama_get_logits_ith — за цими числами семплер обирає наступний токен (наприклад, найімовірніший або за temperature/top_p).
Значення, що повертає llama_decode: 0 — успіх; 1 — у KV-cache не знайшлося вільного слота під цей батч (зменшити батч або збільшити контекст); 2 — обчислення перерване колбеком; -1 — сам батч некоректний; усе, що менше -1, — фатальна помилка, найчастіше невдале виділення буфера. Додатний код — це попередження, а не збій, але 2 теж додатне, тож перевірка лише на від'ємне значення пропустить переривання непоміченим — перевіряти треба на будь-яке значення, крім 0. Після переривання або фатальної помилки вже відпрацьовані підбатчі залишаються в пам'яті контексту; наскільки далеко просунувся батч, можна дізнатися через llama_memory_seq_pos_min і llama_memory_seq_pos_max. Мінімальні приклади, що йдуть у комплекті з llama.cpp, просто вважають будь-який ненульовий код фатальним і зупиняються. Застосунок має перевіряти значення, що повертається, і при помилці припиняти генерацію або виводити повідомлення про помилку.
Крок 13а: Підготовка до проганяння підбатча (пам'ять, параметри графа)
Крок 13 розбито в статті на три частини: підготовка до проганяння підбатча, побудова графа та алокація буферів, запис входів і виконання графа. Уся робота по одному підбатчу виконується в методі llama_context::process_ubatch (файл src/llama-context.cpp). Його викликають з llama_context::decode у циклі. Розбиття на підбатчі відбувається раніше і лише один раз: memory->init_batch викликають з усім батчем, і він повертає об'єкт контексту пам'яті, у якому вже лежать усі підбатчі разом з потрібним їм станом кешу. Далі цикл іде по них — mctx->get_ubatch() віддає поточний, mctx->next() переходить до наступного і повертає false, коли вони закінчилися, — і на кожен підбатч один раз викликає process_ubatch. Усередині покроково відбувається: підготовка пам'яті, рішення — чи перевикористовувати вже побудований граф, чи будувати наново, за потреби побудова графа та алокація буферів, запис вхідних даних у граф, виконання графа на CPU/GPU. Нижче — перша частина кроку 13: точка входу і підготовка.
Для чого потрібен обчислювальний граф:
- модель (трансформер) — це ланцюжок операцій: ембединги, шари уваги (Q, K, V, softmax, зважена сума), нормалізації, feed-forward тощо;
- замість того щоб викликати кожну операцію вручну, рушій будує граф — перелік вузлів (операцій) і зв'язків між ними;
- планувальник потім обходить граф у топологічному порядку і виконує операції на CPU або GPU;
- так можна автоматично розподіляти вузли по пристроях і перевикористовувати граф при однакових розмірах батча.
Сигнатура і вхідні дані process_ubatch (цитата з src/llama-context.cpp):
// src/llama-context.cpp, llama_context::process_ubatch — один підбатч проганяється через модель;// викликається з llama_context::decode у циклі за підбатчамиllm_graph_result * llama_context::process_ubatch(const llama_ubatch & ubatch, llm_graph_type gtype, llama_memory_context_i * mctx, ggml_status & ret) { // 1) фіксуємо стан пам'яті, підготовлений для цього підбатча (комірки KV-cache), до побудови графа if (mctx && !mctx->apply()) { LLAMA_LOG_ERROR("%s: failed to apply memory context\n", __func__); ret = GGML_STATUS_FAILED; return nullptr; }
// 2) результат ПОПЕРЕДНЬОЇ побудови: у ньому лежать граф і його вхідні тензори auto * res = gf_res_prev.get(); auto * gf = res->get_gf();
// 3) усе, від чого залежить топологія графа; граф можна перевикористати лише тоді, // коли ці параметри збігаються з тими, з якими його було побудовано const auto gparams = graph_params(res, ubatch, mctx, gtype);
// ... опущено: рішення про перевикористання, build_graph, set_inputs і graph_compute — див. нижче}Що відбувається на початку process_ubatch і для чого:
-
Викликається
mctx->apply(), якщо переданий контекст пам'яті (mctx).Для чого:
-
контекст пам'яті оновлює стан KV-cache і службових буферів (наприклад, зсуває вказівники на наступні вільні позиції); перед побудовою графа граф звертатиметься до цих буферів — вони мають бути в актуальному стані.
-
gf_res_prev— це результат попередньої побудови графа: сам граф, його вхідні тензори та параметри, з якими його було побудовано. При найпершому виклику він порожній, тому граф будується з нуля; далі він стає кандидатом на перевикористання.Для чого:
-
саме зберігання останнього графа й робить перевикористання можливим — якщо нові параметри збігаються зі збереженими в ньому, той самий граф можна виконати знову з новими значеннями входів.
-
у
gparamsзбирається все, від чого залежить форма графа: архітектура моделі та її гіперпараметри, параметри контексту, копія поточногоubatch(скільки в ньому токенів, як вони згруповані за послідовностями, лежать у ньому ID токенів чи готові ембединги), тип графа, планувальник, завантажені LoRA-адаптери і control vector, контекст пам'яті, семплери за послідовностями та кількість позицій, для яких потрібні виходи.Для чого:
-
правило таке: два графи з однаковими
gparamsмають однакову топологію; саме це й робить можливою перевірку на перевикористання нижче, а ті самі значення потім задають розміри вузлів і вхідних тензорів під час побудови графа.
Якщо mctx->apply() повертає false, функція одразу повертає nullptr і в ret записується GGML_STATUS_FAILED — код, що викликає (decode), обробить помилку і припинить цикл по підбатчах.
Цитата за змістом: що робить mctx->apply() і що входить у gparams (за кодом src/llama-context.cpp і src/llama-kv-cache.cpp):
// mctx->apply() — фіксує стан пам'яті, підготовлений для цього підбатча. Для KV-cache це означає// записати позицію та id послідовності кожного токена у відведені йому комірки й перерахувати,// скільки комірок графу доведеться читати (src/llama-memory.h, src/llama-kv-cache.cpp)if (mctx && !mctx->apply()) { ret = GGML_STATUS_FAILED; return nullptr; }
// graph_params() збирає все, від чого залежить топологія графа: архітектуру, параметри моделі// та контексту, копію підбатча, тип графа, планувальник, адаптери, контекст пам'яті,// семплери і n_outputs — перевірка на перевикористання порівнює саме цей набірconst auto gparams = graph_params(res, ubatch, mctx, gtype);Крок 13б: Побудова обчислювального графа та алокація буферів
Крок 13 (продовження) — побудова графа й виділення під нього буферів. Після підготовки пам'яті та формування gparams рушій вирішує: чи можна перевикористати вже побудований граф (та сама форма підбатча і ті самі параметри побудови), чи потрібно будувати граф наново і виділяти під нього буфери. Якщо граф перевикористовується — одразу переходять до запису входів і виконання. Якщо ні — викликаються res->reset(), ggml_backend_sched_reset(sched.get()), model.build_graph(gparams) і ggml_backend_sched_alloc_graph(sched.get(), gf). Нижче покроково, що за чим відбувається і для чого; кілька цитат з коду.
Цитата: рішення про перевикористання і побудова графа (фрагмент process_ubatch, src/llama-context.cpp):
// граф перебудовуємо лише коли змінилися параметри; повторні decode з одним токеном перевикористовують його if (!graph_reuse_disable && res->can_reuse(gparams)) { // ... опущено: при pipeline parallelism тут синхронізується попередній, ще незавершений розрахунок, // інакше set_inputs перезаписав би тензори, які він ще читає n_reused++; // лічильник перевикористань (профілювальник показує його як "graphs reused") } else { res->reset(); // викидаємо старий граф разом з його вхідними тензорами
ggml_backend_sched_reset(sched.get()); // скидаємо розбиття планувальника та його алокації ggml_backend_sched_set_eval_callback(sched.get(), cparams.cb_eval, cparams.cb_eval_user_data);
gf = model.build_graph(gparams); // ембединги → шари трансформера → логіти
if (!gf) { LLAMA_LOG_ERROR("%s: failed to initialize graph\n", __func__); ret = GGML_STATUS_FAILED; return nullptr; }
// планувальник розбиває граф по пристроях і виділяє пам'ять під кожен тензор if (!ggml_backend_sched_alloc_graph(sched.get(), gf)) { LLAMA_LOG_ERROR("%s: failed to allocate graph\n", __func__); ret = GGML_STATUS_ALLOC_FAILED; return nullptr; } }Покроково (що відбувається і для чого):
-
Перевірка
res->can_reuse(gparams): чи еквівалентний новий набір параметрів тому, з яким було побудовано збережений граф. Порівнюються форма підбатча (скільки токенів, як вони згруповані в послідовності, приходять вони як ID токенів чи як готові ембединги), кількість позицій, для яких запитано виходи, архітектура і тип графа, завантажені адаптери та кілька прапорців контексту; потім у кожного вхідного тензора графа окремо запитують, чи він ще підходить.Для чого:
-
при генерації по одному токену кожен наступний виклик приносить підбатч рівно тієї самої форми — один токен, одна послідовність, один вихід, — тому вся перевірка проходить, граф не перебудовується, і лишається тільки записати в його входи нові значення. Саме це й робить генерацію швидкою: інакше побудова графа для великої моделі коштувала б мілісекунд на кожному токені.
-
Якщо перевикористання вимкнено або параметри змінилися:
res->reset()— очищуються старі вузли графа і прив'язані буфери.Для чого:
-
перед побудовою нового графа старий потрібно скинути, інакше вузли й тензори накопичаться.
-
ggml_backend_sched_reset(sched.get())— планувальник скидає свій стан (розподіл вузлів по бекендах, зарезервовані буфери для цього графа).Для чого:
-
планувальник наново визначатиме, на якому пристрої виконувати кожен вузол нового графа, і виділятиме під нього пам'ять.
-
gf = model.build_graph(gparams)— будується граф GGML: вузли для ембедингів і позицій, потім для кожного шару трансформера — увага (Q, K, V, softmax, зважена сума), нормалізація, feed-forward, знову нормалізація; наприкінці — вихідний шар у логіти (розмір словника).Для чого:
-
граф описує, які операції виконати і в якому порядку; без нього планувальнику нічого виконувати.
llama_model::build_graphуsrc/llama-model.cpp— лише тонка обгортка: вона викликає будівник для конкретної архітектури, а потім додає шар пулінгу, шари семплінгу на пристрої, якщо вони є, і розмітку вихідних тензорів. Розкладає шари саме будівник для архітектури, і в кожної архітектури свій файл уsrc/models/—llama.cpp,gemma3.cpp,qwen3.cppі ще близько 150. -
ggml_backend_sched_alloc_graph(sched.get(), gf)— планувальник обходить граф, для кожного тензора визначає бекенд (CPU або GPU за розбиттям шарів), виділяє буфер на цьому бекенді (або перевикористовує зарезервований при створенні контексту).Для чого:
-
без виділення буферів дані графа нема де зберігати; при першому decode буфери резервуються тут (або при
graph_reserveпри створенні контексту), при наступних перевикористаннях графа повторне виділення не потрібне.
Структура графа по шарах (схема):
- вхід: ембединги токенів підбатча плюс окремий тензор з їхніми позиціями; позиції не додаються до ембедингів, їх використовують усередині кожного шару;
- для кожного шару трансформера: нормалізація входу → блок уваги (Q = вхід × W_q, K = вхід × W_k, V = вхід × W_v; застосування RoPE до Q і K; attention scores = Q × K^T; маска (causal); softmax; зважена сума scores × V; лінійний шар O) → залишкове з'єднання → нормалізація → feed-forward (два лінійні шари з активацією між ними) → залишкове з'єднання;
- після всіх шарів: залишаються тільки рядки тих позицій, для яких запитано вихід, потім фінальна нормалізація → вихідна матриця («розмір прихованого шару × розмір словника») → логіти:
n_vocabчисел для кожної з цих позицій.
Цитата виклику build_graph і ggml_backend_sched_alloc_graph (за змістом коду в src/llama-context.cpp і GGML):
// llama_model::build_graph (src/llama-model.cpp) повертає ggml_cgraph — плоский список вузлів// з їхніми залежностями; gparams уже фіксує форму підбатча, тип графа і контекст пам'ятіggml_cgraph * gf = model.build_graph(gparams);
// ggml_backend_sched_alloc_graph (ggml/include/ggml-backend.h) ріже граф на ланцюжки вузлів,// що належать одному бекенду, і розміщує кожен тензор в обчислювальному буфері цього бекендуbool ok = ggml_backend_sched_alloc_graph(sched.get(), gf);if (!ok) { ret = GGML_STATUS_ALLOC_FAILED; return nullptr; }Резервування буферів робиться один раз, при створенні контексту, у graph_reserve (src/llama-context.cpp). Там створюється фіктивний ubatch заданого розміру, для нього будується граф, і планувальника просять зарезервувати під нього достатньо великі обчислювальні буфери. Конструктор робить це тричі — з ubatch завбільшки з цілий промпт, потім з ubatch по одному токену на послідовність, потім знову з промптовим, — щоб буфери вийшли розрахованими на найгірший випадок і під час інференсу нічого не перевиділялося. Побудований тут граф для перевикористання не зберігається: graph_reserve навмисно скидає збережений попередній результат — саме для того, щоб перший справжній decode побудував свій власний граф. Функція ggml_backend_sched_alloc_graph (бібліотека GGML) потім, при кожній справжній побудові, ріже граф на ланцюжки вузлів, що належать одному бекенду, і розміщує кожен тензор у буфері цього бекенду.
Крок 13в: Запис входів у граф і виконання на CPU/GPU
Крок 13 (завершення) — запис входів у граф і виконання. Після того як граф побудовано (або перевикористано), потрібно записати у вхідні тензори графа дані поточного підбатча — токени або ембединги і позиції — і запустити виконання графа. Це роблять виклики res->set_inputs(&ubatch) і graph_compute(res->get_gf(), ubatch.n_tokens > 1). Нижче — цитати й покрокове пояснення.
Цитата: запис входів і виконання (кінець process_ubatch, src/llama-context.cpp):
res->set_inputs(&ubatch); // заповнюємо вхідні тензори графа цим підбатчем: ID токенів, позиції, індекси запису в KV, маску уваги const auto status = graph_compute(res->get_gf(), ubatch.n_tokens > 1); // обхід вузлів графа, виконання на CPU/GPU if (status != GGML_STATUS_SUCCESS) { ret = status; return nullptr; } ret = GGML_STATUS_SUCCESS; return res; // у res — тензори з логітами; decode потім копіює їх у буфер контексту для llama_get_logits_ith}Що відбувається при set_inputs і для чого:
-
res->set_inputs(&ubatch)обходить вхідні тензори, які граф зареєстрував під час побудови, і заповнює кожен з них із підбатча: ID токенів (або готові ембединги — у рідшому випадку, коли той, хто викликає, передав ембединги, а не токени; інакше lookup по таблиці ембедингів сам є вузлом графа), позиції токенів, індекси комірок KV-cache, у які цей підбатч має записати свої ключі та значення, маску уваги, яка каже, на які збережені позиції кожному токену можна дивитися, і список позицій, для яких потрібні виходи.Для чого:
-
граф обчислює за конкретними даними; без запису входів він працював би з порожніми або застарілими тензорами; при Decode у кеш підставляються буфери, куди дописуються нові ключі та значення для поточної позиції.
Цитата graph_compute (метод контексту, який виконує граф на планувальнику, src/llama-context.cpp):
// src/llama-context.cpp, llama_context::graph_compute — віддає побудований граф планувальникуggml_status llama_context::graph_compute(ggml_cgraph * gf, bool batched) { // "batched" означає, що в підбатчі більше одного токена: обробка промпта бере батчеву кількість потоків, // генерація по одному токену — меншу; на одному токені зайві потоки не окупаються int n_threads = batched ? cparams.n_threads_batch : cparams.n_threads; ggml_threadpool_t tp = batched ? threadpool_batch : threadpool;
// ... опущено: передача обраного пулу потоків CPU-бекенду
// задаємо кількість потоків для всіх бекендів for (const auto & set_n_threads_fn : set_n_threads_fns) { set_n_threads_fn.second(set_n_threads_fn.first, n_threads); }
// один виклик виконує весь граф: планувальник іде по нарізаних ним ланцюжках і запускає кожен // на своєму бекенді. "_async" — тут він не чекає на GPU auto status = ggml_backend_sched_graph_compute_async(sched.get(), gf); if (status != GGML_STATUS_SUCCESS) { LLAMA_LOG_ERROR("%s: ggml_backend_sched_graph_compute_async failed with error %d\n", __func__, status); }
return status;}Що робить graph_compute покроково і для чого:
-
обирає кількість потоків:
n_threads_batch, якщо в підбатчі більше одного токена, інакшеn_threads, і віддає відповідний пул потоків CPU-бекенду.Для чого:
-
обробка довгого промпта — це велика матрична робота, яка масштабується по ядрах; генерація одного токена — ні, і розмазування її по всіх ядрах коштує лише синхронізації. Два окремі налаштування дозволяють користувачеві налаштувати обидва випадки.
-
викликає
ggml_backend_sched_graph_compute_async. Планувальник уже, на етапі алокації, нарізав граф на ланцюжки послідовних вузлів, що належать одному бекенду; тут він виконує ці ланцюжки один за одним, вставляючи копіювання між пристроями там, де ланцюжку потрібен тензор, порахований іншим пристроєм.Для чого:
-
саме тут і виконуються всі шари трансформера. Результат — вихідні тензори графа, заповнені числами, серед них логіти:
n_vocabзначень для кожної позиції, для якої запитано вихід. -
повертає статус, не чекаючи на завершення роботи, — це і означає
_asyncв імені.Для чого:
-
поки GPU ще зайнятий, код, що викликає, уже може поставити в чергу копіювання логітів із графа. Саме очікування відбувається пізніше, у
llama_context::synchronize, який викликає кожен публічний читач результатів — зокремаllama_get_logits_ith, — перш ніж віддати тому, хто викликає, вказівник.
Другий аргумент graph_compute(res->get_gf(), ubatch.n_tokens > 1) називається batched і просто каже, чи більше одного токена в цьому підбатчі. Він обирає кількість потоків і пул потоків: батчеві налаштування для обробки промпта, звичайні — для генерації по одному токену. Значення, що повертається, — статус GGML; при успіху process_ubatch повертає res, а decode копіює логіти з нього у внутрішній буфер контексту — для тих позицій, у яких виставлено прапорець виходу.
При перевикористанні графа (коли розмір батча і тип графа збігаються з попереднім викликом) граф не перебудовується і буфери не перевиділяються — викликаються лише res->set_inputs(&ubatch) і graph_compute. Це пришвидшує повторні виклики decode при генерації по одному токену: граф Decode будується один раз при першому decode з одним токеном і далі перевикористовується. Лічильник n_reused у контексті збільшується при кожному перевикористанні графа — за ним можна оцінити частку перевикористань при профілюванні.
Кроки з 14 до 16: Логіти, семплінг і токен назад у текст
Кроки з 14 до 16 — після проганяння підбатча логіти для останньої позиції опиняються в буфері контексту (крок 14); за ними семплер обирає один наступний токен (крок 15). Тут же, наприкінці розділу, розібрано крок 16 — переклад обраного токена назад у текст. Після llama_decode у внутрішньому буфері контексту лежать логіти — по одному числу на кожен токен словника.
Для чого потрібні логіти:
- модель на виході видає не один токен, а «оцінки» по всіх можливих наступних токенах (по одному числу — логіту — на кожен токен словника);
- за цими числами семплер вирішує, який один токен обрати: наприклад, з максимальним логітом (жадібний вибір) або випадково з урахуванням temperature/top_p;
- без логітів застосунок не зміг би обрати наступний токен.
Логіти можна розуміти як «сирі» оцінки того, наскільки підходить кожен наступний токен; з них семплер обирає один токен. Доступ до логітів — через llama_get_logits_ith (файл src/llama-context.cpp):
- повертається вказівник на масив з
n_vocabfloat — по одному числу на кожен токен словника.
Логіти запитуються лише для тих позицій у батчі, для яких у батчі було встановлено прапорець logits[i] == true (зазвичай лише для останньої позиції). Після кожного виклику process_ubatch контекст вивантажує логіти позицій із цим прапорцем, дописуючи їх у буфер, а не перезаписуючи його: якщо decode розбито на кілька підбатчів, рядки накопичуються в тому порядку, у якому позиції йшли в батчі. Тобто буфер — це таблиця: рядків стільки, скільки запитано позицій, і в кожному рядку n_vocab float (по одному на токен словника); у контексті він виділяється на максимальну кількість вихідних позицій.
Реалізація llama_get_logits_ith у src/llama-context.cpp повертає вказівник на рядок логітів для заданої позиції в батчі. Індекс може бути від'ємним — -1 означає останню позицію, для якої запитували вивід, і саме його передає цикл генерації. Перш ніж щось повернути, функція дочікується бекендів і спершу перевіряє, чи не відпрацював семплер просто на бекенді, залишивши власні логіти; звичайний шлях — другий, «сирі» логіти, вивантажені decode. Індекс, що вказує на позицію, для якої логіти не запитували, — це помилка: у релізній збірці функція поверне нульовий вказівник.
// Крок 14: src/llama-context.cpp — публічний аксесор.// Повертає вказівник на один рядок логітів: n_vocab float, по одному на токен словника.// i може бути від'ємним: -1 — остання позиція, для якої запитували вивід.float * llama_get_logits_ith(llama_context * ctx, int32_t i) { ctx->synchronize(); // чекаємо, доки бекенди дорахують граф
float * res = nullptr;
res = ctx->get_sampled_logits_ith(i); // не null, лише якщо спрацював семплер на бекенді
if (!res) { res = ctx->get_logits_ith(i); // звичайний шлях: «сирі» логіти, вивантажені decode }
return res;}
// src/llama-context.cpp — сам пошук рядкаfloat * llama_context::get_logits_ith(int32_t i) { output_reorder();
try { if (logits.data == nullptr) { throw std::runtime_error("no logits"); }
// output_resolve_row перетворює індекс у батчі на номер рядка: // від'ємний i відлічується з кінця, інакше output_ids[i]; кидає виняток, // якщо для цієї позиції логіти не запитували const int64_t j = output_resolve_row(i);
return logits.data + j*model.vocab.n_tokens(); } catch (const std::exception & err) { LLAMA_LOG_ERROR("%s: invalid logits id %d, reason: %s\n", __func__, i, err.what()); // ... опущено: налагоджувальна збірка тут падає, релізна повертає nullptr return nullptr; }}За цими числами обирається наступний токен за допомогою семплера — компонента, який за логітами вирішує, який токен видати (наприклад, найімовірніший або випадковий з урахуванням temperature/top_p). В API (include/llama.h) виклик має вигляд llama_sampler_sample(smpl, ctx, idx): йому передають ланцюжок семплерів, контекст та індекс вихідної позиції (-1 — остання), логіти цієї позиції він читає сам, застосовує до них ланцюжок і повертає один токен. Він же записує цей токен у власну історію ланцюжка, тож коду, що викликає, робити це не потрібно. При наступному виклику llama_decode токен передається в батчі як єдиний новий; цикл повторюється, доки модель не видасть токен кінця генерації — це перевіряють через llama_vocab_is_eog — або доки не спрацює обмеження за довжиною. Перекласти токен назад у текст — через llama_token_to_piece / llama_detokenize (реалізація в src/llama-vocab.cpp).
Ланцюжок семплерів:
- семплінг у
llama.cppвлаштовано як послідовність кроків; - спочатку до логітів може застосовуватися зсув за повтореннями (repeat penalty) — зменшення ймовірності токенів, що вже з'явилися;
- потім ідуть відсікання, які відкидають кандидатів: top-k залишає k найбільших логітів, top-p (nucleus) — лише токени з накопиченою ймовірністю до порога p, min-p відкидає все, що значно нижче за лідера;
- temperature іде ближче до кінця — ділення логітів на число (temperature > 1 робить розподіл м'якшим, < 1 — гострішим), тобто вона перебудовує те, що залишилося після відсікань;
- останнім з тих, що залишилися, обирається один токен — наприклад, з максимальним логітом (жадібний вибір) або випадково з імовірностями за softmax від логітів;
- сам ланцюжок — цикл по зареєстрованих семплерах, кожен з яких або модифікує логіти, або обирає токен, — лежить у
src/llama-sampler.cpp; ланцюжок за замовчуванням для консольних утиліт збирається за параметрами вcommon/sampling.cpp.
Реалізація перекладу токена в текст — це вже крок 16; за нього відповідають методи llama_vocab::impl::token_to_piece і llama_vocab::impl::detokenize у src/llama-vocab.cpp. token_to_piece за ID токена видає шматочок тексту; для байтових токенів виконується перетворення на символ, для звичайних — береться текст з id_to_token. detokenize проходить по масиву токенів, записуючи шматочки один за одним у переданий буфер і стежачи за тим, скільки місця лишилося, а для деяких словників наприкінці ще проганяє чистку пробілів. API llama_token_to_piece (в include/llama.h) приймає словник — не модель; словник отримують через llama_model_get_vocab — далі токен, вихідний буфер і його розмір, скільки провідних пробілів пропустити та чи потрібно виводити спеціальні токени. Повертає кількість записаних байтів або мінус потрібний розмір, якщо буфер виявився замалим, і завершального нуля не пише — тож результат треба перевіряти, що мінімальні приклади й роблять. Для виведення по одному токену зазвичай використовують llama_token_to_piece; для збирання повного рядка з масиву токенів — llama_detokenize. Фрагмент:
// Крок 16: src/llama-vocab.cpp — llama_vocab::impl::token_to_piece// За ID токена повертаємо шматочок тексту (для виведення користувачеві); id_to_token завантажено в load_vocabint32_t llama_vocab::impl::token_to_piece(llama_token token, char * buf, int32_t length, int32_t lstrip, bool special) const { static const int attr_special = LLAMA_TOKEN_ATTR_UNKNOWN | LLAMA_TOKEN_ATTR_CONTROL; const llama_token_attr attr = token_get_attr(token); if (!special && (attr & attr_special)) { return 0; // керуючий токен, який не просили виводити }
// копіюємо символи шматочка у вихідний буфер тексту // перед копіюванням пропускаємо до 'lstrip' провідних пробілів auto _try_copy = [=] (const char * token, size_t size) -> int32_t { // ... опущено: цикл, що пропускає до 'lstrip' провідних пробілів if (length < (int32_t)size) { return -(int32_t) size; // буфер замалий: потрібний розмір зі знаком мінус } memcpy(buf, token, size); return (int32_t) size; };
// ... опущено: якщо кеш token_to_piece побудовано, текст береться з нього
if (0 <= token && token < (int32_t) id_to_token.size()) { const std::string & token_text = id_to_token[token].text; switch (get_type()) { case LLAMA_VOCAB_TYPE_WPM: case LLAMA_VOCAB_TYPE_SPM: case LLAMA_VOCAB_TYPE_UGM: { if (attr & LLAMA_TOKEN_ATTR_NORMAL) { std::string result = token_text; llama_unescape_whitespace(result); return _try_copy(result.data(), result.size()); } // ... опущено: байтові токени перетворюються на один символ через token_to_byte break; } // ... опущено: гілки BPE (llama_decode_text), RWKV і PLAMO2 default: GGML_ABORT("fatal error"); } }
return 0;}У коді видно: керуючий або невідомий токен просто пропускається, якщо виведення спеціальних токенів не запитували; для словників WPM, SPM і UGM звичайні токени перетворюються на текст через llama_unescape_whitespace, байтові — через token_to_byte; гілка BPE влаштована схоже, але зазвичай іде через llama_decode_text (декодування escape-послідовностей). Усе копіювання проходить через один невеликий помічник, і саме там перевіряється розмір буфера: якщо шматочок не вміщується, не пишеться нічого, а повертається потрібний розмір зі знаком мінус. Функція llama_detokenize в API викликає vocab.detokenize, яка збирає текст цілого масиву токенів у переданий буфер.
Параметри семплінгу в сам виклик не передаються: вони фіксуються під час збирання ланцюжка — llama_sampler_chain_init, далі по одному llama_sampler_chain_add на кожен крок (llama_sampler_init_penalties, llama_sampler_init_top_p, llama_sampler_init_temp і завершальний llama_sampler_init_dist або llama_sampler_init_greedy, який і обирає токен). Сам виклик приймає лише ланцюжок, контекст та індекс вихідної позиції. Усередині він набирає масив кандидатів з логітів цієї позиції, проганяє по ньому весь ланцюжок, бере обраний ним токен, записує його в історію ланцюжка і повертає. Якщо семплер відпрацював на бекенді й токен уже обрано, функція одразу повертає його, не чіпаючи ланцюжок на CPU. Повернений токен потім передається в наступний виклик llama_decode у батчі як єдиний новий токен.
KV-cache та етапи Prefill / Decode (довідково)
У механізмі уваги кожен токен отримує вектор запиту (Q), ключа (K) і значення (V) — це числові вектори, які модель рахує зі своїх ваг.
Для чого потрібні Q, K, V:
- за ними обчислюється «увага» — наскільки кожна попередня позиція важлива для поточної;
- результат — зважена сума значень V з вагами за Q і K.
Логіти для наступного токена залежать від Q поточної позиції і від K, V усіх попередніх позицій — тому при генерації по одному токену старі K і V не змінюються, їх достатньо порахувати один раз і зберегти. KV-cache — це буфер у пам'яті, у якому для кожного шару і кожної позиції зберігаються вже пораховані ключі та значення; так не потрібно перераховувати їх наново на кожному кроці генерації.
Prefill і Decode:
- Prefill — у першому проході обробляються всі токени промпту, для них рахуються K і V та записуються в кеш;
- Decode — у наступних кроках обробляється лише один новий токен: за ним рахуються Q, K, V; K і V спочатку дописуються в кеш, і вже потім Q множиться на всі ключі, що лежать у кеші, — зокрема на ключ поточного токена, — а після маски та softmax на всі значення з кешу. У підсумку отримуємо один вектор контексту для цієї позиції і далі feed-forward тощо. Так ми не перераховуємо увагу по всій історії на кожному кроці, а лише по новому токену — це і дає пришвидшення генерації. Поділ на Prefill (багато токенів за раз, навантаження на обчислювачі) і Decode (один токен, навантаження на пам'ять) характерний для
llama.cppта інших рушіїв.
Реалізація KV-cache — це клас llama_kv_cache у src/llama-kv-cache.cpp; яка комірка якій позиції та якій послідовності належить, відстежується у src/llama-kv-cells.h, а спільний інтерфейс, через який до пам'яті звертається контекст, — init_batch, seq_pos_max і решта — оголошений у src/llama-memory.h. Для кожного кешованого шару моделі виділяються буфери під ключі та значення (розмір залежить від кількості голів KV, розміру голови та довжини контексту). При Prefill у ці буфери записуються K і V для всіх позицій промпту; при Decode для нового токена обчислюються лише нові K і V та дописуються в кінець. Граф моделі при виконанні звертається до цих буферів через контекст пам'яті, переданий йому в параметрах графа, і читає їх через get_k і get_v.
Розташування буферів KV-cache: для кожного шару трансформера створюється два тензори (або один об'єднаний):
- один для ключів;
- один для значень.
Розмірність кожного з них — [кількість голів KV × розмір голови, кількість комірок кешу]: один рядок — це ключі (або значення) однієї позиції для всього шару, а кількість комірок — це довжина контексту, під яку створено кеш. Тензори називаються cache_k_l0, cache_v_l0 і так далі за номером шару — зручний рядок для пошуку в дампі графа, але у вихідниках ім'я збирається з форматного рядка, тому там шукають cache_, а не ім'я цілком. При створенні контексту викликається виділення пам'яті під ці тензори на CPU або GPU (за налаштуваннями). При Prefill граф записує K і V для позицій 0..n-1 (n — кількість токенів у батчі). При Decode граф пише K і V лише для позиції n_cur (поточна позиція) у відповідне місце буфера; читання K і V для всіх позицій 0..n_cur іде з того самого буфера. Так не потрібно перераховувати ключі та значення для вже оброблених токенів.
Обсяг пам'яті KV-cache зростає лінійно з довжиною контексту й кількістю шарів: для кожного шару зберігаються ключі та значення для всіх позицій. При довжині контексту n_ctx і кількості шарів n_layer обсяг пропорційний n_ctx × n_layer × розмір однієї голови × кількість голів KV × 2 (×2 — ключі та значення), помноженому на розмір одного елемента. Тут легко помилитися у двох речах. Перша: рахувати треба кількість голів KV, а не кількість голів уваги — у моделях з grouped-query attention кілька голів запиту ділять одну голову ключів і значень, і кеш виходить у кілька разів меншим, ніж можна вирішити за самою лише кількістю голів. Друга: розмір елемента не фіксований — типи кешів K і V задаються окремо (опції -ctk і -ctv), і це і є та сама «агресивніша квантизація». Тому при обмеженій пам'яті зменшують n_ctx або знижують точність KV-cache. У llama.cpp при створенні контексту буфери виділяються одразу на всю довжину контексту; при генерації заповнюються лише позиції до поточної. Виняток — шари з ковзним вікном уваги: для них кеш виділяється за розміром вікна, а не за всім контекстом.
Метод пам'яті seq_pos_max(s) (оголошений у src/llama-memory.h, реалізований для KV-cache у src/llama-kv-cache.cpp) повертає максимальну позицію, до якої заповнений кеш для послідовності s. Якщо застосунок не проставив позиції токенів сам, за нього їх заповнюють у src/llama-batch.cpp: перший токен батча отримує seq_pos_max(s) + 1, решта — позиції слідом за ним; так батч завжди продовжує послідовність з першої вільної позиції в кеші. Комірки кешу позначаються зайнятими на самому початку process_ubatch, викликом apply у контексту пам'яті, ще до обчислення графа; а якщо обчислення не вдалося, decode прибирає ці позиції з кешу назад.
Резюме: від файлу до першого токена
Коротка послідовність кроків від запуску застосунку до появи першого токена відповіді.
Для чого це корисно:
- при налагодженні або вивченні коду можна звірятися з цим переліком і перевіряти, на якому кроці ви перебуваєте;
- так простіше знайти у вихідниках місце, що відповідає етапу завантаження або генерації.
Підготовка (один раз при старті або зміні моделі):
-
llama_backend_init()— запуск таймера, а якщо жоден бекенд ще не зареєстрований — завантаження всіх доступних.Для чого: один раз при старті застосунку.
-
llama_model_load_from_file(path, params)→llama_model_load_from_file_impl→ перевірка бекенда, колбек прогресу,llama_model_load(...)— а вже всередині нього, серед іншого, збирається список пристроїв для моделі.Для чого: завантажити модель з файлу і отримати вказівник на
llama_model. -
Усередині
llama_model_load:llama_model_loader(відкриття GGUF, індекс тензорів), потімllama_model_create— він запитує у завантажувача архітектуру (ml.get_arch()) і створює об'єкт відповідного класу моделі, — а після нього по черзіload_hparams,load_vocab(у т.ч.vocab.load(ml, kv)),load_statsіload_tensors.Для чого: покроково заповнити модель архітектурою, гіперпараметрами, словником і вагами.
-
llama_init_from_model(model, ctx_params)— створення контексту: параметриn_ctx,n_batch,n_ubatch, ініціалізація пам'яті (KV-cache), планувальника, резерв графів Prefill і Decode (у вихідниках і логах вони називаються pp і tg). Стара назваllama_new_context_with_modelще працює, але позначена застарілою.Для чого: контекст потрібен для виклику
llama_decode; без нього не можна генерувати текст.
Генерація (на кожне повідомлення користувача і кожен новий токен у відповіді):
-
llama_tokenize(vocab, prompt, ...)— промпт у токени; словник заздалегідь беруть у моделі черезllama_model_get_vocab(model). При виклику з порожнім вихідним буфером функція повертає мінус потрібну кількість токенів — так дізнаються, скільки пам'яті виділити.Для чого: модель працює лише з числами (токенами).
-
Формування батча. Найкоротший шлях —
llama_batch_get_one(tokens, n): батч бере токени як є, а логіти лише для останньої позиціїllama.cppзапитує сама. Де потрібен тонший контроль, приклади користуються помічниками бібліотеки common —common_batch_clearі потімcommon_batch_addдля кожного токена, останній аргумент якого каже, чи потрібні логіти для цієї позиції. Ці двоє не входять до публічного API вinclude/llama.h; вони живуть у common/common.h.Для чого: один виклик decode приймає один батч; логіти потрібні лише для останньої позиції, щоб обрати наступний токен.
-
llama_decode(ctx, batch)→ctx->decode(batch)→balloc->init(перевірка батча і заповнення полів, які не заповнив той, хто викликав), оновлення пам'яті,memory->init_batch— він розбиває батч на підбатчі — і потім цикл по цих підбатчах:process_ubatch(apply,build_graph,set_inputs,graph_compute), копіювання логітів. Якщо контекст створено взагалі без пам'яті, decode просто передає батч уencodeі повертається.Для чого: проганяння батча через модель і отримання логітів у внутрішньому буфері контексту.
-
llama_get_logits_ith(ctx, i)— вказівник на логіти одного рядка виводу. Невід'ємний i — це індекс токена всередині батча, який контекст переводить у рядок вихідного буфера черезoutput_ids, а не номер серед виходів і не позиція в послідовності: після Prefill з n токенів, де прапорець стоїть лише на останньому, його логіти просять під номером n-1, а під номером 0 повернеться нульовий вказівник, бо для того токена логіти не запитували. Від'ємний індекс, навпаки, рахується серед рядків виводу, тому надійний спосіб попросити останній вихід —-1.Для чого: за ними семплер обирає наступний токен.
-
Семплер обирає наступний токен;
llama_token_to_piece(vocab, token, buf, size, ...)перетворює його на шматочок тексту — повернуту довжину потрібно перевірити, від'ємне значення означає, що буфер виявився замалий; виведення; токен іде в наступний батч; decode повторюється до токена кінця генерації (llama_vocab_is_eog) або ліміту.Для чого: цикл генерації по одному токену до кінця відповіді.
Таким чином, шлях від файлу моделі до першого токена відповіді проходить через завантажувач GGUF, завантаження архітектури та гіперпараметрів, словника і тензорів, створення контексту з KV-cache і планувальником, токенізацію промпту, батч, decode, побудову графа та його виконання, семплінг за логітами.
При першому decode з повним промптом (Prefill) час виконання залежить від довжини промпту і розміру підбатча (n_ubatch): що довший промпт, то більше підбатчів і проходів через модель; логіти потрібні лише для останньої позиції. При наступних decode (по одному токену) кожен виклик обробляє один токен; граф Decode перевикористовується, основний час іде на обчислення одного шару уваги (Q, K, V для однієї позиції, читання K і V з кешу, softmax, зважена сума) та решти шарів. Оптимізація швидкості генерації пов'язана з оптимізацією цього шляху: ефективне читання KV-cache, перевикористання графа, розподіл по GPU. При профілюванні корисно дивитися час першого decode (Prefill) і час наступних decode (по одному токену): перший залежить від довжини промпту і n_ubatch, наступні — від ефективності одного проходу через модель і копіювання даних між бекендами.
Примітки щодо версій і збірки: структура коду та імена файлів у репозиторії llama.cpp змінюються від версії до версії; усе в цій статті — імена файлів, сигнатури функцій і фрагменти коду — звірено з гілкою master на коміті 8887a48f і у вас може відрізнятися. При збірці з GPU потрібні відповідні бекенди (CUDA, Metal, Vulkan тощо) і змінні середовища; без них рушій працює лише на CPU. Приклади викликів API та імена структур наведені за include/llama.h; при використанні C-обгорток або інших мов (Python, Go тощо) сигнатури можуть відрізнятися, але загальний сценарій завантаження і decode залишається тим самим.
Рекомендації щодо вивчення коду: для розуміння шляху завантаження моделі зручно почати з llama_model_load_from_file у src/llama.cpp і пройти по викликах до llama_model_load, потім через llama_model_create (тут визначається архітектура), load_hparams, load_vocab, load_stats, load_tensors. Для шляху decode — з llama_decode у src/llama-context.cpp, потім ctx->decode, balloc->init, memory->init_batch і цикл по підбатчах з process_ubatch. У process_ubatch дивитися build_graph і graph_compute. Словник і токенізація — src/llama-vocab.cpp (load, tokenize, token_to_piece). Завантажувач GGUF — src/llama-model-loader.cpp (конструктор, get_tensor_meta, create_tensor, load_all_data). Пам'ять і KV-cache — src/llama-kv-cache.cpp (seq_pos_max, init_batch, apply_ubatch), спільний інтерфейс — у src/llama-memory.h. При налагодженні корисно ставити точки зупину на входах у load_hparams, load_vocab, load_tensors і на входах у decode, process_ubatch, build_graph.
Деталізація реалізації за файлами
Нижче — коротка прив'язка описаних у статті кроків до конкретних файлів і функцій репозиторію llama.cpp.
Для чого це потрібно:
- при вивченні або налагодженні можна швидко знайти потрібний код за іменем файлу і функції;
- кожен пункт указує, що шукати у файлі і навіщо це потрібно.
src/llama.cpp:
-
llama_backend_init— запуск таймера і завантаження бекендів ggml, які постачаються окремими динамічними бібліотеками (бекенди, вкомпільовані у збірку, реєструються самі, без цього виклику).Для чого: один раз при старті застосунку.
-
llama_model_load_from_file,llama_model_load_from_file_impl— точка входу завантаження моделі.Для чого: застосунок викликає їх, щоб завантажити модель з файлу.
-
llama_model_load(статична) — послідовний викликllama_model_create(який обирає архітектуру),load_hparams,load_vocab,load_statsіload_tensors.Для чого: тут створюється завантажувач і покроково заповнюється модель. У тому самому файлі — перевірка бекенда, колбек прогресу та список пристроїв (
llama_prepare_model_devices); сам об'єкт моделі створюєllama_model_createуsrc/llama-model.cpp, цей файл його лише викликає.
src/llama-model-loader.cpp:
-
Клас
llama_model_loader: конструктор відкриває GGUF черезgguf_init_from_file, будуєweights_mapза списком тензорів з контексту GGUF.Для чого: за іменем тензора потім можна прочитати дані з файлу.
-
Методи
get_key,get_arch,get_tensor_meta,create_tensor,load_all_data— читання метаданих, опис тензорів і читання їхніх даних.Для чого:
load_hparams,load_vocabіload_tensorsвикликають їх. -
print_info— виведення інформації про файл. Підтримка кількох файлів (splits) і mmap/direct_io.
src/llama-model.cpp:
-
Клас
llama_model:load_hparams(читання ключів GGUF у hparams),load_vocab(викликvocab.load(ml, kv)),load_tensors(формування списків буферів CPU/GPU, розбиття шарів, створення тензорів черезcreate_tensorі читання їхніх даних черезml.load_all_data).Для чого: тут створюються тензори моделі за архітектурою і призначаються буфери на CPU/GPU. Саму архітектуру обирають раніше, поза класом:
llama_model_createзапитує її в завантажувача черезml.get_arch()і створює об'єкт потрібного типу. Усе, що стосується однієї архітектури, — її гіперпараметри, список тензорів і граф — лежить в окремому файлі вsrc/models/, за методамиload_arch_hparams,load_arch_tensorsіbuild_arch_graph, які реалізує кожна модель.
src/llama-vocab.cpp:
-
Клас
llama_vocab,llama_vocab::impl::load— читання типу токенайзера, списків токенів, злиттів BPE, спеціальних токенів з GGUF.Для чого: словник потрібен для токенізації та перекладу токенів у текст.
-
init_tokenizer— ініціалізація токенайзера за типом (SPM, BPE тощо).tokenize— розбиття тексту на токени;token_to_piece,detokenize— переклад токенів у текст. Функціїllama_tokenize,llama_token_to_piece,llama_detokenizeделегують виклики словнику моделі.
src/llama-context.cpp:
-
Клас
llama_context: конструктор створює balloc, задає cparams (n_ctx,n_batch,n_ubatchтощо), ініціалізує пам'ять (KV-cache) і планувальник (ggml_backend_sched), викликаєgraph_reserveдля Prefill і Decode.Для чого: контекст — «робоче середовище» одного сеансу генерації.
-
Метод
decode— перевірка батча,balloc->init, оновлення пам'яті,memory->init_batch(весь батч розбивається на підбатчі одним викликом), потім цикл по цих підбатчах (process_ubatch), копіювання логітів. Контексту, створеному без модуля пам'яті (наприклад, для ембедингової моделі), декодувати нічого, і виклик одразу переадресовується вencode.Для чого: один виклик decode проганяє батч через модель і заповнює буфер логітів.
-
process_ubatch— застосування mctx,build_graphабо перевикористання графа,set_inputs,graph_compute.get_logits_ith— повернення вказівника на логіти для позиції. Публічні функціїllama_decode,llama_get_logits_ithвикликають методи контексту.
src/llama-batch.cpp:
-
Структура
llama_batch(оголошена вinclude/llama.h), класllama_batch_allocr: методinitперевіряє токени, заповнюєn_seq_id,seq_id, pos, logits за їх відсутності; позиції беруться зmemory->seq_pos_max.Для чого: щоб коду, який викликає, не потрібно було вручну виставляти позиції та прапорці логітів. Використовується всередині
llama_context::decodeперед циклом по підбатчах.
src/llama-memory.h, src/llama-kv-cache.cpp:
-
Інтерфейс
llama_memory_i(оголошений уsrc/llama-memory.h) і його основна реалізація — класllama_kv_cacheуsrc/llama-kv-cache.cpp— виділення буферів KV-cache: по одному тензору K і одному тензору V на кожен шар, кожен на всю довжину контексту.Для чого: у них зберігаються ключі та значення механізму уваги. Моделі з іншим видом стану (рекурентні, гібридні, з ковзним вікном) дають власну реалізацію того самого інтерфейсу в сусідньому файлі src/llama-memory-*.cpp або src/llama-kv-cache-*.cpp.
-
seq_pos_max(s)— максимальна позиція для послідовності s.Для чого: при формуванні батча позиції нових токенів беруться як
seq_pos_max(s)+ 1. -
init_batch— розбиття батча на підбатчі (ubatch) розміром не більшеn_ubatchі резервування місця в KV-cache під кожен із них; зайняті позиції оновлюються в міру обробки підбатчів. Планувальник і граф звертаються до буферів KV-cache через параметри графа.
src/llama-graph.cpp:
-
Клас
llm_graph_context— спільні будівельні блоки, з яких файли окремих архітектур уsrc/models/збирають свої шари:build_inp_embd(крок 13, пошук ембедингів токенів —ggml_get_rowsпоtok_embd),build_norm,build_attn,build_ffn.Для чого: архітектури не повторюють ті самі операції, і перший вузол будь-якого графа будується тут.
-
Класи
llm_graph_input_*і їхні методиset_input— запис ідентифікаторів токенів, позицій і масок уваги у вхідні тензори вже побудованого графа.Для чого:
process_ubatchвикликає їх прямо передgraph_compute; саме це дозволяє перевикористати той самий граф для наступного підбатча.
src/llama-sampler.cpp:
-
llama_sampler_chain_init,llama_sampler_chain_add— створення ланцюжка семплерів і додавання до нього кроку (штраф за повтори, top-k, top-p, min-p, температура, фінальний вибір).Для чого: ланцюжок збирається один раз, до генерації; для консольних програм його будують за параметрами в
common/sampling.cpp. -
llama_sampler_sample— крок 15: сам читає логіти потрібної позиції, застосовує до них увесь ланцюжок і повертає один токен;llama_sampler_acceptзаписує цей токен у власну історію ланцюжка.Для чого: тут числа з буфера контексту перетворюються на наступний токен.
GGML/GGUF (каталог ggml/ того самого репозиторію):
-
ggml_init,ggml_free— контекст графа.gguf_init_from_file,gguf_get_*— читання GGUF.Для чого: завантажувач і модель використовують їх для читання файлу та побудови графа.
-
ggml_backend_sched,ggml_backend_sched_alloc_graph,ggml_backend_sched_reset— планувальник і виділення буферів під граф.Для чого: планувальник розподіляє вузли графа по CPU/GPU і виділяє під них пам'ять.
-
Виконання графа (
graph_compute) — обхід вузлів у топологічному порядку, копіювання між бекендами за потреби, запуск операцій на CPU/GPU. Модель будує граф черезmodel.build_graphуsrc/llama-model.cpp: цей метод загальний — він викликаєbuild_arch_graphконкретної архітектури, а потім дописує спільний «хвіст» (пулінг, вихідну проєкцію). Сам граф архітектури лежить в окремому файлі вsrc/models/, по файлу на архітектуру. Підтримка нової моделі додається таким файлом, а не правкоюbuild_graph; загальна схема «ембединги → шари → логіти» зберігається.
При вивченні коду зручно шукати за іменем функції або методу: llama_backend_init, llama_model_load_from_file, llama_model_load, load_arch_hparams, load_arch_tensors, load_hparams, load_vocab, vocab.load, load_tensors, llama_init_from_model, llama_model_get_vocab, llama_decode, decode, process_ubatch, build_graph, build_arch_graph, graph_compute, llama_get_logits_ith, llama_tokenize, llama_token_to_piece. За ними можна простежити весь шлях від завантаження моделі до виведення токена. Найкоротші повні приклади — examples/simple/simple.cpp і examples/simple-chat/simple-chat.cpp: у них показано типовий цикл — завантаження моделі, створення контексту, токенізація, батч, decode, семплінг, виведення; повнофункціональні програми (CLI, сервер) лежать у tools/.
Зв'язок компонентів і потоки даних
Коротко — як дані проходять між компонентами від файлу моделі до виведення токена. Для чого це корисно: при налагодженні або вивченні коду можна простежити, звідки береться кожне значення і куди воно передається.
Завантаження (один раз при старті або зміні моделі):
-
Файл GGUF →
llama_model_loader(метадані та індекс тензорівweights_map).Для чого: завантажувач дає доступ до полів GGUF і до даних тензорів за іменем.
-
ml.get_arch()→llama_model_create(тип моделі: LLaMA, Gemma тощо).Для чого: від типу залежать імена ключів у GGUF.
-
load_hparams(розмірності та параметри:n_ctx_train, n_layer,n_embdтощо; робоча довжина контекстуn_ctxу файлі не зберігається — її обирають пізніше, при створенні контексту, а за замовчуванням беруть рівноюn_ctx_train).Для чого: від них залежить «форма» моделі та розміри тензорів.
-
load_vocab→vocab.load(словник токенів і токенайзер).Для чого: словник потрібен для токенізації та перекладу токенів у текст.
-
load_tensors(ваги з файлу в буфери CPU/GPU).Для чого: модель готова до обчислень. Модель (
llama_model) зберігає hparams, vocab і тензори; завантажувач після завантаження більше не потрібен.
Створення контексту (один раз на сеанс):
-
Модель + параметри контексту →
llama_context: balloc (алокатор батча), memory (KV-cache іinit_batch), sched (планувальник), зарезервовані графи Prefill/Decode.Для чого: контекст — «робоче середовище» одного сеансу генерації; у ньому зберігаються KV-cache, планувальник і графи для decode. Контекст посилається на модель; модель не зберігає посилання на контекст.
Генерація (на кожне повідомлення і кожен новий токен):
-
Текст промпту →
llama_tokenize(model.vocab) → масив токенів → батч (token, pos,seq_id, logits).Для чого: один виклик decode приймає один батч.
-
Батч →
llama_decode→balloc->init(позиції з memory) →memory->init_batch(один виклик: весь батч розбивається на підбатчі) → цикл: ubatch →process_ubatch(build_graphза моделлю,set_inputs,graph_computeна sched) → логіти копіюються в буфер контексту. Пошук ембедингів токенів — не окремий етап, а перший вузол графа, який будуєbuild_graph.Для чого: проганяння батча через модель і отримання логітів.
-
Логіти →
llama_get_logits_ith→ семплер → наступний токен →llama_token_to_piece(model.vocab) → текст.Для чого: за логітами обирається один токен і перекладається в текст для виведення.
-
Токен додається в батч; при наступному decode у батчі один новий токен; memory оновлює зайняті позиції KV-cache; цикл повторюється до EOS або ліміту.
Зведення потоків даних:
- Файл GGUF → завантажувач → модель (hparams, vocab, тензори). Модель + параметри → контекст (memory, sched, графи).
- Промпт → vocab (токенізація) → батч. Батч →
llama_context::decode→ balloc, memory →process_ubatch(модель:build_graph, ваги; memory: KV-cache; sched: виконання графа) → логіти в контексті. - Логіти → семплер → токен → vocab (
token_to_piece) → текст.
Модель і контекст — центральні об'єкти; завантажувач, balloc, memory і sched — допоміжні, прив'язані до контексту або моделі.
Що з цього випливає
Усе описане вкладається у дві фази. Підготовка відбувається один раз: рушій реєструє бекенд, відкриває GGUF, читає з нього архітектуру, розміри та словник, розкладає ваги туди, де вони рахуватимуться, і будує навколо них контекст. Генерація — це цикл: текст перетворюється на токени, токени — на батч, батч проходить через граф, граф повертає логіти, семплер обирає один токен, і цей токен іде в наступний батч. Усе інше в статті — подробиці, навішені на ці два каркаси.
Чотири налаштування змінюють поведінку сильніше за решту, і тепер зрозуміло чому. n_ctx задає, скільки KV-кешу виділяється одразу: пам'ять зростає з довжиною контексту, кількістю шарів і кількістю KV-голів — і з цих трьох величин ви обираєте лише довжину контексту. n_gpu_layers вирішує, які шари живуть у пам'яті пристрою; відображення дістається їм без копіювання лише там, де пристрій уміє побудувати буфер поверх пам'яті хоста, — процесору завжди, єдиній пам'яті на кшталт Apple Silicon теж, дискретній карті ніколи. -ctk і -ctv задають точність ключів і значень окремо: це найдешевший важіль, коли не вміщується саме кеш. А --load-mode визначає, як файл узагалі читається: відображається, замикається в пам'яті, і те й те одразу, читається без буферизації або просто читається — а при типовому значенні auto рушій обирає сам.
Якщо хочеться читати не статтю, а вихідники, найкоротший вхід — examples/simple-chat/simple-chat.cpp: двісті рядків, що проходять увесь шлях — від завантаження моделі до вибору токена. Далі — llama_context::decode у src/llama-context.cpp, де живе цикл по підбатчах. Усі імена в статті написані рівно так, як у чекауті, названому в передмові, — їх можна шукати як є.
На цьому ж сайті: Нейромережі простими словами пояснюють, що таке ваги й увага, які тут рахуються; LoRA — про адаптери, що вливаються в ті самі ваги GGUF; RAG і function calling — про те, що зазвичай будують поверх локального рушія; Offline AI Launcher — цей самий рушій на Android.