Skip to content

Feat(genai,#23415): tts-fishaudio -- route int4 reelle via l'outillage amont de l'image - #23417

Merged
jsboige merged 2 commits into
mainfrom
feature/tts-fishaudio-int4
Oct 31, 2026
Merged

jsboige merged 2 commits into
mainfrom
feature/tts-fishaudio-int4

Conversation

@jsboige

@jsboige jsboige commented Oct 30, 2026

Copy link
Copy Markdown
Owner

Grain: DEEP/genai — lane myia-po-2023:CoursIA — prev: DEEP/research-code #23128

Le fait, et ce que cette PR change

docs/genai/genai-services.md annonçait « BnB 4-bit NF4 · ~5 GB » pour tts-fishaudio, alors que le service charge le snapshot fp16 (--half), mesuré à 20,33 Go de VRAM au warm-up. La PR #23241 (ouverte) corrige cette annonce ; cette PR rend le 4 bits réel.

Jusqu'ici, la seule voie 4 bits identifiée passait par un fork (groxaxo/fish-speech-int4-patch, chemin bnb4) et un checkpoint téléchargé. L'image officielle embarque son propre chemin int4, sans fork : tools/llama/quantize.py + la branche int4 du chargeur.

Le mécanisme, mesuré dans la source de l'image

Élément Emplacement dans l'image
l'outil tools/llama/quantize.py — CLI click : --checkpoint-path, --mode int8|int4, --groupsize, --timestamp
la sortie checkpoints/fs-1.2-int4-g{groupsize}-{timestamp}/model.pth
la branche du chargeur fish_speech/models/text2semantic/llama.py — if "int4" in str(Path(path)) → WeightOnlyInt4QuantHandler ; groupsize relu dans path.name.split("-")[-2]
l'empaquetage torch.ops.aten._convert_weight_to_int4pack — int4 weight-only affine par groupe

Le nom produit par l'outil porte les deux informations que le chargeur relit (int4 et -g128-) : l'outillage amont produit, par construction, un dossier que son propre chargeur sait lire.

Ce n'est pas du bitsandbytes NF4 : l'image ne contient ni bitsandbytes ni accelerate, et aucun chemin bnb4 — un checkpoint NF4 n'y est donc pas chargeable.

Les quatre obstacles amont, et les quatre correctifs

  1. L'empaquetage int4 est cassé sur torch 2.8 (l'obstacle décisif). L'amont appelle _convert_weight_to_int4pack(codes_int32, ...) — écrit pour un torch où l'op acceptait des codes int32 ; sur le torch 2.8 de l'image, l'op exige un uint8 à deux codes 4 bits par octet, le code d'indice pair dans le nibble haut, et un scales_and_zeros en bfloat16 (en fp32 le noyau rend une constante ; en fp16 des valeurs aberrantes). Sans correctif : RuntimeError: Expected in.dtype() == at::kByte. → le wrapper réécrit prepare_int4_weight_and_scales_and_zeros (re-empaquetage uint8 mesuré par round-trip) et s'auto-contrôle avant d'écrire : un round-trip pack → mm sur des codes connus doit restituer les codes à l'identique (erreur max 0.0 mesurée) sinon il sort sans produire de checkpoint.
  2. Pic mémoire. quantize() charge sur CPU et la construction se fait en float32 (modèle dual_ar : 201 nn.Linear, dont 4,05 Md de poids quantifiés + 0,58 Md non quantifiés — mesuré sur le checkpoint produit → pic hors RAM disponible sur le siège). → le wrapper force torch.set_default_device("cuda") + bfloat16 : construction et chargement sur GPU. Le CUDA était déjà requis de toute façon (prepare_int4_weight_and_scales_and_zeros fait .to("cuda")).
  3. Dossier de sortie inexploitable tel quel. L'outil recopie les shards bf16 et model.safetensors.index.json ; or le chargeur préfère l'index, puis le shard, et ne tombe sur model.pth qu'en dernier → sans correction il chargerait les poids bf16 d'origine et ignorerait la quantification (succès apparent, effet nul). → le wrapper retire les shards et l'index.
  4. Le chargeur int4 plante sur un chemin str (côté serveur). La branche int4 relit le groupsize via path.name.split("-") (exige un pathlib.Path), mais l'API server passe le checkpoint en str → AttributeError: 'str' object has no attribute 'name' juste après Using int4 quantization! (mesuré). Le quantificateur amont ne le voit pas : click lui fournit un Path. → serve_int4.py convertit en Path avant l'appel, puis exécute l'API server avec les arguments inchangés — monté en lecture seule, activé par FISHAUDIO_SERVE_SCRIPT=serve_int4.py uniquement sur la route int4.

Cohérence du padding : quantificateur et runtime partagent le même prédicat _check_linear_int4_k ; avec groupsize=128, dim=2560 et intermediate=9728 sont divisibles par 128 → aucun padding, des deux côtés.

Pourquoi le compose change de forme. L'int4 exige le scales_and_zeros en bfloat16, or --half fait model.to(dtype=float16) sur tous les buffers flottants (mesuré : le noyau rend alors des valeurs aberrantes) et l'image n'expose pas de --no-half. Le command: passe donc en forme chaîne avec ${FISHAUDIO_HALF_FLAG---half} — ${VAR:-def} se replie aussi sur une variable vide, ce qui collerait --half de force ; la forme sans deux-points ne se replie que si la variable est indéfinie. L'int4 se sert avec FISHAUDIO_HALF_FLAG= LLAMA_CHECKPOINT_PATH=checkpoints/fs-1.2-int4-g128-20261030.

La commande exacte

cd docker-configurations/services/tts-fishaudio

docker run --rm --gpus '"device=1"' \
  --entrypoint /app/.venv/bin/python \
  -v "$PWD/checkpoints:/app/checkpoints" \
  -v "$PWD/quantize_int4.py:/app/quantize_int4.py:ro" \
  -w /app fishaudio/fish-speech:latest \
  /app/quantize_int4.py --checkpoint-path checkpoints/s2-pro --groupsize 128 --timestamp 20261030

--entrypoint /app/.venv/bin/python est obligatoire : l'entrypoint de l'image lance run_webui.py, qui intercepte les arguments (unrecognized arguments). Et quantize de l'amont est un objet click.Command — l'appeler directement lève TypeError: 'PosixPath' object is not iterable ; le wrapper appelle .callback.

Les mesures

Seat myia-po-2023, GPU 1 (RTX 3090 24 Go), même jour, même texte, seed=42 des deux côtés.

fp16 (--half) int4 g128
poids du modèle de texte 9,12 Go 2,96 Go (model.pth, 35 %)
journal du chargeur All keys matched Using int4 quantization! puis All keys matched — aucun size mismatch
VRAM GPU 1 totale après une synthèse (plancher partagé de la carte : 5 022 MiB) 23 061 MiB 17 429 MiB
VRAM attribuable au service ~18,0 Gio ~12,4 Gio (−5,6 Gio, −31 %)
synthèse du même texte 17,5 s 23,2 s
wav : durée / RMS / pic / ZCR 6,04 s / 5 510 / 27 968 / 0,072 7,38 s / 5 050 / 23 168 / 0,128
transcription whisper-api fr « Bonjour, ceci est un test de la quantification 4 bits du modèle de synthèse vocale de fiches audio. » la même, mot pour mot

Verdict qualité : sortie non muette, non bruitée, intelligible (transcription indépendante identique mot à mot) ; la dégradation observable est prosodique — débit +22 %, enveloppe décorrélée (0,32). Quantification : 364 s sur GPU 1, 201 nn.Linear (4,05 Md poids à 4 bits, 0,58 Md conservés bf16), contrôle d'empaquetage erreur max 0.0.

Fichiers

  • docker-configurations/services/tts-fishaudio/quantize_int4.py — le wrapper de quantification (nouveau)
  • docker-configurations/services/tts-fishaudio/serve_int4.py — le correctif de chargement (nouveau)
  • docker-configurations/services/tts-fishaudio/docker-compose.yml — route int4 documentée + drapeau de précision supprimable + montage du wrapper
  • docs/genai/tts-fishaudio-int4.md — le rapport de mesure (nouveau)

docs/genai/genai-services.md et README.md du service ne sont pas touchés : ils sont dans le périmètre de #23241 (ouverte). Le renvoi croisé y sera ajouté quand #23241 sera mergée.

Closes #23415

🤖 Generated with Claude Code

…l'outillage amont

Quatre defauts de l'outillage officiel corriges par deux wrappers:
- quantize_int4.py : empaquetage uint8 torch 2.8 (auto-controle round-trip
  erreur 0.0), construction GPU bfloat16 (201 nn.Linear, pic CPU hors RAM),
  retrait des shards bf16 du dossier de sortie (le chargeur les prefererait).
- serve_int4.py : la branche int4 du chargeur exige un Path, l'API server
  passe un str (AttributeError) -- conversion avant l'appel.
- docker-compose.yml : route int4 documentee (FISHAUDIO_SERVE_SCRIPT,
  FISHAUDIO_HALF_FLAG vide -- le noyau int4 exige scales_and_zeros bf16),
  wrapper monte en lecture seule.
- docs/genai/tts-fishaudio-int4.md : rapport de mesure.

Mesures (GPU 1, RTX 3090 24 Go, meme texte, seed 42): poids 9,12 -> 2,96 Go
(35 %); VRAM totale 23 061 -> 17 429 MiB (attribuable ~18,0 -> ~12,4 Gio,
-31 %); transcription whisper identique mot a mot; degradation prosodique
(duree +22 %), declaree comme telle.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

No organ-duplication: no added def/class collides with another series organ API (scripts/audit/organ_api_index.yaml).

Detector: python scripts/audit/detect_organ_duplication.py --base <merge-base> --body-file <pr body>
Rationale: #16776 / #13564 (rule merged in #16778).

…numerotation 2.5

docs-index-guard: le nouveau doc etait inatteignable depuis docs/README.md
(275/276). Entree ajoutee dans la section GenAI. Au passage : la section
padding passe de 2.4 (doublon) a 2.5, et le renvoi de la section 3.1
vers le correctif de chargement designe desormais le 2.4.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This was referenced Oct 30, 2026
@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Path-collision (organ #13359/#13615)

Cette PR #23417 (Feat(genai,#23415): tts-fishaudio -- route int4 reelle via l'outillage amont de l'image) touche au moins un chemin de fichier aussi modifie par d'autres PRs ouvertes. Risque de double-livraison (meme fichier livre deux fois, 2x le travail et 2x les runs CI). Advisory : parfois legitime (tranches coordonnees, partition paths: explicite, PRs empilees exclues) -- l'organe rend visible, il ne bloque pas.

@github-actions

Copy link
Copy Markdown
Contributor

Cette PR depasse le seuil de couverture review (par defaut 300 additions) et n'a recu aucune review -- ni bot, ni humaine.

Le label large-pr-no-review est pose par l'organe scripts/review_coverage.py porte par l'issue #11232. Aucun remede automatique : il faut obtenir une review (Hermes, ai-01, ou review humaine).

Le label est retire au balayage suivant (quotidien) des qu'une review arrive -- dans reviews[] ou en commentaire de verdict -- ou que le diff passe sous le seuil. Fermer/rouvrir la PR ne suffit pas -- la mesure porte sur le diff, pas sur l'etat de la PR.

Seuil, historique et exceptions : cf. docs/reference/review-coverage-threshold.md.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

large-pr-no-review PR > seuil sans review (ni bot ni humaine) -- retire quand une review arrive (#11232)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

tts-fishaudio : rendre le 4 bits réel (voie int4 de l'image amont, sans fork) -- quantifier, mesurer VRAM et qualité

1 participant