max / audiofiles
3 files changed,
+244 insertions,
-39 deletions
| @@ -692,6 +692,21 @@ fn main() { | |||
| 692 | 692 | bench_similarity_scaling(); | |
| 693 | 693 | println!(); | |
| 694 | 694 | ||
| 695 | + | // ───────────────────────────────────────────────────────────── | |
| 696 | + | // Section 8: Persistence Scaling (DB import write + load-then-build) | |
| 697 | + | // ───────────────────────────────────────────────────────────── | |
| 698 | + | println!("━━━ 8. PERSISTENCE SCALING ━━━"); | |
| 699 | + | println!(); | |
| 700 | + | println!(" Analysis-row writes to the DB, and loading them back to build the"); | |
| 701 | + | println!(" similarity index. The write path was the headline bottleneck behind"); | |
| 702 | + | println!(" ultra-fuzz CHRONIC #2 (per-row autocommit = one fsync per sample);"); | |
| 703 | + | println!(" the batched-vs-per-row columns below are why it was sealed behind a"); | |
| 704 | + | println!(" transaction token. 'Load+build' is the index build INCLUDING DB I/O"); | |
| 705 | + | println!(" (Section 7 measures the in-memory build alone)."); | |
| 706 | + | println!(); | |
| 707 | + | bench_persistence_scaling(); | |
| 708 | + | println!(); | |
| 709 | + | ||
| 695 | 710 | println!("═══════════════════════════════════════════════════════════════"); | |
| 696 | 711 | println!(" Benchmark complete. {} files analyzed.", n + total_classified); | |
| 697 | 712 | println!("═══════════════════════════════════════════════════════════════"); | |
| @@ -776,3 +791,124 @@ fn bench_similarity_scaling() { | |||
| 776 | 791 | ); | |
| 777 | 792 | } | |
| 778 | 793 | } | |
| 794 | + | ||
| 795 | + | // ── Persistence Scaling ── | |
| 796 | + | ||
| 797 | + | /// Insert one `samples` + `audio_analysis` row pair. Feature values vary with `i` | |
| 798 | + | /// so the built index isn't a degenerate all-equal set. Only the NOT NULL columns | |
| 799 | + | /// plus a few features `load_data` reads are set; tuple params avoid a direct | |
| 800 | + | /// rusqlite dependency (panics on schema mismatch, which a bench wants to surface). | |
| 801 | + | fn insert_analysis_row(db: &audiofiles_core::db::Database, i: usize) { | |
| 802 | + | let hash = format!("{i:064x}"); | |
| 803 | + | db.conn() | |
| 804 | + | .execute( | |
| 805 | + | "INSERT INTO samples (hash, original_name, file_extension, file_size, import_date, last_modified) | |
| 806 | + | VALUES (?1, ?2, 'wav', 100, 0, 0)", | |
| 807 | + | (&hash, format!("s{i}.wav")), | |
| 808 | + | ) | |
| 809 | + | .unwrap(); | |
| 810 | + | let bpm = 60.0 + (i % 140) as f64; | |
| 811 | + | let centroid = (i % 8000) as f64; | |
| 812 | + | let onset = (i % 1000) as f64 / 1000.0; | |
| 813 | + | db.conn() | |
| 814 | + | .execute( | |
| 815 | + | "INSERT INTO audio_analysis | |
| 816 | + | (hash, duration, sample_rate, channels, analyzed_at, bpm, spectral_centroid, onset_strength) | |
| 817 | + | VALUES (?1, 1.0, 44100, 2, 0, ?2, ?3, ?4)", | |
| 818 | + | (&hash, bpm, centroid, onset), | |
| 819 | + | ) | |
| 820 | + | .unwrap(); | |
| 821 | + | } | |
| 822 | + | ||
| 823 | + | fn bench_persistence_scaling() { | |
| 824 | + | use audiofiles_core::db::Database; | |
| 825 | + | use audiofiles_core::similarity::SimilarityIndex; | |
| 826 | + | ||
| 827 | + | let db_path = std::env::temp_dir().join(format!("af_bench_persist_{}.db", std::process::id())); | |
| 828 | + | let _ = std::fs::remove_file(&db_path); | |
| 829 | + | ||
| 830 | + | println!( | |
| 831 | + | " {:>9} {:>14} {:>16} {:>14}", | |
| 832 | + | "Rows", "Batched(ms)", "Per-row(ms)", "Load+build(ms)" | |
| 833 | + | ); | |
| 834 | + | println!(" {}", "─".repeat(58)); | |
| 835 | + | ||
| 836 | + | for &size in &[1_000usize, 10_000] { | |
| 837 | + | // Fresh DB per size so the row counts are clean. | |
| 838 | + | let _ = std::fs::remove_file(&db_path); | |
| 839 | + | let db = match Database::open(&db_path) { | |
| 840 | + | Ok(d) => d, | |
| 841 | + | Err(e) => { | |
| 842 | + | println!(" (skipped: could not open temp DB: {e})"); | |
| 843 | + | return; | |
| 844 | + | } | |
| 845 | + | }; | |
| 846 | + | ||
| 847 | + | // Batched: all inserts in one transaction (the sealed path). | |
| 848 | + | let t = Instant::now(); | |
| 849 | + | let res = db.transaction(|_tx| { | |
| 850 | + | for i in 0..size { | |
| 851 | + | insert_analysis_row(&db, i); | |
| 852 | + | } | |
| 853 | + | Ok(()) | |
| 854 | + | }); | |
| 855 | + | let batched_ms = t.elapsed().as_secs_f64() * 1000.0; | |
| 856 | + | if let Err(e) = res { | |
| 857 | + | println!(" (skipped: batched insert failed: {e})"); | |
| 858 | + | return; | |
| 859 | + | } | |
| 860 | + | ||
| 861 | + | // Per-row autocommit (the pre-CHRONIC-#2 pattern), for contrast. Capped at | |
| 862 | + | // 1k rows — at 10k the autocommit fsyncs dominate the whole bench's runtime. | |
| 863 | + | let per_row_n = size.min(1_000); | |
| 864 | + | let t = Instant::now(); | |
| 865 | + | for i in 0..per_row_n { | |
| 866 | + | // offset the hashes so they don't collide with the batched rows | |
| 867 | + | insert_analysis_row(&db, size + i); | |
| 868 | + | } | |
| 869 | + | let per_row_ms = t.elapsed().as_secs_f64() * 1000.0 | |
| 870 | + | * (size as f64 / per_row_n as f64); // extrapolate to `size` rows | |
| 871 | + | ||
| 872 | + | // Load from DB (I/O) then build the index (CPU) — the full cold-start cost. | |
| 873 | + | let t = Instant::now(); | |
| 874 | + | let data = SimilarityIndex::load_data(&db).unwrap_or_default(); | |
| 875 | + | let _index = SimilarityIndex::build_from_data(data); | |
| 876 | + | let load_build_ms = t.elapsed().as_secs_f64() * 1000.0; | |
| 877 | + | ||
| 878 | + | println!( | |
| 879 | + | " {:>9} {:>14.1} {:>16.1} {:>14.1}", | |
| 880 | + | size, batched_ms, per_row_ms, load_build_ms | |
| 881 | + | ); | |
| 882 | + | } | |
| 883 | + | ||
| 884 | + | let _ = std::fs::remove_file(&db_path); | |
| 885 | + | println!(); | |
| 886 | + | println!(" Per-row is extrapolated from a 1k-row sample; the batched column is"); | |
| 887 | + | println!(" the path the app actually uses (one transaction per analysis flush)."); | |
| 888 | + | } | |
| 889 | + | ||
| 890 | + | #[cfg(test)] | |
| 891 | + | mod tests { | |
| 892 | + | use super::*; | |
| 893 | + | ||
| 894 | + | #[test] | |
| 895 | + | fn persistence_rows_match_schema_and_load() { | |
| 896 | + | // Guards the bench's raw inserts against schema drift: the rows must be | |
| 897 | + | // accepted and round-trip through SimilarityIndex::load_data. | |
| 898 | + | use audiofiles_core::db::Database; | |
| 899 | + | use audiofiles_core::similarity::SimilarityIndex; | |
| 900 | + | ||
| 901 | + | let db = Database::open_in_memory().unwrap(); | |
| 902 | + | db.transaction(|_tx| { | |
| 903 | + | for i in 0..50 { | |
| 904 | + | insert_analysis_row(&db, i); | |
| 905 | + | } | |
| 906 | + | Ok(()) | |
| 907 | + | }) | |
| 908 | + | .unwrap(); | |
| 909 | + | ||
| 910 | + | let data = SimilarityIndex::load_data(&db).unwrap(); | |
| 911 | + | assert_eq!(data.len(), 50, "all inserted analysis rows must load back"); | |
| 912 | + | let _ = SimilarityIndex::build_from_data(data); | |
| 913 | + | } | |
| 914 | + | } |
| @@ -8,13 +8,12 @@ | |||
| 8 | 8 | //! buttons while a job is in flight, and dispatching a new job drops (joins) the prior one. | |
| 9 | 9 | ||
| 10 | 10 | use std::path::PathBuf; | |
| 11 | - | use std::sync::{mpsc, Mutex}; | |
| 12 | - | use std::thread; | |
| 13 | 11 | ||
| 14 | 12 | use tracing::{error, instrument}; | |
| 15 | 13 | ||
| 16 | 14 | use audiofiles_core::analysis::{afcl, cluster, exemplar, trained_head}; | |
| 17 | 15 | use audiofiles_core::db::Database; | |
| 16 | + | use audiofiles_core::worker_runtime::{spawn_job, JobHandle}; | |
| 18 | 17 | ||
| 19 | 18 | use crate::backend::{ClassifierJob, ClassifierJobResult, TrainedHeadInfo}; | |
| 20 | 19 | ||
| @@ -24,54 +23,40 @@ pub enum ClassifierWorkerEvent { | |||
| 24 | 23 | Failed(String), | |
| 25 | 24 | } | |
| 26 | 25 | ||
| 27 | - | /// Handle for a single in-flight classifier job. The receiver is wrapped in a `Mutex` so | |
| 28 | - | /// `BrowserState` stays `Sync` (required by nih-plug); only the GUI thread calls `try_recv`. | |
| 29 | - | pub struct ClassifierHandle { | |
| 30 | - | event_rx: Mutex<mpsc::Receiver<ClassifierWorkerEvent>>, | |
| 31 | - | _thread: Option<thread::JoinHandle<()>>, | |
| 32 | - | } | |
| 26 | + | /// Handle for a single in-flight classifier job. Thin wrapper over the shared | |
| 27 | + | /// one-shot [`JobHandle`], which owns the result channel, the `catch_unwind` | |
| 28 | + | /// panic-isolation, and the join-on-drop. | |
| 29 | + | pub struct ClassifierHandle(JobHandle<ClassifierWorkerEvent>); | |
| 33 | 30 | ||
| 34 | 31 | impl ClassifierHandle { | |
| 35 | 32 | /// Poll for the job's result without blocking. | |
| 36 | 33 | pub fn try_recv(&self) -> Option<ClassifierWorkerEvent> { | |
| 37 | - | self.event_rx.lock().ok()?.try_recv().ok() | |
| 34 | + | self.0.try_recv() | |
| 38 | 35 | } | |
| 39 | 36 | } | |
| 40 | 37 | ||
| 41 | - | impl Drop for ClassifierHandle { | |
| 42 | - | fn drop(&mut self) { | |
| 43 | - | // The worker is one-shot and detached; join so a replaced job finishes cleanly. | |
| 44 | - | if let Some(handle) = self._thread.take() { | |
| 45 | - | let _ = handle.join(); | |
| 46 | - | } | |
| 47 | - | } | |
| 48 | - | } | |
| 38 | + | // Join-on-drop is owned by the inner JobHandle (so a replaced job finishes cleanly). | |
| 49 | 39 | ||
| 50 | 40 | /// Spawn a one-shot worker thread to run `job` against the database at `db_path`. | |
| 41 | + | /// | |
| 42 | + | /// The shared [`spawn_job`] runtime owns the panic isolation: a panic in the job | |
| 43 | + | /// (or `Database::open`) becomes a `Failed` event rather than a silently-dead | |
| 44 | + | /// thread, so the GUI's `try_recv` always resolves and the trigger buttons | |
| 45 | + | /// re-enable. | |
| 51 | 46 | #[instrument(skip_all)] | |
| 52 | 47 | pub fn spawn_classifier_job(db_path: PathBuf, job: ClassifierJob) -> std::io::Result<ClassifierHandle> { | |
| 53 | - | let (event_tx, event_rx) = mpsc::channel::<ClassifierWorkerEvent>(); | |
| 54 | - | ||
| 55 | - | let thread = thread::Builder::new() | |
| 56 | - | .name("classifier-job".to_string()) | |
| 57 | - | .spawn(move || { | |
| 58 | - | // Catch panics so a failed job reports back instead of unwinding the | |
| 59 | - | // thread silently — otherwise the GUI's try_recv() never resolves and | |
| 60 | - | // the trigger buttons stay disabled forever. | |
| 61 | - | let event = match Database::open(&db_path) { | |
| 62 | - | Ok(db) => std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| run_job(&db, job))) | |
| 63 | - | .unwrap_or_else(|_| { | |
| 64 | - | ClassifierWorkerEvent::Failed("classifier job panicked (internal error)".to_string()) | |
| 65 | - | }), | |
| 66 | - | Err(e) => { | |
| 67 | - | error!("Classifier worker failed to open database: {e}"); | |
| 68 | - | ClassifierWorkerEvent::Failed(format!("could not open database: {e}")) | |
| 69 | - | } | |
| 70 | - | }; | |
| 71 | - | let _ = event_tx.send(event); | |
| 72 | - | })?; | |
| 73 | - | ||
| 74 | - | Ok(ClassifierHandle { event_rx: Mutex::new(event_rx), _thread: Some(thread) }) | |
| 48 | + | let handle = spawn_job( | |
| 49 | + | "classifier-job", | |
| 50 | + | || ClassifierWorkerEvent::Failed("classifier job panicked (internal error)".to_string()), | |
| 51 | + | move || match Database::open(&db_path) { | |
| 52 | + | Ok(db) => run_job(&db, job), | |
| 53 | + | Err(e) => { | |
| 54 | + | error!("Classifier worker failed to open database: {e}"); | |
| 55 | + | ClassifierWorkerEvent::Failed(format!("could not open database: {e}")) | |
| 56 | + | } | |
| 57 | + | }, | |
| 58 | + | )?; | |
| 59 | + | Ok(ClassifierHandle(handle)) | |
| 75 | 60 | } | |
| 76 | 61 | ||
| 77 | 62 | fn run_job(db: &Database, job: ClassifierJob) -> ClassifierWorkerEvent { |
| @@ -266,11 +266,95 @@ where | |||
| 266 | 266 | }) | |
| 267 | 267 | } | |
| 268 | 268 | ||
| 269 | + | /// Handle to a one-shot job thread (see [`spawn_job`]). Poll [`try_recv`](Self::try_recv) | |
| 270 | + | /// for the single result event; `Drop` joins the thread. | |
| 271 | + | pub struct JobHandle<Ev> { | |
| 272 | + | event_rx: Mutex<mpsc::Receiver<Ev>>, | |
| 273 | + | thread: Option<thread::JoinHandle<()>>, | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | impl<Ev> JobHandle<Ev> { | |
| 277 | + | /// Poll for the job's result without blocking. | |
| 278 | + | pub fn try_recv(&self) -> Option<Ev> { | |
| 279 | + | self.event_rx.lock().ok()?.try_recv().ok() | |
| 280 | + | } | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | impl<Ev> Drop for JobHandle<Ev> { | |
| 284 | + | fn drop(&mut self) { | |
| 285 | + | if let Some(handle) = self.thread.take() { | |
| 286 | + | let _ = handle.join(); | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | /// Spawn a one-shot job thread: `body` runs once, its return value is sent back | |
| 292 | + | /// as the single result event, and the thread exits. | |
| 293 | + | /// | |
| 294 | + | /// This is the one-shot counterpart to [`spawn_worker`] (which owns a command | |
| 295 | + | /// recv loop). A panic in `body` is caught and mapped to `on_panic()` — the same | |
| 296 | + | /// isolation guarantee — so the GUI's `try_recv` always resolves a terminal event | |
| 297 | + | /// instead of hanging on a silently-dead thread. Use this for any | |
| 298 | + | /// run-once-then-exit thread (classifier jobs, one-shot library passes); use | |
| 299 | + | /// `spawn_worker` for a long-lived command loop. Two bare `thread::spawn` sites | |
| 300 | + | /// remain by design, both different shapes from a compute job: the instrument | |
| 301 | + | /// decode worker (its own per-request `catch_unwind`), and two recoverable | |
| 302 | + | /// threads — a generation-cancelled streaming preview decode (a panic just stops | |
| 303 | + | /// that one preview; the next play spawns a fresh generation) and an OAuth | |
| 304 | + | /// loopback listener (bounded by a timeout). Those recover by replacement rather | |
| 305 | + | /// than by emitting a terminal event, so the job seal does not apply. | |
| 306 | + | pub fn spawn_job<Ev, F>( | |
| 307 | + | name: &str, | |
| 308 | + | on_panic: impl FnOnce() -> Ev + Send + 'static, | |
| 309 | + | body: F, | |
| 310 | + | ) -> std::io::Result<JobHandle<Ev>> | |
| 311 | + | where | |
| 312 | + | Ev: Send + 'static, | |
| 313 | + | F: FnOnce() -> Ev + Send + 'static, | |
| 314 | + | { | |
| 315 | + | let (tx, rx) = mpsc::channel::<Ev>(); | |
| 316 | + | let thread = thread::Builder::new() | |
| 317 | + | .name(name.to_string()) | |
| 318 | + | .spawn(move || { | |
| 319 | + | let ev = std::panic::catch_unwind(std::panic::AssertUnwindSafe(body)) | |
| 320 | + | .unwrap_or_else(|_| on_panic()); | |
| 321 | + | let _ = tx.send(ev); | |
| 322 | + | })?; | |
| 323 | + | Ok(JobHandle { | |
| 324 | + | event_rx: Mutex::new(rx), | |
| 325 | + | thread: Some(thread), | |
| 326 | + | }) | |
| 327 | + | } | |
| 328 | + | ||
| 269 | 329 | #[cfg(test)] | |
| 270 | 330 | mod tests { | |
| 271 | 331 | use super::*; | |
| 272 | 332 | use std::convert::Infallible; | |
| 273 | 333 | ||
| 334 | + | fn poll_job<Ev>(handle: &JobHandle<Ev>) -> Ev { | |
| 335 | + | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); | |
| 336 | + | loop { | |
| 337 | + | if let Some(ev) = handle.try_recv() { | |
| 338 | + | return ev; | |
| 339 | + | } | |
| 340 | + | assert!(std::time::Instant::now() < deadline, "job never emitted"); | |
| 341 | + | std::thread::sleep(std::time::Duration::from_millis(1)); | |
| 342 | + | } | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | #[test] | |
| 346 | + | fn spawn_job_delivers_result() { | |
| 347 | + | let handle = spawn_job("test-job-ok", || 0u32, || 42u32).unwrap(); | |
| 348 | + | assert_eq!(poll_job(&handle), 42); | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | #[test] | |
| 352 | + | fn spawn_job_maps_panic_to_event() { | |
| 353 | + | // A panicking job body must surface the on_panic event, not hang try_recv. | |
| 354 | + | let handle = spawn_job("test-job-panic", || "panicked", || panic!("boom")).unwrap(); | |
| 355 | + | assert_eq!(poll_job(&handle), "panicked"); | |
| 356 | + | } | |
| 357 | + | ||
| 274 | 358 | fn recv_one<Cmd, Ev>(handle: &WorkerHandle<Cmd, Ev>) -> Ev { | |
| 275 | 359 | let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10); | |
| 276 | 360 | while std::time::Instant::now() < deadline { |