Un recorrido humorístico pero real por SwarmKV: despliegue de instantáneas de KV, buffers de host de copia en bifurcación y cómo hacer una canalización analítica de dos agentes ~1,95 veces más rápida (y la latencia de activación de la segunda rama 52 veces más rápida) siendo levemente malo con llama.cpp.
de la serie “Inferencia agente de grado de producción”. Cada parte elimina un tipo de trabajo redundante de un proceso de LLM agente. La parte 1 (esta publicación) elimina el precarga redundante. La parte 2 aborda la espera redundante: cómo 50 microagentes comparten una GPU mediante división de tiempo. La parte 3 mantiene la recuperación de RAG en la GPU con un kernel CUDA Top-K personalizado. La parte 4 mantiene el estado del agente durante las transferencias para que el siguiente agente nunca tenga el problema de arranque en frío.
Conclusiones clave
El problema: cuando varios agentes leen el mismo documento, una pila de publicación predeterminada hace que cada uno vuelva a ejecutar exactamente el mismo precompletado. Ese pase de atención densa y redundante es puro desperdicio.
La solución: ejecute prefill una vez, serialice el caché KV en un búfer de host, memorícelo por rama y restáurelo antes de decodificarlo. "Calcular una vez, desplegar en abanico".
Los recibos: en una GTX 1080 de siete años, una canalización de dos agentes obtuvo un 48,69 % más rápido de extremo a extremo (~1,95 ×) y la latencia de activación del segundo agente cayó un 98,09 % (~52 ×), eliminando 8685 ms de cómputo redundante.
El truco: este no es un algoritmo nuevo. Es ingeniería de sistemas, y es la misma decisión de “transmitir estado compartido una vez” que ha tomado una torre celular 5G cada 80 ms desde LTE.
TL;DR: El servicio LLM estándar hace que cada agente analítico vuelva a completar el mismo documento compartido. Su GPU diligentemente vuelve a ejecutar miles de millones de multiplicaciones redundantes de prefijo-precarga. Los mismos bytes. Los mismos pesos. La misma cuantización. Todo para recalcular un estado que ya terminó de calcular hace cuatro segundos. SwarmKV ejecuta el prellenado una vez, serializa el estado KV resultante en un búfer de host a través de llama_state_get_data, memcpys ese búfer en una asignación por rama y permite que cada rama restaure la instantánea con llama_state_seq_set_data antes de decodificar desde donde quedó el documento. Sí, es un verdadero viaje de ida y vuelta: serializar, copiar, restaurar, pero debido a que el cálculo redundante de precarga escala cuadráticamente mientras que la transferencia de estado KV escala linealmente, mover los datos a través de un bus de memoria Pascal restringido sigue siendo mucho más barato que recalcular las matrices de atención desde cero. Esto se refleja en el resultado en una GTX 1080 de siete años: 48,69% de aceleración de extremo a extremo en una canalización de dos agentes, 98,09% de reducción en la latencia de activación de la rama 2 (~52×), 8685 ms de cómputo denso redundante eliminado, cero nuevos trucos de transformador. Simplemente la posición de ingeniería de sistemas de que "calcular una vez, desplegar" supera a "calcular N veces, espero que nadie se dé cuenta".
Repositorio de Github: https://github.com/AnubhabBanerjee/swarmkv
(Confesión rápida antes de comenzar: llegué a esto con experiencia en ingeniería RAN 5G/6G. Resulta que desplegar un cálculo compartido para muchos consumidores intermedios es sorprendentemente cercano a lo que una torre celular ha estado haciendo cada 80 ms desde LTE cuando transmite SIB1. Hay una sección completa sobre eso a continuación, sección 8, pero también es la razón por la que escribo esto en primer lugar).
Modelo mental de arquitectura: mantén esto abierto mientras lees.
Documento → PrefillNode → llama_state_get_data → búfer KV del host → memcpy por rama → llama_state_seq_set_data → decodificación de AnalyticalNode (RoPE continúa en prefix_seq_len)
Todo lo que aparece a continuación es solo un comentario sobre una parte de esa línea.
1. Una confesión: la mayor parte del “trabajo” de tu segundo agente es una repetición
Si alguna vez ha señalado a dos agentes analíticos en el mismo documento a través de vanilla llama.cpp, esto es lo que realmente sucede (con un poco de dramatización intencional):
Usted: "Proporcione una descripción general de esta especificación de 3500 tokens y, por separado, enumere sus obligaciones de licencia".
llama.cpp (Agente 1): "Claro. Cargando modelo. Rellenando previamente el documento. Decodificando respuesta".
La GPU dedica 4.346 ms a atención densa
llama.cpp (Agente 1): "Listo. Aquí hay una respuesta de 6 tokens".
Tú: "Genial. Ahora Agente 2".
llama.cpp (Agente 2): "Claro. Cargando modelo. Rellenado previo del documento – "
Tú: "Espera, literalmente acabas de hacer eso".
llama.cpp (Agente 2): "Soy un llama_context independiente. No tengo recuerdos del Agente 1. No tengo recuerdos de nada. Soy un hermoso recién nacido apátrida". 🫡
La GPU dedica otros 4339 ms a matemáticas de atención idénticas bit a bit.
Su sensor térmico GPU: hace un buen ejercicio;
Su factura de AWS: desarrolla el sentido del humor;
TTFT de su segundo agente: 4,3 segundos antes de que pueda responder una pregunta de 4 tokens.
Ese es el chiste. Ése es el sucio secreto de todo canal “agente” que se extiende a partir de un documento compartido. Cada rama comienza desde una pizarra en blanco y reconstruye el mismo caché KV que la rama anterior acaba de terminar de construir. Cuanto más profundo sea el documento, peor será el impuesto. Con 3500 tokens en una GPU Pascal, la gran mayoría de la latencia percibida por el segundo agente no es la respuesta: está leyendo el documento nuevamente.
SwarmKV es lo que sucede cuando decides que la segunda lectura es opcional y prefieres escribir 1500 líneas de C++ que permitir que cada agente cree el mismo caché KV una y otra vez.
Ahora imagine, la demostración de juguetes en este repositorio trata sobre dos agentes en un resumen/verificación de licencia. La forma real de la carga de trabajo para la que está diseñado es N evaluadores especializados en un documento técnico denso. Imagine una patente de IA y un proceso de desarrollo de la técnica anterior: una especificación técnica de 50.000 tokens en la raíz y cincuenta ramas concurrentes que evalúan la novedad, mapean reclamos, recuperan el estado de la técnica, verifican la libertad de operar, evalúan el cumplimiento ético y traducen al lenguaje específico de la jurisdicción. El costo base de esa canalización en una pila de servicio predeterminada es de cincuenta precargas completas de la misma especificación. El costo de SwarmKV es un precarga más cincuenta memcpys. Esa asimetría está diseñada intencionalmente y es la única razón por la que existe el repositorio. He escrito por separado sobre la detección de IA en los informes de invenciones; esta es la mitad de esa parte de infraestructura. Los problemas del cuaderno de inventor son exactamente la razón por la que se creó SwarmKV.
2. ¿Por qué existe el prerrellenado? (un curso intensivo de un minuto)
Omita esto si ya lo sabe. Para todos los demás, aquí está la versión corta.
Un LLM autorregresivo atiende una solicitud en dos fases. El prellenado es el paso denso que empuja cada token de solicitud a través de cada capa del transformador una vez y llena la caché de clave/valor (KV) por capa. Luego, Decode se ejecuta token por token, atendiendo al caché KV precargado y haciéndolo crecer de forma incremental.
El costo del prellenado crece aproximadamente de manera lineal con la duración del aviso. En comparación, Decode es barato por token. En una GTX 1080 clase Pascal que ejecuta Qwen2.5-7B Q4_K_M, completar previamente un documento de ~3500 tokens demora aproximadamente 4,3 segundos; decodificar un mensaje de bifurcación corto lleva cientos de milisegundos, ya que está dominado por la configuración, no por la aritmética. Esa diferencia de tiempo entre el prellenado y la decodificación es exactamente el apalancamiento que utiliza SwarmKV.
Las pilas de servicios convencionales (vLLM, TGI, SGLang, el propio servidor de llama.cpp) tratan cada solicitud como un contexto independiente. Algunos de ellos tienen almacenamiento en caché de prefijo, pero generalmente tiene un alcance de solicitud o de sesión, no un alcance de gráfico. Están diseñados para maximizar el rendimiento en muchas indicaciones de usuarios independientes, no para compartir el estado dentro de un proceso analítico que se despliega a partir de un único documento compartido. Para esa carga de trabajo en forma de DAG (una raíz, muchas hojas, los mismos datos), cada pila pública que probé me hizo pagar por la raíz una vez por hoja.
SwarmKV sirve como capa de orquestación explícita, aprovechada en C++ para evitar las abstracciones del tiempo de ejecución, garantizar ciclos de vida deterministas de los punteros e impulsar la eficiencia de memcpy a nivel de hardware.
3. La bombilla "simplemente toma una instantánea del KV" (y por qué es más difícil de lo que parece)
El discurso es simple:
Ejecute el precompletar una vez en el documento compartido con el ID de secuencia kSwarmkvPrefixSeqId. Serialice el estado KV resultante en un búfer de host a través de llama_state_get_data. Para cada rama descendente, almacene ese búfer en una asignación por rama. Inicie un llama_context nuevo, llame a llama_state_seq_set_data para instalar la instantánea, luego decodifique el indicador de rama con posiciones de RoPE que continúan desde prefix_seq_len.
Este es el paradigma de "calcular una vez y desplegar". La única razón por la que se necesita más de un parche llama.cpp de 30 líneas para lograrlo es que tres tediosos casos extremos rompen inmediatamente el enfoque ingenuo. El concepto es maravillosamente simple y debería ser un proyecto fácil de fin de semana, pero las realidades de sistemas y hardware de bajo nivel hacen que su implementación sea un enorme desafío de ingeniería.
Problema A: ¿Qué tamaño tiene el KV?
Una respuesta fácil: n_layers × n_head_kv × n_ctx × head_dim × dtype × 2. Bueno, ese número derivado manualmente varía cada vez que cambia el formato de cuantificación, cada vez que cambia la relación GQA o cada vez que el motor agrega un nuevo campo de estado. El único número honesto es el que le indica el motor en la versión actual.
Entonces MemoryPool activa un llama_context desechable únicamente para preguntar:
size_t MemoryPool::get_required_kv_size(uint32_t n_ctx) { // Comience desde los valores predeterminados de la biblioteca para que los campos que no nos interesan permanezcan sanos. llama_context_params params = llama_context_default_params(); params.n_ctx = n_ctx; // Construya un contexto desechable únicamente para consultar la huella del estado serializado. 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 falló."); } // Pregunta a llama.cpp cuántos bytes ocuparía un blob de estado completo para este ctx. const size_t sz = llama_state_get_size(ctx); llama_free(ctx); // Si el motor informa cero, recurra a una pequeña asignación distinta de cero para que las pruebas // sigan ejercitando el registro sin pretender que conocemos los diseños exactos de los tensores. if (sz == 0) { return size_t{1} << 20; } devolver tamaño; }
La lógica aquí es simple: pregunte cortésmente al motor en lugar de confiar en un pdf o una fórmula matemática. Simplemente cree un contexto, pregunte el tamaño y asigne exactamente esa cantidad: una receta simple pero muy exitosa.
Problema B: llama.cpp es quisquilloso con la decodificación concurrente
Según la revisión anclada de llama.cpp y la configuración de GPU utilizadas en este proyecto, la decodificación simultánea de múltiples subprocesos en una sola GPU no era confiablemente segura. El comportamiento exacto depende del backend, la versión y el programador de gráficos (en revisiones más recientes o con flujos aislados puede comportarse mejor), pero en nuestra configuración los modos de falla fueron uno de: (a) falla, (b) KV dañado o (c) un bloqueo de diez minutos mientras busca en Google si ggml tiene una arena local de subprocesos todavía. Spoiler: en el inmovilizado aguas arriba, en realidad no.
La respuesta sólida es serializar la superficie API de llama en el límite:
namespace swarmkv { // llama.cpp Las rutas CUDA no son seguras para la decodificación simultánea de múltiples subprocesos // en una GPU sin serialización externa. Todos los cuerpos de ejecución de nodos deben // mantener este mutex alrededor de llama_init / llama_decode / llama_free / state I/O. inline std::mutex & llama_api_mutex() { static std::mutex m; devolver m; } } // espacio de nombres swarmkv struct LlamaGuard { std::lock_guard lock; LlamaGuard() : lock(swarmkv::llama_api_mutex()) {} };
Un encabezado simple de 20 líneas define toda la política de concurrencia. El cuerpo de ejecución () de cada nodo contiene esto alrededor de llama_init_from_model / llama_decode / llama_state_seq_set_data / llama_free. La concurrencia a nivel de DAG es real (futuros, dependencias, distribución); el cálculo de la GPU se entrelaza bajo un bloqueo global. Los pedantes notarán correctamente que esto deja el rendimiento en el suelo en comparación con una hipotética decodificación concurrente en sentido ascendente. Mantenga ese pensamiento: es el cuello de botella exacto que persigue la Parte 2 de esta serie.
Problema C: No existe una API de enlace KV externa estable
La implementación estéticamente perfecta sería asignar un búfer KV contiguo, adjuntarlo directamente al nuevo contexto y luego omitir la memcpy por completo. Llama.cpp ascendente expone llama_memory_t y rutas de decodificación de gráficos, pero el encabezado público fijado en este repositorio no incluye un símbolo estable exportado de estilo llama_kv_cache_bind.
Entonces SwarmKV hace lo mejor que puede hacer: mantiene el sitio de llamada, le asigna un nombre honesto y, en su lugar, escribe esta ruta encima de llama_state_set_data.
void KVHandoff::bind_contiguous_cache(llama_context * ctx, ggml_backend_buffer_t cache) { // Valida los argumentos para que el mal uso falle rápidamente durante la activación y las ejecuciones de humo de CI. // Un contexto nulo no puede decodificarse; un identificador de caché nulo es un error de configuración. if (!ctx || !cache) { throw std::invalid_argument("KVHandoff::bind_contiguous_cache: contexto o búfer nulo."); } // Marca explícitamente ambos parámetros como no utilizados intencionalmente en esta revisión. // Esto evita advertencias de parámetros -Wunused bajo indicadores de advertencia estrictos. (nulo) ctx; (nulo) caché; // Aquí no se emite ninguna llamada de enlace estable; vea el comentario a nivel de archivo arriba. // Cuando upstream agregue una API de adjunto compatible, impleméntela solo en esta función. }
Lo sé, lo sé. Es una función que no hace nada. Tiene validación completa de argumentos, una cadena de documentación que duplica la longitud del cuerpo y una posición estable en el gráfico de llamadas. Está esperando pacientemente el día en que la corriente arriba le permita hacer su trabajo. He escrito código más honesto en mi vida, ¡pero no recuerdo cuándo!
Esta es también la parte en la que los lectores atentos dicen "espera, si bind_contiguous_cache no funciona, ¿para qué sirve el buffer MemoryPool?" Excelente pregunta. Es el área de preparación: el búfer canónico donde PrefillNode escribe su blob llama_state_get_data y la fuente desde la que se memcpys cada rama. Decode en sí utiliza el KV administrado internamente del contexto. Búfer de grupo = scratch de distribución del lado del host; contexto KV = cosa propia del motor. Dos regiones de memoria, una instantánea, cero magia.
4. El proceso de cinco pasos (la parte realmente interesante)
Paso 0: validar doc + max_branch + 128 ≤ n_ctx (context_budget.h, falla rápida) Paso 1: construir el DAG; Comprobación DFS de ciclos (Orquestador) Paso 2: Generar trabajadores std::async; puerta en futuros (Orquestador) Paso 3: Precompletar una vez, serializar KV al búfer del host (PrefillNode + MemoryPool) Paso 4: instantánea de memcpy → búfer de rama → decodificar (AnalyticalNode + KVHandoff)
Repasemos cada uno con el código real. Los fragmentos se han mantenido breves deliberadamente, mientras que los archivos completos son pequeños y vale la pena leerlos.
Paso 0: presupuesto contextual a prueba de fallos
Tres líneas que te salvan de un mensaje de Slack a las 3 a.m. de tu yo pasado:
const int32_t requerido = prefix_tokens + max_branch + generate_headroom; if (requerido > límite) { throw std::runtime_error( "Presupuesto de contexto excedido: prefix_tokens=" + std::to_string(prefix_tokens) + " max_branch_tokens=" + std::to_string(max_branch) + " headroom=" + std::to_string(generación_headroom) + " require=" + std::to_string(required) + " n_ctx=" + std::to_string(límite)); }
Esto se ejecuta antes de que se construya cualquier contexto, se asigne cualquier búfer de grupo o se toque cualquier memoria de GPU. Si le pide a SwarmKV que complete previamente 4000 tokens en un contexto n_ctx=4096 con dos ramas y 128 tokens de margen de decodificación, le indica que las matemáticas no funcionan y se queda dormido. Lo más amable que puedes hacer por tu yo futuro es rechazar configuraciones imposibles incluso antes de asignar el primer byte.
Paso 1: detección del ciclo DAG
El orquestador realiza un DFS estándar de 3 colores en la lista de adyacencia de dependencias:
// dfs lambda recorre listas de adyacencia y lanza cuando un borde posterior indica un ciclo. auto dfs = [&](auto self, const std::string & u) -> void { // Marca el nodo u como actualmente en la pila de recursividad (visita). estado[u] = 1; // Explora todos los bordes de dependencia salientes desde u hasta los nodos posteriores v. for (const auto & v : adj[u]) { // Si v está de visita, encontramos un ciclo u -> v y debemos cancelar la configuración de la canalización. if (state[v] == 1) { // Lanzar con nombres de bordes para que la mala configuración del gráfico sea fácil de diagnosticar. throw std::runtime_error("Ciclo de dependencia detectado: " + u + " -> " + v); } // Recurre solo cuando v aún no se ha procesado por completo. if (state[v] == 0) { // Continuar DFS desde el nodo secundario v. self(self, v); } }
Sé que es aburrido, pero créeme, es necesario. Es el equivalente algorítmico de revisar tus cordones antes de correr. Sáltelo una vez y su tubería pasará el resto de su corta vida esperando a sí misma. El mensaje de error incluye el borde ofensivo, por lo que puede encontrar el error tipográfico sin necesidad de buscarlo.
Paso 2: un std::async por nodo, cerrado en futuros compartidos
work_tasks.push_back(std::async( std::launch::async, [this, name, state, dependencies, &completion_promises, &completion_futures]() { // Lee el requisito de marca de agua de este nodo una vez para tomar decisiones de activación de dependencias. const int32_t req = nodes.at(name)->required_prefix_tokens(); // Espera cada dependencia ascendente de acuerdo a las reglas de marca de agua V2 for (const auto & dep_name: dependencias) { // Resolver el puntero del nodo ascendente para la detección del proveedor de precompra. ExecutionNode * dep = nodes.at(dep_name).get(); // Si el ascendente es prefiller y esta rama usa control de marca de agua, espere en la marca de agua if (dep->is_prefill_provider() && req >= 0) { // Bloquear hasta la marca de agua PipelineState >=. require_prefix_tokens (inicio especulativo). state->wait_for_watermark(req); else { // De lo contrario, conserva el comportamiento de V1: espera hasta que se complete el subproceso del nodo ascendente. llama_context_params params = llama_context_default_params(); n_ctx al contexto de canalización predeterminado de SwarmKV para documentos de token multi-k params.n_ctx = kSwarmkvDefaultPipelineCtx; // Agrupa el modelo/grupo/nombre en OrchestratorContext para el nodo OrchestratorContext ctx = { this->memory_pool->get_model(), params, this->memory_pool, name.c_str(), }; los dependientes pueden continuar. // Enviar a la implementación de PrefillNode o AnalyticalNode. nodes.at(name)->execute(state, &ctx); // Señalar la finalización exitosa a los camareros share_future.at(name).set_value(); complete_promises.at(nombre).set_exception(std::current_exception() } catch (…) { } throw;
Una std::promise por nodo, con un std::shared_future para que varias ramas posteriores puedan esperar a que se complete la misma fase ascendente sin jugar a pasar el futuro. La ruta del fracaso siempre establece la excepción, por lo que los dependientes no esperan eternamente. Todos hemos depurado la alternativa y, ¡vaya, no la disfrutamos!
Observe lo que no está en este bucle: ninguna lógica sobre precarga, KV o ramas. El orquestador no sabe qué es un PrefillNode. Conoce nombres, aristas y promesas. El trabajo específico del nodo reside en ejecutar() y es completamente polimórfico detrás de la interfaz virtual ExecutionNode. ¡Solo una responsabilidad para un niño, nada abrumadora!
Paso 3: precompletar una vez, exportar KV
PrefillNode hace cuatro cosas en la siguiente secuencia:
Lea el texto del documento en ejemplos/base_doc.txt. Tokenícelo (con el modismo llama de cambio de tamaño con retorno negativo). Decodifica los tokens en fragmentos delimitados por llama_n_batch(lctx), en el carril de secuencia kSwarmkvPrefixSeqId, con posiciones absolutas de RoPE que coincidan con el índice absoluto del token: // La posición absoluta de RoPE es igual al índice en el flujo de tokens del documento completo. lote.pos[i] = cur + i; // Cada token pertenece exactamente a una lista de ID de secuencia. lote.n_seq_id[i] = 1; // Vincula todos los tokens del documento a la constante de carril de secuencia de prefijo compartido. lote.seq_id[i][0]= kSwarmkvPrefixSeqId; // Deshabilite los logits durante el llenado previo, excepto que aquí mantenemos ceros para todos los tokens. lote.logits[i] = 0;
4. Exporte la secuencia de prefijo KV al búfer del host canónico y selle la marca de agua para las ramas.
KVHandoff::bind_contiguous_cache(lctx, estado->materialized_branch_buffer); // Marca prefill_complete para que las ramas que usan kSwarmkvWaitForPrefillComplete puedan continuar. estado->mark_prefill_complete();
Ese es el objetivo del artículo en dos líneas. Todo lo demás en este repositorio (el orquestador, LlamaGuard, la verificación del presupuesto, la no operación documentada) existe para alimentar esas dos líneas y entregar su producción a las sucursales en una sola memoria sin viajes de ida y vuelta adicionales.
Paso 4: decodificación de sucursales bajo LlamaGuard
1. Asigne un búfer por rama con el mismo tamaño n_ctx que el prefijo:
// Asigne un búfer de rama de tamaño para n_ctx para que la decodificación posterior tenga margen de maniobra en la misma política de blobs. Branch_buf = ctx->memory_pool->allocate_branch_cache(static_cast(ctx->ctx_params.n_ctx));
2. Copie la instantánea canónica en el búfer de rama, también conocido como el famoso "copiar en bifurcación":
// Copias de la ruta de llenado previo completo desde la preparación canónica a la asignación de sucursales. KVHandoff::materialize_branch_cache( estado->materialized_branch_buffer, branch_buf, fork_kv_bytes);
…que, cuando haces clic, se convierte
std::memcpy(dst_ptr, src_ptr, ncopy);
Eso es todo. Eso es "copiar en la bifurcación en la capa de almacenamiento", que literalmente es memcpy. Es lo primitivo. Todo lo sofisticado que ha leído sobre el uso compartido de prefijos (el recuento de referencias de RadixAttention, la dirección indirecta de la tabla de bloques de atención paginada) se basa en la misma idea: no vuelva a calcular, copie los bytes.
3. Crea un nuevo llama_context y restaura la instantánea:
// Restaura solo el carril de secuencia de prefijo para que la decodificación de rama permanezca aislada en la secuencia 0. const size_t n = llama_state_seq_set_data( lctx, static_cast(base), fork_kv_bytes, kSwarmkvPrefixSeqId); // Verifica que llama haya consumido exactamente la cantidad de bytes que copiamos en el búfer de rama. if (n != fork_kv_bytes) { // Libera el contexto antes de lanzar para evitar fugas de VRAM en rutas de falla. llama_free(lctx); // Lanzar con un mensaje claro para que los operadores puedan depurar rápidamente las discrepancias de tamaño. throw std::runtime_error("AnalyticalNode: llama_state_seq_set_data el tamaño no coincide."); }
4. Cree un único llama_batch para el indicador de rama corta con posiciones de cuerda continuando desde donde terminó el prefijo:
for (int i = 0; i < batch.n_tokens; ++i) { // Copie la identificación del token de la i-ésima rama en la ranura del lote. lote.token[i] = tokens[static_cast(i)]; // Coloque los tokens de rama inmediatamente después de las posiciones de los prefijos bifurcados para obtener un RoPE correcto. lote.pos[i] = static_cast(fork_prefix_len) + static_cast(i); // Cada token participa exactamente en una entrada de la lista de ID de secuencia. lote.n_seq_id[i] = 1; // Vincula todos los tokens de rama a la constante de carril de secuencia de prefijo compartido. lote.seq_id[i][0]= kSwarmkvPrefixSeqId; // Deshabilitar logits para todos los tokens excepto el último en este paso de rama. lote.logits[i] = 0; }
Esta es la parte en la que todo el mundo se equivoca la primera vez. Si olvida el desplazamiento y comienza las posiciones de las bifurcaciones en cero, las incrustaciones giratorias se desplazan silenciosamente hacia los lados y el modelo se decodifica desde una posición para la que el prefijo nunca fue entrenado. El síntoma es un sinsentido confiadamente coherente. Bienvenido al peor infierno de depuración; Por favor, deja una propina al salir.
5. Finalmente, un llama_decode. Simplemente escriba una cadena de diagnóstico en PipelineState::node_outputs, registre timings_ms[nombre], libere el lote y el contexto.
Tres líneas de lógica de negocio por sucursal. Dos contextos en vuelo en el momento de la decodificación. Una memoria cada uno. Un bloqueo global. Todo lo demás es fontanería.
5. Los recibos (es decir, los números)
Ahora es el momento de evaluarlo con respecto a la línea de base y ver si valió la pena hacer todos estos problemas. Todos los números provienen de ejemplos/example-run-results/.
Nota rápida sobre la metodología antes de que alguien busque las rocas: cada comparación a continuación ejecuta el mismo modelo (Qwen2.5-7B-Instruct-Q4_K_M.gguf), el mismo documento (un documento sintético determinista de 3501 tokens generado repitiendo “El veloz zorro marrón salta sobre el perro perezoso.” hasta que se alcanza el objetivo simbólico – ejemplos/base_doc.txt), la misma GPU (GTX 1080, 8 GiB, Pascal sm_61), el mismo n_ctx=4096 y el mismo dtype. Línea base = dos instancias secuenciales de llama_context, cada una de las cuales completa previamente el documento completo y luego decodifica su indicador de rama. SwarmKV = PrefillNode una vez + dos ramas de AnalyticalNode sobre la instantánea. Tipo de carga de trabajo: análisis de documentos dominado por prerrelleno (estilo RAG), no chat autorregresivo. Tres pruebas se ejecutan consecutivamente con una espera de GPU inactiva entre ellas; el mejor es seleccionado por 2·TTFT_pct + E2E_pct.
Una definición de métrica antes de la tabla, porque es importante: utilizamos “latencia de activación de rama 2 (proxy TTFT)”, no el TTFT de “solicitud-llegada → primer token de salida” de literatura de servicio de libros de texto. Nos referimos al tiempo que la segunda rama dedica al trabajo específico de la rama: su latencia de activación después de la precarga compartida se amortiza en todas las ramas. En un canal de distribución, el costo que percibe el consumidor intermedio es exactamente este número, porque, por diseño, el precarga ascendente se paga una vez por todo el canal. El valor de referencia para esta métrica es el llenado previo del documento redundante que el segundo llama_context se ve obligado a rehacer antes de poder responder; el valor de SwarmKV es la bifurcación + restauración + decodificación breve.
Titular: GTX 1080, Qwen2.5-7B Q4_K_M, documento de 3501 tokens, dos ramas
Traducción: la línea de base gastó 4.339 ms de la latencia percibida del segundo agente repitiendo el pase de atención densa que acababa de finalizar cuatro segundos antes en los mismos bytes. SwarmKV mira eso y dice "¿y si no lo hiciéramos?" y envía una respuesta de 83 milisegundos. La medición más clara de un solo número de "¿qué tan caro fue ese precarga?" es sólo la proporción de esos dos tiempos; todo lo demás en la rama es un error de redondeo.
A dónde va el ~83 ms por rama
La tesis de todo este artículo se basa en una desigualdad: la restauración + decodificación por rama es mucho, mucho más barata que un llenado previo de documentos redundante. El arnés mide esto directamente a nivel agregado: el reloj de pared por rama (asignar + copiar + restaurar + decodificar, de extremo a extremo) es de 71 a 83 ms dependiendo de qué rama miremos, frente a un costo de precarga redundante de ~4339 ms. Una proporción de ~52× a nivel agregado es lo que hace que todo lo demás en este artículo funcione.
Para obtener más resultados y números, propondré mirar directamente el informe de ejecución de ejemplo.
6. "Está bien, pero ¿en qué se diferencia esto de vLLM/almacenamiento en caché de prefijos/SGLang RadixAttention?"
Una pregunta muy razonable, y vale la pena responderla directamente, porque el mundo de inferencia-infra tiene muchas primitivas superpuestas y un lector de HPC preguntará esto en el primer comentario.
vLLM / lotes continuos / atención paginada. Optimizado para servicio en tiempo de decodificación de múltiples inquilinos: muchas solicitudes simultáneas en diferentes pasos de decodificación, programando el siguiente token entre ellas bajo carga de transmisión. Titular primitivo: atención paginada. Unidad de trabajo: una manguera de transmisión de indicaciones de usuarios independientes. Almacenamiento en caché de prefijos TGI/vLLM. Excelente si su prefijo compartido tiene un ámbito de solicitud o de sesión. No está diseñado para exponer instantáneas de KV como objetos de primera clase que puede entregar a un llama_context diferente que ejecuta una tarea posterior diferente en el mismo proceso. SGLang RadixAtención. El uso compartido de prefijos en forma de árbol dentro de un tiempo de ejecución de servicio es el primo más cercano, pero es un servidor, no una primitiva de orquestación de un solo proceso. Guardar/restaurar el propio estado de llama.cpp. Existe, por contexto. SwarmKV es el pegamento a nivel de canalización: un DAG, un campo de búfer de host dimensionado por el propio motor, un despliegue de memcpy, una política de LlamaGuard y una operación no operativa documentada que espera pacientemente una API de enlace ascendente.
7. Entonces… ¿cómo lo pruebo realmente?
Bueno, ya publiqué el enlace de Github al comienzo del artículo. Si ha llegado tan abajo, trabaje duro una vez más y vuelva a subir hasta la cima.
Los artefactos se encuentran en ejemplos/example-run-results/: best_run.json, all_trials.csv, plots/*.png y una narrativa final_result.docx que explica la metodología y las limitaciones.
Requisitos: Linux, kit de herramientas CUDA, una GPU NVIDIA (Pascal o más nueva; tanto para el consumidor como para el centro de datos funcionan), un modelo GGUF que se ajuste a su VRAM y la paciencia para leer un archivo CMake una vez.
8. Giro de la trama: esto es solo una transmisión de SIB con un disfraz de transformador
Probablemente debería confesar en este punto: no soy una “persona GPU” por formación. Llegué a través de las telecomunicaciones (5G NR con un pie arrastrándose firmemente hacia la investigación de 6G) y comencé a analizar la infraestructura de inferencia de LLM porque cada problema en este código base me parecía extrañamente familiar.
Anillo decodificador de una frase para lectores sin experiencia en 3GPP: en una red 5G, la torre celular no transmite la configuración de red a cada teléfono por separado: transmite un pequeño conjunto de bloques de información del sistema (SIB1, SIB2,…) una vez en un canal compartido, cada teléfono dentro del alcance lee la misma transmisión y los datos por usuario se superponen a ese contexto compartido en un canal dedicado. Las siglas en la siguiente tabla: MIB (Bloque de información maestra, lo primero que lee cada teléfono), PBCH y PDSCH (los canales compartidos de transmisión y datos de enlace descendente), HARQ (el protocolo de retransmisión del receptor “conserva lo que ya decodificamos, solo reenviamos lo que falta”) y RNTI (la identificación temporal que distingue el tráfico de un teléfono del de otro) son solo nombres para los canales e identificadores que separan los compartidos, calculados una vez de los únicos por consumidor. Esa distinción es toda la analogía.
Mire esto uno al lado del otro y dígame con seriedad que estos son problemas diferentes:
Un breve comentario sobre dos públicos muy diferentes
A mis primeros amigos de HPC y CUDA que leen esto: lo sé. La reutilización de KV no es una idea nueva. vLLM tiene almacenamiento en caché de prefijo, SGLang tiene RadixAttention, llama.cpp expone el estado de guardado/restauración. La contribución de SwarmKV no es la primitiva; es la forma de orquestación de proceso único: un pequeño tiempo de ejecución de C++ DAG que expone “precargar una vez, desplegar N ramas” como una operación de primera clase, dimensionada para una GPU de consumo de 8 GiB, con los rieles de seguridad (LlamaGuard, swarmkv_validate_context_budget, el enlace no operativo documentado) que un investigador realmente necesita para enviar una demostración un martes. Por favor, bajen las horcas.
A mis amigos de las telecomunicaciones: si “KV cache” sonaba como un idioma extranjero hasta hace diez minutos, no se han quedado atrás: han llegado temprano. Durante veinte años nuestro mundo estuvo formado por FPGA, ASIC y PRB. Optimizamos el espectro, no el silicio. Luego, los elementos de estudio de AI-RAN, NWDAF, NVIDIA Aerial, AI-RAN Alliance y 3GPP Rel-20 sucedieron aproximadamente en los mismos dieciocho meses, y la próxima década de carreras en telecomunicaciones ahora exige ser bilingüe entre el mundo del espectro y el mundo de la GPU. La intuición se traduce limpiamente. Ha estado distribuyendo la computación compartida a muchos consumidores desde el primer piloto de CRS. El mismo animal, sólo que un nuevo zoológico.
9. Advertencias honestas (porque los comentarios van llegando)
Si vino aquí para encontrar el problema del proyecto, felicidades, el proyecto encontró su primer lector. De la sección de limitaciones de final_result.docx y los comentarios en línea en la fuente:
La puesta en escena de KV es del lado del host. MemoryPool asigna ggml_backend_buffer_t desde el dispositivo CPU (ggml_backend_dev_by_type(GGML_BACKEND_DEVICE_TYPE_CPU)). La decodificación de ramas todavía se ejecuta en la GPU; solo el tránsito de la instantánea se organiza en el host a través de llama_state_get_data → memcpy → llama_state_seq_set_data. Una materialización compatible con el dispositivo vive en la hoja de ruta, bloqueada en la misma API de enlace KV ascendente que está esperando bind_contiguous_cache. Mutex de decodificación compartido (según la revisión ascendente fijada). LlamaGuard serializa cada llamada llama_* de los subprocesos de trabajo. Según la revisión de llama.cpp y la configuración de GPU utilizadas en este proyecto, la decodificación simultánea de múltiples subprocesos en una sola GPU no era confiablemente segura; el comportamiento exacto depende del backend, la versión y la programación de gráficos, pero en nuestra configuración la opción conservadora fue un bloqueo global. La simultaneidad a nivel de DAG es real, pero el cálculo de GPU por solicitud sigue siendo secuencial. Esta es la mayor limitación de rendimiento en la V1, y es exactamente donde comienza la Parte 2 de esta serie. SwarmKV_Prefill_Ms informa 0. Brecha de instrumentación conocida en cómo se consume OrchestratorContext::node_name dentro de PrefillNode. Se ejecutó el precompletado (ve su costo en End_To_End_Ms y el costo efectivo derivado del precompletado compartido), simplemente no se ingresa correctamente en timings_ms. El relleno previo compartido efectivo se calcula como SwarmKV_End_To_End_Ms − max(SwarmKV_AgentA_Ms, SwarmKV_AgentB_Ms) ≈ 5189 ms. Informar de un error, no de un error de corrección. Registrado. Documento sintético. El punto de referencia construye un documento determinista de 3501 tokens repitiendo “El veloz zorro marrón salta sobre el perro perezoso” hasta que se alcanza el objetivo simbólico. Esto aísla la señal de interpretación de los efectos del contenido y mantiene las pruebas reproducibles bit por bit. Los documentos reales producirán tiempos absolutos por prueba más ruidosos; los ratios estructurales no se moverán. Clase de GPU única. Todos los números en el informe provienen de una GTX 1080 clase Pascal. Las GPU más nuevas (Ada, Hopper) se precargan mucho más rápido: los números absolutos de ms se reducirán, pero la relación estructural entre el costo de precarga completo y el costo de decodificación corta (que es lo que explota SwarmKV) no. bind_contiguous_cache es una operación no operativa documentada. Sí, todavía. Hasta que el flujo ascendente obtenga una API de conexión KV externa estable, la función valida sus argumentos, los anula y regresa a casa.
Pero no te preocupes, todo lo que está en esta lista está en la hoja de ruta. Sin embargo, nada de esto cambia el resultado del titular. El objetivo de ponerlo por escrito es que no debería tener que buscarlo, y en el momento en que una publicación de blog de referencia oculta sus advertencias, es el momento en que sus números dejan de ser confiables.
10. El techo V1 (y la configuración para la Parte 2)
SwarmKV demuestra que puedes dejar de recargar. Pero si vuelve a leer la advertencia n.° 2, ya habrá detectado el siguiente límite: el cálculo de la GPU en sí todavía está serializado.
Esto es lo que realmente sucede en el reloj de pared. La simultaneidad a nivel de DAG es genuina: las ramas son trabajadores std::async reales con activación de dependencia real. Pero llama_decode de cada rama se ejecuta dentro de LlamaGuard, un único mutex global. Entonces, mientras la orquestación se despliega, el trabajo de la GPU se alinea en una sola fila. Se turnan dos ramas. Cincuenta ramas dan cincuenta vueltas. En realidad, la GPU nunca se comparte; se multiplexa en el tiempo a mano, un candado a la vez, sin garantía de equidad y sin forma de medir quién está matando de hambre a quién.
Eso está bien para una demostración de dos agentes. Se desmorona en el momento en que ejecuta la carga de trabajo para la que está diseñado SwarmKV: 50 microagentes especializados que compiten por una GPU. A esa escala, deja de preocuparse por "¿evitamos volver a llenar" y comienza a preocuparse por las preguntas que un mutex enrollado a mano no puede responder:
Cuando 50 agentes quieren la GPU a la vez, ¿quién va primero y cómo podemos hacerlo justo? ¿Cuál es la latencia p50, p95 y p99 que ve cada agente al compartir una tarjeta? ¿Cuánta inquietud añade la contención y dónde colapsa el rendimiento? ¿Cómo dividimos los ciclos de cómputo de la GPU a propósito y no por accidente?
Esta es la Parte 2 de esta serie: División de tiempo de la GPU para enjambres de agentes concurrentes. Los agentes de juguetes se ejecutan secuencialmente en Python. Los agentes de producción se ejecutan simultáneamente en bare metal, y administrar VRAM y computación cuando muchos microagentes comparten una GPU NVIDIA es su propia disciplina. La parte 2 crea un perfilador de intervalos de tiempo a nivel de Kubernetes que asigna dinámicamente ciclos de cómputo y mide los servidores proxy de latencia, fluctuación y rendimiento p50/p95/p99 cuando las cargas de trabajo de inferencia agente comparten una GPU a través del complemento de dispositivo Kubernetes con división de tiempo CUDA. El mutex global en SwarmKV es exactamente lo que reemplaza con algo medible.
(Para los curiosos: hay una limitación separada y ortogonal de V1 que vale la pena publicar en el futuro de SwarmKV V2: la canalización actualmente espera a que finalice todo el prellenado antes de que comience cualquier rama, incluso cuando una rama solo necesita los primeros 500 tokens de contexto. Dejar que las ramas comiencen en el instante en que se materializa el segmento de prefijo requerido es una verdadera victoria, pero es su propia historia y su propio punto de referencia. No es la Parte 2. La Parte 2 trata sobre compartir la GPU entre muchos agentes; esa idea de transmisión de precarga es un seguimiento.)
Nos vemos en la Parte 2.
Descargo de responsabilidad: las ilustraciones de este artículo (el banner del héroe, el diagrama de arquitectura, el panel dividido de telecomunicaciones vs. SwarmKV y la imagen de división de tiempo de la GPU) se generaron utilizando IA (Claude Opus 4.8). Son ilustrativos, no fotográficos, y cualquier etiqueta visible dentro de las imágenes está estilizada en lugar de autorizada; consulte el cuerpo del artículo y el código mismo para obtener nombres precisos de funciones, valores métricos y detalles de arquitectura.