Divendres es va llançar el model i no vam poder esperar

La notícia del Qwen3.8-27B va sortir un divendres i, com gairebé sempre ens passa, no vam ser capaços d’esperar al dilluns per posar-lo en marxa. Aquest post és la primera crònica d’aquest desplegament: el vam fer per damunt, a mà, sobre la infra que ja teníem, perquè encara no hem documentat com governem tot amb Flux CD ni com muntem Forgejo. Això ho veurem en posts següents. Aquí, de moment, ens interessa contar la part que més ens va sorprendre: quant ens costa —o deixa de costar— fer córrer un model de 27B a casa, i quines lliçons de les receptes oficials ens van fer saltar de 9 a més del doble de tokens per segon.

És un post inicial, a propòsit. No pretén ser el manual definitiu del desplegament: és la foto del que va passar, amb les xifres que vam treure dels logs, i amb les sensacions pròpies que ens vam portar del camí.

L’escenari: una DGX Spark dins del clúster

El nostre clúster K3s té dos nodes. El primer, gamorcloud01, és el control plane i el worker de CPU. El segon, gx10-db4d, és el node amb GPU on des de fa anys hi dormen les càrregues de inferència. Allà és on va aterrar Qwen3.8-27B, al namespace dgx-ia, com un Deployment de Kubernetes que correia el contenedor vllm/vllm-openai:v0.27.1.

El model que vam pujar és la versió cuantitzada en NVFP4 — la recepta que van publicar — descarregada des de Hugging Face amb el seu propi initContainer, i muntada en un PVC perquè no torni a baixar 40 GB cada vegada que reinicia:

containers:
  - name: vllm-nvfp4
    image: vllm/vllm-openai:v0.27.1
    command: [vllm, serve, /models/qwen3.8-27b-inferact-nvfp4]
    resources:
      limits:
        nvidia.com/gpu: "1"
        memory: "120Gi"
      requests:
        nvidia.com/gpu: "1"
        memory: "64Gi"

Dos coses que importen i que solen fer mal en aquest tipus de desplegaments casolans:

  • El startupProbe amb failureThreshold: 180. Un model de 27B triga a engegar-se. Si el probe és impacient, el clúster et mata el pod abans que hagi après a respirar. Li vam donar gairebé una hora de marge d’engegada i després li vam deixar un readinessProbe normal.
  • L’initContainer amb un .download-complete. La primera vegada baixa el model i toca un marcador; les següents veu el marcador i salta directament al servei. Sense això, cada reinici era una espera nova.

Tot això sense Flux CD encara: ho vam aplicar a mà, per poder veure la diferència entre config i config sense que el GitOps nos la emmascari. Això, de moment, és un deute que pagarem en un post dedicat a com ho governem per GitOps.

La recepta base: i per què donava 9 tokens/s

Vam començar amb una configuració conservadora, una “primera vegada que el provo i no vull que es tombi”. Tenia sentit: --gpu-memory-utilization 0.45, sense prefix caching explícit, sense MTP. Engegava. Respondia. Però a poc a poc.

La xifra base que ens va quedar gravada va ser 9 tokens/segon. Pocs, però estables. I allà va començar la curiositat, perquè les receptes oficials de Qwen3.8 amb vLLM parlaven d’un escenari força més àgil del que estavem obtenint.

La recepta nova: tres palanques, un canvi

El que vam fer va ser aplicar la recepta recomanada per a Qwen3.8 en vLLM, palanca a palanca. Vam tenir molt de compte de no tocar allò que ja anava bé: --kv-cache-dtype fp8, --max-model-len 262144, --enable-chunked-prefill, i els parsers de raonament i tool-calling (--reasoning-parser qwen3, --tool-call-parser qwen3_coder, --enable-auto-tool-choice). Vam canviar només tres coses:

# 1) MTP (Multi-Token Prediction): Qwen3.8 trae MTP; vLLM 0.27.1 lo soporta.
- --speculative-config
- '{"method": "qwen3_next_mtp", "num_speculative_tokens": 2}'

# 2) Memoria: 0.45 -> 0.90. El KV cache solo usaba 9-9,8% de la GPU;
#    ~55% quedaba sin asignar. (Riesgo: si hay OOM, bajar a 0.85.)
- --gpu-memory-utilization
- "0.90"

# 3) Prefix caching explícito: por defecto va ON en el V1 engine, pero
#    queríamos asegurarlo. Antes teníamos hit rate 0%.
- --enable-prefix-caching

Cada una de les tres resolia un problema diferent, i per això les separo:

1. MTP (Multi-Token Prediction)

Qwen3.8 porta, de fàbrica, una capa de predicció de diversos tokens a la vegada (MTP). vLLM 0.27.1 sap aprofitar-la amb --speculative-config i num_speculative_tokens 2. La idea: el model especula un parell de tokens d’una, i el model gran confirma o descarta. Si hi acapem, guanyem; si ens equívoca, no perdem (es descarta i s’hi torna a començar). Als logs veiem una taxa d’acceptació de borrador entre el 60 % i el 82 %, i una longitud mitjana d’acceptació de 2,3–2,6 tokens. No és màgia, però és una manera elegant d’avançar.

2. Memòria de la GPU: 0.45 → 0.90

Aquesta va ser la que més ens va sorprendre en el diagnòstic. Amb 0.45, el KV cache només usava 9-9,8 % de la GPU. És a dir, teníem més de la meitat de la memòria de la targeta sense assignar al que més la necessitava: la cau de context. Pujar a 0.90 no accelera per si sol el token, però allibera la cau per poder atendre més peticions i més context, i prepara el terra per al punt 3. L’únic risc, i el vam deixar escrit al propi manifest, és un OOM: si passara, s’abaixa a 0.85.

3. Prefix caching explícit

Aquest és el que canvia el dia. El V1 engine de vLLM porta el prefix caching ON per defecte, però nosaltres teníem un hit rate del 0 % — la cau s’activava, però no servia per a res. En afegir-lo explícit, el hit rate va pujar al 84-86 % que vam veure als logs. Per què importa tant? Perquè quan dues peticions comparteixen context (el mateix system prompt, la mateixa conversa, el mateix prefixe d’eina), en lloc de recalculat-lo des de zero, el motor reutilitza el que ja havia calculat. En una infra amb molts xats que comparteixen plantilles, això és el 86 % del treball estalviat.

Els números: de 9 a més del doble, i a vegades de 4 a 6 vegades

Aquí és on convé ser honest, perquè “ràpid” pot voler dir coses diferents, i als logs es veuen dues velocitats que no són la mateixa.

En throughput brut (el que miden les receptes): les xifres surten per damunt del doble. La base era 9 tokens/s. Amb la recepta nova, en peticions individuals (una sola en vol) els logs mostren ~22–31 tokens/s de generació; i quan el motor és amb les 4 peticions concurrents que habilita --max-num-seqs 4, els pics pugen a 40–56 tokens/s. O sigui: més del doble que la recepta base, tal com deien les receptes.

En temps de resposta (el que vam percebre en l’ús real): el salt s’amplifica. La sensació va ser d’un x4 o x6. I té sentit, perquè el temps de resposta no depèn només del throughput brut: el prefix caching al 86 % corta el temps fins al primer token en les peticions que comparteixen context, i el MTP anticipa la generació. Allò que “sembla més del doble” en les estadístiques es tradueix, en l’experiència, en una resposta que arriba de 4 a 6 vegades més ràpid.

Escenari 27B sense optimitzar 27B optimitzat Salt
Throughput brut (peticions individuals) ~9 tokens/s ~22–31 tokens/s > 2×
Throughput en pic (4 peticions concurrents) ~9 tokens/s 40–56 tokens/s ~4–6×
Hit rate de prefix caching 0 % 84–86 % el gran canvi
Sensació de resposta referència 4–6× més ràpida el que es percep

No confiem “més del doble” amb “4–6 vegades”: és la mateixa configuració, mesurada de dues maneres. El throughput es veu als logs; la sensació de 4–6× és el que es nota en fer-lo servir, i ve de que la cau i l’especulació redueixen el temps fins a la resposta, no de que cada token sigui 6× més ràpid.

La sensació real: davant del Qwen3.6 35B

Aquí és on la cosa es posa interessant, i on convé ser molt honest amb el tipus de comparació — perquè no és “model A contra model B a la mateixa velocitat”. Són dues coses diferents, i convé separar-les.

Allò que teníem abans — Qwen3.6 35B, un MoE A3B. És un model mixture of experts que només activa 3.000 milions de paràmetres per token. Això el feia molt ràpid: el cost per token és baix perquè per cada token només treballen uns pocs milers de milions de paràmetres. Però amb un preu: una precisió que ens decepcionava. Ràpid, sí; però en les tasques fines es notava que perdia qualitat, que no hi acavia tant de bé quan importava el detall.

El nou — Qwen3.8-27B, sense optimitzar. Aquí va venir la decepció. Sense la recepta, aquest model anava molt lent: 9 tokens/s. Lent fins al punt de que, davant del 35B MoE, semblàvem haver retrogradat. I tot i així tenia allò que el 35B no tenia: una precisió molt més gran.

I aleshores, amb les tres optimitzacions. Allò que de veritat ens va sorprendre. No només vam recuperar la velocitat —la recepta nova ens porta a una velocitat similar a la del 35B MoE (i, en el temps de resposta percebut davant del 27B sense optimitzar, un 4-6× millor)—, sinó que ho fa conservant aquesta precisió. És a dir: vam aconseguir la rapidesa del 35B i la qualitat del 27B. El millor dels dos, en el mateix desplegament.

Això és la sensació: no és que “un model petit aplasti un de gran”. És que el 27B, mal muntat, semblava un desastre; bé muntat, és a la vegada el més ràpid i el més precís. La recepta no només va accelerar: ens va tornar un model que a més es comporta millor. En una infra casolana, on cada segon de GPU compta, tenir velocitat i precisió i eficàcia en el mateix desplegament és el que de veritat canvia l’experiència.

Allò que ens portem (i allò que ve)

Tres lliçons del camí:

  • No confis en els defaults sense mesurar-los. El prefix caching anava “ON” per defecte i teníem un hit rate del 0 %. A vegades el default està desactivat de fet. Mesura’l, no el suposis.
  • La memòria sense assignar és memòria de rendiment. El 0.45 de gpu-memory-utilization no era un error, era una oportunitat: la meitat de la GPU dormia.
  • Optimitzar pot fer més que canviar de model. El 27B sense recepta anava més lent que el 35B MoE; amb les tres optimitzacions va arribar a una velocitat similar al 35B i amb una precisió molt més gran. No sempre cal canviar de model: a vegades cal muntar-lo bé.

I allò que ve —perquè aquest post és a propòsit la primera part—: com governem aquests desplegaments amb Flux CD (així la propera vegada no ho fem a mà), i com muntem Forgejo per a que tot això sigui un PR amb revisió humana en lloc d’un kubectl apply a mitjanit.

Mentrestant, el model segueix corrent a gx10-db4d, a dgx-ia, sense caure’s des de que el vam pujar. I, si m’ho demanes, la resposta és: sí, es nota.