mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-11 17:26:57 +02:00
Le build de 51 minutes a échoué au lien final : « libcuda.so.1 not found », références cuMem* non résolues, et une libggml-cuda.so PARTAGÉE alors que BUILD_SHARED_LIBS=OFF est censé être passé. Ce dernier détail était le vrai indice : les drapeaux n'atteignaient pas cmake du tout. ARG WHISPER_CMAKE_FLAGS="…" était déclaré entre deux étapes. Règle Docker : un ARG posé après un FROM appartient à l'étape où il apparaît ; les « ARG » nus des étapes whisperbuild-* héritent, eux, du scope GLOBAL — où rien n'était défini. Valeur vide, cmake sans aucun drapeau : ggml en bibliothèques partagées (échec de lien contre les stubs du pilote), et surtout -march=native — le SIGILL de la PR #18 revenu en silence sur les deux binaires. Les tests du Dockerfile n'y voyaient rien : ils vérifiaient le texte, pas les règles de portée de Docker. - L'ARG remonte avant le premier FROM, à côté de LLAMACPP_IMAGE. - Chaque étape vérifie désormais SON binaire : un ldd qui montre libggml ou libwhisper en dynamique fait échouer le build sur-le-champ, au lieu de laisser partir un binaire qui ne trouvera pas ses .so dans l'image finale. - TestDockerfileArgFlagsGlobal verrouille la position de l'ARG, ancré en début de ligne — une première version se laissait berner par une occurrence en commentaire, sa contre-épreuve l'a montré. libcuda.so.1 reste une dépendance dynamique normale du binaire CUDA : c'est le pilote, injecté à l'exécution par le NVIDIA Container Toolkit, et l'édition de liens la résout via les stubs du toolkit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01W3ewMAsXhkw9RY11kb9Dc9
205 lines
7.5 KiB
Go
205 lines
7.5 KiB
Go
package loki
|
|
|
|
import (
|
|
"os"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// etapesWhisperbuild découpe le Dockerfile en étapes « FROM … AS whisperbuild* »
|
|
// et rend le texte de chacune.
|
|
func etapesWhisperbuild(t *testing.T) map[string]string {
|
|
t.Helper()
|
|
b, err := os.ReadFile("../../Dockerfile")
|
|
if err != nil {
|
|
t.Fatalf("lecture du Dockerfile : %v", err)
|
|
}
|
|
out := map[string]string{}
|
|
for _, bloc := range strings.Split(string(b), "\nFROM ") {
|
|
entete, reste, ok := strings.Cut(bloc, "\n")
|
|
if !ok {
|
|
continue
|
|
}
|
|
_, nom, ok := strings.Cut(entete, " AS ")
|
|
if !ok || !strings.HasPrefix(strings.TrimSpace(nom), "whisperbuild") {
|
|
continue
|
|
}
|
|
out[strings.TrimSpace(nom)] = entete + "\n" + reste
|
|
}
|
|
return out
|
|
}
|
|
|
|
// argWhisperFlags rend la valeur de l'ARG WHISPER_CMAKE_FLAGS, continuations
|
|
// de ligne comprises. Vide si l'ARG n'existe pas : les étapes devront alors
|
|
// porter les drapeaux en clair, et le test le vérifiera.
|
|
func argWhisperFlags(t *testing.T) string {
|
|
t.Helper()
|
|
b, err := os.ReadFile("../../Dockerfile")
|
|
if err != nil {
|
|
t.Fatalf("lecture du Dockerfile : %v", err)
|
|
}
|
|
_, apres, ok := strings.Cut(string(b), "ARG WHISPER_CMAKE_FLAGS=")
|
|
if !ok {
|
|
return ""
|
|
}
|
|
var val strings.Builder
|
|
for _, ligne := range strings.Split(apres, "\n") {
|
|
val.WriteString(" " + ligne)
|
|
if !strings.HasSuffix(strings.TrimSpace(ligne), "\\") {
|
|
break
|
|
}
|
|
}
|
|
return val.String()
|
|
}
|
|
|
|
// La dictée est morte en production parce que whisper 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.
|
|
// Les drapeaux qui l'évitent ne se voient pas à l'exécution — rien ne les
|
|
// rappelle au prochain qui touchera cette étape de build. Ce test le fait, sur
|
|
// CHAQUE étape, y compris celle ajoutée pour CUDA.
|
|
func TestDockerfileWhisperNonNatif(t *testing.T) {
|
|
etapes := etapesWhisperbuild(t)
|
|
if len(etapes) < 2 {
|
|
t.Fatalf("%d étape(s) whisperbuild, 2 attendues (CPU et CUDA) : %v", len(etapes), clefs(etapes))
|
|
}
|
|
// Les drapeaux communs sont factorisés dans WHISPER_CMAKE_FLAGS. Une étape
|
|
// est conforme si elle les porte en clair OU si elle référence cet ARG —
|
|
// ce qui compte est qu'ils atteignent cmake, pas qu'ils soient recopiés.
|
|
commun := argWhisperFlags(t)
|
|
for nom, txt := range etapes {
|
|
effectif := txt
|
|
if strings.Contains(txt, "${WHISPER_CMAKE_FLAGS}") {
|
|
effectif += " " + commun
|
|
}
|
|
for _, drapeau := range []string{"-DGGML_NATIVE=OFF", "-DGGML_AMX_TILE=OFF", "-DGGML_AVX512=OFF"} {
|
|
if !strings.Contains(effectif, drapeau) {
|
|
t.Errorf("étape %s : %s absent — le binaire sera compilé pour le processeur du runner et mourra d'un SIGILL ailleurs", nom, drapeau)
|
|
}
|
|
}
|
|
if !strings.Contains(txt, "whisper-server") {
|
|
t.Errorf("étape %s : ne construit pas la cible whisper-server", nom)
|
|
}
|
|
// « -j » nu autorise un parallélisme ILLIMITÉ chez Make. Sur l'étape
|
|
// CUDA (~200 nvcc à 1-2 Go pièce), le runner GitHub était tué par
|
|
// l'OOM sans écrire une ligne d'erreur. L'étape CPU y survivait, ce
|
|
// qui rendait le piège invisible.
|
|
for _, ligne := range strings.Split(txt, "\n") {
|
|
if !strings.Contains(ligne, "cmake --build") {
|
|
continue
|
|
}
|
|
if regexp.MustCompile(`-j(\s|$|\\)`).MatchString(ligne) {
|
|
t.Errorf("étape %s : « cmake --build -j » sans nombre — parallélisme illimité, le runner sera tué par l'OOM. Utiliser -j\"$(nproc)\".", nom)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Deux binaires, pas un : sur une image runtime bâtie sans CUDA, un binaire lié
|
|
// à CUDA ne démarre pas du tout — l'éditeur de liens échoue avant la première
|
|
// instruction, donc aucun repli n'est possible depuis le programme.
|
|
func TestDockerfileDeuxBinairesWhisper(t *testing.T) {
|
|
b, err := os.ReadFile("../../Dockerfile")
|
|
if err != nil {
|
|
t.Fatalf("lecture du Dockerfile : %v", err)
|
|
}
|
|
src := string(b)
|
|
var avecCuda bool
|
|
for nom, txt := range etapesWhisperbuild(t) {
|
|
if strings.Contains(txt, "-DGGML_CUDA=ON") {
|
|
avecCuda = true
|
|
_ = nom
|
|
}
|
|
}
|
|
if !avecCuda {
|
|
t.Error("aucune étape whisperbuild ne passe -DGGML_CUDA=ON : la dictée ne pourra jamais utiliser le GPU")
|
|
}
|
|
for _, bin := range []string{"whisper-server-cpu", "whisper-server-cuda"} {
|
|
if !strings.Contains(src, "/usr/local/bin/"+bin) {
|
|
t.Errorf("le runtime ne reçoit pas %s — dictate_server.go le cherche à cet emplacement", bin)
|
|
}
|
|
}
|
|
}
|
|
|
|
// L'étape CUDA doit être bâtie avec un nvcc qui CONNAÎT les GPU visés. CUDA
|
|
// 12.4 ignore Blackwell (RTX 50xx, sm_120) : il refuse l'architecture, et le
|
|
// binaire ne tournerait au mieux que par recompilation PTX au chargement.
|
|
// 12.8 est le plancher, et c'est aussi la version avec laquelle llama.cpp bâtit
|
|
// l'image amont qui fournit libcudart.
|
|
func TestDockerfileCudaAssezRecent(t *testing.T) {
|
|
const majeurMin, mineurMin = 12, 8
|
|
trouve := false
|
|
for nom, txt := range etapesWhisperbuild(t) {
|
|
m := regexp.MustCompile(`nvidia/cuda:(\d+)\.(\d+)`).FindStringSubmatch(txt)
|
|
if m == nil {
|
|
continue
|
|
}
|
|
trouve = true
|
|
maj, _ := strconv.Atoi(m[1])
|
|
min, _ := strconv.Atoi(m[2])
|
|
if maj < majeurMin || (maj == majeurMin && min < mineurMin) {
|
|
t.Errorf("étape %s : CUDA %s.%s — trop ancien pour Blackwell (sm_120), il faut au moins %d.%d",
|
|
nom, m[1], m[2], majeurMin, mineurMin)
|
|
}
|
|
// Sans borne d'architectures, ggml compile de Maxwell à Blackwell :
|
|
// chaque architecture multiplie le temps de compilation, et le build
|
|
// dépassait quarante minutes.
|
|
if !strings.Contains(txt, "CMAKE_CUDA_ARCHITECTURES") {
|
|
t.Errorf("étape %s : aucune borne CMAKE_CUDA_ARCHITECTURES — la compilation vise toutes les architectures connues", nom)
|
|
}
|
|
}
|
|
if !trouve {
|
|
t.Error("aucune étape whisperbuild ne part d'une image nvidia/cuda")
|
|
}
|
|
}
|
|
|
|
// L'ARG qui porte les drapeaux doit être déclaré AVANT le premier FROM. Un ARG
|
|
// posé entre deux étapes appartient à l'étape où il apparaît ; les « ARG » nus
|
|
// des étapes whisperbuild-* héritent alors du scope global — vide. cmake a
|
|
// tourné sans aucun drapeau : ggml en bibliothèques partagées (échec de lien),
|
|
// et -march=native — le SIGILL de la PR #18 revenu en silence. 51 minutes de
|
|
// build pour le découvrir.
|
|
func TestDockerfileArgFlagsGlobal(t *testing.T) {
|
|
b, err := os.ReadFile("../../Dockerfile")
|
|
if err != nil {
|
|
t.Fatalf("lecture du Dockerfile : %v", err)
|
|
}
|
|
src := string(b)
|
|
// Ancré en début de ligne : une occurrence dans un commentaire ne compte
|
|
// pas — c'est précisément ainsi qu'une première version de ce test s'est
|
|
// laissée berner par sa propre contre-épreuve.
|
|
argLoc := regexp.MustCompile(`(?m)^ARG WHISPER_CMAKE_FLAGS=`).FindStringIndex(src)
|
|
fromLoc := regexp.MustCompile(`(?m)^FROM `).FindStringIndex(src)
|
|
if argLoc == nil {
|
|
t.Fatal("ARG WHISPER_CMAKE_FLAGS= introuvable dans le Dockerfile")
|
|
}
|
|
if fromLoc != nil && argLoc[0] > fromLoc[0] {
|
|
t.Error("ARG WHISPER_CMAKE_FLAGS= est déclaré APRÈS un FROM : les étapes whisperbuild-* hériteront d'une valeur vide et cmake tournera sans aucun drapeau")
|
|
}
|
|
}
|
|
|
|
func clefs(m map[string]string) []string {
|
|
out := make([]string, 0, len(m))
|
|
for k := range m {
|
|
out = append(out, k)
|
|
}
|
|
return out
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|