Files
rustytorch/crates/models/rtx-csm/examples/stt_profile.rs
T
osobhandClaude Opus 4.7 85ce697ffa rtx-csm: Phase 7.1 — profile Kyutai STT step_pcm
New examples/stt_profile binary. Loads kyutai/stt-1b-en_fr, feeds PCM
in fixed-size chunks, reports per-call latency p50/p95/min/max plus
realtime factor.

Findings (M-series Metal, 10.43s LibriSpeech in):
  frame_batch=1 (80ms): mean=80.8ms p50=81.7ms RT=1.01x
  frame_batch=3 (240ms): mean=308.6ms p50=298ms RT=1.29x

Headline: Kyutai STT 1B on Metal saturates at ~1.0x real-time. There
is no slack in the existing model on this hardware. Per-call overhead
amortizes poorly when batching frames (3 frames takes 3.8x single
frame, not 3x). To go faster requires a smaller model (Whisper-tiny
via the existing whisper-rs feature) or a Kyutai variant if available.

Note: converse_server's measured recv_phase (~4-5s for 10.4s audio)
is faster than this profile predicts (~10s). Discrepancy not yet
resolved but the optimization conclusion stands: STT model swap is
the only lever for the receive phase.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
2026-04-27 04:57:56 -07:00

125 lines
4.6 KiB
Rust

//! Profile Kyutai STT step_pcm latency. Loads the standard 1B en/fr
//! model, feeds PCM frames in fixed chunk sizes, and reports per-frame
//! latency stats so we can see where the ~50%-realtime cost comes from.
//!
//! Usage:
//! ```bash
//! cargo run -p rtx-csm --release --features metal --example stt_profile -- \
//! --in /tmp/asr_test.flac --frame-batch 1
//! ```
//!
//! `--frame-batch 1` measures pure per-frame cost (one 80ms frame per
//! step_pcm call). `--frame-batch 8` simulates the converse_server's
//! receive pattern (200ms chunks ≈ 2.5 frames; 8 here is generous).
use anyhow::{Context, Result};
use clap::Parser;
use rtx_csm::{audio_io, stt::Stt};
use std::path::PathBuf;
use std::time::Instant;
#[derive(Debug, Parser)]
struct Cli {
/// Input audio (any rate, mono OK; will be resampled to 24 kHz).
#[arg(long = "in", default_value = "/tmp/asr_test.flac")]
input: PathBuf,
/// Number of 80ms frames (1920 samples each) per step_pcm call.
/// 1 = pure per-frame cost. The server typically sees ~2-3.
#[arg(long, default_value_t = 1)]
frame_batch: usize,
/// CPU only (skip Metal).
#[arg(long)]
cpu: bool,
}
const SAMPLES_PER_FRAME: usize = 1920;
fn main() -> Result<()> {
tracing_subscriber::fmt().with_max_level(tracing::Level::WARN).init();
let cli = Cli::parse();
let device = if cli.cpu {
candle_core::Device::Cpu
} else if candle_core::utils::metal_is_available() {
candle_core::Device::new_metal(0)?
} else {
candle_core::Device::Cpu
};
eprintln!("device: {device:?}");
eprintln!("loading audio: {}", cli.input.display());
let pcm = audio_io::load_mono_at_rate(&cli.input, 24_000).context("load audio")?;
eprintln!("loaded {} samples ({:.2}s @ 24 kHz)", pcm.len(), pcm.len() as f32 / 24_000.0);
eprintln!("loading Kyutai STT 1B en/fr (~3 GB)...");
let load_t = Instant::now();
let mut stt = Stt::load_default(&device).context("load stt")?;
eprintln!("model loaded in {:.2}s", load_t.elapsed().as_secs_f32());
let chunk_size = SAMPLES_PER_FRAME * cli.frame_batch.max(1);
let n_chunks = pcm.len() / chunk_size;
eprintln!(
"feeding {} chunks of {} samples ({} frames each, {} ms)",
n_chunks,
chunk_size,
cli.frame_batch,
cli.frame_batch * 80,
);
// Warm up: first call pays Metal kernel compilation + first-frame KV
// cache init. Don't include in stats.
let warm_t = Instant::now();
let _ = stt.step_pcm(&pcm[..chunk_size]).context("warm step_pcm")?;
eprintln!("warm-up first chunk: {} ms", warm_t.elapsed().as_millis());
// Steady-state: time each subsequent step_pcm call.
let mut per_chunk_ms: Vec<f64> = Vec::with_capacity(n_chunks - 1);
let mut total_events = 0usize;
let total_t = Instant::now();
for i in 1..n_chunks {
let start = i * chunk_size;
let end = start + chunk_size;
let t = Instant::now();
let events = stt
.step_pcm(&pcm[start..end])
.with_context(|| format!("step_pcm chunk {i}"))?;
per_chunk_ms.push(t.elapsed().as_secs_f64() * 1000.0);
total_events += events.len();
}
let total_wall_ms = total_t.elapsed().as_millis() as f64;
// Stats.
per_chunk_ms.sort_by(|a, b| a.partial_cmp(b).unwrap());
let n = per_chunk_ms.len() as f64;
let mean = per_chunk_ms.iter().sum::<f64>() / n;
let p50 = per_chunk_ms[per_chunk_ms.len() / 2];
let p95 = per_chunk_ms[((n * 0.95).min(n - 1.0)) as usize];
let min = per_chunk_ms[0];
let max = per_chunk_ms[per_chunk_ms.len() - 1];
let chunk_audio_ms = (cli.frame_batch * 80) as f64;
let realtime_factor = mean / chunk_audio_ms;
println!();
println!("=== Kyutai STT step_pcm profile ===");
println!("frame_batch: {} (= {} ms audio per call)", cli.frame_batch, cli.frame_batch * 80);
println!("steady-state n: {}", per_chunk_ms.len());
println!();
println!("per-call latency:");
println!(" mean = {mean:.1} ms");
println!(" p50 = {p50:.1} ms");
println!(" p95 = {p95:.1} ms");
println!(" min = {min:.1} ms");
println!(" max = {max:.1} ms");
println!();
println!("realtime factor: {realtime_factor:.2}x (mean step_pcm / audio duration)");
println!(" < 1.0 = faster than real-time (good for streaming)");
println!(" > 1.0 = slower than real-time (will lag behind incoming audio)");
println!();
println!("aggregate:");
println!(" total wall = {total_wall_ms:.0} ms");
println!(" total audio = {:.0} ms", n * chunk_audio_ms);
println!(" total events = {total_events}");
Ok(())
}