Funk-lab/ansible/roles/lm_studio/TROUBLESHOOTING.md

12 KiB

LM Studio sur AlmaLinux 9 — Déblocages et solutions

Contexte

  • Machine : gpu-01, AlmaLinux 9.7, GCC 11, libstdc++ → max GLIBCXX_3.4.29
  • Problème initial : LM Studio refuse de démarrer avec GLIBCXX_3.4.30 not found
  • Cause racine : watcher.node (module Node natif livré avec LM Studio) a été compilé avec GCC 12 et déclare une dépendance ELF vers GLIBCXX_3.4.30. AlmaLinux 9 ne fournit que GCC 11 → GLIBCXX_3.4.29 maximum.

Blocage 1 — LD_PRELOAD ne peut pas satisfaire DT_VERNEED

Ce qui a été tenté

Construire un shim .so qui exporte _ZNSt18condition_variable4waitERSt11unique_lockISt5mutexE sous le tag de version GLIBCXX_3.4.30, puis l'injecter via LD_PRELOAD.

// glibcxx_compat.c
__asm__(".symver _ZNSt18condition_variable4waitERSt11unique_lockISt5mutexE_compat,"
        "_ZNSt18condition_variable4waitERSt11unique_lockISt5mutexE@GLIBCXX_3.4.30");
gcc -shared -fPIC -Wl,--version-script=glibcxx_compat.map \
    -o libglibcxx_compat.so glibcxx_compat.c /usr/lib64/libstdc++.so.6

Pourquoi ça échoue

Dans ELF, DT_VERNEED lie une exigence de version à une bibliothèque nominalement identifiée. La section .gnu.version_r de watcher.node dit :

« J'ai besoin de GLIBCXX_3.4.30 fourni par libstdc++.so.6. »

Le linker dynamique (ld.so) vérifie cette version directement dans libstdc++.so.6, pas dans les bibliothèques chargées via LD_PRELOAD. Un shim préchargé sous un autre nom de fichier ne peut pas satisfaire cette exigence — même s'il exporte le bon symbole sous le bon nom de version.

Erreur observée

Malgré LD_PRELOAD, le daemon plante toujours avec :

/lib64/libstdc++.so.6: version GLIBCXX_3.4.30 not found

Blocage 2 — Patch texte simple ne suffit pas (hash ELF non mis à jour)

Ce qui a été tenté

Remplacer la chaîne GLIBCXX_3.4.30 par GLIBCXX_3.4.29 directement dans le binaire avec sed ou un patch Python simple.

Pourquoi ça échoue

La section .gnu.version_r contient des structures Elf64_Vernaux :

typedef struct {
    Elf64_Word  vna_hash;   // hash ELF du nom de version
    Elf64_Half  vna_flags;
    Elf64_Half  vna_other;
    Elf64_Word  vna_name;   // offset dans la string table
    Elf64_Word  vna_next;
} Elf64_Vernaux;

Chaque entrée contient deux références au nom de version :

  1. vna_name → offset vers la chaîne dans .dynstr
  2. vna_hash → hash ELF de cette chaîne (précalculé)

Un patch texte change la chaîne mais laisse vna_hash intact. Le linker dynamique vérifie le hash en premier — il ne trouve pas hash(GLIBCXX_3.4.29) dans libstdc++.so.6 et rejette le module.

Erreur observée

/lib64/libstdc++.so.6: version GLIBCXX_3.4.29 not found

(nouveau message — la chaîne a changé mais le hash est toujours celui de GLIBCXX_3.4.30)


Blocage 3 — Mauvais offset dans le parseur ELF

Ce qui a été tenté

Écrire patch-glibcxx-compat.py pour parser .gnu.version_r et patcher hash + chaîne.

Erreur de décodage

Structure Elf64_Verneed :

offset +0  : vn_version (2 bytes)
offset +2  : vn_cnt     (2 bytes)
offset +4  : vn_file    (4 bytes)  ← oublié !
offset +8  : vn_aux     (4 bytes)
offset +12 : vn_next    (4 bytes)

Le premier jet lisait vn_aux à +4 (en sautant vn_file) — le parseur pointait vers de mauvaises entrées, ne trouvait rien à patcher.

Fix appliqué

Corriger les offsets dans le script Python :

vn_aux  = struct.unpack_from(f'{endian}I', data, vneed_pos + 8)[0]   # +8, pas +4
vn_next = struct.unpack_from(f'{endian}I', data, vneed_pos + 12)[0]  # +12, pas +8

Blocage 4 — Patcher vers GLIBCXX_3.4.29 mais le symbole n'y existe pas

Ce qui a été tenté

Rediriger la dépendance de GLIBCXX_3.4.30 vers GLIBCXX_3.4.29 (version juste en dessous, disponible sur AlmaLinux 9).

Pourquoi ça échoue

Le symbole _ZNSt18condition_variable4waitERSt11unique_lockISt5mutexE n'existe pas sous GLIBCXX_3.4.29 dans la libstdc++ système. Il est exporté sous GLIBCXX_3.4.11 :

$ nm -D /lib64/libstdc++.so.6 | grep condition_variable | grep wait
...
_ZNSt18condition_variable4waitERSt11unique_lockISt5mutexE@@GLIBCXX_3.4.11

Patcher vers .4.29 donnait encore version GLIBCXX_3.4.29 not found (pas de ce symbole sous cette version).

Fix appliqué

Cibler GLIBCXX_3.4.11 dans le patcher — c'est là que le symbole vit réellement :

old_ver  = b'GLIBCXX_3.4.30'
new_ver  = b'GLIBCXX_3.4.11'   # même longueur → patch in-place sûr
old_hash = elf_hash('GLIBCXX_3.4.30')
new_hash = elf_hash('GLIBCXX_3.4.11')

Les deux chaînes font 14 octets → remplacement in-place sans décalage de la table de chaînes.


Blocage 5 — bytearray.index(0, ...) lève ValueError

Ce qui a été tenté

Lire le nom de version depuis .dynstr :

name_end = data.index(0, name_off)  # cherche le '\0' terminal

Pourquoi ça échoue

bytearray.index() attend un entier ou une séquence d'octets selon le contexte, mais la forme data.index(0, ...) sur bytearray lève ValueError: 0 is not in bytearray (comportement différent de bytes).

Fix appliqué

name_end = data.index(b'\x00', name_off)  # forme bytes, toujours valide

Blocage 6 — gcc -lstdc++ introuvable

Ce qui a été tenté

Compiler le shim LD_PRELOAD (approche abandonnée) avec -lstdc++.

Pourquoi ça échoue

AlmaLinux 9 ne fournit pas le lien symbolique libstdc++.so (sans version) nécessaire au linker. Seul libstdc++.so.6 (avec version) est présent.

Fix appliqué

Passer le chemin complet :

gcc -shared -fPIC -Wl,--version-script=glibcxx_compat.map \
    -o libglibcxx_compat.so glibcxx_compat.c /usr/lib64/libstdc++.so.6

Blocage 7 — Systemd tuait le daemon après démarrage (lms server start quitte après 0)

Ce qui a été tenté

Service Type=simple avec ExecStart=lms server start --port 1234 --bind 0.0.0.0.

Comportement observé

lms server start :

  1. Lance le daemon llmster en arrière-plan
  2. Attend que le serveur HTTP soit prêt
  3. Affiche Success! Server is now running on port 1234
  4. Quitte avec code 0

Systemd détecte la fin du processus principal → KillMode=control-group (défaut) → tue tout le cgroup → llmster mort → service inactive.

Le daemon survivait parfois grâce à un double-fork qui lui permettait d'échapper au cgroup, mais c'était non fiable et systemctl status montrait inactive même quand le serveur tournait.

Ce qui n'a pas fonctionné en intermédiaire

Lancer llmster directement via ExecStart + ExecStartPost=lms server start :

ExecStart=/opt/lmstudio/.lmstudio/llmster/0.0.12-1/llmster
ExecStartPost=/bin/bash -c 'sleep 5 && lms server start --port 1234 --bind 0.0.0.0'

Erreur :

Failed to authenticate: Invalid passkey for lms CLI client.

lms server start utilise un passkey stocké/généré lors du démarrage du daemon via lms. Démarrer llmster directement bypasse ce mécanisme → authentification impossible depuis lms.

Fix appliqué — wrapper run-llmster.sh

#!/bin/bash
LMS="${HOME}/.lmstudio/bin/lms"

# lms gère le passkey ET le démarrage du daemon
"$LMS" server start --port "$LMS_PORT" --bind "$LMS_BIND"

# Rester en vie tant que llmster tourne → systemd garde le service "active"
while pgrep -u "$(id -un)" -x llmster > /dev/null 2>&1; do
    sleep 15
done

exit 1  # déclenche Restart=on-failure si le daemon meurt

Résultat :

  • Le wrapper est le processus principal (Type=simple)
  • lms server start gère correctement passkey + démarrage du daemon
  • La boucle maintient le service active (running) dans systemd
  • Si llmster meurt, le wrapper sort avec code 1 → Restart=on-failure relance tout

Blocage 8 — Port 1234 inaccessible depuis le réseau

Symptôme

curl http://192.168.10.20:1234/v1/models depuis storage-01 → No route to host

Le serveur écoutait bien sur 0.0.0.0:1234 mais le pare-feu AlmaLinux bloquait le port.

Fix appliqué

firewall-cmd --permanent --add-port=1234/tcp
firewall-cmd --reload

Ajouté dans tasks/main.yml via ansible.posix.firewalld pour idempotence.


Solution finale

Composant Approche retenue
Dépendance GLIBCXX Patch ELF de watcher.node : réécriture de vna_hash + chaîne GLIBCXX_3.4.30GLIBCXX_3.4.11
Service systemd Wrapper shell : lms server start + boucle de monitoring pgrep llmster
Firewall firewall-cmd --permanent --add-port=1234/tcp

Fichiers du rôle Ansible

roles/lm_studio/
├── files/
│   ├── patch-glibcxx-compat.py    # parseur ELF, patch hash+string VERNEED
│   ├── install-lmstudio-compat.sh # télécharge bundle, patche watcher.node, bootstrap
│   └── run-llmster.sh             # wrapper systemd
└── templates/
    └── lm-studio.service.j2       # Type=simple, ExecStart=run-llmster.sh

Vérification

# Depuis storage-01
curl http://192.168.10.20:1234/v1/models
# → {"data":[...],"object":"list"}

# Sur gpu-01
systemctl status lm-studio
# → active (running), MainPID=run-llmster.sh, llmster dans le cgroup

Blocage 9 — llmster rebind sur 127.0.0.1 après chargement Vulkan

Symptôme

lms server start --bind 0.0.0.0 démarre correctement, mais après lms load <modele> --gpu max avec le backend Vulkan, llmster rebind son serveur HTTP sur 127.0.0.1:1234. Le port 1234 devient inaccessible depuis le réseau LAN.

# Sur gpu-01 après lms load :
ss -tlnp | grep 1234
# LISTEN 0 128 127.0.0.1:1234   ← loopback seulement

Cause

Comportement interne de llmster lors de l'initialisation du backend Vulkan : le processus recrée son socket HTTP et le bind sur loopback plutôt que sur l'adresse configurée au démarrage.

Fix appliqué — socat proxy dans run-llmster.sh

Après le chargement du modèle, un proxy socat est lancé sur l'IP LAN pour forwarder vers le loopback. Les deux sockets sont non-conflictuels (IPs différentes) :

LAN_IP=$(ip -4 addr show scope global | grep -oP '(?<=inet\s)\d+(\.\d+){3}' | head -1)
socat TCP-LISTEN:1234,fork,bind="${LAN_IP}",reuseaddr TCP:127.0.0.1:1234 &

Pourquoi ne pas relancer lms server start --bind 0.0.0.0 ? Relancer lms server start après le chargement recharge le modèle avec le contexte par défaut (4096 tokens), écrasant le --context-length 65536 passé à lms load.


Blocage 10 — Gemma 4 crash Vulkan sur prompts longs (>800 tokens)

Symptôme

Avec google/gemma-4-e4b ou google/gemma-4-e2b, la première inférence avec un prompt de plus de ~800 tokens provoque un crash du processus llmster :

HTTP 400 {"error":"The model has crashed without additional information. (Exit code: null)"}

Après le crash, llmster recharge le modèle avec le contexte par défaut (4096), et les requêtes suivantes échouent avec n_keep >= n_ctx.

Cause

Bug dans l'implémentation Vulkan de llama.cpp pour l'architecture Gemma 4. Gemma 4 utilise une attention interleaved global/local avec un sliding window de 512 tokens. Quand le prompt dépasse 512 tokens, les layers d'attention globale crashent dans le shader Vulkan.

Seuil confirmé par test binaire :

  • OK à 500 tokens (~100x "You are a helpful assistant.")
  • CRASH à ~900 tokens (~130x)

La VRAM n'est pas en cause : avec GQA aggressif et sliding window, le KV cache de Gemma 4 à 65536 tokens est ~2 GB seulement (bien dans les 12GB VRAM).

Fix

Utiliser un modèle avec attention GQA standard (sans sliding window interleaved) :

  • Qwen3.5-9B ✓ — support Vulkan mature, GQA standard
  • Qwen2.5-7B-Instruct ✓ — alternative

Ne pas utiliser Gemma 4 E2B ou E4B avec le backend Vulkan de LM Studio jusqu'à correction du bug upstream dans llama.cpp.