Архив рубрики ~Обо всем~

Предварительное заполнение один раз, распространение по сети: обмен снимками ключ-значение для многоагентных конвейеров обработки данных LLM.

Предварительное заполнение один раз, распространение по сети: обмен снимками ключ-значение для многоагентных конвейеров обработки данных LLM.

Системно-инженерный подход к повторному использованию кэша ключ-значение, доказывающий, что вычисление общей подсказки один раз превосходит N параллельных предварительных заполнений каждый раз.

Делиться

acf42a1797ba1dff63596e927406731e

Юмористический, но реалистичный обзор SwarmKV — разветвление снимков ключ-значение, буферы хоста с копированием при создании ветви и как сделать аналитический конвейер с двумя агентами примерно в 1,95 раза быстрее (и сократить задержку активации второй ветви в 52 раза), слегка издеваясь над файлом llama.cpp.

Это первая часть серии статей «Производственный уровень агентного вывода» . Каждая часть устраняет один из видов избыточной работы в конвейере агентного LLM. В первой части (этой статье) устраняется избыточное предварительное заполнение. Во второй части рассматривается избыточное ожидание — как 50 микроагентов совместно используют один графический процессор с помощью разделения по времени. В третьей части получение RAG-данных остается на графическом процессоре с помощью пользовательского ядра CUDA Top-K. В четвертой части состояние агента сохраняется при передаче управления, чтобы у следующего агента никогда не возникала проблема холодного запуска.

Основные выводы

Проблема: когда несколько агентов читают один и тот же документ, стек обработки по умолчанию заставляет каждого из них повторно запускать одно и то же предварительное заполнение. Этот избыточный, трудоемкий проход механизма внимания — чистая трата ресурсов.

Решение: выполнить предварительное заполнение один раз, сериализовать кэш ключ-значение в буфер хоста, memcpy его в память для каждой ветви и восстановить перед декодированием. «Вычислить один раз, распределить по ветвям».

Результаты: на семилетней видеокарте GTX 1080 конвейер обработки данных с двумя агентами стал быстрее на 48,69% в сквозном режиме (~1,95×), а задержка активации второго агента снизилась на 98,09% (~52×), что позволило исключить 8685 мс избыточных вычислительных ресурсов.

Самое интересное: это не новый алгоритм. Это системная инженерия — и это то же самое решение «однократно передавать общее состояние», которое вышка сотовой связи 5G принимает каждые 80 мс со времен LTE.

Вкратце : стандартная обработка LLM-данных заставляет каждого аналитического агента повторно заполнять один и тот же общий документ. Ваш графический процессор добросовестно выполняет миллиарды избыточных умножений префикса на предварительное заполнение. Те же байты. Те же веса. То же квантование. Все это для пересчета состояния, вычисление которого уже было завершено четыре секунды назад. SwarmKV выполняет предварительное заполнение один раз, сериализует полученное состояние ключ-значение в буфер хоста с помощью llama_state_get_data , memcpy этот буфер в выделенную память для каждой ветви и позволяет каждой ветви восстановить снимок с помощью llama_state_seq_set_data перед декодированием с того места, где документ остановился. Да, это реальный цикл — сериализация, копирование, восстановление, но поскольку избыточные вычисления предварительного заполнения масштабируются квадратично, а передача состояния ключ-значение — линейно, перемещение данных по ограниченной шине памяти Pascal все равно значительно дешевле, чем пересчет матриц внимания с нуля. Это отражается на результатах, полученных на семилетней GTX 1080: ускорение на 48,69% в сквозном режиме для конвейера с двумя агентами, снижение задержки активации ветви 2 на 98,09% (~52×), устранение 8685 мс избыточных вычислений в плотной области, отсутствие новых приемов работы с трансформерами. Уже сама позиция системных инженеров, согласно которой «вычислить один раз, разветвить» лучше, чем «вычислить N раз, надеясь, что никто не заметит».

Репозиторий на GitHub : https://github.com/AnubhabBanerjee/swarmkv

(Небольшое признание, прежде чем мы начнем: я пришел к этому с опытом работы в области проектирования сетей RAN для 5G/6G. Как оказалось, распределение общих вычислений между множеством потребителей на нижестоящих устройствах поразительно близко к тому, что делает базовая станция каждые 80 мс со времен LTE, когда она передает сигнал SIB1. Об этом есть целый раздел ниже — раздел 8 — но именно поэтому я и пишу это.)

Архитектурная ментальная модель — держите её открытой во время чтения.

Document → PrefillNode → llama_state_get_data → host KV buffer → memcpy per branch → llama_state_seq_set_data → AnalyticalNode decode (RoPE continues at prefix_seq_len)

Всё, что ниже приведено, — это лишь комментарии к одной части этой строки.

518d7ba7643a9a333e82ae8199cec1da
Архитектурный обзор SwarmKV

1. Признание: большая часть «работы» вашего второго агента — это повтор.

Если вы когда-либо направляли два аналитических агента на один и тот же документ через стандартный llama.cpp, вот что происходит на самом деле (с небольшим намеренным преувеличением):

Вы: «Пожалуйста, предоставьте краткий обзор спецификации этого продукта, включающей 3500 токенов, и отдельно перечислите лицензионные обязательства».

llama.cpp (Агент 1): «Конечно. Загрузка модели. Предварительное заполнение документа. Расшифровка ответа».

Графический процессор тратит 4346 мс на обработку плотного внимания.

llama.cpp (Агент 1): «Готово. Вот ответ из 6 токенов».

Вы: «Отлично. Теперь агент 2».

llama.cpp (Агент 2): «Конечно. Загрузка модели. Предварительное заполнение документа —»

Вы: «Подождите, вы же только что это сделали».

llama.cpp (Агент 2): «Я независимый llama_context . У меня нет воспоминаний об Агенте 1. У меня нет воспоминаний ни о чём. Я прекрасный новорождённый, не имеющий собственного государства». 🫡

Графический процессор тратит еще 4339 мс на побитово идентичные вычисления с механизмом внимания.

Термодатчик вашей видеокарты: отлично поработал;
Ваш счёт от AWS: развивает чувство юмора;
Время ответа вашего второго агента на вопрос из 4 токенов: 4,3 секунды.

В этом и заключается вся шутка. В этом и заключается грязный секрет каждого «агентного» конвейера, который разветвляется на основе общего документа. Каждая ветвь начинается с чистого листа и перестраивает тот же кэш ключ-значение, который только что построила предыдущая ветвь. Чем глубже документ, тем больше «налог». При 3500 токенах на графическом процессоре Pascal большая часть воспринимаемой задержки второго агента — это не решение проблемы, а повторное чтение документа.

SwarmKV — это результат решения о том, что второе чтение необязательно, и что лучше написать 1500 строк кода на C++, чем позволить каждому агенту снова и снова создавать один и тот же кэш ключ-значение.

Теперь представьте, что демонстрационный пример в этом репозитории посвящен двум агентам, занимающимся проверкой сводки/лицензии. Реальная рабочая нагрузка, для которой он создан, — это N специализированных экспертов, работающих над одним сложным техническим документом. Представьте себе конвейер обработки патентов и предшествующего уровня техники в области ИИ: одна техническая спецификация из 50 000 токенов в корне и пятьдесят параллельных ветвей, оценивающих новизну, сопоставляющих формулы изобретения, извлекающих предшествующий уровень техники, проверяющих свободу действий, оценивающих этическое соответствие и переводящих на язык, специфичный для конкретной юрисдикции. Базовая стоимость этого конвейера на стандартном стеке обслуживания составляет пятьдесят полных предварительных заполнений одной и той же спецификации. Стоимость SwarmKV — одно предварительное заполнение плюс пятьдесят операций memcpy . Эта асимметрия намеренно разработана, и именно поэтому существует этот репозиторий. Я отдельно писал об обнаружении ИИ в отчетах об изобретениях — это инфраструктурная часть этой работы. Проблемы, связанные с блокнотом изобретателя, — это именно та причина, по которой построен SwarmKV.

2. Зачем вообще нужна функция предварительного заполнения? (краткий курс за одну минуту)

Если вы уже всё знаете, пропустите этот раздел. Для всех остальных — краткая версия.

Авторегрессивный LLM обрабатывает запрос в два этапа. Предварительное заполнение — это плотный проход, в ходе которого каждый токен запроса проходит через каждый слой преобразователя один раз и заполняет кэш ключ/значение (KV) для каждого слоя. Затем декодирование выполняется токен за токеном, обращаясь к предварительно заполненному кэшу KV и постепенно расширяя его.

Стоимость предварительного заполнения растет примерно линейно с длиной запроса. Декодирование, напротив, обходится дешево в расчете на токен. На видеокарте GTX 1080 класса Pascal под управлением Qwen2.5-7B Q4_K_M предварительное заполнение документа из ~3500 токенов занимает около 4,3 секунды; декодирование короткого запроса на переход занимает сотни миллисекунд, поскольку оно в основном связано с настройкой, а не с арифметическими операциями. Эта разница во времени между предварительным заполнением и декодированием — именно то преимущество, которое использует SwarmKV.

Основные стеки обработки запросов (vLLM, TGI, SGLang, собственный сервер llama.cpp) рассматривают каждый запрос как независимый контекст. Некоторые из них используют кэширование префиксов, но обычно оно ограничено областью действия запроса или сессии, а не графа. Они созданы для максимизации пропускной способности при обработке множества независимых запросов пользователей, а не для совместного использования состояния внутри одного аналитического конвейера, который разветвляется из одного общего документа. Для такой рабочей нагрузки в виде направленного ациклического графа (DAG) — один корень, много листьев, одни и те же данные — каждый публичный стек, который я пробовал, заставлял меня платить за корень один раз за каждый лист.

SwarmKV служит явным уровнем оркестровки, используемым в C++ для обхода абстракций времени выполнения, гарантирования детерминированного жизненного цикла указателей и повышения эффективности копирования memcpy на аппаратном уровне.

3. Загадка «просто сделайте снимок КВ» (и почему это сложнее, чем кажется)

Суть предложения проста:

  1. Выполните предварительное заполнение один раз для общего документа с идентификатором последовательности kSwarmkvPrefixSeqId .
  2. Последовательно перенесите полученное состояние ключ-значение в буфер хоста с помощью llama_state_get_data .
  3. Для каждой нижестоящей ветви выполните копирование этого буфера с помощью memcpy в выделенную для каждой ветви область.
  4. Создайте новый llama_context , вызовите llama_state_seq_set_data для установки снимка, затем декодируйте приглашение к ветвлению с позициями RoPE, продолжающимися от prefix_seq_len .

Это парадигма «вычислить один раз, разветвить». Единственная причина, по которой для её реализации требуется более 30 строк кода llama.cpp заключается в том, что три утомительных крайних случая сразу же нарушают наивный подход. Концепция удивительно проста и должна быть лёгким проектом на выходные, но реалии низкоуровневого оборудования и систем делают её реализацию огромной инженерной задачей.

Задача А: Каков размер КВ?

Простой ответ: n_layers × n_head_kv × n_ctx × head_dim × dtype × 2 Однако это число, полученное вручную, меняется каждый раз, когда изменяется формат квантизации, каждый раз, когда изменяется коэффициент GQA, или каждый раз, когда движок добавляет новое поле состояния. Единственное достоверное число — это то, которое движок показывает в текущей сборке.

Таким образом, MemoryPool создает одноразовый llama_context исключительно для того, чтобы задать следующий вопрос:

 size_t MemoryPool::get_required_kv_size(uint32_t n_ctx) { // Start from library defaults so fields we do not care about remain sane. llama_context_params params = llama_context_default_params(); params.n_ctx = n_ctx; // Construct a disposable context solely to query serialized state footprint. llama_context * ctx = llama_init_from_model(model_ref, params); if (!ctx) { throw std::runtime_error("MemoryPool::get_required_kv_size: llama_init_from_model failed."); } // Ask llama.cpp how many bytes a full state blob would occupy for this ctx. const size_t sz = llama_state_get_size(ctx); llama_free(ctx); // If the engine reports zero, fall back to a small non-zero allocation so tests // still exercise the registry without pretending we know exact tensor layouts. if (sz == 0) { return size_t{1} << 20; } return sz; }

Логика здесь проста: вежливо запросить у движка данные, вместо того чтобы доверять PDF-файлу или математической формуле. Просто создайте контекст, запросите размер и выделите ровно столько, сколько необходимо — простой, но очень эффективный рецепт.

Проблема B: llama.cpp очень привередлив в отношении параллельного декодирования.

В используемой в этом проекте версии llama.cpp , закрепленной в репозитории, и конфигурации GPU одновременное декодирование из нескольких потоков на одном GPU не было надежно безопасным. Точное поведение зависит от бэкенда, версии и планировщика графов — в более новых версиях или с изолированными потоками оно может работать лучше, — но в нашей конфигурации режимы сбоев были одними из следующих: (а) сбой, (б) поврежденный ключ-значение или (в) зависание на десять минут, пока вы ищете в Google, есть ли у ggml уже локальная среда для потоков. Спойлер: в закрепленной версии репозитория — нет.

Наиболее надежным решением является сериализация поверхности API ламы на границе:

 namespace swarmkv { // llama.cpp CUDA paths are not safe for concurrent decode from multiple threads // on one GPU without external serialization. All node execute() bodies must // hold this mutex around llama_init / llama_decode / llama_free / state I/O. inline std::mutex & llama_api_mutex() { static std::mutex m; return m; } } // namespace swarmkv struct LlamaGuard { std::lock_guard lock; LlamaGuard() : lock(swarmkv::llama_api_mutex()) {} };

Простой заголовок из 20 строк определяет всю политику параллельного выполнения. Тело функции execute() каждого узла содержит эту информацию в виде строк llama_init_from_model / llama_decode / llama_state_seq_set_data / llama_free . Параллельное выполнение на уровне DAG реально (фьючерсы, зависимости, разветвление); вычисления на GPU чередуются под глобальной блокировкой. Педанты справедливо заметят, что это значительно снижает производительность по сравнению с гипотетическим параллельным декодированием в вышестоящем потоке. Запомните эту мысль — именно это узкое место рассматривается во второй части этой серии.

Проблема C: Отсутствует стабильный внешний API для привязки ключ-значение.

Идеальная с эстетической точки зрения реализация заключалась бы в выделении одного непрерывного буфера ключ-значение, непосредственном подключении его к новому контексту и полном отказе от копирования memcpy . В исходном коде llama.cpp доступны пути llama_memory_t и декодирования графа, но в общедоступном заголовочном файле, закрепленном в этом репозитории, отсутствует стабильный экспортируемый символ в стиле llama_kv_cache_bind .

Поэтому SwarmKV делает наилучший из возможных вариантов: он сохраняет место вызова, дает ему честное имя и записывает этот путь поверх функции llama_state_set_data .

 void KVHandoff::bind_contiguous_cache(llama_context * ctx, ggml_backend_buffer_t cache) { // Validate arguments so misuse fails fast during bring-up and CI smoke runs. // A null context cannot decode; a null cache handle is a configuration bug. if (!ctx || !cache) { throw std::invalid_argument("KVHandoff::bind_contiguous_cache: null context or buffer."); } // Explicitly mark both parameters as intentionally unused in this revision. // This prevents -Wunused-parameter warnings under strict warning flags. (void) ctx; (void) cache; // No stable bind call is issued here; see file-level comment above. // When upstream adds a supported attachment API, implement it only in this function. }

Я знаю, я знаю. Это функция, которая ничего не делает. У неё есть полная проверка аргументов, строка документации вдвое длиннее тела функции и стабильное положение в графе вызовов. Она терпеливо ждёт того дня, когда разработчики позволят ей наконец-то выполнить свою работу. Я писал более честный код в своей жизни, просто не помню, когда!

Здесь внимательные читатели могут задаться вопросом: «Подождите, если bind_contiguous_cache ничего не делает, для чего вообще нужен буфер MemoryPool ?» Отличный вопрос. Это промежуточная область — канонический буфер, куда PrefillNode записывает свой блок llama_state_get_data , и источник, из которого каждая ветвь memcpy данные. Decode сам использует внутренне управляемый ключ-значение контекста. Буфер пула = временный буфер для разветвления данных на стороне хоста; ключ-значение контекста = собственная структура движка. Две области памяти, один снимок, никакой магии.

4. Пятиэтапный конвейер (самая интересная часть)

 Step 0: Validate doc + max_branch + 128 ≤ n_ctx (context_budget.h, fail-fast) Step 1: Build the DAG; DFS-check for cycles (Orchestrator) Step 2: Spawn std::async workers; gate on futures (Orchestrator) Step 3: Prefill once, serialize KV to host buffer (PrefillNode + MemoryPool) Step 4: memcpy snapshot → branch buffer → decode (AnalyticalNode + KVHandoff)

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

Шаг 0 — Контекстный бюджет для быстрого выявления и устранения ошибок

Три строчки, которые спасут вас от сообщения в Slack в 3 часа ночи от вас самих из прошлого:

 const int32_t required = prefix_tokens + max_branch + generation_headroom; if (required > limit) { throw std::runtime_error( "Context budget exceeded: prefix_tokens=" + std::to_string(prefix_tokens) + " max_branch_tokens=" + std::to_string(max_branch) + " headroom=" + std::to_string(generation_headroom) + " required=" + std::to_string(required) + " n_ctx=" + std::to_string(limit)); }

Этот процесс выполняется до того, как будет создан какой-либо контекст, выделен какой-либо буфер пула или задействована какая-либо память графического процессора. Если вы попросите SwarmKV предварительно заполнить 4000 токенов в контексте n_ctx=4096 с двумя ветвями и 128 токенами запаса декодирования, он сообщит вам, что вычисления не работают, и перейдет в спящий режим. Самое лучшее, что вы можете сделать для себя в будущем, — это отклонить невозможные конфигурации еще до выделения первого байта.

Шаг 1 — Выявление цикла DAG

Оркестратор выполняет стандартный трехцветный поиск в глубину (DFS) по списку смежности зависимостей:

 // dfs lambda walks adjacency lists and throws when a back-edge indicates a cycle. auto dfs = [&](auto self, const std::string & u) -> void { // Mark node u as currently on the recursion stack (visiting). state[u] = 1; // Explore all outgoing dependency edges from u to downstream nodes v. for (const auto & v : adj[u]) { // If v is visiting, we found a cycle u -> v and must abort pipeline setup. if (state[v] == 1) { // Throw with edge names so graph misconfiguration is easy to diagnose. throw std::runtime_error("Dependency cycle detected: " + u + " -> " + v); } // Recurse only when v has not been fully processed yet. if (state[v] == 0) { // Continue DFS from child node v. self(self, v); } }

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

Шаг 2 — Один std::async на узел, управляемый через общие фьючерсы.

 worker_tasks.push_back(std::async( std::launch::async, [this, name, state, dependencies, &completion_promises, &completion_futures]() { // Read this node's watermark requirement once for dependency gating decisions. const int32_t req = nodes.at(name)->required_prefix_tokens(); // Wait for each upstream dependency according to V2 watermark rules. for (const auto & dep_name : dependencies) { // Resolve upstream node pointer for prefill provider detection. ExecutionNode * dep = nodes.at(dep_name).get(); // If upstream is prefiller and this branch uses watermark gating, wait on watermark. if (dep->is_prefill_provider() && req >= 0) { // Block until PipelineState watermark >= required_prefix_tokens (speculative start). state->wait_for_watermark(req); } else { // Otherwise preserve V1 behavior: wait until upstream node thread completes. completion_futures.at(dep_name).wait(); } } // Build llama_context_params with orchestrator default n_ctx budget. llama_context_params params = llama_context_default_params(); // Lift n_ctx to SwarmKV default pipeline context for multi-k token documents. params.n_ctx = kSwarmkvDefaultPipelineCtx; // Bundle model/pool/name into OrchestratorContext for node execute(). OrchestratorContext ctx = { this->memory_pool->get_model(), params, this->memory_pool, name.c_str(), }; // Run node logic and fulfill promise so dependents can proceed. try { // Dispatch to PrefillNode or AnalyticalNode implementation. nodes.at(name)->execute(state, &ctx); // Signal successful completion to shared_future waiters. completion_promises.at(name).set_value(); } catch (...) { if (req > 0) { state->signal_milestone_consumed(req); } try { completion_promises.at(name).set_exception(std::current_exception()); } catch (...) { } throw; } }));

Один std::promise на узел, с std::shared_future , чтобы несколько нижестоящих ветвей могли ожидать завершения одной и той же вышестоящей ветви, не прибегая к механизму pass-the-future. Путь обработки ошибки всегда устанавливает исключение, поэтому зависимые ветви не ждут бесконечно. Мы все отлаживали альтернативный вариант, и, боже мой, как же нам это не понравилось!

Обратите внимание, чего нет в этом цикле: никакой логики, касающейся предварительного заполнения, ключ-значение или ветвлений. Оркестратор не знает, что такое PrefillNode . Он знает только имена, ребра и промисы. Работа, специфичная для узла, выполняется в execute() и полностью полиморфна за виртуальным интерфейсом ExecutionNode . Дочерний узел отвечает только за одну задачу, что совсем не перегружает!

Шаг 3 — Заполните поле один раз, экспортируйте KV.

PrefillNode выполняет четыре действия в следующей последовательности:

  1. Прочитайте текст документа из examples/base_doc.txt .
  2. Разбейте его на токены (используя идиому "изменение размера при отрицательном результате").
  3. Декодируйте токены блоками, ограниченными параметром llama_n_batch(lctx) , на дорожке последовательности kSwarmkvPrefixSeqId , используя абсолютные позиции RoPE, соответствующие абсолютному индексу токена:
 // Absolute RoPE position equals index in the full document token stream. batch.pos[i] = cur + i; // Each token belongs to exactly one sequence id list. batch.n_seq_id[i] = 1; // Bind all document tokens to the shared prefix sequence lane constant. batch.seq_id[i][0] = kSwarmkvPrefixSeqId; // Disable logits during prefill except we keep zeros for all tokens here. batch.logits[i] = 0;

4. Экспортируйте последовательность префиксов KV в канонический буфер хоста и добавьте водяной знак для ветвей.

 KVHandoff::bind_contiguous_cache(lctx, state->materialized_branch_buffer); // Mark prefill_complete so branches using kSwarmkvWaitForPrefillComplete can proceed. state->mark_prefill_complete();

В этом и заключается вся суть статьи, изложенная в двух строках. Всё остальное в этом репозитории — оркестратор, LlamaGuard, проверка бюджета, задокументированная операция бездействия — существует для того, чтобы передавать эти две строки кода и доставлять их результаты в ветки одним memcpy без дополнительных запросов.

Шаг 4 — Декодирование ветвей с использованием LlamaGuard

1. Выделите для каждой ветки буфер размером, соответствующим n_ctx, как и префикс:

 // Allocate a branch buffer sized for n_ctx so later decode has headroom in the same blob policy. branch_buf = ctx->memory_pool->allocate_branch_cache(static_cast(ctx->ctx_params.n_ctx));

2. Скопируйте канонический снимок в буфер ветки, или, как это еще называют, "копирование при создании форка":

 // Full-prefill path copies from canonical staging into the branch allocation. KVHandoff::materialize_branch_cache( state->materialized_branch_buffer, branch_buf, fork_kv_bytes);

…который, если вы перейдете по ссылке, станет

 std::memcpy(dst_ptr, src_ptr, ncopy);

Вот и всё. Это «копирование при создании дочернего процесса на уровне хранения», что буквально означает memcpy . Это примитив. Все сложные механизмы, о которых вы читали в отношении совместного использования префиксов — подсчет ссылок в RadixAttention, косвенное обращение к таблице блоков в paged attention — основаны на одной и той же идее: не пересчитывать, а копировать байты.

3. Создайте новый llama_context и восстановите снимок:

 // Restore only the prefix sequence lane so branch decode stays isolated on seq 0. const size_t n = llama_state_seq_set_data( lctx, static_cast(base), fork_kv_bytes, kSwarmkvPrefixSeqId); // Verify llama consumed exactly the number of bytes we copied into the branch buffer. if (n != fork_kv_bytes) { // Free the context before throwing to avoid leaking VRAM on failure paths. llama_free(lctx); // Throw with a clear message so operators can debug size mismatches quickly. throw std::runtime_error("AnalyticalNode: llama_state_seq_set_data size mismatch."); }

4. Создайте единый llama_batch для приглашения короткого перехода, при этом позиции RoPE будут продолжаться с того места, где закончился префикс:

 for (int i = 0; i < batch.n_tokens; ++i) { // Copy the i-th branch token id into the batch slot. batch.token[i] = tokens[static_cast(i)]; // Place branch tokens immediately after the forked prefix positions for correct RoPE. batch.pos[i] = static_cast(fork_prefix_len) + static_cast(i); // Each token participates in exactly one sequence id list entry. batch.n_seq_id[i] = 1; // Bind all branch tokens to the shared prefix sequence lane constant. batch.seq_id[i][0] = kSwarmkvPrefixSeqId; // Disable logits for all tokens except the last one in this branch step. batch.logits[i] = 0; }

Вот тут все ошибаются с первого раза. Если забыть смещение и начать ветвление с нуля, вращательные эмбеддинги незаметно смещаются в сторону, и модель декодирует данные из позиции, для которой префикс никогда не был предназначен для обучения. Симптом – это уверенная, логичная бессмыслица. Добро пожаловать в худший вид ада отладки; пожалуйста, оставьте чаевые на выходе.

5. Наконец, один llama_decode . Просто запишите диагностическую строку в PipelineState::node_outputs , запишите timings_ms[name] , освободите пакет и контекст.

Три строки бизнес-логики на каждую ветвь. Два контекста в процессе декодирования. По одному копированию памяти на каждую. Одна глобальная блокировка. Всё остальное — техническая инфраструктура.

5. Чеки (т.е., цифры)

Сейчас самое время сравнить результаты с базовым показателем и посмотреть, стоило ли прилагать все эти усилия. Все данные взяты из examples/example-run-results/ .

Краткое замечание по методологии, прежде чем кто-либо начнет критиковать: каждое сравнение ниже использует одну и ту же модель ( Qwen2.5-7B-Instruct-Q4_K_M.gguf ), один и тот же документ (детерминированный синтетический документ из 3501 токена, сгенерированный путем повторения фразы «Быстрая коричневая лиса перепрыгивает через ленивую собаку» до достижения целевого значения токена — examples/base_doc.txt ), один и тот же графический процессор (GTX 1080, 8 ГБ, Pascal sm_61), один и тот же n_ctx=4096 и один и тот же тип данных. Базовый вариант = два последовательных экземпляра llama_context , каждый из которых предварительно заполняет весь документ, а затем декодирует подсказку для ветвления. SwarmKV = PrefillNode один раз + два AnalyticalNode ветвления по снимку. Тип рабочей нагрузки: анализ документов с преобладанием предварительного заполнения (в стиле RAG), а не авторегрессивный чат. Три испытания выполняются подряд с задержкой в режиме ожидания графического процессора между ними; Наилучший вариант выбирается с помощью 2·TTFT_pct + E2E_pct .

Перед таблицей, напомним об одном важном определении метрики: мы используем «Задержка активации ветви 2 (TTFT proxy)» , а не стандартную для сервисов TTFT «поступление запроса → первый выходной токен». Мы имеем в виду время, которое вторая ветвь тратит на работу, специфичную для этой ветви: задержка активации после того, как общее предварительное заполнение распределяется между всеми ветвями. В конвейере с разветвлением затраты, которые ощущает конечный потребитель, составляют именно это число, поскольку предварительное заполнение в вышестоящем потоке оплачивается один раз за весь конвейер по умолчанию. Базовое значение для этой метрики — это избыточное предварительное заполнение документа, которое второй llama_context вынужден переделать, прежде чем сможет ответить; значение SwarmKV — это форк + восстановление + декодирование короткого запроса.

Заголовок: GTX 1080, Qwen2.5-7B Q4_K_M, документ на 3501 токен, две ветки

Метрическая система Базовый уровень (в стиле HF) SwarmKV Дельта
Настенные часы, охватывающие всю длину корпуса. 10 275 мс 5272 мс −48,69 % (~ 1,95× )
Задержка активации ветви 2 (прокси TTFT) 4339 мс 83 мс −98,09 % (~ 52,3× )
Базовый препарат Agent-1 (предварительное заполнение) 4346 мс
Базовый предварительный заполнение Agent-2 4339 мс
Декодирование каждой ветви в SwarmKV (в среднем) 77 мс
Устранена избыточная предварительная заливка. 8685 мс

Перевод: базовый вариант потратил 4339 мс из воспринимаемой задержки второго агента на повторное выполнение плотного этапа внимания, который он только что завершил четырьмя секундами ранее на тех же байтах. SwarmKV анализирует это и спрашивает: «А что, если бы мы этого не сделали?», и выдает ответ с задержкой в 83 миллисекунды. Наиболее точный однозначный показатель «насколько затратным было это предварительное заполнение?» — это просто отношение этих двух временных интервалов; все остальное в ветви — это ошибка округления.

Куда уходит примерно 83 мс на каждую ветвь

Основная идея всей этой статьи основана на одном неравенстве: восстановление и декодирование для каждой ветви обходится гораздо, гораздо дешевле, чем избыточное предварительное заполнение документа. Инструмент измеряет это напрямую на агрегированном уровне — время выполнения для каждой ветви (выделение + копирование + восстановление + декодирование, от начала до конца) составляет 71–83 мс в зависимости от рассматриваемой ветви, тогда как стоимость избыточного предварительного заполнения составляет около 4339 мс . Примерно 52-кратное увеличение на агрегированном уровне — вот что обеспечивает работоспособность всего остального в этой статье.

Для получения более подробных результатов и цифр я предлагаю вам ознакомиться непосредственно с примером отчета о выполнении программы.

6. «Хорошо, но чем это отличается от vLLM / префиксного кэширования / RadixAttention для SGLang?»

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

  • vLLM / непрерывная пакетная обработка / страничное внимание. Оптимизировано для обслуживания во время декодирования в многопользовательском режиме: множество одновременных запросов на разных этапах декодирования, планирование следующего токена между ними при потоковой нагрузке. Основной примитив: страничное внимание. Единица работы: поток независимых запросов от пользователей.
  • Кэширование префиксов TGI/vLLM. Отлично подходит, если ваш общий префикс ограничен областью действия запроса или сессии. Не предназначено для предоставления снимков ключ-значение в качестве объектов первого класса, которые можно передать другому llama_context , выполняющему другую задачу в том же процессе.
  • SGLang RadixAttention. Древовидное совместное использование префиксов внутри среды выполнения сервера — ближайший аналог, но это сервер, а не примитив оркестровки для одного процесса.
  • Функция сохранения/восстановления состояния в llama.cpp. Существует в зависимости от контекста. SwarmKV — это связующее звено на уровне конвейера: направленный ациклический граф (DAG), буферная арена хоста, размер которой определяется самим движком, механизм разветвления memcpy, политика LlamaGuard и документированная операция, не выполняющая никаких действий, терпеливо ожидающая API-интерфейса привязки от вышестоящего разработчика.

7. Итак… как же мне это попробовать?

Что ж, я уже разместил ссылку на GitHub в начале статьи. Если вы дочитали до этого места, пожалуйста, ещё раз постарайтесь и прокрутите страницу вверх.

В папке examples/example-run-results/ находятся следующие файлы и материалы: best_run.json , all_trials.csv , plots/*.png , а также документ final_result.docx , в котором подробно описывается методология и ограничения.

Требования: Linux, инструментарий CUDA, графический процессор NVIDIA (Pascal или более новая модель; подойдут как потребительские, так и для дата-центров), модель GGUF, помещающаяся в вашу видеопамять, и терпение, чтобы один раз прочитать файл CMake.

8. Неожиданный поворот сюжета — это всего лишь трансляция SIB в костюме трансформера.

84b5cf8b5ad9d3d8337e0e73fb112e44

Наверное, стоит признаться: я не специалист по графическим процессорам по образованию. Я начинал свою карьеру в телекоммуникационной отрасли — 5G NR с неразрывным проникновением в исследования 6G — и начал изучать инфраструктуру для инференции LLM, потому что каждая проблема в этом коде казалась мне странно знакомой.

Для читателей без опыта работы с 3GPP: в сети 5G базовая станция не отправляет конфигурацию сети каждому телефону отдельно — она передает небольшой набор блоков системной информации (SIB1, SIB2, …) один раз по общему каналу, каждый телефон в зоне действия считывает один и тот же широковещательный сигнал, а данные для каждого пользователя передаются поверх этого общего контекста по выделенному каналу. Аббревиатуры в таблице ниже — MIB (Master Information Block, первое, что считывает каждый телефон), PBCH и PDSCH (общие широковещательные и нисходящие каналы передачи данных), HARQ (протокол повторной передачи приемника «сохранить уже декодированное, повторно отправить только недостающее») и RNTI (временный идентификатор, который отличает трафик одного телефона от трафика другого) — это просто названия каналов и идентификаторов, которые разделяют общий, вычисляемый один раз, от уникального для каждого потребителя. В этом и заключается вся аналогия.

Сравните эти два примера и скажите мне совершенно серьезно:

Трансляция данных в сотовой сети 5G NR (на базовой станции gNB) SwarmKV (на графическом процессоре)
Один MIB на PBCH на каждый SS-пакет Один общий документ токенизирован один раз.
Повторяющиеся SIB (SIB1, SIB2, …) на PDSCH Последовательный снимок ключ-значение в пуле памяти
Каждый подключенный к сотовой сети абонентский терминал (UE) считывает один и тот же сигнал SI. Каждая аналитическая ветвь считывает один и тот же снимок состояния системы.
Специализированный для UE канал PDSCH для одноадресной передачи пользовательских данных. Декодирование приглашения к вводу/выводу для каждой ветки llama_context
RNTI для каждого UE позволяет различать одноадресные потоки. Буфер для каждой ветви + идентификатор последовательности позволяют различать состояние ветви.
Мягкий буфер HARQ сохраняется при повторных передачах Снимок KV сохраняется во всех филиалах.
Пропуск широковещательной передачи → каждый UE принудительно переключается на одноадресную передачу SI → интерфейс радиосвязи выходит из строя Пропустить снимок → каждая ветка заново заполняет документ → графический процессор перегревается

Небольшое замечание, адресованное двум совершенно разным аудиториям.

Моим друзьям, работающим с высокопроизводительными вычислениями и CUDA: я знаю. Повторное использование ключ-значение — не новая идея. vLLM использует префиксное кэширование, SGLang — RadixAttention, а сам llama.cpp предоставляет возможность сохранения/восстановления состояния. Вклад SwarmKV заключается не в примитиве, а в структуре оркестровки для одного процесса — крошечной среде выполнения DAG на C++, которая предоставляет возможность «предварительного заполнения один раз, разветвления на N ветвей» как первоклассную операцию, рассчитанную на одну потребительскую видеокарту с 8 ГБ памяти, с механизмами безопасности ( LlamaGuard , swarmkv_validate_context_budget , документированная операция bind no-op), которые действительно необходимы исследователю для выпуска демо-версии в любой момент. Пожалуйста, прекратите возмущаться.

Моим друзьям из телекоммуникационной отрасли: если еще десять минут назад «кэш КВ» звучал для вас как иностранный язык, вы не отстаете — вы опережаете события. Двадцать лет наш мир состоял из FPGA, ASIC и PRB. Мы оптимизировали спектр, а не кремний. Затем AI-RAN, NWDAF, NVIDIA Aerial, альянс AI-RAN и исследования 3GPP Rel-20 — все это произошло примерно за те же восемнадцать месяцев, и следующее десятилетие карьеры в телекоммуникационной отрасли теперь требует двуязычия между миром спектра и миром GPU. Интуиция понятна. Вы распределяли общие вычисления между множеством потребителей с момента первого пилотного проекта CRS. То же самое, просто новый зоопарк.

9. Честные оговорки (потому что комментарии не заставят себя долго ждать)

Если вы пришли сюда, чтобы узнать, что не так с проектом, — поздравляем, проект нашел своего первого читателя. Из раздела об ограничениях файла final_result.docx и встроенных комментариев в исходном коде:

  1. Подготовка KV-данных осуществляется на стороне хоста. MemoryPool выделяет ggml_backend_buffer_t из ЦП-устройства ( ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_CPU) ). Декодирование ветвлений по-прежнему выполняется на графическом процессоре; только передача снимка подготавливается на стороне хоста через llama_state_get_data → memcpy → llama_state_seq_set_data . Материализация с учетом устройства находится в планах разработки, заблокирована на том же API привязки KV, что и bind_contiguous_cache .
  2. Общий мьютекс декодирования (в рамках закрепленной в исходном коде ревизии). LlamaGuard сериализует каждый вызов llama_* из рабочих потоков. В ревизии llama.cpp и конфигурации GPU, используемых в этом проекте, одновременное декодирование из нескольких потоков на одном GPU не было надежно безопасным — точное поведение зависит от бэкенда, версии и планирования графа, но в нашей настройке консервативным выбором была глобальная блокировка. Параллелизм на уровне DAG реален, но вычисления на GPU для каждого запроса остаются последовательными. Это самое большое ограничение производительности в V1, и именно с этого начинается вторая часть этой серии.
  3. SwarmKV_Prefill_Ms сообщает о значении 0. Известная ошибка в механизме обработки OrchestratorContext::node_name внутри PrefillNode . Предварительное заполнение выполнено (его стоимость видна в End_To_End_Ms , а также вычисленная эффективная стоимость общего предварительного заполнения), просто оно некорректно передается в timings_ms . Эффективное общее предварительное заполнение рассчитывается как SwarmKV_End_To_End_Ms − max(SwarmKV_AgentA_Ms, SwarmKV_AgentB_Ms) ≈ 5189 мс. Сообщается об ошибке, а не об ошибке корректности. Зарегистрировано.
  4. Синтетический документ. Тестовая программа создает детерминированный документ из 3501 токена, повторяя фразу «Быстрая коричневая лиса перепрыгивает через ленивую собаку» до тех пор, пока не будет достигнуто целевое количество токенов. Это позволяет изолировать сигнал производительности от влияния содержимого и обеспечивает воспроизводимость результатов испытаний побитово. Реальные документы будут демонстрировать более шумные абсолютные значения времени для каждого испытания; структурные соотношения останутся неизменными.
  5. Класс с одной видеокартой. Все данные в отчете получены с одной видеокарты GTX 1080 класса Pascal. Более новые видеокарты (Ada, Hopper) заполняют память гораздо быстрее — абсолютные значения в миллисекундах уменьшатся, но структурное соотношение между стоимостью полного заполнения и стоимостью короткого декодирования (которое использует SwarmKV) останется неизменным.
  6. bind_contiguous_cache согласно документации, ничего не делает. Да, по-прежнему. Пока разработчики не внедрят стабильный API для подключения внешних ключ-значение, функция проверяет свои аргументы, преобразует их в void и завершает работу.

Но не волнуйтесь, всё в этом списке есть в плане действий. Однако ничто из этого не меняет итоговый результат. Смысл того, что мы это написали, в том, чтобы вам не приходилось искать это самостоятельно — и как только в статье о сравнительном анализе скрываются оговорки, цифры перестают быть достоверными.

10. Потолок V1 (и подготовка ко второй части)

ba688d43c283d918b702852a41ee796e

SwarmKV доказывает, что можно прекратить повторное заполнение. Но если вы перечитаете предостережение №2, вы уже заметили следующий предел: сами вычисления на графическом процессоре по-прежнему выполняются последовательно.

Вот что на самом деле происходит по часам. Параллелизм на уровне DAG является подлинным — ветви представляют собой реальные рабочие процессы std::async с реальным управлением зависимостями. Но llama_decode каждой ветви выполняется внутри LlamaGuard , единого глобального мьютекса. Таким образом, пока оркестровка разветвляется, работа на GPU выстраивается в единый файл. Две ветви по очереди выполняют задачи. Пятьдесят ветвей выполняют пятьдесят ходов. GPU никогда не используется совместно; он мультиплексируется по времени вручную, по одной блокировке за раз, без гарантии справедливости и без возможности измерить, кто кого «лишает» ресурсов.

Это вполне подходит для демонстрации с двумя агентами. Но всё рушится, как только вы запускаете рабочую нагрузку, для которой SwarmKV действительно предназначен: 50 специализированных микроагентов, конкурирующих за один графический процессор. В таком масштабе вас перестаёт волновать вопрос «избежали ли мы повторного заполнения» и начинают волновать вопросы, на которые не может ответить мьютекс, созданный вручную:

  • Когда 50 агентов одновременно хотят использовать графический процессор, кто выберет его первым и как обеспечить справедливость?
  • Каковы значения задержки p50, p95 и p99, которые наблюдаются у каждого агента при совместном использовании одной карты?
  • Насколько сильно увеличивается нестабильность из-за конкуренции за ресурсы, и где происходит резкое снижение пропускной способности?
  • Как нам целенаправленно, а не случайно, распределять вычислительные циклы графического процессора?

Это вторая часть серии: разделение вычислительных ресурсов GPU для параллельно работающих роев агентов. Игрушечные агенты работают последовательно на Python. Производственные агенты работают параллельно на физическом оборудовании, и управление видеопамятью и вычислительными ресурсами, когда множество микроагентов используют один графический процессор NVIDIA, — это отдельная дисциплина. Во второй части создаётся профилировщик вычислительных ресурсов уровня Kubernetes, который динамически выделяет вычислительные циклы и измеряет задержку p50/p95/p99, дрожание и пропускную способность, когда рабочие нагрузки агентного вывода используют один и тот же графический процессор через плагин Kubernetes Device Plugin с разделением вычислительных ресурсов CUDA. Глобальный мьютекс в SwarmKV — это именно то, что он заменяет измеримым параметром.

(Для любопытных: существует отдельное, не связанное с первой версией ограничение, о котором будет рассказано в будущей публикации о SwarmKV V2 — в настоящее время конвейер ожидает завершения всего предварительного заполнения, прежде чем начнется какая-либо ветка, даже если ветке нужны только первые 500 токенов контекста. Возможность запуска веток в тот момент, когда материализуется необходимый им фрагмент префикса, — это действительно большой плюс, но это отдельная история и отдельный бенчмарк. Это не часть 2. Часть 2 посвящена совместному использованию графического процессора многими агентами; идея потоковой передачи предварительного заполнения будет рассмотрена позже.)

Увидимся во второй части.

Примечание: Иллюстрации в этой статье (главный баннер, схема архитектуры, панель сравнения telecom и SwarmKV, а также изображение разделения по времени на графическом процессоре) были созданы с помощью ИИ (Claude Opus 4.8). Они носят иллюстративный, а не фотографический характер, и любые подписи, видимые на изображениях, являются стилизованными, а не авторитетными — для получения точных названий функций, значений метрик и сведений об архитектуре обратитесь к тексту статьи и самому коду.

Анубхаб Банерджи Посмотреть все в Анубхаб Банерджи

Источник: towardsdatascience.com

✅ Найденные теги: Заполнение, новости, Один, Предварительное, Раз, Распространение
Читайте также
Архив рубрики ~Лента новостей~ Видеоаналитика на промышленном объекте: почему большинство внедрений разочаровывают и как сделать правильно Архив рубрики ~Лента новостей~ Письмо — это упражнение в искусстве убеждения. Если мы используем ИИ, мы теряем это искусство. | Алан Финкель Архив рубрики ~Лента новостей~ Компания Anthropic делает Mythos доступным для широкой публики с помощью Claude Fable 5, своей самой мощной общедоступной модели за всю историю. Архив рубрики ~Лента новостей~ Университет штата Калифорния заключил огромную сделку с OpenAI, и это обернулось катастрофой Архив рубрики ~Лента новостей~ ⚡️ Anthropic готовится представить мощнеющую коммерческую версию Mythos Архив рубрики ~Лента новостей~ Тени странных петель Архив рубрики ~Лента новостей~ Кроманьонцы использовали ребро мамонта как разделочную доску. На это указали находки из Баварии Архив рубрики ~Лента новостей~ Мужчину приговорили к месяцу тюремного заключения, несмотря на то, что, согласно данным Flock, он находился в 8 километрах от места преступления. Архив рубрики ~Лента новостей~ Компания OpenAI конфиденциально подала документы на IPO вслед за компанией Anthropic. Архив рубрики ~Лента новостей~ Метод patch-clamp раскрывает электрический язык зарождающихся фоторецепторов сетчатки глаза  Архив рубрики ~Лента новостей~ Владелец RTX 5090 ежемесячно проверял состояние 12V-2×6, но он всё равно сгорел Архив рубрики ~Лента новостей~ Преднамеренное повреждение файла ZFS Архив рубрики ~Коротко из Telegram~ Собираем собственный ИИ-офис — инструмент Agent Teams запускает целую команду… Архив рубрики ~Коротко из Telegram~ ❗️Krea презентовали сразу несколько обновлений и это стоит разобрать ⚡️Krea… Архив рубрики ~Лента новостей~ Видеоаналитика на промышленном объекте: почему большинство внедрений разочаровывают и как сделать правильно Архив рубрики ~Лента новостей~ Письмо — это упражнение в искусстве убеждения. Если мы используем ИИ, мы теряем это искусство. | Алан Финкель Архив рубрики ~Лента новостей~ Компания Anthropic делает Mythos доступным для широкой публики с помощью Claude Fable 5, своей самой мощной общедоступной модели за всю историю. Архив рубрики ~Лента новостей~ Университет штата Калифорния заключил огромную сделку с OpenAI, и это обернулось катастрофой Архив рубрики ~Лента новостей~ ⚡️ Anthropic готовится представить мощнеющую коммерческую версию Mythos Архив рубрики ~Лента новостей~ Тени странных петель Архив рубрики ~Лента новостей~ Кроманьонцы использовали ребро мамонта как разделочную доску. На это указали находки из Баварии Архив рубрики ~Лента новостей~ Мужчину приговорили к месяцу тюремного заключения, несмотря на то, что, согласно данным Flock, он находился в 8 километрах от места преступления. Архив рубрики ~Лента новостей~ Компания OpenAI конфиденциально подала документы на IPO вслед за компанией Anthropic. Архив рубрики ~Лента новостей~ Метод patch-clamp раскрывает электрический язык зарождающихся фоторецепторов сетчатки глаза  Архив рубрики ~Лента новостей~ Владелец RTX 5090 ежемесячно проверял состояние 12V-2×6, но он всё равно сгорел Архив рубрики ~Лента новостей~ Преднамеренное повреждение файла ZFS Архив рубрики ~Коротко из Telegram~ Собираем собственный ИИ-офис — инструмент Agent Teams запускает целую команду… Архив рубрики ~Коротко из Telegram~ ❗️Krea презентовали сразу несколько обновлений и это стоит разобрать ⚡️Krea…

Оставить комментарий

Подписка на рассылку

Получайте свежие новости и идеи на почту. Без спама — только самое интересное.

Нажимая «Подписаться», вы соглашаетесь с политикой конфиденциальности.