diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0aa20b4..3fc12a9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -80,6 +80,11 @@ criterion = { version = "0.8", default-features = false, features = ["cargo_benc name = "commands" harness = false +# ⚠️ Its own target so `cargo bench` stays usable: it seeds 26 000 notes. +[[bench]] +name = "corpus_size" +harness = false + [profile.release] lto = true codegen-units = 1 diff --git a/src-tauri/benches/commands.rs b/src-tauri/benches/commands.rs index ab8c3bc..8240b06 100644 --- a/src-tauri/benches/commands.rs +++ b/src-tauri/benches/commands.rs @@ -16,13 +16,12 @@ use std::hint::black_box; use criterion::{Criterion, criterion_group, criterion_main}; -use devbox_lib::attachments::store as attachments; use devbox_lib::notes::model::NotePatch; use devbox_lib::notes::store; -use devbox_lib::notes::view::{self, NoteFilter, NotesQuery}; +use devbox_lib::notes::view::{NoteFilter, NotesQuery}; use devbox_lib::transfer::{bundle, file}; -use corpus::{Corpus, NOTES, build, now}; +use corpus::{NOTES, build, now, run_query}; fn query(search: &str) -> NotesQuery { NotesQuery { @@ -37,19 +36,6 @@ fn query(search: &str) -> NotesQuery { } } -/// Everything `query_notes` does, in its order, serialisation included. -fn run_query(corpus: &mut Corpus, request: &NotesQuery) -> String { - let (notes, facets) = store::fetch(&mut corpus.connection, request).expect("a view"); - let counts = attachments::counts(&mut corpus.connection).expect("the counters"); - let globals = store::global_placeholder_values(&mut corpus.connection).expect("the globals"); - - let mut built = view::build(notes, facets, request); - view::apply_attachment_counts(&mut built, &counts); - view::apply_global_defaults(&mut built, &globals); - - serde_json::to_string(&built).expect("a serialisable view") -} - /// The one that matters most: it runs on every keystroke, behind the 150 ms debounce. fn whole_corpus_read(c: &mut Criterion) { let mut corpus = build(); diff --git a/src-tauri/benches/corpus.rs b/src-tauri/benches/corpus.rs index e3b5d06..a6fb33f 100644 --- a/src-tauri/benches/corpus.rs +++ b/src-tauri/benches/corpus.rs @@ -6,16 +6,22 @@ //! ⚠️ **File-backed, never `open_in_memory`.** An in-memory database has no pager behind a //! file, no page cache and no I/O, so it measures something the application never does. +// ⚠️ Two bench targets compile their own copy of this module and each uses a part of it: +// `commands` never calls `build_of`, `corpus_size` never reads `note_ids`. +#![allow(dead_code)] + use std::path::PathBuf; use chrono::{DateTime, TimeDelta, Utc}; use devbox_lib::db::Library; +use devbox_lib::attachments::store as attachments; use devbox_lib::db; use devbox_lib::notes::checklist::{ChecklistItem, NoteKind}; use devbox_lib::notes::language::Language; use devbox_lib::notes::model::{NoteDraft, NoteLifecycle}; use devbox_lib::notes::store; +use devbox_lib::notes::view::{self, NotesQuery}; use devbox_lib::spaces::store as spaces; pub(crate) const NOTES: usize = 8000; @@ -104,8 +110,28 @@ fn draft(seed: usize, space_id: &str) -> NoteDraft { } } +/// Everything `query_notes` does, in its order, serialisation included — the string it +/// returns is what would cross the bridge. +pub(crate) fn run_query(corpus: &mut Corpus, request: &NotesQuery) -> String { + let (notes, facets) = store::fetch(&mut corpus.connection, request).expect("a view"); + let counts = attachments::counts(&mut corpus.connection).expect("the counters"); + let globals = store::global_placeholder_values(&mut corpus.connection).expect("the globals"); + + let mut built = view::build(notes, facets, request); + view::apply_attachment_counts(&mut built, &counts); + view::apply_global_defaults(&mut built, &globals); + + serde_json::to_string(&built).expect("a serialisable view") +} + /// Seeded once per benchmark group, never inside a `b.iter`. pub(crate) fn build() -> Corpus { + build_of(NOTES) +} + +/// ⚠️ The size is a parameter only because #21 asks what a query costs as a corpus grows. +/// Every other group takes [`NOTES`], so its numbers stay comparable across runs. +pub(crate) fn build_of(notes: usize) -> Corpus { let directory = std::env::temp_dir().join(format!("devbox-bench-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&directory).expect("a writable temporary directory"); @@ -126,7 +152,7 @@ pub(crate) fn build() -> Corpus { .collect(); let at = now(); - let note_ids: Vec = (0..NOTES) + let note_ids: Vec = (0..notes) .map(|seed| { let space_id = &space_ids[seed % SPACES]; store::create(&mut connection, draft(seed, space_id), at) diff --git a/src-tauri/benches/corpus_size.rs b/src-tauri/benches/corpus_size.rs new file mode 100644 index 0000000..da0ce86 --- /dev/null +++ b/src-tauri/benches/corpus_size.rs @@ -0,0 +1,84 @@ +//! What a query costs as the corpus grows, and how much of it crosses the bridge. +//! +//! The measurement #21 asks for, kept out of `commands` so that bench's numbers stay +//! comparable across runs and its corpus stays one size. +//! +//! ⚠️ Slow by construction: it seeds three corpora of ~13 kB notes, the largest of them +//! 20 000. Run it on its own. +//! +//! ``` +//! cargo bench --bench corpus_size +//! ``` + +mod corpus; + +use std::hint::black_box; + +use criterion::{BenchmarkId, Criterion, criterion_group, criterion_main}; + +use devbox_lib::notes::store; +use devbox_lib::notes::view::{NoteFilter, NotesQuery}; + +use corpus::{Corpus, build_of, now, run_query}; + +/// The sizes the ticket names. 20 000 notes of ~13 kB is ~260 MB of body — well past +/// what a person writes, which is the point: the shape of the curve is what decides. +const SIZES: [usize; 3] = [1_000, 5_000, 20_000]; + +fn query(search: &str) -> NotesQuery { + NotesQuery { + space_id: None, + search: search.to_string(), + filter: NoteFilter::All, + tags: Vec::new(), + languages: Vec::new(), + now: now(), + tz_offset_minutes: -120, + pinned_first: true, + } +} + +/// Everything the query does before the view: the SQL, the rows, and opening every +/// sealed value. No `view::build`, no serde. +fn read_only(corpus: &mut Corpus, request: &NotesQuery) -> usize { + let (notes, _) = store::fetch(&mut corpus.connection, request).expect("a view"); + + notes.len() +} + +fn query_by_corpus_size(c: &mut Criterion) { + let mut group = c.benchmark_group("query by corpus size"); + // A pass over 20 000 notes is long enough that a hundred samples would take an hour. + group.sample_size(10); + + for size in SIZES { + let mut corpus = build_of(size); + + for (name, request) in [("open", query("")), ("search", query("étape"))] { + // Printed rather than measured: the payload is a size, not a duration, and it + // is half of what the ticket is about. + let payload = run_query(&mut corpus, &request).len(); + println!("{size} notes, {name}: {payload} bytes on the wire"); + + group.bench_with_input(BenchmarkId::new(name, size), &size, |b, _| { + b.iter(|| black_box(run_query(&mut corpus, &request))); + }); + + // ⚠️ The same query stopped before the view is built and serialised: the gap + // between the two is what a lighter wire shape could take away, and what a + // LIMIT could not. + group.bench_with_input( + BenchmarkId::new(format!("{name}, read only"), size), + &size, + |b, _| { + b.iter(|| black_box(read_only(&mut corpus, &request))); + }, + ); + } + } + + group.finish(); +} + +criterion_group!(benches, query_by_corpus_size); +criterion_main!(benches);