Тринадцатого ноября команда llama.cpp объявила релиз библиотек версии 1.0.0 и этим закрыла три с половиной года жизни без формальных обещаний. Всё это время проект выходил под номерами сборок вида b6523, и каждый, кто писал обвязку поверх libllama, сам решал, какой номер считать безопасным и когда сломается очередная функция. Теперь у проекта есть правила: изменения публичного C API будут отмечаться повышением мажорной версии, а сами библиотеки libllama и libggml объявлены основой для сторонних приложений. Для тех, у кого llama.cpp встроен в продукт, релиз означает не новые возможности, а возможность наконец планировать обновления. Ниже разобрано, как устроены новые правила, какие вызовы в коде стоит заменить заранее, как собрать бэкенды отдельными динамическими библиотеками и как закрепить версии в продакшене, чтобы очередное обновление не превращалось в ночную отладку.

Что именно объявили 13 ноября и почему версия 1.0.0 появилась только через три с половиной года

В релиз вошли две библиотеки. Первая, libllama, отвечает за вывод языковых моделей на C и C++ и публикует заголовки в каталоге include внутри репозитория llama.cpp. Вторая, libggml, - тензорная библиотека для машинного обучения, её публичные заголовки лежат в каталоге ggml/include. Обе получили версию 1.0.0 одновременно, и именно эти заголовки теперь считаются публичной границей.

Главное обещание сформулировано коротко: несовместимости API и ABI будут отражаться повышением мажорной версии. Авторы прямо пишут, что по мере роста числа пользователей станут строже относиться к тому, чтобы эти границы были видны тем, кто использует библиотеки. За фразой стоит простая арифметика зависимости. Каждая новая интеграция означает ещё одного разработчика, который держит у себя копию вызовов libllama, и чем их больше, тем дороже каждый скрытый перенос функции из одного места в другое.

Вместе с версией команда зафиксировала масштаб проекта. Сейчас llama.cpp поддерживает больше 150 вариантов архитектур моделей и все основные методы спекулятивного декодирования. Ядро, ggml, рассчитано на работу практически на любом железе, а это достигается отдельными бэкендами. Каждый бэкенд можно собрать как динамическую библиотеку и при желании загружать во время работы программы. Этот пункт для обвязки важнее остальных, и ему посвящены отдельные разделы ниже.

Есть и организационная часть. Тестовую инфраструктуру проекта масштабируют с помощью HF Buckets и HF Jobs, а на каждое изменение, которое попадает в основные ветки, прогоняются проверки корректности. Производители железа тоже участвуют: NVIDIA, AMD, Qualcomm, Arm, IBM и Intel предоставляют аппаратную поддержку, и в проект добавляются собственные раннеры на всех основных платформах. Для читателя это значит, что пометка "поддерживается" теперь подкреплена регулярными прогонами на реальных устройствах, а не только на машинах разработчиков.

Правила semver для libllama и libggml и что считается ломающим изменением

Схема использует классическое семантическое версионирование, и расшифровка у неё прямая. Мажорная версия растёт при ломающем изменении публичного C API в каталоге include. Минорная растёт при обратно совместимой функции, обновлении бэкендов, поддержке новой модели или добавлении в API. Патч выходит при исправлении ошибки без изменения API.

Самая интересная строка в этой таблице - обновления бэкендов в минорной версии. Формально они совместимы, то есть собранное приложение продолжит запускаться. Но бэкенд отвечает за то, как именно считаются тензоры на конкретном устройстве, и после обновления у одной и той же модели могут слегка поменяться скорость и численные результаты. Если приложение сравнивает выходы модели с эталоном, минорное обновление тоже надо проверять, и не по одной сборке, а на реальном железе заказчиков.

Второй нюанс касается границы обещания. Правила говорят о публичном C API в каталоге include. Про флаги командной строки утилит, внутренние вспомогательные каталоги и порядок вывода логов в них ничего нет. Если в проекте есть скрипты, которые разбирают текст из llama-server или передают ему длинные цепочки параметров, закладываться на их стабильность по новым правилам нельзя. Безопаснее вызывать библиотеку напрямую через C API или хотя бы обернуть утилиту слоем, который проверяется тестами после каждого обновления.

Третий нюанс: 1.0.0 не значит "больше ничего не изменится". Авторы формулируют иначе: изменения могут случаться и дальше, но их будет видно по номеру версии. Это обещание о предсказуемости, а не о неподвижности, и для планирования релизов продукта оно и нужно.

Миграция вызовов C API с устаревших функций на современные имена

Анонс не содержит списка удалённых функций, поэтому единственный честный способ узнать, что сломается, - сверить свой код с заголовком llama.h тега 1.0.0. Но направление переименований хорошо известно: за последние версии проект перешёл на схему, где вокруг модели, контекста, словаря и памяти собственные группы функций с префиксами llama_model_, llama_vocab_ и llama_memory_. Если обвязка писалась давно, в ней, скорее всего, остались вызовы старого образца.

Основные замены выглядят так:

// было                                   // стало
llama_load_model_from_file(...)           llama_model_load_from_file(...)
llama_free_model(model)                   llama_model_free(model)
llama_new_context_with_model(model, cp)   llama_init_from_model(model, cp)

llama_n_vocab(model)                      llama_vocab_n_tokens(vocab)
llama_token_bos(model)                    llama_vocab_bos(vocab)
llama_token_eos(model)                    llama_vocab_eos(vocab)
llama_token_is_eog(model, tok)            llama_vocab_is_eog(vocab, tok)

llama_kv_cache_clear(ctx)                 llama_memory_clear(llama_get_memory(ctx), true)
llama_kv_cache_seq_rm(ctx, s, p0, p1)     llama_memory_seq_rm(llama_get_memory(ctx), s, p0, p1)

Общий принцип здесь один. Вызовы, которые раньше принимали модель и спрашивали у неё про токены, теперь принимают отдельный объект словаря, который берётся один раз через llama_model_get_vocab. Операции с кэшем контекста переехали в группу работы с памятью. Если привыкнуть к этой логике, остальные переименования угадываются без справочника.

Найти устаревшие места в проекте можно за минуту обычным поиском:

grep -rnE "llama_(load_model_from_file|free_model|new_context_with_model|kv_cache_|kv_self_|n_vocab|token_bos|token_eos|token_is_eog)" src/

Надёжнее добавить к компиляции флаг -Wdeprecated-declarations и временно сделать предупреждения ошибками на последней сборке до 1.0.0. Всё, что компилятор подсветит, надо чинить сразу, а оставшееся проверить при первой же сборке против заголовков 1.0.0. Минимальный образец современной загрузки модели выглядит так:

#include "llama.h"

int main(void) {
    ggml_backend_load_all();

    struct llama_model_params mp = llama_model_default_params();
    mp.n_gpu_layers = 99;

    struct llama_model * model = llama_model_load_from_file("model.gguf", mp);
    if (!model) return 1;

    struct llama_context_params cp = llama_context_default_params();
    cp.n_ctx = 8192;

    struct llama_context * ctx = llama_init_from_model(model, cp);
    const struct llama_vocab * vocab = llama_model_get_vocab(model);
    (void) vocab;

    llama_free(ctx);
    llama_model_free(model);
    return 0;
}

Строка ggml_backend_load_all в начале нужна, когда бэкенды собраны динамически. Без неё библиотека не найдёт ни одного устройства и откажется загружать модель, а в логе появится подсказка про необходимость сначала загрузить бэкенд.

Сборка бэкендов как динамических библиотек и загрузка их во время работы приложения

Обычная сборка вшивает выбранные бэкенды в итоговую библиотеку. Это удобно на одной машине и неудобно в поставке: если собрать приложение с поддержкой CUDA, оно потребует CUDA даже там, где её нет. Режим динамических бэкендов решает проблему. Ядро собирается один раз, а каждый бэкенд ложится рядом отдельным файлом вида libggml-cuda.so или libggml-vulkan.so, и нужный подхватывается при запуске.

Базовый набор флагов для переносимой сборки на Linux выглядит так:

cmake -B build \
  -DBUILD_SHARED_LIBS=ON \
  -DGGML_BACKEND_DL=ON \
  -DGGML_NATIVE=OFF \
  -DGGML_CPU_ALL_VARIANTS=ON \
  -DGGML_CUDA=ON \
  -DGGML_VULKAN=ON \
  -DCMAKE_INSTALL_RPATH='$ORIGIN'
cmake --build build --config Release -j

Каждый флаг решает отдельную задачу. Параметр GGML_BACKEND_DL требует общих библиотек и включает загрузку бэкендов во время работы. Параметр GGML_NATIVE нужно выключить, потому что оптимизация под процессор сборочной машины несовместима с динамической загрузкой. Вместо неё работает GGML_CPU_ALL_VARIANTS: он собирает процессорный бэкенд в нескольких вариантах под разные наборы инструкций, а при запуске выбирается лучший из подходящих. На практике рядом оказываются файлы вроде libggml-cpu-haswell.so и libggml-cpu-sse42.so, и один пакет одинаково работает на старом и на новом процессоре.

Есть и подводный камень. В отчётах об ошибках описан случай, когда файлы процессорного бэкенда лежали не рядом с исполняемым файлом, а в отдельной папке библиотек, и загрузчик их не находил, хотя путь был указан в переменной окружения. Результат - то самое сообщение "no backends are loaded". Поэтому в приложении лучше не рассчитывать на окружение, а указать каталог явно:

#include "ggml-backend.h"

void init_backends(const char * dir) {
    ggml_backend_load_all_from_path(dir);
}

Каталог можно вычислить по пути к самому приложению и передать сюда. Тогда поведение на машине пользователя не зависит от того, какие переменные там выставлены и в каком порядке запускаются процессы.

Собственный бэкенд в виде отдельного файла .so, который подхватывается без пересборки llama.cpp

Динамическая загрузка полезна не только для готовых бэкендов. Если у компании есть собственный ускоритель или нестандартная схема вычислений, бэкенд можно собрать как отдельную библиотеку и подключить через общий вызов загрузки, не трогая исходники llama.cpp. Основой служит функция ggml_backend_load, которая принимает путь к библиотеке и регистрирует найденный в ней бэкенд.

Библиотека должна экспортировать точку входа ggml_backend_init, а необязательная функция ggml_backend_score возвращает оценку пригодности: ноль означает, что бэкенд на этой машине использовать нельзя. Каркас выглядит так:

#include "ggml-backend.h"

ggml_backend_reg_t ggml_backend_mydev_reg(void);

GGML_BACKEND_API ggml_backend_reg_t ggml_backend_init(void) {
    return ggml_backend_mydev_reg();
}

GGML_BACKEND_API int ggml_backend_score(void) {
    return 1;
}

Сама регистрация устройства, выделение буферов и реализация операций - большая работа, и писать её с нуля не нужно. Самый простой существующий бэкенд, например BLAS, удобно взять за образец: у него небольшой набор файлов, и по нему видно, какие функции обязательны. Сборка собственного файла в CMake сводится к нескольким строкам:

add_library(ggml-mydev SHARED mydev.c)
target_link_libraries(ggml-mydev PRIVATE ggml-base)
target_compile_definitions(ggml-mydev PRIVATE GGML_BACKEND_BUILD GGML_BACKEND_SHARED)

Точные макросы экспорта лучше сверить с соседним бэкендом из репозитория нужной версии, чтобы не наступить на различия между платформами. Загрузка в приложении выглядит одной строкой: ggml_backend_load("/opt/myapp/lib/libggml-mydev.so").

Теперь, когда у ABI есть правила, ограничение становится понятным и проверяемым. Бэкенд, собранный против заголовков ggml одной мажорной версии, нужно загружать только в ту же мажорную ветку. Смешивать файлы из версии 1.x и будущей 2.x нельзя: именно для таких случаев и существует повышение мажорной версии. Внутри ветки 1.x собранный файл должен продолжать работать, но проверять это лучше автоматически, а не на веру.

Закрепление версий в продакшене и проверка ABI в CI перед каждым обновлением

До релиза 1.0.0 закреплять приходилось номер сборки, и по самому номеру нельзя было понять, безопасен ли переход. Теперь логика другая: версия сообщает, какого характера изменения внутри. Для сборки из исходников зафиксировать нужный тег можно прямо в CMake:

include(FetchContent)
FetchContent_Declare(llama
    GIT_REPOSITORY ${LLAMA_CPP_GIT_URL}
    GIT_TAG        v1.0.0
)
FetchContent_MakeAvailable(llama)

Для готовых пакетов фиксация делается средствами системы. В Homebrew это команда brew pin llama.cpp, в Debian и Ubuntu подходит apt-mark hold с именем пакета, а образы Docker надёжнее закреплять не по имени, а по цифровому отпечатку содержимого, то есть по sha256. Тогда обновление образа на стороне поставщика не изменит вашу сборку без вашего ведома.

Самую ценную проверку можно добавить в конвейер сборки. Утилита abidiff из набора libabigail сравнивает две версии библиотеки и сообщает, изменился ли бинарный интерфейс. В примере в каталогах old и new лежат собранные и установленные прежняя и новая версии:

abidiff \
  --headers-dir1 old/include --headers-dir2 new/include \
  old/lib/libllama.so new/lib/libllama.so

Ненулевой код возврата означает, что между версиями есть несовместимость. Если версия при этом выросла только минорная, а проверка красная, значит, обещание нарушено, и об этом стоит сообщить авторам. Если же красный цвет появился при переходе на 2.0.0, всё в порядке: так и задумано, и пора читать заметки к релизу.

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

  1. Закреплять в сборке точную версию, а диапазон разрешать только внутри одной мажорной ветки;
  2. Принимать патчи после прогона собственных тестов, не меняя ничего в коде;
  3. Принимать минорные версии после сравнения выходов модели на контрольном наборе, потому что обновления бэкендов могут менять скорость и численные результаты;
  4. Переходить на новую мажорную версию только в отдельной ветке, после чтения заметок и замены устаревших вызовов;
  5. Запускать abidiff в конвейере сборки перед каждым обновлением и считать красный результат сигналом остановиться;
  6. Хранить собранные файлы .so и таблицу версий рядом с релизом приложения, чтобы откат занимал минуты.

Шестой пункт выглядит скучным, пока откат не понадобится. Когда рядом лежит проверенный набор библиотек, вернуть предыдущее состояние можно копированием каталога, а не новой сборкой под давлением времени.

Что стоит за номером версии, то есть каналы поставки, десктопные приложения и неизвестные пока детали

Релиз 1.0.0 сопровождается расширением способов получить библиотеку. Готовые сборки до релиза выходят для Linux, macOS, Windows, Android и iOS. Кроме них доступны образы Docker для Linux, пакеты для Debian, Homebrew на macOS и Linux, conda-forge для Windows, macOS и Linux, MacPorts на macOS, Winget на Windows и XCFramework для iOS и macOS. Для проекта, который собирает продукт из нескольких платформенных версий, такой список означает меньше собственных сборочных скриптов и меньше расхождений между системами.

Отдельно команда упомянула десктопные приложения Llama для macOS и Windows, созданные теми, кто делает llama.cpp вместе с Hugging Face. Это показывает, что библиотека используется не только как внутренняя деталь чужих продуктов, но и как основа готового пользовательского решения. В комментариях к анонсу сразу спрашивали про версию для Linux, и ответа на этот вопрос пока нет.

Остаются вещи, которых анонс не раскрывает. Нет перечня функций, удалённых или переименованных вместе с версией 1.0.0, поэтому первый проход по коду придётся делать самостоятельно, сверяя вызовы с заголовками. Нет обещаний по стабильности производительности: semver защищает форму интерфейса, а не скорость на конкретной видеокарте. И нет срока, когда ждать версию 2.0.0, но это как раз нормально: мажорная версия выйдет тогда, когда появится причина ломать API, и теперь об этом хотя бы предупредят.

Практический вывод из всего этого прост. Обвязку вокруг llama.cpp стоит привести в порядок сейчас, пока вокруг релиза тихо: заменить устаревшие вызовы, перевести бэкенды на динамическую загрузку, закрепить версии и подключить проверку ABI. Затраты на это невелики, а результат заметен при первом же обновлении: оно перестаёт быть событием и становится обычной рабочей операцией.