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: определяне на типа на модела – архитектурата, вече прочетена от зареждача от ключа general.architecture в GGUF, определя кой клас модел се създава (
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 при отказ чрез callback за прогреса.За какво е нужно: при отрицателен статус указателят към модела вече е нулев, така че извикващият код само записва в лога причината и връща на приложението 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. Всичко останало – проверката, че бекендът е регистриран, callback-ът за прогреса по подразбиране, извикването на 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– 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; // Ако не е подаден callback за прогреса – слагаме свой: извежда точки до 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) бекендът не е задължителен. -
Настройка на callback-а за прогреса – ако приложението не е подало свой, слага се callback по подразбиране, който извежда точки.
За какво е нужно: потребителят вижда, че зареждането върви; при желание може да се подаде свой callback и да се показва лента за напредъка или да се отмени зареждането (като върне 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 – отмяна чрез callback-а за прогреса) и се връща
nullptr. Извикването наllama_model_freeв този клон е само застраховка:llama_model_loadсама унищожава недоизградения модел и при неуспех никога не връща указателя навън.За какво е нужно: по nullptr приложението разбира, че зареждането не е успяло, и не използва непълния модел.
Callback-ът за прогреса се извиква вътре в load_tensors при четенето на всеки тензор: на него се подава число от 0,0 до 1,0 (делът на заредените данни). Ако callback-ът върне 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; - при отмяна чрез callback-а за прогреса (callback-ът връща false вътре в
load_tensors)load_tensorsвръща false, изключение не се хвърля, ноllama_model_loadвръща -2 (отмяна); - при всеки неуспех недоизграденият модел се унищожава веднага: той живее в
unique_ptr, който се предава на извикващия код само при успех, затоваllama_model_load_from_file_implполучава нулев указател, записва причината в лога и връщаnullptr; - по този начин приложението може да отмени дългото зареждане чрез callback и коректно да освободи ресурсите.
Стъпка по стъпка (какво се случва и за какво е нужно):
-
създава се зареждачът
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, при отмяна (callback-ът за прогреса върна false) – -2, при грешка – -1.
Редът на извикванията при зареждане на модела (обобщение):
llama_model_load_from_file→llama_model_load_from_file_impl;- в impl: проверка, че източникът на модела е зададен точно веднъж, проверка на бекенда, callback за прогреса по подразбиране и извикване на
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 и т.н.);
- списъците с токени;
- типовете на токените;
- сливанията (merges) на 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)}; };
// от прехвърлянето на входния слой към GPU почти няма полза, затова той винаги остава на 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 и нулирането му за архитектури без връщане назад на 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 на токените (или готови ембединги – в по-редкия случай, когато извикващият е подал ембединги, а не токени; иначе търсенето в таблицата с ембединги само е възел на графа), позициите на токените, индексите на клетките в 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→ проверка на бекенда, callback за прогреса,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.За какво е нужно: тук се създава зареждачът и стъпка по стъпка се попълва моделът. В същия файл са проверката на бекенда, callback-ът за прогреса и списъкът с устройствата (
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.