Dominic Böttger

← Zurück zum Blog

WhisperX auf der AMD Radeon 890M: GPU-Transkription mit ROCm im Docker-Container

Veröffentlicht am 13. August 2026 von Dominic Böttger (heute) · 14 Min. Lesezeit

Der langweiligste Teil am Videoschnitt ist das Suchen. Wo genau fängt der Satz an, den ich behalten will? Wo ist die Stelle, an der jemand dreimal ansetzt? Ab wann spricht der andere?

Genau dafür benutze ich WhisperX. Es liefert nicht einfach ein Transkript, sondern wortgenaue Zeitstempel: Whisper für die Spracherkennung, ein wav2vec2-Modell, das anschließend jedes einzelne Wort auf die Millisekunde an die Tonspur nagelt, und pyannote für die Sprechererkennung. Was dabei herauskommt, ist im Grunde eine durchsuchbare Schnittliste: Ein Strg+F im Transkript, und man hat den Timecode. Sprecherwechsel stehen als Marken drin, und Untertitel fallen als SRT nebenbei mit ab.

Vor allem aber fällt das Ganze als JSON heraus und nicht als Fließtext — ein Transkript, in dem jedes einzelne Wort seinen Timecode und seinen Sprecher kennt. Damit ist es maschinenlesbar, und genau das macht es zur Datengrundlage für KI-gestützten Schnitt. Deshalb ist json weiter unten auch das Standard-Ausgabeformat meiner CLI.

Auf der CPU funktioniert das tadellos, es dauert nur: knapp vier Minuten für eine Aufnahme von viereinhalb Minuten. Also ungefähr Echtzeit, mit dem Lüfter auf voller Drehzahl. Bei einem Interview von einer Stunde sitzt man entsprechend lange davor, bevor man überhaupt anfangen kann zu schneiden.

Dabei sitzt in meinem Framework-Laptop ein AMD Ryzen AI 9 HX 370 mit einer integrierten Radeon 890M. Eine GPU mit 16 Compute Units, die die ganze Zeit nichts tat. Der naheliegende Gedanke: die Transkription dorthin verlagern.

Der naheliegende Gedanke war auch der, an dem ich zuerst gescheitert bin. Ein ROCm-fähiges CTranslate2 zu bauen ist nämlich nur der halbe Weg — die Pipeline rechnete danach trotzdem jedes Mal auf der CPU. Ohne Fehlermeldung, ohne Warnung. Sie tat einfach so, als wäre keine GPU da.

Was danach kam, war eine ziemlich lehrreiche Fehlersuche. Am Ende steht ein Setup, das die komplette Pipeline auf 1:13 min bringt — und drei Erkenntnisse, die man nirgends dokumentiert findet, weil sie alle still scheitern.

Das Ergebnis zuerst

Ein dauerhaft laufender Docker-Container und ein Bash-Skript im $PATH. Mehr Bedienoberfläche gibt es nicht:

whisperx aufnahme.m4a                    # Deutsch, large-v3, Diarization -> JSON
whisperx *.wav --output_format all       # eigene Flags gewinnen immer
whisperx video.mkv --language en --min_speakers 2 --max_speakers 4
whisperx --no-diarize interview.wav      # ohne Sprechererkennung
whisperx --cpu datei.wav                 # CPU-Fallback ohne ROCm

Gemessen an einer deutschen Aufnahme von 4:40 min mit large-v3:

CPU (--cpu)GPU (Default)
Komplette Pipeline3:58 min1:13 min
davon Transkription63 s34 s
davon Alignment (wav2vec2)17 s12 s
davon Diarization (pyannote)~145 s~14 s

Gut 3× schneller über die ganze Pipeline. Interessant ist, wo der Gewinn herkommt: nicht primär aus der Transkription (knapp 2×), sondern aus der Diarization — die Sprechererkennung wird rund 10× schneller. Sie war vorher der eigentliche Flaschenhals, was einem im CPU-Betrieb gar nicht auffällt, weil man die Gesamtzeit sieht und nicht ihre Aufteilung.

Die Architektur

Nichts davon ist auf dem Host installiert. Kein ROCm, kein PyTorch, kein Python-Environment:

  • Basis-Image: rocm/dev-ubuntu-24.04:7.1-complete
  • PyTorch/torchaudio/Triton: die offiziellen AMD-ROCm-7.1-Wheels von repo.radeon.com
  • CTranslate2: selbst aus Source gebaut, mit HIP-Backend
  • WhisperX: auf einen festen Commit gepinnt
  • Zugriff auf die GPU über /dev/kfd und /dev/dri, ganz ohne --privileged

Der Container läuft mit restart: unless-stopped und sleep infinity dauerhaft im Hintergrund. Das CLI-Skript ruft docker exec darauf auf. Kein Container-Start pro Aufruf, kein Modell-Nachladen, keine Wartezeit.

Das Herzstück ist CTranslate2 — die Inferenz-Engine hinter faster-whisper und damit hinter WhisperX. Für NVIDIA gibt es dafür fertige Wheels. Für AMD nicht. Sie muss aus dem Quellcode gebaut werden, und genau in diesem Build stecken alle drei Fallstricke.

Fallstrick 1: Die falsche GPU-Architektur

CTranslate2 wird für eine konkrete GPU-Architektur übersetzt, und welche das ist, muss man beim Build explizit angeben:

-DCMAKE_HIP_ARCHITECTURES=gfx1150

Die eigene Architektur bestimmt man so:

rocminfo | grep gfx

Die 890M ist Strix Point und meldet sich als gfx1150. Steht in der CMake-Zeile etwas anderes, enthält das fertige Binary schlicht keinen Code, der auf dieser Hardware läuft.

Die Falle daran ist, wie unverdächtig eine falsche Zeile aussieht. Praktisch alle Anleitungen, die man zu ROCm auf AMD-iGPUs findet, sind für die Radeon 780M geschrieben — also für gfx1103. Das ist die Generation davor (Phoenix), sie steckt in sehr vielen Laptops, und ihre Bauanleitungen sind entsprechend verbreitet. Man übernimmt die Zeile beim Zusammenbauen des eigenen Dockerfiles, ohne dass es einen Anlass gäbe, sie zu hinterfragen.

Das eigentlich Fatale ist aber, was danach passiert: nichts. CTranslate2 stellt beim Start fest, dass für das vorhandene Gerät kein lauffähiger Code existiert, und nimmt kommentarlos den CPU-Pfad. --device cuda wird trotzdem akzeptiert, die Transkription läuft durch, das Ergebnis ist korrekt — nur eben auf der CPU.

Fallstrick 2: Ohne cuDNN kein Conv1D

Der zweite Fund war der subtilste — eine fehlende Option:

-DWITH_CUDNN=ON

CTranslate2 kann Conv1D-Layer auf der GPU ausschließlich über cuDNN beziehungsweise dessen ROCm-Pendant MIOpen rechnen. Ohne diese Option enthält der Build gar keinen GPU-Kernel für Faltungen.

Und der Whisper-Encoder beginnt mit genau zwei Conv1D-Layern. Sie stehen ganz vorne in der Pipeline, direkt hinter dem Mel-Spektrogramm. Fehlt der Kernel dafür, fällt der komplette Encoder auf die CPU zurück — und mit ihm faktisch die ganze Transkription.

Der Name ist hier das eigentliche Problem beim Debuggen. Man sucht auf einem AMD-System nicht nach einer Option, die CUDNN heißt. Bei CTranslate2 ist das aber schlicht der Schalter für „nimm die Faltungs-Bibliothek des Herstellers” — bei einem HIP-Build ist das MIOpen.

Fallstrick 3: Ein CUDA-Wheel überschreibt den eigenen Build

Der dritte war der ärgerlichste, weil er alles zunichtemacht, was man vorher richtig gemacht hat.

Man baut CTranslate2 sauber gegen ROCm, installiert es, alles ist gut. Danach kommt im Dockerfile:

pip install faster-whisper

faster-whisper hat ctranslate2 als Abhängigkeit. pip sieht den Namen, holt sich das Wheel von PyPI — das CUDA-Build mit gebündeltem libcudnn — und schreibt damit den mühsam gebauten ROCm-Build einfach über.

Die Fehlermeldung, die dann irgendwann kommt:

CUDA driver version is insufficient for CUDA runtime version

Eine NVIDIA-Fehlermeldung auf einem System, in dem keine NVIDIA-Hardware steckt. Man sucht daraufhin nach Treiberproblemen, nach ROCm-Versionskonflikten, überlegt ein Downgrade — und das Problem ist in Wahrheit eine Zeile pip install, die stillschweigend eine Datei ersetzt hat.

Die Lösung besteht aus zwei Teilen. Erstens: Das ROCm-CTranslate2 wird als allerletzter Schritt im Dockerfile installiert, nach allem anderen. Zweitens, und wichtiger, prüft der Build selbst nach, dass das auch geklappt hat:

RUN python3 -m pip uninstall -y ctranslate2 && \
    python3 -m pip install --no-deps /opt/wheels/*.whl && \
    ldconfig && \
    python3 - <<'PY'
import ctranslate2, glob, os, subprocess, sys
ext = glob.glob(os.path.join(os.path.dirname(ctranslate2.__file__), "*.so"))[0]
ldd = subprocess.run(["ldd", ext], capture_output=True, text=True).stdout
if "libamdhip64" not in ldd and "libctranslate2" not in ldd:
    sys.exit("FEHLER: ctranslate2 ist nicht gegen HIP gelinkt")
if "cudnn" in ldd or "cudart" in ldd:
    sys.exit("FEHLER: CUDA-Wheel von PyPI hat den ROCm-Build ueberschrieben")
PY

Ein ldd auf die kompilierte Extension, und wenn dort CUDA-Bibliotheken auftauchen, bricht der Build ab. Das ist meine Lieblingszeile im ganzen Dockerfile. Sie verwandelt einen Fehler, der sich erst Wochen später als „warum ist das eigentlich so langsam?” bemerkbar macht, in einen Build, der sofort rot wird.

Das ist überhaupt das Muster hinter allen drei Fallstricken: Der GPU-Pfad scheitert nicht, er verschwindet. Es gibt keinen Absturz, keine Warnung, kein Log. Die einzige Spur ist eine Laufzeit, die man ohne Vergleichswert nicht als verdächtig erkennt. Wer sich so ein Setup baut, sollte deshalb von Anfang an eine Prüfung einbauen, die aktiv nachweist, dass die GPU rechnet — statt darauf zu vertrauen, dass ein Fehler schon auffallen würde.

Der gfx1100-Trick

Damit lief die Transkription auf der GPU. Die Diarization nicht — pyannote brach ab mit:

cannot compile inline asm

MIOpen kann die BatchNorm-Kernel für gfx1150 nicht übersetzen. Damit sterben Voice Activity Detection und Sprechererkennung, also alles außer der reinen Transkription.

Die Lösung ist ein alter ROCm-Klassiker: Man belügt die Runtime über die Hardware.

environment:
  HSA_OVERRIDE_GFX_VERSION: "11.0.0"

Die 890M gibt sich damit als gfx1100 aus — als RDNA-3-Desktop-Karte. Und weil CTranslate2 im Dockerfile für beide Architekturen gebaut wird (GPU_ARCH=gfx1100;gfx1150), gibt es passenden Code. MIOpen kompiliert seine Kernel, pyannote läuft.

Der überraschende Teil: Es läuft dadurch nicht nur, es läuft auch deutlich schneller.

KonfigurationEchtzeitfaktor
GPU fp16, batched(8), als gfx11504,33×
GPU fp16, batched(8–24), als gfx11007,3–8,5×

Fast doppelt so schnell, nur weil die Karte behauptet, eine andere zu sein. Der Grund ist unspektakulär: ROCm liefert für gfx1100 handoptimierte rocBLAS- und MIOpen-Kernel mit, weil das eine breit verkaufte Desktop-Architektur ist. Für gfx1150 gibt es die (noch) nicht, dort läuft generischer Code. Die Hardware ist eng verwandt genug, dass die 1100er-Kernel korrekt rechnen — sie sind nur eben getunt.

Sobald ROCm gfx1150 sauber unterstützt, kann der Override ersatzlos weg. Der Build ist darauf schon vorbereitet.

Denselben Kniff benutze ich auf derselben Hardware übrigens für lokales Voice Cloning mit Qwen3-TTS — auch dort ist diese eine Variable der Unterschied zwischen CPU-Fallback und voller GPU-Auslastung.

Die doppelte OpenMP-Runtime

Der letzte Fund gehört in die Kategorie „hätte ich nie geraten”.

PyTorch bringt seine eigene OpenMP-Runtime mit (GCCs libgomp). CTranslate2 linkt gegen LLVMs libomp. Beide landen im selben Python-Prozess, beide glauben, die Maschine für sich zu haben, und beide starten einen Thread-Pool in der Größe der Kernanzahl. Ergebnis: doppelt so viele aktive Threads wie Kerne, die sich gegenseitig von der CPU verdrängen.

Der gemessene Effekt auf einen 20-Sekunden-Clip in der CPU-Inferenz:

6,7 s → 93,5 s. Vierzehnfach langsamer.

Und zwar in jedem einzelnen WhisperX-Lauf, denn WhisperX importiert torch grundsätzlich vor CTranslate2. Die Lösung ist eine Zeile:

LD_PRELOAD: /usr/lib/x86_64-linux-gnu/libomp.so.5

Beide Bibliotheken werden auf dieselbe Runtime gezwungen, es gibt wieder nur einen Thread-Pool.

Zwei Sackgassen auf dem Weg dorthin, falls jemand vor demselben Problem sitzt:

  • Über OMP_NUM_THREADS, KMP_BLOCKTIME und OMP_WAIT_POLICY bekommt man das nur teilweise eingefangen — bestenfalls 48 s statt 93,5 s. Man reduziert das Oversubscription, löst es aber nicht auf.
  • -DOPENMP_RUNTIME=NONE beim CTranslate2-Build macht es schlimmer (91 s). CTranslate2 braucht OpenMP für seine eigenen CPU-Kernel; nimmt man es ihm weg, verliert man mehr, als man gewinnt.

Version-Pinning ist hier nicht optional

Der komplette Python-Stack liegt in einer requirements-lock.txt mit exakten Versionen, eingefroren auf einem Stand, der nachweislich funktioniert. Auch WhisperX selbst hängt an einem festen Commit:

RUN git clone https://github.com/m-bain/whisperX.git && \
    cd whisperX && git checkout d32ec3e && \
    python3 -m pip install --no-deps -e .

Das ist kein Vorsichtsreflex, sondern Erfahrung. Ungepinnt zieht pip zum Beispiel lightning in der 2.6er-Reihe herein, und die scheitert beim Laden des pyannote-VAD-Modells an torch.load(weights_only=True) — einer Sicherheitsverschärfung in PyTorch, mit der ältere Modell-Checkpoints nicht mehr zusammenpassen.

Der feste WhisperX-Commit hat einen zweiten Grund: Die Satz-Segmentierung ändert sich zwischen Versionen. Wer transkribierte Texte über Monate hinweg vergleichbar halten will, will nicht, dass ein docker build im Hintergrund die Segmentgrenzen verschiebt.

Das Docker-Setup

Ein paar Entscheidungen in der docker-compose.yml, die den Alltag ausmachen:

services:
  whisperx:
    image: whisperx-gpu:latest
    container_name: whisperx-gpu
    restart: unless-stopped
    devices:
      - /dev/kfd:/dev/kfd
      - /dev/dri:/dev/dri
    security_opt:
      - seccomp:unconfined
    ipc: host
    user: "1000:1000"
    environment:
      HSA_OVERRIDE_GFX_VERSION: "11.0.0"
      LD_PRELOAD: /usr/lib/x86_64-linux-gnu/libomp.so.5
      MIOPEN_FIND_MODE: FAST
      MIOPEN_USER_DB_PATH: /home/you/.cache/miopen
    volumes:
      - /home:/home
      - /mnt:/mnt
      - /run/media:/run/media
      - /srv:/srv
      - /tmp:/tmp
    command: ["sleep", "infinity"]
  • devices statt privileged: Für ROCm reichen /dev/kfd (der Kernel-Fusion-Treiber) und /dev/dri. Ein privilegierter Container ist nicht nötig.
  • user: "1000:1000": Der Container schreibt seine Ergebnisse als mein eigener Benutzer. Kein chown hinterher, kein root-eigenes JSON im Home-Verzeichnis.
  • Identische Pfade Host ↔ Container: /home, /mnt, /run/media, /srv und /tmp sind 1:1 gemountet. Dadurch kann die CLI jeden beliebigen Pfad annehmen, ohne Dateien vorher irgendwohin zu kopieren. Der Container sieht sie unter genau derselben Adresse.
  • MIOPEN_FIND_MODE=FAST und ein persistenter MIOPEN_USER_DB_PATH: Ohne das sucht MIOpen bei jedem Start minutenlang nach optimalen Kernel-Konfigurationen. Mit einer dauerhaften Datenbank passiert das genau einmal.
  • Der Hugging-Face-Modellcache liegt in ~/.cache/huggingface auf dem Host und wird mitbenutzt. Ein Rebuild lädt large-v3 nicht neu herunter.

Die CLI

Das Wrapper-Skript ist bewusst dünn. Es setzt Defaults, löst Pfade auf und reicht alles Weitere unverändert an WhisperX durch:

defaults=(--model "$MODEL" --language "$LANGUAGE" --batch_size "$BATCH_SIZE"
          --device "$DEVICE" --compute_type "$COMPUTE" --output_format "$FORMAT")
[ "$DIARIZE" = 1 ] && defaults+=(--diarize --speaker_embeddings)

Die Defaults stehen dabei vor den Nutzerargumenten. Pythons argparse lässt das spätere Vorkommen gewinnen — womit jedes selbst gesetzte Flag automatisch den Default überschreibt, ohne dass das Skript irgendwelche Argumente parsen müsste. Zusätzlich lässt sich jeder Default über eine Umgebungsvariable setzen (WHISPERX_MODEL, WHISPERX_LANGUAGE, …).

Ein Detail, das mir wichtig war: Der Hugging-Face-Token für die gated pyannote-Modelle steht nicht im Skript. Er kommt aus $HF_TOKEN oder ~/.config/whisperx/hf_token. Ein Token im Skript macht dieses unteilbar — man kann es niemandem zeigen und in kein Repository legen, ohne vorher etwas herauszulöschen. Und genau das vergisst man irgendwann.

Und weil GPU-Setups eben doch mal brechen, gibt es einen ehrlichen Ausweg:

whisperx --cpu datei.wav

Rechnet mit int8 auf der CPU, ohne ROCm. Langsamer, aber unabhängig davon, ob gerade ein Kernel-Update die Treiberwelt durcheinandergebracht hat.

Wo der Gewinn wirklich herkommt

Die interessanteste Zahlenreihe ist die, die nur die ASR-Stufe isoliert misst:

KonfigurationEchtzeitfaktor
CPU int8, sequenziell2,09×
CPU int8, batched(16)3,0–4,6×
GPU fp16, sequenziell1,73×
GPU fp16, batched(8), als gfx11504,33×
GPU fp16, batched(8–24), als gfx11007,3–8,5×

Zeile drei ist die wichtige: Sequenziell ist die GPU langsamer als die CPU. 1,73× gegen 2,09×. Hätte ich nur das gemessen, wäre das ganze Projekt als gescheitert in der Schublade gelandet.

Der Grund ist, dass eine integrierte GPU sich den LPDDR5X-Speicher mit der CPU teilt. Sie hat keinen eigenen schnellen VRAM. Damit ist sie nicht rechen-, sondern bandbreitenlimitiert: Für jeden Forward-Pass müssen die Modellgewichte über denselben Speicherbus, den auch die CPU benutzt. Verarbeitet man ein einzelnes Audiosegment nach dem anderen, verbringt die GPU die meiste Zeit mit Warten auf Daten.

Erst das Batching dreht das um. Werden 8 bis 16 Segmente gleichzeitig durchgeschoben, werden die einmal geladenen Gewichte auch mehrfach benutzt, und die Rechenleistung kommt zum Tragen.

Der Gesamtgewinn setzt sich damit aus drei Faktoren zusammen, und keiner davon ist „die GPU an sich”:

  1. Batching — ohne das verliert die iGPU gegen die CPU.
  2. Der gfx1100-Override — fast Faktor 2 durch getunte Kernel.
  3. Eine einzige OpenMP-Runtime — sonst frisst der Thread-Kampf alles auf.

Zwei Dinge, die nicht geholfen haben: int8 auf der GPU ist langsamer als float16 — die Quantisierung kostet mehr, als sie an Bandbreite spart. Und Batches über 16 bringen nichts Verlässliches mehr, weil die APU unter Dauerlast thermisch drosselt. Die Streuung zwischen zwei identischen Läufen (6× bis 8,5×) ist größer als der Effekt der Batch-Größe. Die 1:13 min oben gelten für eine kalte APU; nach einer halben Stunde Dauerlast sind es eher 1:55 min.

Rechnet die GPU dasselbe?

Eine berechtigte Frage: Andere Hardware, andere Kernel, andere Präzision (float16 statt int8) — kommt am Ende derselbe Text heraus?

Ich habe die GPU-Ausgabe gegen einen CPU-Lauf derselben Aufnahme verglichen:

  • 99,04 % identische Wortfolge
  • 99,8 % der Wörter mit identischem Zeitstempel, Median der Abweichung: 0,000 s

Die verbleibenden Abweichungen sind Bindestrich-Varianten (E-Mail gegen E Mail) und normales Beam-Search-Rauschen. Keine inhaltlichen Fehler, keine ausgelassenen Sätze, keine verschobenen Sprecherwechsel.

Nebenbei: Ein ROCm-Downgrade auf 6.4.3, das ich anfangs für die Lösung hielt, war nie nötig. ROCm 7.1 spricht diese Hardware einwandfrei. Alle Fehler steckten im Build, nicht im Stack.

Fazit

Für ein Setup dieser Art gilt: Die GPU-Beschleunigung scheitert nicht laut, sie scheitert leise. Ein fehlendes CMake-Flag, eine Architekturnummer daneben, ein pip-Paket, das ein anderes überschreibt — nichts davon erzeugt einen Fehler. Alles davon erzeugt eine Zahl, die man erst dann als falsch erkennt, wenn man einen Vergleichswert hat.

Deshalb ist die wichtigste Zeile im ganzen Dockerfile nicht die, die CTranslate2 baut, sondern die, die hinterher mit ldd nachschaut, wogegen es gelinkt ist. Und die wichtigste Angewohnheit ist, jede Optimierung tatsächlich zu messen — statt anzunehmen, dass sie greift, weil sie greifen sollte.

Das Ergebnis ist ein Laptop, der ein einstündiges Interview in etwa einer Viertelstunde in eine durchsuchbare Schnittliste verwandelt — mit wortgenauen Zeitstempeln, Sprecherzuordnung und fertigen Untertiteln, ohne dass ein einziges Byte davon das Gerät verlässt. Auf einer integrierten Grafikeinheit, die eigentlich zum Fensterschieben gedacht ist.

Genau darauf setzt bei mir der eigentliche Schnitt-Workflow auf, in dem eine KI auf diesem JSON arbeitet. Aber das ist ein eigener Artikel.

Der Videoschnitt ist dabei nur der Anlass, aus dem ich das Ganze gebaut habe. Ein Transkript, das Wortgrenzen und Sprecher kennt, ist genauso die Vorstufe für ein Tutorial, das aus einer Bildschirmaufnahme entsteht, oder für einen Wissensdatenbank-Artikel, den man sich einfach selbst erzählt hat, statt ihn zu tippen. Am Setup ändert das nichts — es ändert sich nur, was danach mit dem JSON passiert.

Geschrieben von Dominic Böttger

← Zurück zum Blog

Kommentare werden über GitHub Discussions bereitgestellt. Zum Kommentieren wird ein GitHub-Konto benötigt.

Aktuelle Blogbeiträge