Dictée : whisper-cli compilé pour la machine de build, pas pour la tienne

La dictée n'a jamais transcrit une seule phrase. Le micro enregistrait bien —
contexte HTTPS, autorisation accordée, bouton rouge — mais le serveur répondait
500 sur chaque envoi :

    whisper-cli : AMX is not ready to be used!
    read_audio_data: reading audio data from '/tmp/loki-dictee-...wav' ...
    read_audio_data: trying to decode with miniaudio

Deux indices dans ces trois lignes. « AMX is not ready to be used » ne sort que
d'un binaire COMPILÉ avec les instructions AMX, celles d'un Xeon récent — le
runner GitHub. Et le journal s'arrête net : ni « failed to decode », ni texte
transcrit. Le processus ne renvoie pas une erreur, il meurt.

ggml compile en -march=native par défaut. Le binaire partait donc taillé pour
le processeur qui l'avait construit, et rencontrait une instruction illégale
dès qu'on le posait ailleurs.

- GGML_NATIVE=OFF à l'étape whisperbuild, AVX-512 et AMX explicitement coupés.
  Reste la ligne de base AVX2/FMA/F16C de ggml, présente sur tout x86-64 depuis
  2013. Un test lit le Dockerfile et garde ces drapeaux : rien à l'exécution ne
  rappellerait leur raison d'être au prochain qui touchera cette étape.
- Le handler ne renvoyait que la dernière ligne de stderr. Sur une mort par
  signal, cette ligne est celle d'AVANT le coup fatal : elle ressemble à une
  explication sans en être une, et envoie chercher du côté de l'audio. Il dit
  maintenant comment le processus a fini — « signal: illegal instruction » vaut
  diagnostic à lui seul.

L'image doit être reconstruite : whisper-cli y est compilé.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
This commit is contained in:
MichaelandClaude Opus 5 committed 2026-08-21 16:27:46 +02:00
1 parent 393986faa6
commit a19b490b6a
3 files changed
+96 -3

No files matched your search

+10
View File
@@ -36,6 +36,14 @@ RUN GOBIN=/out GOTOOLCHAIN=auto go install golang.org/x/tools/gopls@latest
# trimballer, et pas de compilation CUDA (des minutes de build pour un gain
# nul sur des dictées de quelques secondes). Ubuntu 22.04 : glibc plus
# ancienne que l'image runtime, donc compatible quoi qu'elle embarque.
#
# ⚠️ GGML_NATIVE=OFF est OBLIGATOIRE. Par défaut ggml compile en -march=native,
# c'est-à-dire pour le processeur DU RUNNER DE BUILD — un Xeon récent chez
# GitHub, avec AVX-512 et AMX. Le binaire partait alors sur une machine qui
# n'a pas ces instructions et mourait d'un SIGILL en pleine transcription :
# « AMX is not ready to be used! », puis plus rien, l'interface affichant un
# échec sans raison. OFF retombe sur la ligne de base AVX2/FMA/F16C de ggml,
# présente sur tout x86-64 depuis 2013.
FROM ubuntu:22.04 AS whisperbuild
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential cmake git ca-certificates \
@@ -43,6 +51,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
RUN git clone --depth 1 https://github.com/ggml-org/whisper.cpp /w \
&& cmake -S /w -B /w/build -DCMAKE_BUILD_TYPE=Release \
-DBUILD_SHARED_LIBS=OFF -DWHISPER_BUILD_TESTS=OFF \
-DGGML_NATIVE=OFF -DGGML_AVX512=OFF -DGGML_AMX_TILE=OFF \
-DGGML_AMX_INT8=OFF -DGGML_AMX_BF16=OFF \
&& cmake --build /w/build -j --target whisper-cli
# ── Étape 2 : runtime sur l'image serveur CUDA officielle ───────────────
+38 -3
View File
@@ -8,6 +8,7 @@ package loki
import (
"context"
"errors"
"fmt"
"io"
"net/http"
@@ -180,12 +181,46 @@ func handleTranscribe(w http.ResponseWriter, r *http.Request) {
cmd.Stderr = &stderr
out, err := cmd.Output()
if err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
// Ne garder QUE stderr est trompeur quand le binaire meurt d'un signal :
// la dernière ligne écrite avant l'exécution du coup fatal ressemble à
// une explication (« AMX is not ready to be used! ») alors que la vraie
// cause est la mort brutale. On dit donc toujours comment il a fini —
// un SIGILL/SIGSEGV désigne un binaire compilé pour un autre
// processeur, pas un problème d'audio.
msg := whisperExitReason(err)
if last := lastLine(stderr.String()); last != "" {
msg += " — dernière sortie : " + last
}
sendJSON(w, 500, map[string]any{"error": "whisper-cli : " + msg})
return
}
sendJSON(w, 200, map[string]any{"text": strings.TrimSpace(string(out))})
}
// whisperExitReason traduit la fin du processus en une phrase utilisable.
// ProcessState.String() dit déjà « exit status 2 » ou « signal: illegal
// instruction » ; le second cas mérite son explication, parce que rien dans la
// dictée ne laisse deviner que le binaire ne tourne pas sur ce processeur.
func whisperExitReason(err error) string {
var ee *exec.ExitError
if !errors.As(err, &ee) {
return err.Error()
}
st := ee.ProcessState.String()
if strings.Contains(st, "signal:") {
return st + " (binaire compilé pour un autre processeur : image à reconstruire)"
}
return st
}
// lastLine : la dernière ligne non vide. whisper-cli bavarde beaucoup avant de
// tomber ; seule la fin renseigne, et un pavé ne tient pas dans un bandeau.
func lastLine(s string) string {
lines := strings.Split(strings.TrimSpace(s), "\n")
for i := len(lines) - 1; i >= 0; i-- {
if l := strings.TrimSpace(lines[i]); l != "" {
return l
}
}
return ""
}
+48
View File
@@ -0,0 +1,48 @@
package loki
import (
"os"
"strings"
"testing"
)
// La dictée est morte en production parce que whisper-cli avait été compilé
// avec -march=native, donc pour le processeur du runner GitHub (AVX-512, AMX)
// et pas pour la machine qui fait tourner l'image : SIGILL en pleine
// transcription. Le drapeau qui l'évite ne se voit pas à l'exécution — rien ne
// le rappelle au prochain qui touchera cette étape de build. Ce test le fait.
func TestDockerfileWhisperNonNatif(t *testing.T) {
b, err := os.ReadFile("../../Dockerfile")
if err != nil {
t.Fatalf("lecture du Dockerfile : %v", err)
}
src := string(b)
_, apres, ok := strings.Cut(src, "AS whisperbuild")
if !ok {
t.Fatal("étape whisperbuild introuvable dans le Dockerfile")
}
// L'étape suivante commence au FROM d'après : ne pas déborder dessus.
if fin := strings.Index(apres, "\nFROM "); fin >= 0 {
apres = apres[:fin]
}
for _, drapeau := range []string{"-DGGML_NATIVE=OFF", "-DGGML_AMX_TILE=OFF"} {
if !strings.Contains(apres, drapeau) {
t.Errorf("l'étape whisperbuild ne passe plus %s : le binaire sera compilé pour le processeur du runner et mourra d'un SIGILL ailleurs", drapeau)
}
}
}
func TestLastLine(t *testing.T) {
cas := []struct{ in, want string }{
{"", ""},
{" \n\n ", ""},
{"une seule ligne", "une seule ligne"},
{"AMX is not ready to be used!\nread_audio_data: ...\n", "read_audio_data: ..."},
{"fin utile\n\n \n", "fin utile"},
}
for _, c := range cas {
if got := lastLine(c.in); got != c.want {
t.Errorf("lastLine(%q) = %q, attendu %q", c.in, got, c.want)
}
}
}