chantier B STT #3 : decoder zero-copy + sanity vocab + checklist dev

Optim decoder identifiée après bench sur audio FR continu (39 tokens) :
le `std::vector<>::emplace_back(data)` à chaque step recopiait 12 MB de
self KV inutilement = 2.4 GB de bande passante mémoire sur le loop entier.

Refactor zero-copy :
 - Pré-allocation UNE FOIS des buffers persistants (self_k/v, mask, input_ids,
   position_ids) au début de transcribe()
 - Pré-création UNE FOIS des Ort::Value pointant sur ces buffers (les Ort::Value
   sont des "borrowing views" — ORT lit les buffers au Run())
 - Cache des indices k_self_out / v_self_out / logits_out (parsing 1 fois,
   pas N fois dans le loop)
 - Update self KV : memcpy direct depuis dec_outputs vers nos buffers persistants
   (qui sont déjà les inputs du step suivant)
 - mask_buf updaté à 1 fp16 par step (juste le slot R-to-L), pas 200

Bench zero-copy sur Snapdragon 8 Elite :
 - Decoder/token : 98 ms -> 36 ms (-63%)
 - Decoder total 39 tokens : 3831 ms -> 1390 ms (-64%)
 - RTF audio 10s FR continu : 0.42 -> 0.18 (mieux que prod 0.51)
 - RAM peak : 378 MB inchangée (zero-copy n'augmente pas la peak)

Sanity vérifié au runtime :
 - Logits decoder shape = 51865 elements (= constante VOCAB_SIZE OK,
   pas de buffer over-read sur argmax). Le vocab.json contient 50258 tokens
   texte, les 1607 manquants sont les tokens langue/timestamp non décodés.

Doc STT_INTEGRATION.md :
 - Chiffres perf à jour (RTF 0.18, decoder 36 ms/token)
 - Nouvelle section "Checklist d'intégration" (6 points à vérifier en premier
   côté app : System.loadLibrary, transcribe vs WhisperHybridEngine, null/edge,
   VAD drop-in, threading IO, cycle de vie release)
 - Roadmap optim future via Ort::IoBinding (gain estimé 30% sur decoder restant)
This commit is contained in:
Richard Loyer 2026-05-31 22:26:40 +02:00
parent 8f6253204c
commit 970f731e01
2 changed files with 157 additions and 86 deletions

View File

@ -5,17 +5,22 @@
**Runtime** : ONNX Runtime 1.24.3 + QNN ExecutionProvider HTP V79 — **inchangé**. Pas de régression NPU (les contextes QAIRT Qualcomm 545 MB sont conservés tels quels).
**Perf de référence** (mesures sur Snapdragon 8 Elite / SM8750, Whisper-Small, FR) :
**Perf de référence** (mesures sur Snapdragon 8 Elite / SM8750, Whisper-Small, FR continu, decoder zero-copy) :
| Métrique | Prod actuelle (Kotlin) | Lib unifiée (C++) | Gain |
| Métrique | Prod actuelle (Kotlin) | Lib unifiée (C++ zero-copy) | Gain |
|---|---:|---:|---:|
| Mel extraction | 189 ms (DFT brute force) | **60 ms** (FFT radix-2) | -68 % |
| Encoder NPU | 125 ms | 311 ms* | +149 % |
| Decoder/token NPU | 23 ms | 100 ms* | +335 % |
| **RTF (audio 5 s, 16 tokens FR)** | 0.51 (sur 1.6 s) | **0.41-0.43** | **mieux** |
| **RAM peak** | 545 MB | **378 MB** | **-31 %** |
| Mel extraction | 189 ms (DFT brute force) | **57 ms** (FFT radix-2) | -70 % |
| Encoder NPU | 125 ms | 315 ms* | +152 % |
| Decoder/token NPU | 23 ms | **36 ms** | +56 % |
| **RTF (10 s audio, 39 tokens FR continu)** | 0.51 (sur 1.6 s, 22 tokens) | **0.18** | **2.8× mieux** |
| **RAM peak** | 545 MB | **379 MB** | **-30 %** |
\* Decoder et encoder C++ sont localement plus lents qu'en ORT Java à cause d'overhead memcpy fp16 et ré-allocation des `Ort::Value` à chaque step. Optim future via IO bindings ORT documentée §8. **Le RTF global et la RAM sont meilleurs que la prod actuelle.**
\* L'encoder C++ reste localement plus lent qu'ORT Java (overhead de l'initial Ort::Value bound — différence d'optim interne ORT). Sur le RTF global et la RAM, **la version C++ bat la prod**. Le decoder a été optimisé en zero-copy (Ort::Value persistants pointant sur les buffers self_k/v, memcpy in-place des outputs) — gain x2.5 vs version initiale.
**Vérifs sanity faites au runtime** :
- Logits decoder shape = 51865 (= constante `VOCAB_SIZE`), pas de buffer overread sur argmax
- vocab.json (50258 tokens) + tokens spéciaux Whisper (1607 langue/timestamp) couvrent les 51865 logits
- Transcription FR cohérente sur 5 audios différents (damien, richard 1.6/3/5/10 s)
\* Decoder et encoder C++ légèrement plus lents qu'en ORT Java à cause d'overhead memcpy fp16 ; optim future via IO bindings ORT. Le RTF global reste sous la cible.
@ -239,17 +244,42 @@ total 684 ms for 2.50 s audio => RTF 0.274
---
## 8. Notes pour optim future (non bloquant)
## 8. Notes pour optim future (non bloquant, opt-in)
Le decoder C++ est ~4× plus lent par token que la prod Kotlin (100 ms vs 23 ms). Causes identifiées :
- `memcpy` de 24 tenseurs self KV (~12 MB chacun) à chaque step
- Ré-allocation `Ort::Value` à chaque step
Le decoder C++ est ~1.5× plus lent par token que la prod Kotlin (36 ms vs 23 ms). Reste de l'écart probablement dans :
- L'argmax FP16 C++ vs Java (Java pourrait avoir vectorisation auto)
- Différences d'optim ORT C++ vs Java (jvm hot path)
- Le memcpy des self KV outputs (24 × ~300 KB) — pourrait être éliminé via `Ort::IoBinding` (pré-alloue les buffers de sortie une fois pour toutes et ORT écrit dedans, plus de memcpy needed)
Solution : passer aux **IO bindings ORT** (`Ort::IoBinding`) — pré-alloue les buffers de sortie une fois pour toutes et ORT écrit dedans en place. Estimation gain : 3-4× sur le decoder = total ~600 ms pour 5 s audio = RTF 0.12. Optim qui peut attendre la prochaine itération si la perf actuelle est suffisante.
Estimation gain IoBinding : ~30 % sur le decoder = decoder 25 ms/token, RTF ~0.13. Optim qui peut attendre.
**Optim déjà appliquée** (`8f62532` → ce livrable) : zero-copy sur les inputs decoder (Ort::Value persistants pointant sur les buffers, pas de `vector::emplace_back` qui dupliquait 12 MB à chaque step). Gain mesuré : decoder/token 98 ms → 36 ms (-63 %), RTF 0.42 → 0.18 sur 10 s audio.
---
## 9. Contacts & support
## 9. Checklist d'intégration côté app (à vérifier en premier)
Ces points sont **vérifiés en bench standalone** mais pas dans une vraie JVM Android, donc à valider en premier dans l'app :
1. **`System.loadLibrary("kazeia_stt")` réussit** au démarrage de l'app — vérifie qu'aucune dépendance ORT n'est manquante. Si erreur, vérifier que `onnxruntime-android-qnn:1.24.3` est bien dans les deps Gradle.
2. **Charge un engine + un transcribe** sur un audio fixture FR (par ex 3 s de parole continue) : le texte sorti doit être lisible et cohérent avec ce que sort `WhisperHybridEngine.kt` sur le même fichier. Si différence majeure (>2 tokens d'écart), capturer dans un bug et signaler — possible drift FP16 NPU acceptable mais à valider.
3. **Sanity sur null/edge** : la façade Kotlin valide déjà via `require()` au constructeur (handle != 0L). Mais tester explicitement :
- `pcm = ShortArray(0)` → err -1 ou -2
- `language = ""` → utilise "fr" par défaut côté C++
- audio < 1 s padded à 30 s automatiquement par le mel
- audio > 30 s → tronqué à 30 s (limite Whisper standard)
4. **VAD drop-in** : comparer comportement `SttVad` vs `VadStage.kt` sur la même séquence PCM. Doit déclencher SPEECH/END_OF_SPEECH aux mêmes endroits (même seuil RMS=150, même fenêtre 100 ms).
5. **Threading** : `SttEngine.transcribe()` est synchrone bloquant (~500 ms pour 1.6 s audio, ~2 s pour 10 s). À appeler depuis `Dispatchers.IO` côté Kotlin (comme l'actuel `WhisperHybridEngine.transcribe`).
6. **Cycle de vie** : `release()` ferme les sessions ORT et libère les contextes QAIRT (~378 MB RAM). À appeler quand l'app sort du foreground (autrement les 378 MB restent).
Si ces 6 points passent → migration validée. Sinon → me signaler le delta.
## 10. Contacts & support
Code source : `/opt/Kazeia-engine/dist/jni/stt_engine.{h,cpp}` + `kazeia_mel.{h,cpp}` + `kazeia_stt_jni.cpp`
Test CLI : `/opt/Kazeia-engine/dist/jni/stt_cli.cpp`

View File

@ -618,10 +618,13 @@ SttTranscribeResult stt_engine_transcribe(SttEngine * eng, const SttTranscribeCf
}
enc_outputs.clear();
// 5) Decoder loop autoregressif KV-cache (port exact decodeHfKvCache.kt)
// 5) Decoder loop autoregressif KV-cache (port optimisé zero-copy)
//
// ZERO-COPY : on alloue UNE FOIS tous les buffers, on crée UNE FOIS les Ort::Value
// qui pointent dedans, et on met juste à jour les valeurs in-place entre steps.
// Critical path : pas de std::vector::emplace_back(data) qui copie 12 MB/step.
double td0 = now_s();
const int kv_slots = MEAN_DECODE_LEN - 1; // 199
// Self KV layout : k [H, 1, head_dim, slots] ; v [H, 1, slots, head_dim].
const size_t self_k_n = (size_t)eng->num_decoder_heads * HEAD_DIM * kv_slots;
const size_t self_v_n = (size_t)eng->num_decoder_heads * kv_slots * HEAD_DIM;
const std::vector<int64_t> self_k_shape = {eng->num_decoder_heads, 1, HEAD_DIM, kv_slots};
@ -633,59 +636,99 @@ SttTranscribeResult stt_engine_transcribe(SttEngine * eng, const SttTranscribeCf
self_v[l].shape = self_v_shape; self_v[l].data.assign(self_v_n, fp32_to_fp16(0.0f));
}
std::vector<float> mask_f32((size_t)MEAN_DECODE_LEN, MASK_NEG);
std::vector<uint16_t> mask_fp16((size_t)MEAN_DECODE_LEN);
std::vector<int> generated;
generated.reserve(MEAN_DECODE_LEN);
int current_token = SOT;
int position_id = 0;
// Préalloue les noms decoder (input_ids, attention_mask, k/v_cache_self_*_in,
// k/v_cache_cross_*, position_ids). On les chercher dans dec_in_names_owned.
// Cache des indices pour Run() : on passe les Value dans l'ordre des noms cachés.
for (int step = 0; step < MEAN_DECODE_LEN - 1; ++step) {
// Mask R-to-L
mask_f32[MEAN_DECODE_LEN - step - 1] = 0.0f;
for (int i = 0; i < MEAN_DECODE_LEN; ++i) mask_fp16[i] = fp32_to_fp16(mask_f32[i]);
// Build Values aligned avec dec_in_names_owned[i] ordering
tensor_buffers.clear();
std::vector<std::vector<int32_t>> int_buffers;
// Buffers persistants pour input_ids (int32 [1,1]), attention_mask (fp16 [1,1,1,200]),
// position_ids (int32 [1])
std::vector<int32_t> input_ids_buf(1);
std::vector<uint16_t> mask_buf((size_t)MEAN_DECODE_LEN, fp32_to_fp16(MASK_NEG));
std::vector<int32_t> pos_ids_buf(1);
const std::vector<int64_t> input_ids_shape = {1, 1};
const std::vector<int64_t> mask_shape = {1, 1, 1, (int64_t)MEAN_DECODE_LEN};
const std::vector<int64_t> pos_ids_shape = {1};
// Pré-création des Ort::Value DECODER inputs UNE FOIS, alignés avec dec_in_names_owned.
// Chaque Value pointe sur son buffer permanent : ORT lit le buffer à chaque Run, on
// n'a qu'à updater le contenu in-place avant Run.
std::vector<Ort::Value> dec_inputs;
dec_inputs.reserve(eng->dec_in_names.size());
dec_inputs.reserve(eng->dec_in_names_owned.size());
for (size_t i = 0; i < eng->dec_in_names_owned.size(); ++i) {
const std::string & nm = eng->dec_in_names_owned[i];
if (nm == "input_ids") {
dec_inputs.emplace_back(make_int32_tensor(*eng, current_token, {1, 1}, int_buffers));
dec_inputs.emplace_back(Ort::Value::CreateTensor<int32_t>(
*eng->mem_info, input_ids_buf.data(), input_ids_buf.size(),
input_ids_shape.data(), input_ids_shape.size()));
} else if (nm == "attention_mask") {
dec_inputs.emplace_back(make_fp16_tensor(*eng, mask_fp16,
{1, 1, 1, (int64_t)MEAN_DECODE_LEN}, tensor_buffers));
dec_inputs.emplace_back(Ort::Value::CreateTensor(
*eng->mem_info, mask_buf.data(), mask_buf.size() * sizeof(uint16_t),
mask_shape.data(), mask_shape.size(),
ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16));
} else if (nm == "position_ids") {
dec_inputs.emplace_back(make_int32_tensor(*eng, position_id, {1}, int_buffers));
dec_inputs.emplace_back(Ort::Value::CreateTensor<int32_t>(
*eng->mem_info, pos_ids_buf.data(), pos_ids_buf.size(),
pos_ids_shape.data(), pos_ids_shape.size()));
} else if (nm.rfind("k_cache_self_", 0) == 0 && nm.find("_in") != std::string::npos) {
int idx = std::stoi(nm.substr(13));
dec_inputs.emplace_back(make_fp16_tensor(*eng, self_k[idx].data, self_k[idx].shape, tensor_buffers));
dec_inputs.emplace_back(Ort::Value::CreateTensor(
*eng->mem_info, self_k[idx].data.data(), self_k[idx].data.size() * sizeof(uint16_t),
self_k[idx].shape.data(), self_k[idx].shape.size(),
ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16));
} else if (nm.rfind("v_cache_self_", 0) == 0 && nm.find("_in") != std::string::npos) {
int idx = std::stoi(nm.substr(13));
dec_inputs.emplace_back(make_fp16_tensor(*eng, self_v[idx].data, self_v[idx].shape, tensor_buffers));
dec_inputs.emplace_back(Ort::Value::CreateTensor(
*eng->mem_info, self_v[idx].data.data(), self_v[idx].data.size() * sizeof(uint16_t),
self_v[idx].shape.data(), self_v[idx].shape.size(),
ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16));
} else if (nm.rfind("k_cache_cross_", 0) == 0) {
int idx = std::stoi(nm.substr(14));
dec_inputs.emplace_back(make_fp16_tensor(*eng, cross_k[idx].data, cross_k[idx].shape, tensor_buffers));
dec_inputs.emplace_back(Ort::Value::CreateTensor(
*eng->mem_info, cross_k[idx].data.data(), cross_k[idx].data.size() * sizeof(uint16_t),
cross_k[idx].shape.data(), cross_k[idx].shape.size(),
ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16));
} else if (nm.rfind("v_cache_cross_", 0) == 0) {
int idx = std::stoi(nm.substr(14));
dec_inputs.emplace_back(make_fp16_tensor(*eng, cross_v[idx].data, cross_v[idx].shape, tensor_buffers));
dec_inputs.emplace_back(Ort::Value::CreateTensor(
*eng->mem_info, cross_v[idx].data.data(), cross_v[idx].data.size() * sizeof(uint16_t),
cross_v[idx].shape.data(), cross_v[idx].shape.size(),
ONNX_TENSOR_ELEMENT_DATA_TYPE_FLOAT16));
} else {
fprintf(stderr, "stt_engine_transcribe: input decoder inconnu : %s\n", nm.c_str());
R.err = -5; return R;
}
}
// Run decoder
// Cache l'index du tensor "logits" dans dec_out_names_owned
int logits_out_idx = -1;
for (size_t i = 0; i < eng->dec_out_names_owned.size(); ++i) {
if (eng->dec_out_names_owned[i] == "logits") { logits_out_idx = (int)i; break; }
}
if (logits_out_idx < 0) {
fprintf(stderr, "stt_engine_transcribe: pas de sortie 'logits' au decoder\n");
R.err = -7; return R;
}
// Cache la correspondance self_*_out -> idx layer (parsing 1 fois suffit)
std::vector<int> k_self_out_layer(eng->dec_out_names_owned.size(), -1);
std::vector<int> v_self_out_layer(eng->dec_out_names_owned.size(), -1);
for (size_t i = 0; i < eng->dec_out_names_owned.size(); ++i) {
const std::string & nm = eng->dec_out_names_owned[i];
if (nm.rfind("k_cache_self_", 0) == 0 && nm.find("_out") != std::string::npos) {
k_self_out_layer[i] = std::stoi(nm.substr(13));
} else if (nm.rfind("v_cache_self_", 0) == 0 && nm.find("_out") != std::string::npos) {
v_self_out_layer[i] = std::stoi(nm.substr(13));
}
}
std::vector<int> generated;
generated.reserve(MEAN_DECODE_LEN);
int current_token = SOT;
int position_id = 0;
int real_vocab_size = -1; // détecté au 1er step depuis le shape des logits
for (int step = 0; step < MEAN_DECODE_LEN - 1; ++step) {
// Update mask : un seul fp16 à écrire par step (le slot R-to-L)
mask_buf[MEAN_DECODE_LEN - step - 1] = fp32_to_fp16(0.0f);
input_ids_buf[0] = current_token;
pos_ids_buf[0] = position_id;
// Run decoder — les Ort::Value pointent déjà sur les bons buffers (à jour in-place)
std::vector<Ort::Value> dec_outputs;
try {
dec_outputs = eng->dec_sess->Run(
@ -697,38 +740,36 @@ SttTranscribeResult stt_engine_transcribe(SttEngine * eng, const SttTranscribeCf
R.err = -6; return R;
}
// Parse outputs : logits + updated self KV
int logits_idx = -1;
for (size_t i = 0; i < eng->dec_out_names_owned.size(); ++i) {
if (eng->dec_out_names_owned[i] == "logits") { logits_idx = (int)i; break; }
// argmax logits avec shape réelle (détectée au step 0)
const auto logits_shape = dec_outputs[logits_out_idx].GetTensorTypeAndShapeInfo().GetShape();
if (real_vocab_size < 0) {
size_t n = 1; for (auto d : logits_shape) n *= (size_t)d;
real_vocab_size = (int)n;
if (step == 0) {
fprintf(stderr, "stt_engine_transcribe: logits shape detected = %d elements (VOCAB_SIZE const=%d)\n",
real_vocab_size, VOCAB_SIZE);
}
if (logits_idx < 0) {
fprintf(stderr, "stt_engine_transcribe: pas de sortie 'logits' au decoder\n");
R.err = -7; return R;
}
const uint16_t * logits_data = dec_outputs[logits_idx].GetTensorMutableData<uint16_t>();
int token = argmax_fp16_logits(logits_data, VOCAB_SIZE);
const uint16_t * logits_data = dec_outputs[logits_out_idx].GetTensorMutableData<uint16_t>();
int token = argmax_fp16_logits(logits_data, real_vocab_size);
// Override translate -> transcribe
if (cfg.force_transcribe && token == TRANSLATE_TOK) token = TRANSCRIBE_TOK;
// Update self KV from outputs
// Update self KV from outputs : memcpy direct dans nos buffers persistants
// (les Ort::Value dec_inputs pointent DÉJÀ dessus, prêts pour le step suivant)
for (size_t i = 0; i < eng->dec_out_names_owned.size(); ++i) {
const std::string & nm = eng->dec_out_names_owned[i];
if (nm.rfind("k_cache_self_", 0) == 0 && nm.find("_out") != std::string::npos) {
int idx = std::stoi(nm.substr(13));
int k_idx = k_self_out_layer[i];
int v_idx = v_self_out_layer[i];
if (k_idx >= 0) {
auto & val = dec_outputs[i];
size_t n = 1; for (auto d : val.GetTensorTypeAndShapeInfo().GetShape()) n *= (size_t)d;
if (n == self_k[idx].data.size()) {
std::memcpy(self_k[idx].data.data(), val.GetTensorMutableData<uint16_t>(), n * 2);
}
} else if (nm.rfind("v_cache_self_", 0) == 0 && nm.find("_out") != std::string::npos) {
int idx = std::stoi(nm.substr(13));
std::memcpy(self_k[k_idx].data.data(),
val.GetTensorMutableData<uint16_t>(),
self_k[k_idx].data.size() * sizeof(uint16_t));
} else if (v_idx >= 0) {
auto & val = dec_outputs[i];
size_t n = 1; for (auto d : val.GetTensorTypeAndShapeInfo().GetShape()) n *= (size_t)d;
if (n == self_v[idx].data.size()) {
std::memcpy(self_v[idx].data.data(), val.GetTensorMutableData<uint16_t>(), n * 2);
}
std::memcpy(self_v[v_idx].data.data(),
val.GetTensorMutableData<uint16_t>(),
self_v[v_idx].data.size() * sizeof(uint16_t));
}
}
dec_outputs.clear();