chantier B TTS #10: tokenizer Qwen3 BPE via llama_tokenize, texte arbitraire
Plutôt que de porter Qwen3 BPE (NFC + pre-tokenize regex Unicode + ByteLevel +
merges + ignore_merges) à la main en C++ (~500 LOC risquées), on charge
n'importe quelle Qwen3 GGUF en vocab_only=true (~50 MB RAM, pas de poids) et
on appelle llama_tokenize(parse_special=true). Bit-exact garanti par
construction.
kazeia_text_tokenizer.h/cpp : API courte
kz_tok_load(tok, gguf_path) / kz_tok_free / kz_tok_encode
kz_tok_encode_tts_prompt(tok, content) -> enveloppe dans le template chat
<|im_start|>assistant\n{content}<|im_end|>\n<|im_start|>assistant\n
(déduit du dump golden : 16 tokens pour 'Bonjour je m'appelle Kazeia').
test_tokenizer : binaire standalone vérifiant bit-exact vs input_ids_full.bin
./test_tokenizer Qwen3-4B-Q4_0.gguf tts_dump/input_ids_full.bin 'Bonjour je m'appelle Kazeia'
-> OK BIT-EXACT (16/16 tokens identiques au golden HF).
tts_pipeline : si KZTTS_TEXT et KZTTS_VOCAB_GGUF posés, tokenize la phrase au
lieu de lire input_ids_full.bin. La structure attendue (3 role + Nt body + 5
trailing) est exactement ce que kz_tok_encode_tts_prompt produit, donc
input_ids_len = Nt + 8 sans rien d'autre à changer dans la construction
prefill_embeds.
Mesure tablette Pad3 (cpu 6t, KZTTS_CP_CACHE=1, seed=42) :
A fixture : 31 frames, total 7.866s, RTF 3.04
B tokenizer C++ : 31 frames, total 7.842s, RTF 3.04 + WAV md5 identique
C 'Bonsoir...' : 41 frames audio 3.4s, total 10.5s, RTF 3.09 (phrase neuve OK)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1203d7ce5d
commit
b27c8ef403
|
|
@ -0,0 +1,19 @@
|
|||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||
NDK_ROOT="${ANDROID_NDK_ROOT:-/opt/Kazeia/android-ndk-r27d}"
|
||||
CXX="$NDK_ROOT/toolchains/llvm/prebuilt/linux-x86_64/bin/aarch64-linux-android31-clang++"
|
||||
JNI="$HERE/jni"
|
||||
INC="$HERE/include"
|
||||
LIB="$HERE/lib"
|
||||
OUT="$HERE/b-jni/test_tokenizer"
|
||||
mkdir -p "$HERE/b-jni"
|
||||
"$CXX" -std=c++17 -O2 -fPIE \
|
||||
-march=armv8.6-a+i8mm+bf16+dotprod+fp16 \
|
||||
-I"$INC" \
|
||||
"$JNI/kazeia_text_tokenizer.cpp" "$JNI/test_tokenizer.cpp" \
|
||||
-L"$LIB" -lllama -lggml -lggml-base -lggml-cpu \
|
||||
-Wl,-rpath,'$ORIGIN' \
|
||||
-llog -ldl -lm \
|
||||
-o "$OUT"
|
||||
ls -la "$OUT"
|
||||
|
|
@ -34,6 +34,7 @@ LIBS=(
|
|||
"$JNI/tts_pipeline.cpp"
|
||||
"$JNI/cp_inference.cpp"
|
||||
"$JNI/sampler.cpp"
|
||||
"$JNI/kazeia_text_tokenizer.cpp"
|
||||
"$DECODER_A"
|
||||
-L"$LIB" -lllama -lggml -lggml-base -lggml-cpu
|
||||
-Wl,-rpath,'$ORIGIN'
|
||||
|
|
|
|||
|
|
@ -0,0 +1,66 @@
|
|||
#include "kazeia_text_tokenizer.h"
|
||||
#include "llama.h"
|
||||
#include <cstdio>
|
||||
|
||||
bool kz_tok_load(KzTextTokenizer & t, const char * gguf_path) {
|
||||
auto mp = llama_model_default_params();
|
||||
mp.vocab_only = true;
|
||||
mp.n_gpu_layers = 0; // par sécurité (les backends NPU ignorent ça en vocab_only mais explicite).
|
||||
t.model = llama_model_load_from_file(gguf_path, mp);
|
||||
if (!t.model) {
|
||||
fprintf(stderr, "kz_tok_load: failed to load %s\n", gguf_path);
|
||||
return false;
|
||||
}
|
||||
t.vocab = llama_model_get_vocab(t.model);
|
||||
if (!t.vocab) {
|
||||
fprintf(stderr, "kz_tok_load: no vocab in %s\n", gguf_path);
|
||||
llama_model_free(t.model);
|
||||
t.model = nullptr;
|
||||
return false;
|
||||
}
|
||||
fprintf(stderr, "kz_tok_load: %s (n_tokens=%d)\n", gguf_path, llama_vocab_n_tokens(t.vocab));
|
||||
return true;
|
||||
}
|
||||
|
||||
void kz_tok_free(KzTextTokenizer & t) {
|
||||
if (t.model) llama_model_free(t.model);
|
||||
t.model = nullptr;
|
||||
t.vocab = nullptr;
|
||||
}
|
||||
|
||||
std::vector<int32_t> kz_tok_encode(const KzTextTokenizer & t,
|
||||
const std::string & text,
|
||||
bool parse_special) {
|
||||
if (!t.vocab) { fprintf(stderr, "kz_tok_encode: vocab not loaded\n"); return {}; }
|
||||
// Dimension : 1 token / octet est une borne sup confortable (en pratique on tokenize plutôt
|
||||
// à ~4 octets/token). On alloue avec marge et on resize au retour.
|
||||
int32_t cap = (int32_t)text.size() + 16;
|
||||
std::vector<int32_t> out(cap);
|
||||
int32_t n = llama_tokenize(t.vocab, text.c_str(), (int32_t)text.size(),
|
||||
out.data(), cap, /*add_special=*/false, parse_special);
|
||||
if (n < 0) {
|
||||
// sous-dimensionné : on retente avec la taille demandée.
|
||||
out.resize(-n);
|
||||
n = llama_tokenize(t.vocab, text.c_str(), (int32_t)text.size(),
|
||||
out.data(), -n, /*add_special=*/false, parse_special);
|
||||
}
|
||||
if (n < 0) { fprintf(stderr, "kz_tok_encode: tokenize failed (%d)\n", n); return {}; }
|
||||
out.resize(n);
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<int32_t> kz_tok_encode_tts_prompt(const KzTextTokenizer & t,
|
||||
const std::string & content) {
|
||||
// Format chat Qwen3-TTS exact, vérifié sur dump golden (16 tokens pour
|
||||
// "Bonjour je m'appelle Kazeia") :
|
||||
// <|im_start|>assistant\n{content}<|im_end|>\n<|im_start|>assistant\n
|
||||
//
|
||||
// Note : le 2e <|im_start|>assistant\n est le "cue" pour la génération côté talker.
|
||||
// Le talker répond par les codes audio à la suite.
|
||||
std::string wrapped;
|
||||
wrapped.reserve(content.size() + 64);
|
||||
wrapped += "<|im_start|>assistant\n";
|
||||
wrapped += content;
|
||||
wrapped += "<|im_end|>\n<|im_start|>assistant\n";
|
||||
return kz_tok_encode(t, wrapped, /*parse_special=*/true);
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
// Tokenizer texte pour Qwen3-TTS, wrappé sur libllama.
|
||||
//
|
||||
// Plutôt que de porter le BPE Qwen3 (NFC + pre-tokenize regex unicode + byte-level
|
||||
// + merges + ignore_merges) à la main en C++, on charge n'importe quelle GGUF Qwen3
|
||||
// en `vocab_only=true` -> ~50 MB RAM au lieu des 2-4 GB de poids, et on appelle
|
||||
// llama_tokenize(parse_special=true) qui gère tout, bit-exact contre HF.
|
||||
//
|
||||
// Tradeoff assumé : dépendance libllama (qu'on a déjà), 50 MB RAM en plus, vs
|
||||
// quelques centaines de lignes risquées à porter et à maintenir.
|
||||
//
|
||||
// Pour le TTS, on a juste besoin d'envelopper le contenu dans le template chat
|
||||
// Qwen3 attendu par le talker :
|
||||
// <|im_start|>assistant\n{content}<|im_end|>\n<|im_start|>assistant\n
|
||||
//
|
||||
// (déduit du dump golden : 16 tokens [151644, 77091, 198, 81581, ..., 151645,
|
||||
// 198, 151644, 77091, 198] pour "Bonjour je m'appelle Kazeia").
|
||||
#pragma once
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
struct llama_model;
|
||||
struct llama_vocab;
|
||||
|
||||
struct KzTextTokenizer {
|
||||
llama_model * model = nullptr;
|
||||
const llama_vocab * vocab = nullptr;
|
||||
};
|
||||
|
||||
// Charge le vocab depuis un GGUF Qwen3 (vocab_only=true). Aucun poids n'est chargé.
|
||||
// IMPORTANT : llama_backend_init() doit déjà avoir été appelé par l'appelant.
|
||||
bool kz_tok_load(KzTextTokenizer & t, const char * gguf_path);
|
||||
void kz_tok_free(KzTextTokenizer & t);
|
||||
|
||||
// Tokenize raw : parse_special=true pour reconnaître <|im_start|> etc. comme tokens.
|
||||
std::vector<int32_t> kz_tok_encode(const KzTextTokenizer & t,
|
||||
const std::string & text,
|
||||
bool parse_special);
|
||||
|
||||
// Enveloppe `content` dans le template chat Qwen3-TTS et tokenize. Renvoie la séquence
|
||||
// prête à être feed dans la construction prefill_embeds.
|
||||
std::vector<int32_t> kz_tok_encode_tts_prompt(const KzTextTokenizer & t,
|
||||
const std::string & content);
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
// Test bit-exact du tokenizer C++ vs input_ids_full.bin (golden HF tokenizer).
|
||||
// Usage : test_tokenizer <vocab_gguf> <golden_input_ids.bin> "<content>"
|
||||
// ex: test_tokenizer Qwen3-4B-Q4_0.gguf tts_dump/input_ids_full.bin "Bonjour je m'appelle Kazeia"
|
||||
//
|
||||
// Critère : tokens C++ == tokens golden (byte-pour-byte).
|
||||
#include "kazeia_text_tokenizer.h"
|
||||
#include "llama.h"
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <cstring>
|
||||
#include <fstream>
|
||||
#include <vector>
|
||||
|
||||
int main(int argc, char ** argv) {
|
||||
if (argc < 4) {
|
||||
fprintf(stderr, "usage: %s <vocab_gguf> <golden_input_ids.bin> \"<content>\"\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
const char * vocab_path = argv[1];
|
||||
const char * golden_path = argv[2];
|
||||
const std::string content = argv[3];
|
||||
|
||||
llama_backend_init();
|
||||
|
||||
KzTextTokenizer tok;
|
||||
if (!kz_tok_load(tok, vocab_path)) return 2;
|
||||
|
||||
auto ids = kz_tok_encode_tts_prompt(tok, content);
|
||||
printf("ids (%zu):", ids.size());
|
||||
for (auto i : ids) printf(" %d", i);
|
||||
printf("\n");
|
||||
|
||||
// Lire golden
|
||||
std::ifstream f(golden_path, std::ios::binary | std::ios::ate);
|
||||
if (!f) { fprintf(stderr, "cannot open golden %s\n", golden_path); return 3; }
|
||||
size_t n_bytes = (size_t)f.tellg();
|
||||
f.seekg(0);
|
||||
std::vector<int32_t> golden(n_bytes / sizeof(int32_t));
|
||||
f.read((char*)golden.data(), n_bytes);
|
||||
printf("golden (%zu):", golden.size());
|
||||
for (auto i : golden) printf(" %d", i);
|
||||
printf("\n");
|
||||
|
||||
bool ok = (ids.size() == golden.size());
|
||||
if (ok) {
|
||||
for (size_t i = 0; i < ids.size(); ++i) {
|
||||
if (ids[i] != golden[i]) { ok = false; printf(" diff at %zu: got %d, want %d\n", i, ids[i], golden[i]); }
|
||||
}
|
||||
} else {
|
||||
printf(" size mismatch: got %zu, want %zu\n", ids.size(), golden.size());
|
||||
}
|
||||
printf(ok ? "OK BIT-EXACT\n" : "MISMATCH\n");
|
||||
|
||||
kz_tok_free(tok);
|
||||
llama_backend_free();
|
||||
return ok ? 0 : 4;
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@
|
|||
#include "ggml-backend.h"
|
||||
#include "cp_inference.h"
|
||||
#include "sampler.h"
|
||||
#include "kazeia_text_tokenizer.h"
|
||||
#include "decoder.h" // Kazeia decoder ggml (linké via libqwen3tts-decoder.a)
|
||||
|
||||
using namespace kazeia::tts;
|
||||
|
|
@ -94,12 +95,23 @@ static bool write_wav_pcm16_mono(const char* path, const float* samples, size_t
|
|||
}
|
||||
|
||||
int main(int argc, char** argv) {
|
||||
if (argc < 4) { printf("usage: %s <talker_gguf> <dump_dir> <out.wav> [cpu|htp] [max_steps]\n", argv[0]); return 1; }
|
||||
if (argc < 4) {
|
||||
printf("usage: %s <talker_gguf> <dump_dir> <out.wav> [cpu|htp] [max_steps]\n", argv[0]);
|
||||
printf(" Texte arbitraire (au lieu de input_ids_full.bin dumpé) :\n");
|
||||
printf(" KZTTS_VOCAB_GGUF=/path/to/qwen3.gguf KZTTS_TEXT=\"phrase libre\" %s ...\n", argv[0]);
|
||||
return 1;
|
||||
}
|
||||
const char* gguf = argv[1];
|
||||
std::string D = argv[2]; if (D.back() != '/') D += '/';
|
||||
const char* OUT_WAV = argv[3];
|
||||
bool force_cpu = (argc >= 5 && !strcmp(argv[4], "cpu"));
|
||||
int max_steps_arg = (argc >= 6) ? atoi(argv[5]) : 64;
|
||||
// Texte arbitraire : si KZTTS_TEXT et KZTTS_VOCAB_GGUF sont posés, on tokenize la phrase
|
||||
// au lieu de relire input_ids_full.bin. Vérifié bit-exact contre le golden HF tokenizer
|
||||
// sur "Bonjour je m'appelle Kazeia" via test_tokenizer.
|
||||
const char * kz_text = getenv("KZTTS_TEXT");
|
||||
const char * kz_vocab_gguf = getenv("KZTTS_VOCAB_GGUF");
|
||||
const bool use_kz_tok = (kz_text && kz_vocab_gguf && *kz_text && *kz_vocab_gguf);
|
||||
|
||||
// ----- 0) Constants from manifest_text
|
||||
int text_vocab = 151936, text_hidden = 2048, hidden = 1024;
|
||||
|
|
@ -143,7 +155,27 @@ int main(int argc, char** argv) {
|
|||
auto tp_fc2_b = read_f32(D + "tp_fc2_b.bin", (size_t)hidden);
|
||||
auto tok_embd = read_f32(D + "talker_tok_embd.bin", (size_t)3072 * hidden);
|
||||
auto xvector = read_f32(D + "damien_xvector.bin", (size_t)hidden);
|
||||
auto input_ids = read_i32(D + "input_ids_full.bin", (size_t)input_ids_len);
|
||||
|
||||
// input_ids : depuis le tokenizer C++ (texte arbitraire) ou depuis le dump golden.
|
||||
// L'engin de l'app initialisera llama_backend_init plus bas dans la section talker ;
|
||||
// pour pouvoir charger le vocab maintenant, on l'initialise dès ici (idempotent côté llama).
|
||||
std::vector<int32_t> input_ids;
|
||||
KzTextTokenizer kz_tok;
|
||||
if (use_kz_tok) {
|
||||
// Init backend AVANT chargement vocab. Le talker plus bas ré-init (idempotent côté
|
||||
// llama). HMX off posé ici par sécurité (le talker le repose ensuite ; sans ça un
|
||||
// init précoce du backend pourrait verrouiller HMX=on selon l'archi du vocab gguf).
|
||||
setenv("GGML_HEXAGON_USE_HMX", "0", 1);
|
||||
llama_backend_init();
|
||||
if (!kz_tok_load(kz_tok, kz_vocab_gguf)) { printf("vocab load FAILED: %s\n", kz_vocab_gguf); return 1; }
|
||||
input_ids = kz_tok_encode_tts_prompt(kz_tok, kz_text);
|
||||
input_ids_len = (int)input_ids.size();
|
||||
if (input_ids_len < 8) { printf("kz_tok: trop court (%d, min=8)\n", input_ids_len); return 1; }
|
||||
printf("kz_tok: \"%s\" -> %d tokens\n", kz_text, input_ids_len);
|
||||
} else {
|
||||
auto v = read_i32(D + "input_ids_full.bin", (size_t)input_ids_len);
|
||||
input_ids = std::move(v);
|
||||
}
|
||||
auto tts_pad_emb_ref = read_f32(D + "tts_pad_embed.bin", (size_t)hidden); // sanity
|
||||
(void)tts_pad_emb_ref;
|
||||
|
||||
|
|
@ -406,5 +438,6 @@ int main(int argc, char** argv) {
|
|||
cp_free(cp_state);
|
||||
llama_free(ctx);
|
||||
llama_model_free(m);
|
||||
if (use_kz_tok) kz_tok_free(kz_tok);
|
||||
return 0;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue