La recherche en IA te passionne ?
Les papers et avancées qui comptent, expliqués simplement, chaque soir. Gratuit.
Inclus dès l'inscription : notre sélection des meilleurs guides & comparatifs IA.
Choisis ton rythme
Gratuit · Pas de spam · Désabonnement en 1 clic
Exécution de 3 LLMs sur un GPU de 8 Go : défi relevé
3 Agents. 3 LLMs. 1 GPU ancien : Ingénierie de l'inférence parallèle sur Bare Metal
Comment un petit démon C++ utilise un contrôle d'admission de style 5G et un pipeline de couches asynchrone pour faire fonctionner trois LLMs sur une carte graphique vieille de 8 ans.
L'architecture VRAM Conductor : combinaison d'un contrôle d'admission de connexion de style 5G (lmxd) avec un streaming de couches double tampon asynchrone pour multiplexage sécurisé de plusieurs LLMs sur une seule GTX 1080 de 8 Go.
Il y a trois agents utilisant trois LLMs différents. Le GPU est ancien et il n'est pas possible de le mettre à jour. Il est nécessaire d'exécuter ces agents en parallèle, mais sur ce vieux GPU, un seul survit et les deux autres plantent. Voici le petit démon C++ qui résout ce problème, et l'histoire vraie de la façon dont ils survivent tous ensemble.
Le problème que vous avez réellement
Voici une situation à laquelle il est facile de s'identifier.
Il y a quelques agents IA : l'Agent A génère le code brut, l'Agent B le passe en revue pour détecter les failles de sécurité pendant qu'il est écrit, et l'Agent C rédige simultanément la documentation. Pour obtenir une expérience de développement fluide et en temps réel sans pics de latence massifs, les trois doivent être présents en mémoire en même temps. Chacun fonctionne mieux avec un petit LLM instructif différent — un SmolLM ici, un Qwen là, un petit Llama ailleurs. Ils sont pointés vers une machine, qui a un GPU très ancien. Il n'est pas possible de le mettre à jour ce trimestre, cette année, ou possiblement dans cette vie (oui, les moyens financiers sont vraiment limités !). C'est un NVIDIA GTX 1080 avec seulement 8 Go de VRAM, et il a été dit pendant des années que cela devrait encore suffire pour des modèles "petits".
Alors, la chose évidente est faite. Trois terminaux sont ouverts pour lancer ces agents en parallèle via trois processus de llama-completion. Et puis il est attendu de voir cela :
« OK, même si le GPU est ancien, trois petits modèles devraient fonctionner correctement. »
llama-completion (Agent 1, Llama 3.2 1B) : « Chargement du backend. Réservation du cache KV à l'avance pour n_ctx=172032, n_batch=8192, -ngl 99. » La mémoire GPU passe à 6,536 MiB sur 8,192.
« Cool. Maintenant, Agent 2 — Qwen2 0.5B. »
llama-completion (Agent 2) : « Allocation de 1,536 MiB sur le dispositif 0… » cudaMalloc : « ❌ mémoire insuffisante. »
llama-completion (Agent 2) : « le processus qwen a pris fin. » 🫡
« Très bien. SmolLM2 360M, alors. Celui-là est minuscule. »
llama-completion (Agent 3) : « Allocation de 5,120 MiB sur le dispositif 0… » cudaMalloc : « ❌ mémoire insuffisante. »
llama-completion (Agent 3) : « le processus smol a pris fin. »
Le nvidia-smi montre toujours un seul processus à 6,512 MiB. La démo à trois agents est en fait une démo à un agent avec deux journaux de plantage.
Si cela semble familier, rien n'est fait de mal. C'est exactement ce que chaque tutoriel sur "multi-agent sur un seul GPU" conseille de faire. Le problème est que le tutoriel est trop optimiste concernant le silicium. Cependant, il n'y a pas lieu de s'inquiéter, une solution est disponible, et c'est de cela dont cet article parle.
Le reste de cet article est constitué de deux choses : une explication d'une minute sur pourquoi le deuxième et le troisième processus échouent, et un petit démon C++ appelé lmxd qui admet les trois sur la même carte sans la loterie OOM. Il y a exactement un post précédent qui pourrait être utile comme contexte, Warpgroup-backend, et même cela est optionnel.
Pourquoi les 3 LLMs ne peuvent pas fonctionner en parallèle (version d'une minute)
llama.cpp et llama-completion (et amis) réservent le cache KV (mémoire par token utilisée par les couches d'attention pendant le décodage) pour la fenêtre de contexte configurée au départ, lorsque le llama_context est créé. Oui, c'est à l'avance, pas au fur et à mesure, pour garantir que le processus de décodage se déroule sans accroc. Avec -c 172032 et -ngl 99, le premier processus consomme joyeusement 6,536 MiB / 8,192 MiB de la carte avant même d'avoir décodé un seul token, et presque aucune de ces données ne provient des poids du modèle. C'est la réservation KV.
À ce moment-là, lorsque le deuxième processus essaie de construire son propre contexte et obtient exactement cela dans son journal :
0.00.592.688 E ggml_backend_cuda_buffer_type_alloc_buffer : allocation de **1536.00 MiB**
sur le dispositif 0 : cudaMalloc a échoué : mémoire insuffisante
0.00.593.132 E llama_init_from_model : échec de l'initialisation du contexte :
échec de l'allocation du buffer pour le cache KV
Ce n'est pas un bug. C'est llama.cpp qui fait la chose sécurisée pour un processus : pré-réserver le KV afin que le décodage ne soit jamais interrompu en cours de route. Cela s'avère être une chose profondément dangereuse lorsque trois processus indépendants font cela sur la même carte de 8 Go, car la carte n'a pas de file d'attente et il n'y a pas de comptabilité partagée. Il n'y a que cudaMalloc qui devient littéralement un coup de dés au moment où la carte est remplie à plus de 80 %.
La solution n'est pas "un meilleur algorithme". C'est la plus simple de toutes : la comptabilité. Quelqu'un doit regarder la carte, décider si le prochain agent va réellement tenir, et refuser la demande avant que le prochain processus n'essaie d'allouer. Construisons cela. Et au cas où de l'aide serait nécessaire, le dépôt GitHub est disponible à la fin de cet article.
La solution : un petit démon C++ qui fait la comptabilité
Un comptable était nécessaire, alors il a été construit. lmxd est un processus à long terme (environ 1,500 lignes de C++17 uniquement) qui possède le GPU au nom des agents. Les agents ne lancent plus leurs propres binaires de llama-completion, ils communiquent simplement avec le démon via un petit protocole texte de socket Unix (HELP, STATUS, LIST, REGISTER, UNREGISTER), et le démon décide si le nouvel agent peut être admis, et seulement alors charge le modèle.
Toute la politique repose sur un seul chiffre : 90 % de la VRAM totale de la carte, et une seule règle : admettre un nouvel agent uniquement si currently_used + new_estimate ≤ 90 % cap. Tout ce qui suit est juste une mise en œuvre honnête de cette règle.
Le registre qui impose la limite est suffisamment petit pour être lu en une seule fois. À partir de src/vram_ledger.cpp :
bool VramLedger::try_reserve(uint64_t model_table_bytes) {
// Une seule section critique couvre la comparaison et l'augmentation afin que les enregistrements parallèles ne puissent pas surcommettre.
std::lock_guard<std::mutex> lock(mu_);
if (!initialized_) {
uint64_t projected = 0;
if (!add_u64(allocated_bytes_, model_table_bytes, &projected)) {
if (projected > max_vram_bytes_) {
allocated_bytes_ = projected;
Seulement quelques lignes de code réelles, appliquant la politique. Vérifiez que la somme ne déborde pas silencieusement + que le total projeté reste sous la limite, puis engagez-vous seulement. Le mutex est important : si deux agents appellent REGISTER en même temps, ils ne peuvent pas être laissés passer tous les deux le contrôle sur un état obsolète et surcommettre avec un modèle. Cette course a été déboguée par beaucoup et n'a jamais été appréciée.
Le gestionnaire qui décide quoi faire avec une ligne REGISTER entrante est encore plus court — et l'ordre des opérations est tout le jeu. À partir de src/daemon_app.cpp :
uint64_t v_new = 0;
if (!table_.lookup(model_key, &v_new)) {
return std::string("ERR clé_model inconnue pour la recherche dans la table VRAM\n");
if (!ledger_.try_reserve(v_new)) {
const VramLedger::Snapshot st = ledger_.snapshot();
std::ostringstream os;
os << "ERR VRAM_LEDGER_DENY code=CAP ledger_max_bytes=" << st.max_bytes
<< " ledger_allocated_bytes=" << st.allocated_bytes << " requested_table_bytes=" << v_new;
return os.str();
}
std::string lerr;
if (!llama_.acquire_model(model_key, &lerr)) {
ledger_.release(v_new);
return std::string("ERR échec d'acquisition de llama : ") + lerr + "\n";
}
À ce stade, il serait bon de prendre quelques minutes pour examiner ces lignes de près. Elles suivent l'ordre simple des opérations : rechercher l'estimation en octets, réserver dans le registre, puis charger le modèle. Si le registre refuse, le démon renvoie une ligne structurée ERR VRAM_LEDGER_DENY code=CAP et ne touche jamais au GPU. Si le registre accepte mais que le chargement du modèle échoue pour une autre raison — I/O disque, fichier corrompu, quoi que ce soit — la réservation est libérée sur le même chemin. Soit l'agent finit enregistré avec ses octets réservés, soit absolument rien ne se passe.
C'est probablement la partie qui échoue dans presque chaque version "naïve". Si le modèle est chargé en premier et le budget vérifié ensuite, l'I/O disque a déjà été payé pour un modèle qui est sur le point d'être refusé, et il suffit d'une course pour réussir à charger un modèle dont les octets ne peuvent jamais être facturés. Réservez avant de construire. Toujours.
Le chargeur de modèle lui-même fait une chose encore plus ennuyeuse mais importante : un processus, un llama_backend_init, une carte de chemins GGUF chargés avec comptage de références. À partir de src/llama_single_service.cpp :
bool LlamaSingleService::acquire_model(const std::string& path, std::string* err_out) {
// Protéger chaque point d'entrée llama afin que l'enregistrement multi-agent ne concurrence jamais l'exécution.
std::lock_guard<std::mutex> lock(mu_);
if (!backend_inited_) {
// Élever les backends CPU/GPU une fois ; les appels suivants ne sont que des augmentations de comptage de références peu coûteuses.
llama_backend_init();
backend_inited_ = true;
const auto it = models_.find(path);
if (it != models_.end()) {
// Réutiliser un GGUF déjà mappé et augmenter le comptage de références pour le nouvel agent.
it->second.refcount += 1;
// Nouveau chemin : charger les paramètres par défaut puis mapper les poids depuis le disque via les parseurs de llama.cpp.
llama_model_params params = llama_model_default_params();
llama_model* model = llama_model_load_from_file(path.c_str(), params);
if (model == nullptr) {
if (err_out != nullptr) {
*err_out = "llama_model_load_from_file a échoué pour le chemin : " + path;
}
ModelSlot slot{};
slot.model = model;
slot.refcount = 1;
models_.emplace(path, std::move(slot));
Le démon appelle llama_backend_init exactement une fois. L'approche naïve des "trois terminaux" génère trois contextes principaux CUDA indépendants sur la même carte, chacun consommant des centaines de mégaoctets en frais généraux silencieux du pilote avant que...




