3 agentes. 3 másteres en maestría. 1 GPU envejecida: ingeniería de inferencia paralela en metal desnudo

agentes que utilizan tres LLM diferentes. Tienes una GPU antigua y eres demasiado pobre para actualizarla. Debe ejecutar estos agentes en paralelo, pero en esta GPU antigua, exactamente uno sobrevive y los otros dos fallan. Aquí está el pequeño demonio de C++ que soluciona este problema y la verdadera historia de cómo sobreviven todos juntos.

El problema que realmente tienes

Permítanme describir una situación con la que pueden identificarse muy fácilmente.

Tiene algunos agentes de IA: el Agente A genera el código sin formato, el Agente B lo revisa activamente en busca de fallas de seguridad a medida que se escribe y el Agente C redacta simultáneamente la documentación. Para lograr una experiencia de desarrollador fluida y en tiempo real sin picos de retraso masivos, los tres deben residir en la memoria al mismo tiempo. Cada uno de ellos funciona mejor con un LLM de instrucción pequeña diferente: un SmolLM aquí, un Qwen allá, una pequeña Llama en otro lugar. Los apuntas a tu máquina, que tiene una GPU muy antigua. No puedes actualizarlo este trimestre, ni este año, ni posiblemente esta vida (sí, ¡eres así de pobre!). Es una NVIDIA GTX 1080 con sólo 8 GB de VRAM, y llevas años diciéndote tranquilamente que todavía debería ser suficiente para modelos “pequeños”.

Entonces haces lo obvio. Abres tres terminales para lanzar estos agentes en paralelo a través de tres procesos de finalización de llama. Y luego esperas a ver esto:

Tú: "Está bien, aunque la GPU es antigua, tres modelos pequeños deberían funcionar bien".

llama-completion (Agente 1, Llama 3.2 1B): "Cargando backend. Reservando caché KV por adelantado para n_ctx=172032, n_batch=8192, -ngl 99". La memoria de la GPU salta a 6.536 MiB de 8.192.

Tú: "Genial. Ahora Agente 2: Qwen2 0.5B".

llama-completion (Agente 2): "Asignando 1536 MiB en el dispositivo 0…" cudaMalloc: "❌ sin memoria".

finalización de llama (Agente 2): "el proceso qwen finalizó". 🫡

Tú: "Bien. SmolLM2 360M, entonces. Ese es pequeño".

llama-completion (Agente 3): "Asignando 5120 MiB en el dispositivo 0…" cudaMalloc: "❌ sin memoria".

finalización de llama (Agente 3): "el proceso de smol finalizó".

Su nvidia-smi: todavía muestra solo un proceso a 6512 MiB. Su demostración de tres agentes: de hecho, es una demostración de un agente con dos registros de fallos.

Si esto le resulta familiar, no está haciendo nada malo. Está haciendo exactamente lo que le indica cada tutorial de “multiagente en una sola GPU”. El problema es que el tutorial está siendo optimista respecto al silicio. Sin embargo, no te preocupes, tengo una solución para ti y de eso trata este artículo.

El resto de este artículo son dos cosas: una explicación de un minuto de por qué mueren el segundo y tercer proceso, y un pequeño demonio de C++ llamado lmxd que admite los tres en la misma tarjeta sin la lotería OOM. Hay exactamente una publicación anterior que quizás quieras como fondo, Warpgroup-backend, e incluso esa es opcional.

Por qué los 3 LLM no pueden ejecutarse en paralelo (la versión de un minuto)

Llama-completion (y sus amigos) de llama.cpp reservan el caché KV (memoria por token que las capas de atención usan durante la decodificación) para la ventana de contexto configurada completa, por adelantado, cuando se crea llama_context. Sí, es por adelantado, no a medida que escribe, para garantizar que el proceso de decodificación se desarrolle sin problemas y sin contratiempos. Con -c 172032 y -ngl 99, el primer proceso consume felizmente 6,536 MiB / 8,192 MiB de su tarjeta antes de decodificar un solo token, y casi nada de eso corresponde al peso del modelo. Es la reserva KV.

En ese momento, cuando el segundo proceso intenta construir su propio contexto y obtiene exactamente esto en su registro:

0.00.592.688 E ggml_backend_cuda_buffer_type_alloc_buffer: asignación de 1536.00 MiB en el dispositivo 0: cudaMalloc falló: sin memoria 0.00.593.132 E llama_init_from_model: no se pudo inicializar el contexto: no se pudo asignar el búfer para el caché kv

Esto no es un error. Es llama.cpp lo que hace lo seguro para un proceso: reservar previamente KV para que la decodificación nunca se detenga a mitad de camino. Resulta muy inseguro que tres procesos independientes lo hagan en la misma tarjeta de 8 GB, porque la tarjeta no tiene cola y no hay contabilidad compartida. Solo existe cudaMalloc, que se convierte literalmente en un lanzamiento de moneda en el momento en que la tarjeta supera aproximadamente el 80 % de su capacidad.

La solución no es "un algoritmo mejor". Es el más sencillo de todos: la contabilidad. Alguien tiene que mirar la tarjeta, decidir si el siguiente agente realmente encajará y rechazar la solicitud antes de que el siguiente proceso intente asignarla. ¡Yo se, verdad! Vamos a construirlo. Y en caso de que necesite ayuda, busque el repositorio de github al final de este artículo.

La solución: un pequeño demonio de C++ que lleva la contabilidad

Necesitábamos un contable, así que construyamos ese contable. lmxd es un proceso de larga duración (alrededor de 1500 líneas de C++17 únicamente) que posee la GPU en nombre de los agentes. Los agentes ya no generan sus propios binarios de finalización de llama, simplemente hablan con el demonio a través de un pequeño protocolo de texto de socket Unix (AYUDA, ESTADO, LISTA, REGISTRO, DESREGISTRACIÓN), y el demonio decide si el nuevo agente encaja, y solo entonces carga el modelo.

Toda la política se basa en un número: 90 % de la VRAM total de la tarjeta y una regla: admitir un nuevo agente solo si actualmente se utiliza + nueva_estimación ≤ 90 % de límite. Todo lo que aparece a continuación es sólo una implementación honesta de esa regla.

El libro de contabilidad que aplica el límite es lo suficientemente pequeño como para leerlo de una sola vez. Desde src/vram_ledger.cpp:

bool VramLedger::try_reserve(uint64_t model_table_bytes) { // La sección crítica única cubre la comparación y mejora para que los REGISTROS paralelos no puedan comprometerse en exceso. std::lock_guard bloqueo(mu_); if (!initialized_) {retorna falso; } uint64_t proyectado = 0; if (!add_u64(allocated_bytes_, model_table_bytes, &projected)) { return false; } if (proyectado > max_vram_bytes_) { return false; } bytes_asignados_ = proyectado; devolver verdadero; }

Sólo unas pocas líneas de política real y de cumplimiento. Verifique que la suma no se desborde silenciosamente + que el total proyectado permanezca por debajo del límite, y luego solo comprométase. El mutex importa: si dos agentes llaman a REGISTER al mismo tiempo, no puede hacer que ambos pasen la verificación de estado obsoleto y se comprometan en exceso por un modelo. Sé que hablo en nombre de todos cuando digo que todos hemos depurado esa carrera y ninguno la disfrutamos.

El controlador que decide qué hacer con una línea de REGISTRO entrante es aún más corto y el orden de las operaciones es todo el juego. Desde src/daemon_app.cpp:

uint64_t v_new = 0; if (!table_.lookup(model_key, &v_new)) { return std::string("ERR clave_modelo desconocida para la búsqueda de tabla VRAMn"); } if (!ledger_.try_reserve(v_new)) { const VramLedger::Snapshot st = ledger_.snapshot(); std::ostringstream sistema operativo; os << "ERR VRAM_LEDGER_DENY code=CAP ledger_max_bytes=" << st.max_bytes << " ledger_allocated_bytes=" << st.allocated_bytes << " request_table_bytes=" << v_new << "n"; devolver os.str(); } std::string lerr; if (!llama_.acquire_model(model_key, &lerr)) { ledger_.release(v_new); return std::string("ERR error en la adquisición de llama: ") + lerr + "n"; } // Registre la ranura por contexto del agente para que las llamadas DECODE posteriores puedan impulsar el intercambio KV // baile a través de LlamaContextManager. La creación de espacios se registra únicamente; aún no se ha creado ningún contexto. std::cadena cerr; if (!ctx_mgr_.create_slot(agent_id, model_key, &cerr)) { // Relajarse: liberar el recuento del modelo + bytes del libro mayor para que el REGISTRO fallido no deje rastro. llama_.release_model(model_key); libro mayor_.release(v_new); return std::string("ERR ctx_mgr create_slot falló: ") + cerr + "n"; } agente_a_modelo_[agente_id] = clave_modelo; std::ostringstream sistema operativo; os << "OK agente registrado=" << agent_id << " model=" << model_key << "n";

En este punto, sería fantástico que nos tomáramos unos minutos para examinar esas líneas de cerca. Siguen un orden de operación simple: buscan la estimación de bytes, reservan en el libro mayor y luego cargan el modelo. Si el libro mayor se niega, el demonio devuelve una línea estructurada ERR VRAM_LEDGER_DENY code=CAP y nunca toca la GPU. Si el libro mayor acepta pero la carga del modelo falla por algún otro motivo (E/S de disco, archivo corrupto, lo que sea), la reserva se libera en la misma ruta. O el agente acaba registrado con sus bytes reservados o no pasa absolutamente nada.

Esta es probablemente la parte que sale mal en casi todas las versiones “ingenuas”. Si carga el modelo primero y luego verifica el presupuesto, ya ha pagado la E/S del disco por un modelo que está a punto de rechazar, y está a una carrera de cargar exitosamente un modelo cuyos bytes nunca podrá cargar en ningún lado. Reserva antes de construir. Siempre.

El cargador de modelos en sí está haciendo una cosa más aburrida pero importante: un proceso, un llama_backend_init, un mapa recontado de rutas GGUF a los modelos cargados. Desde src/llama_single_service.cpp:

bool LlamaSingleService::acquire_model(const std::string& path, std::string* err_out) { // Protege cada punto de entrada de llama para que el registro de múltiples agentes nunca acelere el tiempo de ejecución. std::lock_guard bloqueo(mu_); if (!backend_inited_) { // Abre los backends de CPU/GPU una vez; Las llamadas posteriores son sólo aumentos de recuento baratos. llama_backend_init(); backend_inited_ = verdadero; } const auto it = modelos_.find(ruta); if (it != models_.end()) { // Reutilizar un GGUF ya asignado y aumentar el recuento para el nuevo inquilino del agente. it->segundo.refcount += 1; devolver verdadero; } // Ruta nueva: cargue los parámetros predeterminados y luego asigne pesos desde el disco a través de los analizadores llama.cpp. llama_model_params params = llama_model_default_params(); llama_model* modelo = llama_model_load_from_file(path.c_str(), params); if (model == nullptr) { if (err_out != nullptr) { *err_out = "llama_model_load_from_file falló para la ruta: " + ruta; } devolver falso; } ranura ModelSlot{}; ranura.modelo = modelo; ranura.refcount = 1; models_.emplace(ruta, std::move(ranura)); devolver verdadero; }

El demonio llama a llama_backend_init exactamente una vez. El ingenuo enfoque de “tres terminales” genera tres contextos primarios CUDA independientes en la misma tarjeta, cada uno de los cuales quema cientos de megabytes en un controlador silencioso antes de que se toque siquiera un solo tensor. El demonio se niega a hacer eso: un backend por GPU. Además, si dos agentes solicitan exactamente el mismo GGUF, asigna los pesos a la VRAM solo una vez y simplemente genera un contador de referencia.

Ese es el sistema completo: un número (90 %), un libro de contabilidad honesto, un orden estricto de operaciones y un backend compartido. No es una ciencia informática nueva. Es contabilidad, escrito en C++ y expuesto a través de un socket Unix para que un ingeniero cansado a las 2 a.m. pueda nc -U /tmp/lmxd.sock y simplemente hablar con él.

Los recibos (es decir, una tabla fea y cinco capturas de pantalla)

Toda la demostración se puede empaquetar en un script de shell (aunque no se proporciona actualmente en el repositorio, ya que me centré en la parte de la solución, no en la parte de demostración): que ejecuta la pila ingenua y la pila del demonio consecutivamente y vuelca los pares de transcripción PNG +. El mismo hardware, los mismos tres GGUF (SmolLM2-360M-Instruct-Q4_K_M, Qwen2-0.5B-Instruct-Q4_K_M, Llama-3.2-1B-Instruct-Q4_K_M — combinados ~1,4 GiB en disco, todo de la cuenta de Bartowski Hugging Face), mismo -ngl 99 -c 172032. La tarjeta es una NVIDIA GTX 1080 (8 GB, Pascal), controlador 535.309.01, con llama.cpp fijado en la etiqueta b9724.

Pista A: tres terminales, tres binarios de finalización de llamas

Línea de base, antes que nada: 22 MiB utilizados en la tarjeta.

Inicie Llama 3.2 1B primero. Un proceso, inmediatamente 6.536 MiB:

Intente agregar Qwen2 0.5B. La transcripción termina con el proceso qwen finalizado y un cudaMalloc falló: sin memoria al asignar un búfer KV de 1536 MiB:

Pruebe SmolLM2 360M, el más pequeño de los tres. Mismo resultado:

Puntuación final: un residente de proceso, dos registros de fallos. La “demostración de tres agentes” es, de hecho, una demostración de un agente con dos rastros de error.

Pista B: lmxd admite los tres

Misma tarjeta. Los mismos tres modelos. Abra el demonio con una pequeña tabla de texto que asigna cada GGUF a su tamaño en el disco, apúntelo a un presupuesto –admission-percent 90 y active tres líneas de REGISTRO secuenciales a través de nc -U. El intercambio final de ESTADO + LISTA es el titular completo de esta publicación:

Tres modelos diferentes de instrucciones pequeñas, tres identificaciones de agentes distintas, 1,58 GB reservados contra un límite máximo de 7,73 GB, en el mismo hardware que le dio a la pista A solo un superviviente.

El titular no es "el demonio es más rápido". Es el demonio que logra colocar tres mientras que la pila ingenua solo logra uno. Y cuando finalmente rechaza una solicitud, devuelve una única línea de cable (ERR VRAM_LEDGER_DENY code=CAP ledger_max_bytes=… ledger_allocated_bytes=… request_table_bytes=…) con los tres números exactos que un operador necesita para depurarlo. El fracaso que se explica solo es lo más bonito, ¿no?

Continuación de la pista B: agentes registrados que realmente decodifican

Admitir agentes es sólo la mitad de la historia. Si esos agentes no pueden funcionar en paralelo, entonces se pierde toda esta publicación. La otra mitad es decidir cuál obtiene el llama_context en vivo en este momento, porque en cualquier milisegundo dado solo uno de ellos está multiplicando tensores. El demonio también incluye esa mitad: ciclo de vida de contexto por agente en lmx::LlamaContextManager, decodificación real de llama.cpp en lmx::AgentRuntime, desalojo de caché KV para alojar la RAM a través de lmx::KvSwapHelper en cada cambio de agente, todo detrás de un verbo IPC adicional: DECODE.

Mismo enchufe. Dos agentes registrados (smol, qwen). Realizamos tres llamadas DECODE secuenciales para alternar entre ellas: un inicio en frío, un intercambio que envía la caché KV del agente activo a la memoria del host y un intercambio final que lo regresa a un contexto nuevo. En cada paso, la respuesta del cable informa explícitamente exactamente lo que el demonio tuvo que mover:

Tres llamadas, tres estados diferentes de intercambio de KV, en un reloj de pared total de ~440 ms en la misma GTX 1080:

LlamarKV_swap_evictedKV_swap_restoredQué pasóDECODE smol…ningunofalsoArranque en frío. Manager creó llama_context Fresh.DECODE qwen de smol…smolfalseManager serializó el estado de contexto completo de smol en un buffer temporal del host, liberó el contexto de smol, construyó qwen'sfresh.DECODE smol…qwentrueManager desalojó a qwen de la misma manera, luego restauró el KV guardado por smol desde el principio del host a un contexto completamente nuevo para que la conversación continúe.

¿Qué hay realmente en la GPU en el estado estable donde ambos modelos están registrados y los DECODE están volando?

926 MiB para dos modelos pequeños registrados con contexto en vivo, muy por debajo del presupuesto de 7,7 GiB. El proceso lmxd es el único inquilino CUDA en la tarjeta. Los agentes suspendidos pagan cero bytes de VRAM; su estado KV vive en la RAM del host hasta que recuperan la ranura activa. Así es como “registrar muchos, decodificar cualquiera” resulta barato.

La forma de una respuesta DECODIFICAR, para los escépticos a nivel de cable:

OK esquema=lmx-daemon/1 OK invoke=DECODE agent_id=smol OK Prompt_tokens=4 generate_tokens=24 stop_on_eos=false OK elapsed_ms=125.495 OK kv_swap_evicted=qwen kv_swap_restored=true BEGIN_RESPONSE [Clima]. Tengo muchas ganas de verlos a todos. Soy [Tu nombre] y soy [Tu END_RESPONSE

Las líneas kv_swap_evicted / kv_swap_restored son todo el flujo operativo. Si un DECODE termina en 125 ms y la línea dice kv_swap_restored=true, sabes exactamente dos cosas: el demonio pagó un viaje de ida y vuelta PCIe para recuperar la conversación, y el agente con el que acabas de hablar había sido suspendido (no eliminado, no sometido a OOM) antes de la llamada.

Confesión honesta

Debo confesar en este punto: no soy una “persona GPU” por formación. Llegué a través de las telecomunicaciones (5G con un pie arrastrándose en la investigación de 6G) y cada problema de “infraestructura” en la IA agente sigue pareciéndose a algo que ya resolvimos en la capa de radio hace años.

La versión muy corta: cuando tu teléfono quiere configurar una nueva llamada (o una nueva sesión de datos) en un celular, la estación base no se limita a decir "claro, estás al aire". Ejecuta Control de Admisión de Conexión (CAC). ¿Puede la celda respetar esta nueva sesión sin romper los SLA de cada sesión ya admitida? Si es así, admítelo. En caso negativo, rechace durante la configuración con un código de causa claro. Nunca cortes silenciosamente una llamada en vivo para dejar espacio para una nueva.

Mire esto uno al lado del otro y dígame con seriedad que estos son problemas diferentes:

Torre celular 5G (control de admisión de conexión)lmxd en una GPU (libro mayor de VRAM)Capacidad de la celda = recursos de radio disponiblesCapacidad del dispositivo = 90 % de vram_total_bytesLas sesiones admitidas consumen el presupuesto de recursos conocidoLos agentes admitidos consumen el libro mayor_allocated_bytes conocidoLa nueva sesión llega con una solicitud estimadaEl nuevo agente llega con table_bytes de la tabla VRAMAdmitir si existe + nuevo ≤ presupuesto de celdaAdmitir si se asigna + nuevo ≤ ledger_max_bytesLa decisión es antes de que se establezca el portador. La decisión es antes de que se cargue cualquier GGUF. La ruta de rechazo deja las llamadas existentes ilesas. La ruta de rechazo deja a los agentes existentes ilesos. Saltar CAC → la celda colapsa, todos admitieron, nadie atendió. Saltar el libro mayor → todos compiten con cudaMalloc, uno sobrevive.

El programador MAC de cada torre de telefonía móvil desde 3G ha estado realizando exactamente esta llamada. Si propusiera un sistema LTE en el que todos los teléfonos fueran admitidos a su llegada y el programador “lo descubriera más tarde”, lo acompañarían cortésmente fuera de la reunión 3GPP. Y, sin embargo, eso es exactamente lo que hace cada demostración de “generar tres procesos llama-cli y esperar” en una GPU de 8 GB. El mismo animal en un zoológico nuevo.

El último truco: carga solo la capa que realmente necesitas

Tomémonos un momento y pensemos en los números. Tres agentes que desean tres LLM diferentes (digamos, ~4 GB cada uno, en cuantificación de 4 bits) suman ~12 GB en disco. Tu tarjeta tiene sólo 8 GB. Cargar ingenuamente “los tres modelos, totalmente residentes, todo el tiempo” siempre iba a perder. Pero eso no es lo que realmente necesita un pase hacia adelante en un milisegundo cualquiera. Un paso de decodificación de transformador toca una capa a la vez. Entonces, si solo coloca una capa de transformador en VRAM (~1,5 GB), más el contexto CUDA (~500 MB), más el fragmento de caché KV activo, su huella en un solo milisegundo nunca supera los 3 a 4 GB, y los otros 14 GB de pesos viven en la RAM del host fijada, esperando su turno.

El problema es que "esperar su turno" no puede significar "detener los núcleos CUDA mientras buscamos la siguiente capa a través de PCIe". En PCIe 3.0 ×16 de una GTX 1080 (~12 GB/s en el mundo real), leer una sola capa de 1,5 GB tarda ~125 ms. Si lo espera en serie, habrá creado el LLM más lento del mundo. El truco, y realmente es el único truco, es hacer que el cálculo en la Capa N se superponga con la transferencia de la Capa N+1, en dos flujos CUDA diferentes, de modo que cuando los núcleos terminen de multiplicarse, ya tenga los pesos de la siguiente capa en una ranura de intercambio preasignada. Intercambio de puntero. Repetir. Para siempre.

Un campo de host con página bloqueada fijada (cudaHostAlloc), dos buffers de ping-pong del lado del dispositivo, dos flujos CUDA, tiempos cudaEvent por capa y el bucle activo:

for (int i = 0; i < cfg_.n_layers; ++i) { // Paso 1: cudaMemcpyAsync host->dispositivo en transfer_stream, luego sincronizar. Sin superposición. check_cuda("cudaEventRecord(transfer_start.serial)", cudaEventRecord(impl_->ev_transfer_start[i], impl_->transfer_stream)); check_cuda( "cudaMemcpyAsync(serial)", cudaMemcpyAsync(d_curr, impl_->pool.slot_ptr(static_cast(i)), cfg_.bytes_per_layer, cudaMemcpyHostToDevice, impl_->transfer_stream)); check_cuda("cudaEventRecord(transfer_end.serial)", cudaEventRecord(impl_->ev_transfer_end[i], impl_->transfer_stream)); check_cuda("cudaStreamSynchronize(transfer.serial)", cudaStreamSynchronize(impl_->transfer_stream)); // Paso 2: inicie el kernel de cálculo en Compute_stream y sincronice antes del siguiente iter. check_cuda("cudaEventRecord(compute_start.serial)", cudaEventRecord(impl_->ev_compute_start[i], impl_->compute_stream)); Layer_compute_kernel<<compute_stream>>>( static_cast(d_curr), static_cast(impl_->d_output), impl_->elements_per_layer, cfg_.compute_iters); check_cuda("error de inicio del kernel (serie)", cudaGetLastError()); check_cuda("cudaEventRecord(compute_end.serial)", cudaEventRecord(impl_->ev_compute_end[i], impl_->compute_stream)); check_cuda("cudaStreamSynchronize(compute.serial)", cudaStreamSynchronize(impl_->compute_stream)); }

La corriente A (cómputo) está sudando por todo el trabajo duro; La corriente B (transferencia) está transportando silenciosamente la siguiente capa a sus espaldas; Nadie en la GPU está nunca inactivo esperando el autobús. Las dos llamadas cudaStreamSynchronize son los únicos puntos de serie verdaderos por capa y, en una canalización bien ajustada, no son operativos: el flujo B finalizó su transferencia hace aproximadamente 30 ms y ha estado esperando que el flujo A se ponga al día.

Una CLI independiente, Layer_stream_demo, ejecuta este bucle junto con una línea de base estrictamente en serie (transferencia y luego cálculo, una secuencia a la vez) en el mismo hardware, las mismas asignaciones, los mismos pesos sintéticos e imprime los dos relojes de pared uno al lado del otro. En el cuadro de referencia de GTX 1080 en la configuración predeterminada (8 capas × 64 MiB × 64 iteros FMA por elemento), informa:

TÍTULO: serie=151,41 ms, superpuesto=117,85 ms, ahorro=22,17%, aceleración=1,285x

Lleve la configuración a un extremo limitado por el ancho de banda (–n-layers 16 –bytes-per-layer 67108864 –compute-iters 64) y el ahorro aumentará a ~32 % (una aceleración de 1,47 veces). Para ser completamente transparente, el kernel por capa probado aquí es un barrido FMA de costo representativo. El alcance honesto es demostrar que el patrón de superposición asincrónica funciona en silicio real, sin afirmar que hayamos reescrito todo el gráfico de decodificación de llama.cpp. Cambiar este núcleo sintético por un paso directo de transformador completo es una tarea de varios meses; lo que incluye este repositorio es la primitiva C++ básica que hace posible esa futura empresa.

Hay una segunda mitad del truco, que se debe directamente a la arquitectura de SwarmKV. Cuando se cambia del Modelo 1 al Modelo 2, la geometría de la caché KV cambia por completo: diferentes recuentos de cabezas, diferentes dimensiones, diferentes tipos de datos. Para sobrevivir, el orquestador debe serializar el fragmento KV activo en la RAM del host en el momento en que se retira el pase hacia adelante del Modelo 1, liberando VRAM para el Modelo 2.

Es exactamente el mismo baile llama_state_get_data → host buffer → llama_state_set_data, simplemente ejecutándose entre distintos modelos en lugar de ramas del mismo modelo. Enviamos esta primitiva como lmx::KvSwapHelper, un contenedor sin estado sobre la API de serialización de estado de llama.cpp. Cuando el administrador de contexto ejecuta esto en cada DECODE entre agentes, produce los registros kv_swap_evicted y kv_swap_restored exactos que vio anteriormente. Eso es lo que hace que esas cifras sean una realidad mecánica, no sólo una aspiración.

Cómo probarlo, qué está dentro del alcance y qué no

Bien, ahora es el momento de analizar cómo puedes utilizar esta solución en tu vida diaria.

Enlace al repositorio de github: https://github.com/AnubhabBanerjee/VRAM-Conductor

Lo llamé “VRAM Conductor” porque actúa exactamente como un conductor de autobús durante las horas pico: revisa los boletos, organiza quién sube a la GPU y le dice activamente al siguiente pasajero: “Lo siento, el autobús está lleno”, antes de que todo el vehículo vuelque. Además, mi opción alternativa “Bouncer que evita que tres agentes de IA se apuñalen entre sí en el último megabyte de memoria” era demasiado larga para una URL de GitHub.

Si quieres reproducir esto en tu propia tarjeta:

git clone && cd cmake -S. -B build -DCMAKE_BUILD_TYPE=Release -DLMX_WITH_LLAMA_CUDA=ON cmake –build build -j"$(nproc)" # Demostración de superposición independiente (no se requiere archivo de modelo): ./build/src/layer_stream_demo # Daemon. Una vez que está activo, cada REGISTRO lo admite, cada DECODIFICACIÓN ejecuta llama.cpp real con el baile # KV-swap sucediendo de forma transparente en cada cambio de agente: ./build/src/lmxd –socket /tmp/lmxd.sock –vram-table configs/model_vram.example.txt & nc -U /tmp/lmxd.sock <

Los requisitos no son sorprendentes: Linux, kit de herramientas CUDA, NVML, una GPU NVIDIA (Pascal o posterior). El demonio necesita que las rutas GGUF en su tabla VRAM realmente existan; la demostración en streaming se ejecuta con pesas sintéticas y solo necesita el kit de herramientas.

Antes de que alguien busque las rocas, la lista honesta de lo que este repositorio no afirma:

El kernel por capa de LayerStreamer es un barrido FMA de costo representativo, no un verdadero paso hacia adelante de la capa transformadora. Reemplazarlo con uno que ejecute los núcleos matmul cuantificados de llama.cpp bajo nuestra orquestación de flujo requiere reimplementar esa pila matmul contra un puntero de peso transmitido o realizar una cirugía en el corredor de gráficos de llama.cpp; ambos son proyectos de varios meses y explícitamente fuera del alcance de este repositorio. Lo que está al alcance es la demostración de ingeniería primitiva: host anclado + dos flujos CUDA + intercambio de doble búfer en realidad se superponen en silicio real, con ahorros medidos en el reloj de pared, en la misma tarjeta tres procesos ingenuos de finalización de llamas ni siquiera pueden compartir. La ruta DECODE ejecuta llama.cpp real de extremo a extremo; simplemente no conduce el transmisor dentro del bucle de decodificación. El modelo de contexto de una sola ranura serializa agentes en la GPU. Sólo hay un llama_context activo a la vez; El KV de los demás está en la RAM del host. La decodificación simultánea de dos agentes a la vez no está dentro del alcance; eso necesita el trabajo de decodificación interna de LayerStreamer anterior, que es la versión de varios meses de este repositorio. El libro mayor utiliza estimaciones de bytes proporcionadas por el operador (aquí: tamaños exactos de estadísticas(1) escalados 1,5×). Con la decodificación real ahora en escena, esas estimaciones deben crecer para cubrir el caché de KV, las activaciones y el margen de almacenamiento en caché de CUDA con mayor precisión, ya sea como una fórmula por modelo o como una medición en línea después de la primera DECODIFICACIÓN. El demonio toma muestras de NVML una vez durante el arranque y luego deriva solo a través de su propio try_reserve/release. Live NVML está expuesto en ESTADO para los operadores, pero no se utiliza para detectar otros procesos que crecen durante la vida útil del demonio. Las pilas de producción se volverían a sincronizar con un temporizador. Una GPU, un proceso, un cliente a la vez. La ubicación de múltiples GPU y el IPC de alto despliegue están fuera de alcance.

Sin embargo, nada de esto cambia el titular.

Envoltura

Seamos honestos: la mayoría de las demostraciones de “multiagente en una GPU” en 2026 no realizan una programación de memoria inteligente. Simplemente están lanzando tres procesos a una tarjeta gráfica, cerrando los ojos y esperando que las cosas funcionen mágicamente.

lmxd no es un nuevo algoritmo mágico de IA. Es sólo un portero con un portapapeles. Requiere control de admisión de conexión, un concepto más antiguo que el primer teléfono plegable, y lo conecta mediante un demonio C++, un estricto libro de contabilidad VRAM y un backend compartido. ¿El resultado? Tres modelos de instrucción diferentes comparten cortésmente una tarjeta de 8 GB en lugar de luchar a muerte por ella.

La conclusión es simple: rechazar un trabajo imposible vale más que optimizar el trabajo posible. Puede tener la decodificación especulativa y el enrutamiento MoE más avanzados del mundo, pero no lo salvará si admite ciegamente un tercer agente que físicamente no cabe en la memoria. Los sistemas bien diseñados verifican el presupuesto antes de realizar el trabajo.

Clona el repositorio. Ejecute el demonio. Luego, observe detenidamente sus propios oleoductos para ver cuántos sobreviven silenciosamente gracias a la suerte ciega. Si vino aquí preguntándose por qué lanzar tres LLM a una GPU antigua no funciona de inmediato, felicitaciones: ahora comprende las limitaciones de hardware mejor que la mayoría de las personas que escriben estos tutoriales.

Ahora ve a gritarle a tu asignador. Cariñosamente.

Las capturas de pantalla de admisión (Pista A, 01–04) y el panel demonio ESTADO+LISTA (05) son representaciones directas de transcripciones reales de nvidia-smi y lmxd capturadas el 20 de junio de 2026. El panel DECODE-with-KV-swap (06) y el panel nvidia-smi de estado estable (07) (ambos en la pista B, continuación) provienen del demonio lmxd que ejecuta la decodificación real llama.cpp en la misma NVIDIA GTX 1080 el 22 de junio de 2026. Los paneles 06 y 07 agregan colores de sintaxis claros en el momento del renderizado (verde para las líneas de evidencia kv_swap_*, azul para OK, amarillo para los mensajes $) para hacer que el recorrido de intercambio sea más fácil de seguir. Ambas son representaciones PIL de la salida estándar real del demonio.