Je ne vais pas vous dire dans cet article lequel de MLX ou de GGUF est le plus rapide sur un Mac. Des tableaux comparatifs, il en circule déjà beaucoup. La plupart comparent un modèle quantifié en 4 bits d’un côté avec un autre en 5 bits de l’autre, avec des contextes différents et un seul essai. Leurs chiffres ne disent pas grand chose de ce qui se passera sur votre machine, avec votre modèle et vos prompts.
Dans LM Studio, la question revient à chaque téléchargement. L’onglet de recherche propose souvent le même modèle dans les deux formats, et le choix se fait au hasard ou par habitude. Nous allons voir comment comparer le même modèle dans les deux formats sur votre propre Mac, avec trois scripts que vous pourrez relancer à chaque mise à jour de LM Studio ou de ses runtimes. Les chiffres seront les vôtres, pas les miens.
Présentation de la maquette
- Machine: Mac mini M4, 32 Go de mémoire unifiée
- Logiciel: LM Studio 0.4.x avec la CLI
lmset le serveur local sur le port 1234 - Outils: Python 3 (bibliothèque standard uniquement),
vm_stat,sysctl, Moniteur d’activité - Répertoire de travail:
~/bench-mlx-gguf
Rien d’autre à installer. Les scripts ci dessous n’utilisent aucune dépendance externe, pour éviter qu’une bibliothèque cliente ajoute sa propre latence ou ses propres paramètres par défaut.
Deux formats, deux moteurs
Derrière chaque format, il y a un moteur d’inférence différent, et c’est lui que l’on compare réellement. LM Studio embarque plusieurs runtimes et choisit celui qui correspond au format du modèle.
GGUF et llama.cpp
Un modèle GGUF tient dans un seul fichier, qui contient les poids quantifiés et les métadonnées: architecture, tokenizer, template de chat, etc… Il est exécuté par llama.cpp, compilé pour Apple Silicon avec le backend Metal. La documentation de l’API REST de LM Studio donne d’ailleurs un exemple de réponse où le runtime s’appelle llama.cpp-mac-arm64-apple-metal-advsimd, avec "format": "gguf" et "quant": "Q4_K_M" dans le bloc model_info (REST API v0). Nous allons nous servir de ces champs pour vérifier à chaque requête quel moteur a vraiment répondu.
Les quantifications GGUF portent des noms du type Q4_K_M, Q5_K_M, Q6_K, Q8_0 ou IQ4_XS. Les « K-quants » mélangent plusieurs précisions selon les tenseurs. Un Q4_K_M n’est donc pas un 4 bits pur: en moyenne il tourne plutôt autour de 4,8 bits par poids (c’est un ordre de grandeur, la valeur exacte dépend de l’architecture du modèle).
MLX et mlx-engine
Un modèle MLX se présente comme un dossier: des fichiers safetensors, un config.json et les fichiers du tokenizer. Côté LM Studio, il est exécuté par mlx-engine, un moteur open source (licence MIT) construit sur mlx-lm d’Apple pour le texte et mlx-vlm pour la vision. D’après son dépôt, ce moteur est livré avec LM Studio pour Mac depuis la version 0.3.4. L’équipe LM Studio a décrit dans cet article comment elle a unifié les deux bibliothèques: les implémentations texte de mlx-lm servent pour tous les modèles, et les composants vision de mlx-vlm s’y ajoutent sous forme d’add-ons.
La quantification MLX classique est affine, par groupes de poids (64 par défaut), avec une échelle et un biais en 16 bits par groupe. Ces deux valeurs ajoutent environ 0,5 bit par poids. Un MLX « 4bit » revient donc à environ 4,5 bits par poids, et un « 8bit » à environ 8,5. On trouve aussi des variantes 3, 5 ou 6 bits, du bf16 non quantifié (la note de version de LM Studio 0.4.0 corrige justement un bug où bf16 n’était pas reconnu comme quantification valide), et sur Hugging Face des variantes « DWQ » publiées par la communauté.
Un point m’a été demandé plusieurs fois: LM Studio ne quantifie rien lui même. Les quantifications disponibles dépendent de ce que les auteurs ont publié sur Hugging Face pour chaque modèle. La liste change donc d’un modèle à l’autre: vérifiez sur la page du modèle que les deux formats existent dans des quantifications comparables avant de télécharger quoi que ce soit.
Quelle paire de quantifications comparer?
Je compare des variantes dont le nombre de bits par poids est le plus proche possible:
| GGUF | MLX | Bits par poids (approx.) | Commentaire |
|---|---|---|---|
Q8_0 |
8bit | 8,5 / 8,5 | La paire la plus propre, les deux formats sont quasiment à égalité |
Q4_K_M |
4bit | ~4,8 / ~4,5 | La paire la plus utilisée, léger avantage de taille pour MLX |
Q6_K |
6bit | ~6,6 / ~6,5 | Bon compromis si les deux existent |
Je commence toujours par la paire Q8_0 / 8bit: les deux formats y stockent quasiment le même nombre de bits par poids, donc un écart de vitesse ou de qualité à ce niveau s’explique par le moteur. Ensuite seulement je regarde la paire 4 bits, qui est celle que l’on utilise vraiment au quotidien sur 32 Go.
Choix de deux modèles qui tiennent en 32 Go
Sur un Mac à mémoire unifiée, tout n’est pas disponible pour le GPU. macOS limite la part de mémoire que Metal peut verrouiller. On lit souvent que cette limite se situe entre deux tiers et trois quarts de la RAM selon la machine, mais je n’ai trouvé aucune documentation Apple officielle qui le confirme. Je pars donc du principe qu’il faut viser un modèle et son cache de contexte sous 20 Go environ, pour garder de la marge pour macOS et LM Studio.
Le calcul de base est simple: nombre de paramètres × bits par poids / 8. Il faut ensuite ajouter le cache KV, qui grossit avec la longueur de contexte.
| Modèle (exemple) | 4 bits (~4,5 à 4,8 bpp) | 8 bits (~8,5 bpp) |
|---|---|---|
| Dense ~14 milliards de paramètres | ~8 à 8,5 Go | ~15 Go |
| MoE ~30 milliards (3 milliards actifs) | ~17 à 18 Go | ~32 Go, hors jeu |
Je retiens donc deux profils:
- Un modèle dense d’environ 14 milliards de paramètres (la famille Qwen3 14B par exemple, ou son successeur s’il existe dans votre catalogue). Il passe dans les deux paires,
Q8_0/ 8bit etQ4_K_M/ 4bit. - Un modèle MoE d’environ 30 milliards de paramètres totaux avec peu de paramètres actifs (du type Qwen3-30B-A3B). Uniquement en 4 bits. Son intérêt est ailleurs: la lecture des poids ne concerne qu’une fraction des experts à chaque token, et les deux moteurs ne gèrent pas forcément ce cas avec la même efficacité.
Sur une machine dont la bande passante mémoire est limitée (de l’ordre de 120 Go/s sur le M4 de base), un modèle dense et un MoE ne sollicitent pas la mémoire de la même façon, et un moteur peut s’en sortir mieux que l’autre sur l’un des deux. Avant de télécharger quoi que ce soit, la CLI peut estimer la mémoire nécessaire sans charger le modèle (documentation de lms load):
lms ls
lms load --estimate-only <cle_du_modele> --context-length 8192D’après la documentation, l’estimation tient compte de la longueur de contexte, de flash attention et du fait que le modèle gère ou non la vision.
Préparer un banc équitable
La plupart des écarts que je vois passer viennent d’ailleurs que du format. Avant toute mesure, je fixe donc les points suivants:
- Un seul modèle chargé à la fois:
lms unload --allentre chaque série. - Même longueur de contexte des deux côtés, passée explicitement avec
--context-length. Je prends 8192, assez pour mes prompts sans gonfler inutilement le cache KV. - Décodage spéculatif désactivé (il existe dans LM Studio depuis la 0.3.10). Un modèle brouillon fausse complètement les tokens/s.
- Quantification du cache KV désactivée des deux côtés, et pour le GGUF, déchargement complet des couches sur le GPU. Vérifiez ces réglages dans le panneau de chargement du modèle, les libellés évoluent d’une version à l’autre.
- Température à 0 et même
max_tokens. Même à température 0, les deux moteurs ne produiront pas exactement le même texte: les calculs en virgule flottante diffèrent et une seule divergence de token suffit à changer toute la suite. - Pour un modèle « thinking », même mode de raisonnement des deux côtés, sinon les longueurs de réponse ne sont plus comparables.
- Mac sur secteur, autres applications fermées, et des séries alternées (GGUF, MLX, GGUF, MLX) plutôt que tout un format puis tout l’autre, pour lisser les effets thermiques et le bruit de fond du système.
Le chargement se fait avec un identifiant explicite, ce qui permet au script de cibler le bon modèle sans ambiguïté:
lms server start
lms unload --all
lms load <cle_variante_gguf> --identifier bench-14b-q8-gguf --context-length 8192
lms psSi les deux variantes apparaissent sous des clés proches dans lms ls, prenez le temps de vérifier laquelle est chargée. Le script relit de toute façon le format et le moteur dans chaque réponse.
Script d’appel via l’API locale avec chronométrage
LM Studio expose une API compatible OpenAI sur /v1, mais elle ne renvoie pas les statistiques de génération. Son API REST propre les renvoie: le bloc stats contient tokens_per_second, time_to_first_token et generation_time, et les blocs model_info et runtime indiquent le format, la quantification et le moteur (REST API v0). La documentation recommande désormais la nouvelle API v1 pour les nouveaux projets. Son endpoint de chat renvoie lui aussi tokens_per_second et time_to_first_token_seconds (Chat with a model). J’utilise ici la v0 parce que son format de requête est celui de l’API OpenAI et qu’elle renvoie le moteur utilisé. Le passage à la v1 ne change que quelques lignes.
Je ne me contente pas des statistiques du serveur. Le script mesure aussi le temps mur côté client, ce qui permet de repérer une incohérence (temps de chargement caché, file d’attente, etc…).
Le jeu de prompts est un fichier JSONL. Chaque ligne contient un identifiant, le prompt et les éléments attendus dans la réponse, qui serviront pour la notation de qualité:
{"id": "krb01", "prompt": "Explique le rôle du PAC dans un ticket Kerberos Active Directory.", "attendu": ["MS-PAC", "SID de groupes", "signature KDC"]}
{"id": "ntlm01", "prompt": "Pourquoi le relais NTLM fonctionne-t-il sans connaître le mot de passe ?", "attendu": ["challenge/réponse", "absence de liaison au canal", "signature SMB"]}
{"id": "saml01", "prompt": "Quelle est la différence entre la signature de la Response et celle de l'Assertion en SAML 2.0 ?", "attendu": ["XML Signature", "assertion seule", "wrapping"]}
{"id": "long01", "prompt": "Résume le texte suivant en 10 points : ...", "attendu": []}Je mélange volontairement des prompts courts, où c’est la génération qui domine, et au moins un prompt long (quelques milliers de tokens de texte collé), où c’est le traitement du prompt qui domine. Les deux moteurs ne se classent pas forcément de la même façon sur les deux phases.
#!/usr/bin/env python3
# bench.py - mêmes prompts, mêmes paramètres, un modèle chargé à la fois
# usage: python3 bench.py bench-14b-q8-gguf prompts.jsonl 3
import csv, json, sys, time, urllib.request
API = "http://localhost:1234/api/v0/chat/completions"
MODEL = sys.argv[1]
PROMPTS = sys.argv[2]
RUNS = int(sys.argv[3]) if len(sys.argv) > 3 else 3
def ask(prompt):
body = {
"model": MODEL,
"messages": [{"role": "user", "content": prompt}],
"temperature": 0,
"max_tokens": 1024,
"stream": False,
}
req = urllib.request.Request(API, data=json.dumps(body).encode(),
headers={"Content-Type": "application/json"})
t0 = time.perf_counter()
with urllib.request.urlopen(req, timeout=900) as r:
data = json.load(r)
return data, time.perf_counter() - t0
prompts = [json.loads(l) for l in open(PROMPTS, encoding="utf-8") if l.strip()]
ask("Réponds uniquement: OK") # échauffement, non mesuré
with open(f"mesures-{MODEL}.csv", "w", newline="") as f_csv, \
open(f"reponses-{MODEL}.jsonl", "w", encoding="utf-8") as f_rep:
w = csv.writer(f_csv)
w.writerow(["modele", "format", "quant", "runtime", "id", "run",
"prompt_tokens", "completion_tokens", "ttft_s",
"tokens_s", "generation_s", "mur_s", "stop"])
for run in range(1, RUNS + 1):
for p in prompts:
d, mur = ask(p["prompt"])
st, mi = d.get("stats", {}), d.get("model_info", {})
rt, us = d.get("runtime", {}), d.get("usage", {})
w.writerow([MODEL, mi.get("format"), mi.get("quant"), rt.get("name"),
p["id"], run, us.get("prompt_tokens"), us.get("completion_tokens"),
st.get("time_to_first_token"),
round(st.get("tokens_per_second", 0), 2),
st.get("generation_time"), round(mur, 3), st.get("stop_reason")])
if run == 1:
f_rep.write(json.dumps({"id": p["id"], "modele": MODEL,
"reponse": d["choices"][0]["message"]["content"]},
ensure_ascii=False) + "\n")
print(f"{p['id']:8} run {run} {st.get('tokens_per_second', 0):6.1f} tok/s "
f"TTFT {st.get('time_to_first_token')} s [{mi.get('format')} {mi.get('quant')}]")Quelques remarques sur ce script:
- -> L’appel d’échauffement absorbe la compilation des shaders et les éventuelles allocations du premier passage. Sans lui, la première mesure de chaque série est systématiquement plus lente.
- -> Les colonnes
format,quantetruntimeservent de garde-fou. Si vous lisezggufdans un fichier censé contenir des mesures MLX, inutile d’aller plus loin. - -> Comparez
completion_tokensentre les deux formats. Un tokens/s n’a de valeur que sur des longueurs de réponse comparables, et une réponse coupée parmax_tokens(stopdifférent de la fin normale) doit être signalée. - -> Le même prompt rejoué plusieurs fois peut profiter d’un cache de prompt côté moteur, ce qui fait baisser le TTFT dès le deuxième passage. Pour le TTFT, je ne retiens que le run 1 de chaque prompt. Pour les tokens/s de génération, les runs 2 et 3 sont exploitables.
Pour agréger, une médiane suffit. La moyenne est trop sensible à une mesure perturbée par un processus en arrière-plan:
#!/usr/bin/env python3
# synthese.py - usage: python3 synthese.py mesures-*.csv
import csv, sys, statistics as s
rows = [r for f in sys.argv[1:] for r in csv.DictReader(open(f))]
for m in sorted({r["modele"] for r in rows}):
rr = [r for r in rows if r["modele"] == m]
tps = [float(r["tokens_s"]) for r in rr if r["run"] != "1"]
ttft = [float(r["ttft_s"]) for r in rr if r["run"] == "1"]
print(f"{m:24} {rr[0]['format']:5} {rr[0]['quant']:8} "
f"tok/s med {s.median(tps):6.1f} (min {min(tps):5.1f} max {max(tps):5.1f}) "
f"TTFT med {s.median(ttft):5.2f} s")Si le prompt long donne un résultat très différent des prompts courts, séparez-le dans la synthèse. Le mélanger aux autres noierait justement l’information qui vous intéresse.
Relevé de la mémoire avec Moniteur d’activité ou vm_stat
Mesurer la mémoire consommée par un modèle sur Apple Silicon est moins simple qu’il n’y paraît. La colonne « Mémoire » d’un processus dans Moniteur d’activité ne reflète pas toujours les tampons Metal alloués pour le GPU. De plus, un fichier GGUF peut être projeté en mémoire (mmap) et compté comme cache de fichiers plutôt que comme mémoire du processus. Je ne cherche donc pas à attribuer la mémoire à un processus précis. Je regarde l’évolution globale de la mémoire câblée (wired), où atterrissent les allocations verrouillées pour le GPU, et de la mémoire compressée, qui signale que le système commence à manquer d’air.
Dans Moniteur d’activité, l’onglet « Mémoire » affiche en bas « Mémoire câblée », « Compressée » et le graphe « Pression de la mémoire ». C’est suffisant pour un contrôle visuel. Pour un relevé exploitable, vm_stat fait le travail en ligne de commande. Sur Apple Silicon, la taille de page est de 16 Ko, mais je la lis avec sysctl plutôt que de la coder en dur:
#!/bin/bash
# mem.sh - relève la mémoire câblée et compressée chaque seconde
# usage: ./mem.sh mem-14b-q8-gguf.csv (Ctrl+C pour arrêter)
PAGE=$(sysctl -n hw.pagesize)
OUT=${1:-mem.csv}
echo "horodatage,cablee_go,compressee_go" > "$OUT"
while true; do
vm_stat | awk -v p="$PAGE" -v t="$(date +%H:%M:%S)" '
/Pages wired down/ { gsub(/\./, "", $4); w = $4 }
/Pages occupied by compressor/ { gsub(/\./, "", $5); c = $5 }
END { printf "%s,%.2f,%.2f\n", t, w*p/1073741824, c*p/1073741824 }' >> "$OUT"
sleep 1
doneLa procédure pour chaque variante:
lms unload --all, puis lancer./mem.shet attendre une dizaine de secondes. Cela donne la valeur de base.- Charger le modèle avec
lms load. Le palier atteint donne le coût des poids et du cache alloué. - Lancer
bench.py. Le pic pendant le prompt long donne le coût réel en utilisation. - Arrêter
mem.shet noter: base, palier après chargement, pic. On retient les deltas, pas les valeurs absolues.
Si la mémoire compressée grimpe pendant le bench, la mesure de vitesse de cette série n’est plus fiable: le système échange des pages et vous mesurez le compresseur de macOS plutôt que le moteur d’inférence.
Il existe un réglage sysctl iogpu.wired_limit_mb qui permet de relever la limite de mémoire verrouillable par le GPU. Je ne l’utilise pas pour ce comparatif. Il ne change rien à l’équité entre les deux formats, et pousser cette limite sur une machine de 32 Go revient surtout à priver macOS de mémoire.
Notation de la qualité
Les tokens/s se mesurent. La qualité, beaucoup moins. À quantification équivalente, les deux formats contiennent en théorie les mêmes poids à une approximation près. En pratique, les méthodes de quantification diffèrent (blocs, mélange de précisions, quels tenseurs restent en haute précision) et les réponses divergent.
Je procède à l’aveugle, sur un jeu de 15 à 20 questions de mon domaine dont je connais parfaitement les réponses. Le champ attendu du fichier de prompts liste les éléments qu’une bonne réponse doit contenir. Le script ci dessous mélange les réponses des deux variantes, les renomme A et B, et garde la correspondance dans un fichier séparé que je n’ouvre qu’après la notation:
#!/usr/bin/env python3
# aveugle.py - usage: python3 aveugle.py reponses-X.jsonl reponses-Y.jsonl
import json, random, sys
a = {r["id"]: r for r in map(json.loads, open(sys.argv[1], encoding="utf-8"))}
b = {r["id"]: r for r in map(json.loads, open(sys.argv[2], encoding="utf-8"))}
cle, sortie = {}, []
for i in sorted(a.keys() & b.keys()):
paire = [a[i], b[i]]
random.shuffle(paire)
cle[i] = {"A": paire[0]["modele"], "B": paire[1]["modele"]}
sortie.append(f"## {i}\n\n### A\n{paire[0]['reponse']}\n\n### B\n{paire[1]['reponse']}\n")
open("notation.md", "w", encoding="utf-8").write("\n".join(sortie))
json.dump(cle, open("cle.json", "w"), indent=2)Pour chaque réponse, je note sur une grille simple: 0 si un élément attendu est faux, 1 s’il manque, 2 s’il est présent et juste. J’ajoute une pénalité pour toute affirmation technique inventée (un attribut LDAP qui n’existe pas, un numéro de RFC erroné…). Sur un jeu aussi petit, un écart de quelques points entre les deux formats n’a rien de significatif. Seul un écart net et répété, par exemple une variante qui se trompe systématiquement sur les prompts longs, mérite d’être pris en compte.
Grille de lecture et limites
| Mesure | Source | Ce qu’elle dit | Ce qu’elle ne dit pas |
|---|---|---|---|
| tokens/s (runs 2 et 3) | stats.tokens_per_second |
Vitesse de génération ressentie en lecture | Le temps d’attente avant la première ligne |
| TTFT (run 1) | stats.time_to_first_token |
Coût du traitement du prompt, décisif sur les longs contextes | Le comportement en conversation longue avec cache |
| Temps mur | chronomètre client | Contrôle de cohérence de bout en bout | La répartition entre prompt et génération |
| Delta mémoire câblée | vm_stat |
Empreinte réelle du modèle et du cache | Une attribution précise par processus |
| Note qualité | grille à l’aveugle | Régression évidente d’une quantification | Une différence fine, le jeu est trop petit |
Avant de choisir un format sur la base de ces mesures, gardez en tête ce qu’elles ne couvrent pas:
- Les résultats dépendent d’un instant précis. LM Studio met à jour ses runtimes indépendamment de l’application, et un gain d’un format peut disparaître avec la version suivante de l’autre moteur. Notez la version de LM Studio et le nom de runtime présent dans le CSV à chaque série.
- Une quantification « 4 bits » n’en vaut pas une autre.
Q4_K_Mcontre MLX 4bit n’est pas une égalité parfaite, d’où l’intérêt de commencer par la paire 8 bits. - Les fonctionnalités ne sont pas identiques. Certaines options (formats de sortie structurée, quantification du cache, vision, décodage spéculatif) n’ont pas le même support ni la même maturité selon le moteur. Un format plus rapide mais privé d’une option dont vous avez besoin ne vous sert à rien.
- L’usage réel n’est pas un bench. Un agent qui renvoie 20 000 tokens de contexte à chaque tour sera surtout sensible au TTFT et à la mémoire. Une discussion courte, aux tokens/s.
Sur un Mac mini M4 de 32 Go, la mémoire est la contrainte qui tranche le plus souvent. Si les deux formats sont proches en vitesse et en qualité sur vos prompts, prenez celui qui laisse le plus de marge en mémoire câblée sur le prompt long. C’est cette marge qui décidera si vous pouvez garder un deuxième modèle chargé à côté ou monter la longueur de contexte sans voir la pression mémoire passer au jaune.
Rémi Vernier
Responsable des opérations cybersécurité dans un grand groupe international. Identité, Active Directory, Entra ID, cloud hybride, et désormais SAP, IA et modèles en local.