From 12eccc9879a9efef85a794087c80cde991114df9 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 4 Oct 2026 16:28:09 +0000 Subject: [PATCH 01/25] Initial commit with task details Adding .gitkeep for PR creation (default mode). This file will be removed when the task is complete. Issue: https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/issues/107 --- .gitkeep | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitkeep diff --git a/.gitkeep b/.gitkeep new file mode 100644 index 0000000..aa23f94 --- /dev/null +++ b/.gitkeep @@ -0,0 +1 @@ +# .gitkeep file auto-generated at 2026-10-04T16:28:09.171Z for PR creation at branch issue-107-bce58c21006b for issue https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/issues/107 \ No newline at end of file From 229ae26adab9780393ac24f8b0b09ebca1bd6a25 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 4 Oct 2026 17:05:53 +0000 Subject: [PATCH 02/25] Rewrite Rust benchmarks on stable doublets 0.5.0 and rusqlite 0.40 - One CLI runs links or objects benchmarks for 32 or 64 bit ids at any size - SQLite stores links(id, from, to) with (from, to) and (to, from) indexes - Doublets objects mirror Platform.Data.Doublets.Sequences, with and without a sequences cache - Every timed batch is validated by count and order-sensitive checksum - Work around a doublets 0.5.0 split store bug with links updated to reference themselves --- .gitignore | 3 + experiments/split_store_delete/Cargo.lock | 274 ++++++++ experiments/split_store_delete/Cargo.toml | 11 + experiments/split_store_delete/src/main.rs | 81 +++ rust/Cargo.lock | 707 ++++++--------------- rust/Cargo.toml | 40 +- rust/benches/bench.rs | 29 - rust/benches/benchmarks/each.rs | 51 -- rust/benches/benchmarks/links.rs | 57 -- rust/benches/benchmarks/mod.rs | 157 ----- rust/benches/benchmarks/objects.rs | 44 -- rust/examples/inspect_storage.rs | 76 --- rust/out.py | 98 --- rust/results.md | 19 - rust/rust-toolchain.toml | 3 - rust/rustfmt.toml | 2 +- rust/src/benched/doublets_benched.rs | 190 ------ rust/src/benched/mod.rs | 27 - rust/src/benched/sqlite_benched.rs | 93 --- rust/src/dataset.rs | 99 +++ rust/src/doublets_impl.rs | 492 -------------- rust/src/exclusive.rs | 46 -- rust/src/fork.rs | 40 -- rust/src/harness.rs | 519 +++++++++++++++ rust/src/lib.rs | 155 +---- rust/src/links.rs | 253 ++++++++ rust/src/main.rs | 92 +++ rust/src/objects.rs | 449 +++++++++---- rust/src/sqlite_impl.rs | 339 ---------- rust/test_out.py | 103 --- rust/tests/storages.rs | 291 ++++----- 31 files changed, 1979 insertions(+), 2861 deletions(-) create mode 100644 experiments/split_store_delete/Cargo.lock create mode 100644 experiments/split_store_delete/Cargo.toml create mode 100644 experiments/split_store_delete/src/main.rs delete mode 100644 rust/benches/bench.rs delete mode 100644 rust/benches/benchmarks/each.rs delete mode 100644 rust/benches/benchmarks/links.rs delete mode 100644 rust/benches/benchmarks/mod.rs delete mode 100644 rust/benches/benchmarks/objects.rs delete mode 100644 rust/examples/inspect_storage.rs delete mode 100644 rust/out.py delete mode 100644 rust/results.md delete mode 100644 rust/rust-toolchain.toml delete mode 100644 rust/src/benched/doublets_benched.rs delete mode 100644 rust/src/benched/mod.rs delete mode 100644 rust/src/benched/sqlite_benched.rs create mode 100644 rust/src/dataset.rs delete mode 100644 rust/src/doublets_impl.rs delete mode 100644 rust/src/exclusive.rs delete mode 100644 rust/src/fork.rs create mode 100644 rust/src/harness.rs create mode 100644 rust/src/links.rs create mode 100644 rust/src/main.rs delete mode 100644 rust/src/sqlite_impl.rs delete mode 100644 rust/test_out.py diff --git a/.gitignore b/.gitignore index 6377d22..4bdaf41 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ __pycache__/ *.swp *~ .DS_Store + +# Rust build output +target/ diff --git a/experiments/split_store_delete/Cargo.lock b/experiments/split_store_delete/Cargo.lock new file mode 100644 index 0000000..95de11d --- /dev/null +++ b/experiments/split_store_delete/Cargo.lock @@ -0,0 +1,274 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c880a97d28a3681c0267bd29cff89621202715b065127cd445fa0f0fe0aa2880" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "beef" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "doublets" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677be8ee593349204e8e7cbc2398d4b98fb95fd1386f3a7eaf286860a5788c8f" +dependencies = [ + "cfg-if", + "leak_slice", + "platform-data", + "platform-mem", + "platform-num", + "platform-trees", + "tap", + "thiserror", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "leak_slice" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecf3387da9fb41906394e1306ddd3cd26dd9b7177af11c19b45b364b743aed26" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "platform-data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6782bc71345465116de96d250a36dcf49336010a2320d958d12a5d4390186c90" +dependencies = [ + "beef", + "platform-num", + "thiserror", +] + +[[package]] +name = "platform-mem" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27cff7c92440ac926c8c91ea3151db6e52a262f602d0c157f254e422fc15b12" +dependencies = [ + "allocator-api2", + "memmap2", + "tempfile", + "thiserror", +] + +[[package]] +name = "platform-num" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c4ca8e18138b1c90ad802aff931f946a0e6bd760c35af30f1ff2489489ab54a" +dependencies = [ + "num-traits", +] + +[[package]] +name = "platform-trees" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40e25a531617fa762c8505826c930f6c1cfcc226f63dea09882b56ae0b8ed078" +dependencies = [ + "platform-num", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "split_store_delete" +version = "0.1.0" +dependencies = [ + "doublets", +] + +[[package]] +name = "syn" +version = "3.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thiserror" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] diff --git a/experiments/split_store_delete/Cargo.toml b/experiments/split_store_delete/Cargo.toml new file mode 100644 index 0000000..996216e --- /dev/null +++ b/experiments/split_store_delete/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "split_store_delete" +version = "0.1.0" +edition = "2024" +publish = false + +[dependencies] +doublets = "0.5.0" + +[workspace] + diff --git a/experiments/split_store_delete/src/main.rs b/experiments/split_store_delete/src/main.rs new file mode 100644 index 0000000..d440380 --- /dev/null +++ b/experiments/split_store_delete/src/main.rs @@ -0,0 +1,81 @@ +//! Minimal reproductions of a `doublets` 0.5.0 split store bug. +//! +//! `split::Store::update_links` detaches the link and only then asks `is_virtual(new_source)`, +//! which is `is_unused`: `size_as_target == 0 && source != 0`. Right after the detach that is true +//! for the link itself, so updating a link to reference itself attaches it to the external +//! (header) trees. A later delete looks for it in its own internal trees and panics with +//! "index out of bounds" in `InternalSourcesRecursionlessTree::detach_core`. +//! Creating such a link works, because a fresh link has `source == 0`. +//! +//! Usage: `cargo run --release -- `, exit code 101 is the panic. +use doublets::{Doublets, mem::Global, split, unit}; + +type Step = (&'static str, u32, u32, u32); + +fn run>(mut store: D, steps: &[Step]) -> u32 { + for &(operation, id, from, to) in steps { + match operation { + "create" => assert_eq!(store.create_link(from, to).unwrap(), id), + "update" => drop(store.update(id, from, to).unwrap()), + _ => drop(store.delete(id).unwrap()), + } + } + store.count() +} + +fn main() { + let scenarios: [(&str, Vec); 6] = [ + ("point: update to itself, delete", vec![("create", 1, 1, 1), ("update", 1, 1, 1), ("delete", 1, 0, 0)]), + ("point: delete", vec![("create", 1, 1, 1), ("delete", 1, 0, 0)]), + ( + "swap a link that references itself, delete", + vec![("create", 1, 1, 1), ("create", 2, 1, 2), ("update", 2, 2, 1), ("delete", 2, 0, 0), ("delete", 1, 0, 0)], + ), + ( + "swap a link that references itself, no delete", + vec![("create", 1, 1, 1), ("create", 2, 1, 2), ("update", 2, 2, 1)], + ), + ( + "swap links without self references, delete", + vec![ + ("create", 1, 1, 1), + ("create", 2, 2, 2), + ("create", 3, 1, 2), + ("create", 4, 3, 1), + ("update", 3, 2, 1), + ("update", 4, 1, 3), + ("delete", 4, 0, 0), + ("delete", 3, 0, 0), + ("delete", 1, 0, 0), + ], + ), + ( + "create a link that references itself, delete", + vec![("create", 1, 1, 1), ("create", 2, 1, 2), ("create", 3, 3, 1), ("delete", 2, 0, 0), ("delete", 3, 0, 0), ("delete", 1, 0, 0)], + ), + ]; + let kind = std::env::args().nth(1).unwrap_or_default(); + let scenario: usize = std::env::args().nth(2).unwrap().parse().unwrap(); + let (name, steps) = &scenarios[scenario]; + print!("{kind:5} {name:40} "); + let count = match kind.as_str() { + "unit" => run(unit::Store::::new(Global::new()).unwrap(), steps), + "dump" => { + let mut store = split::Store::::new(Global::new(), Global::new()).unwrap(); + for &(operation, id, from, to) in steps { + match operation { + "create" => drop(store.create_link(from, to).unwrap()), + "update" => drop(store.update(id, from, to).unwrap()), + _ => drop(store.delete(id).unwrap()), + } + eprintln!("after {operation} {id} {from} {to}:"); + for link in 1..=3 { + eprintln!(" {link}: {:?} {:?} unused={}", store.get_data_part(link), store.get_index_part(link), store.is_unused(link)); + } + } + store.count() + } + _ => run(split::Store::::new(Global::new(), Global::new()).unwrap(), steps), + }; + println!("ok, count = {count}"); +} diff --git a/rust/Cargo.lock b/rust/Cargo.lock index cdc7556..1e263b6 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1,43 +1,18 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. -version = 3 +version = 4 [[package]] -name = "ahash" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" -dependencies = [ - "getrandom", - "once_cell", - "version_check", -] - -[[package]] -name = "aho-corasick" -version = "0.7.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc936419f96fa211c1b9166887b38e5e40b19958e5b895be7c1f93adec7071ac" -dependencies = [ - "memchr", -] - -[[package]] -name = "atty" -version = "0.2.14" +name = "allocator-api2" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" -dependencies = [ - "hermit-abi 0.1.19", - "libc", - "winapi", -] +checksum = "c880a97d28a3681c0267bd29cff89621202715b065127cd445fa0f0fe0aa2880" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "beef" @@ -47,27 +22,21 @@ checksum = "3a8241f3ebb85c056b509d4327ad0358fbbba6ffb340bf388f26350aeda225b1" [[package]] name = "bitflags" -version = "1.3.2" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bumpalo" -version = "3.11.1" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "572f695136211188308f16ad2ca5c851a712c464060ae6974944458eb83880ba" - -[[package]] -name = "cast" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "cc" -version = "1.2.52" +version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd4932aefd12402b36c60956a4fe0035421f544799057659ff86f923657aada3" +checksum = "f74872d07caf508b30a21f6836e7d7016a2eaf7d9ff4f48deaa58cd8a0407630" dependencies = [ "find-msvc-tools", "shlex", @@ -75,149 +44,41 @@ dependencies = [ [[package]] name = "cfg-if" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" - -[[package]] -name = "clap" -version = "2.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" -dependencies = [ - "bitflags", - "textwrap", - "unicode-width", -] - -[[package]] -name = "criterion" -version = "0.3.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b01d6de93b2b6c65e17c634a26653a29d107b3c98c607c765bf38d041531cd8f" -dependencies = [ - "atty", - "cast", - "clap", - "criterion-plot", - "csv", - "itertools", - "lazy_static", - "num-traits", - "oorandom", - "plotters", - "rayon", - "regex", - "serde", - "serde_cbor", - "serde_derive", - "serde_json", - "tinytemplate", - "walkdir", -] - -[[package]] -name = "criterion-plot" -version = "0.4.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2673cc8207403546f45f5fd319a974b1e6983ad1a3ee7e6041650013be041876" -dependencies = [ - "cast", - "itertools", -] - -[[package]] -name = "crossbeam-channel" -version = "0.5.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-deque" -version = "0.8.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-utils" -version = "0.8.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" - -[[package]] -name = "csv" -version = "1.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "acdc4883a9c96732e4733212c01447ebd805833b7275a73ca3ee080fd77afdaf" -dependencies = [ - "csv-core", - "itoa", - "ryu", - "serde", -] - -[[package]] -name = "csv-core" -version = "0.1.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" -dependencies = [ - "memchr", -] - -[[package]] -name = "delegate" -version = "0.7.0" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d70a2d4995466955a415223acf3c9c934b9ff2339631cdf4ffc893da4bacd717" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" [[package]] name = "doublets" -version = "0.1.0-pre+beta.15" -source = "git+https://github.com/linksplatform/doublets-rs.git?rev=5522d91c536654934b7181829f6efb570fb8bb44#5522d91c536654934b7181829f6efb570fb8bb44" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677be8ee593349204e8e7cbc2398d4b98fb95fd1386f3a7eaf286860a5788c8f" dependencies = [ - "bumpalo", "cfg-if", "leak_slice", "platform-data", "platform-mem", + "platform-num", "platform-trees", "tap", "thiserror", ] [[package]] -name = "either" -version = "1.15.0" +name = "errno" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] [[package]] name = "fallible-iterator" -version = "0.2.0" +version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" [[package]] name = "fallible-streaming-iterator" @@ -227,114 +88,76 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "1.8.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7a407cfaa3385c4ae6b23e84623d48c2798d06e3e6a1878f7f59f17b3f86499" -dependencies = [ - "instant", -] +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.7" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f449e6c6c08c865631d4890cfacf252b3d396c9bcc83adb6623cdb02a8336c41" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] -name = "funty" -version = "2.0.0" +name = "foldhash" +version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" [[package]] name = "getrandom" -version = "0.2.8" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "libc", - "wasi", + "r-efi", ] -[[package]] -name = "half" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b43ede17f21864e81be2fa654110bf1e793774238d86ef8555c37e6519c0403" - [[package]] name = "hashbrown" -version = "0.11.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab5ef0d4909ef3724cc8cce6ccc8572c5c817592e9285f5464f8e86f8bd3726e" -dependencies = [ - "ahash", -] - -[[package]] -name = "hashlink" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7249a3129cbc1ffccd74857f81464a323a152173cdb134e0fd81bc803b29facf" -dependencies = [ - "hashbrown", -] - -[[package]] -name = "hermit-abi" -version = "0.1.19" +version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ - "libc", + "foldhash", ] [[package]] -name = "hermit-abi" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" - -[[package]] -name = "instant" -version = "0.1.13" +name = "hashbrown" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" dependencies = [ - "cfg-if", + "foldhash", ] [[package]] -name = "itertools" -version = "0.10.5" +name = "hashlink" +version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb" dependencies = [ - "either", + "hashbrown 0.17.1", ] [[package]] name = "itoa" -version = "1.0.5" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fad582f4b9e86b6caa621cabeb0963332d92eea04729ab12892c2533951e6440" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "js-sys" -version = "0.3.61" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "445dde2150c55e483f3d8416706b97ec8e8237c307e5b7b4b8dd15e6af2a0730" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ + "cfg-if", "wasm-bindgen", ] -[[package]] -name = "lazy_static" -version = "1.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" - [[package]] name = "leak_slice" version = "0.2.0" @@ -343,15 +166,15 @@ checksum = "ecf3387da9fb41906394e1306ddd3cd26dd9b7177af11c19b45b364b743aed26" [[package]] name = "libc" -version = "0.2.180" +version = "0.2.190" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bcc35a38544a891a5f7c865aca548a982ccb3b8650a5b06d0fd33a10283c56fc" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" [[package]] name = "libsqlite3-sys" -version = "0.24.2" +version = "0.38.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "898745e570c7d0453cc1fbc4a701eb6c662ed54e8fec8b7d14be137ebeeb9d14" +checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8" dependencies = [ "cc", "pkg-config", @@ -359,22 +182,22 @@ dependencies = [ ] [[package]] -name = "log" -version = "0.4.29" +name = "linux-raw-sys" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "memchr" -version = "2.7.6" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.5.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83faa42c0a078c393f6b29d5db232d8be22776a891f8f56e5284faee4a20b327" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -388,228 +211,150 @@ dependencies = [ "autocfg", ] -[[package]] -name = "num_cpus" -version = "1.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" -dependencies = [ - "hermit-abi 0.5.2", - "libc", -] - [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" - -[[package]] -name = "oorandom" -version = "11.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "pkg-config" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "platform-data" -version = "0.1.0-beta.3" -source = "git+https://github.com/linksplatform/doublets-rs.git?rev=5522d91c536654934b7181829f6efb570fb8bb44#5522d91c536654934b7181829f6efb570fb8bb44" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6782bc71345465116de96d250a36dcf49336010a2320d958d12a5d4390186c90" dependencies = [ "beef", - "funty", + "platform-num", "thiserror", ] [[package]] name = "platform-mem" -version = "0.1.0-pre+beta.2" -source = "git+https://github.com/linksplatform/doublets-rs.git?rev=5522d91c536654934b7181829f6efb570fb8bb44#5522d91c536654934b7181829f6efb570fb8bb44" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f27cff7c92440ac926c8c91ea3151db6e52a262f602d0c157f254e422fc15b12" dependencies = [ - "delegate", + "allocator-api2", "memmap2", - "tap", "tempfile", "thiserror", ] [[package]] -name = "platform-trees" -version = "0.1.0-beta.1" -source = "git+https://github.com/linksplatform/doublets-rs.git?rev=5522d91c536654934b7181829f6efb570fb8bb44#5522d91c536654934b7181829f6efb570fb8bb44" -dependencies = [ - "funty", - "platform-data", -] - -[[package]] -name = "plotters" -version = "0.3.6" +name = "platform-num" +version = "0.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a15b6eccb8484002195a3e44fe65a4ce8e93a625797a063735536fd59cb01cf3" +checksum = "2c4ca8e18138b1c90ad802aff931f946a0e6bd760c35af30f1ff2489489ab54a" dependencies = [ "num-traits", - "plotters-backend", - "plotters-svg", - "wasm-bindgen", - "web-sys", ] [[package]] -name = "plotters-backend" -version = "0.3.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" - -[[package]] -name = "plotters-svg" -version = "0.3.7" +name = "platform-trees" +version = "0.3.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +checksum = "40e25a531617fa762c8505826c930f6c1cfcc226f63dea09882b56ae0b8ed078" dependencies = [ - "plotters-backend", + "platform-num", ] [[package]] name = "proc-macro2" -version = "1.0.36" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c7342d5883fbccae1cc37a2353b09c87c9b0f3afd73f5fb9bba687a1f733b029" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ - "unicode-xid", + "unicode-ident", ] [[package]] name = "quote" -version = "1.0.15" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "864d3e96a899863136fc6e99f3d7cae289dafe43bf2c5ac19b70df7210c0a145" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] [[package]] -name = "rayon" -version = "1.6.1" +name = "r-efi" +version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db3a213adf02b3bcfd2d3846bb41cb22857d131789e01df434fb7e7bc0759b7" -dependencies = [ - "either", - "rayon-core", -] +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" [[package]] -name = "rayon-core" -version = "1.10.2" +name = "rsqlite-vfs" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "356a0625f1954f730c0201cdab48611198dc6ce21f4acff55089b5a78e6e835b" +checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c" dependencies = [ - "crossbeam-channel", - "crossbeam-deque", - "crossbeam-utils", - "num_cpus", -] - -[[package]] -name = "redox_syscall" -version = "0.2.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb5a58c1855b4b6819d59012155603f0b22ad30cad752600aadfcb695265519a" -dependencies = [ - "bitflags", -] - -[[package]] -name = "regex" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48aaa5748ba571fb95cd2c85c09f629215d3a6ece942baa100950af03a34f733" -dependencies = [ - "aho-corasick", - "memchr", - "regex-syntax", -] - -[[package]] -name = "regex-automata" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6c230d73fb8d8c1b9c0b3135c5142a8acee3a0558fb8db5cf1cb65f8d7862132" -dependencies = [ - "regex-syntax", -] - -[[package]] -name = "regex-syntax" -version = "0.6.28" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "456c603be3e8d448b072f410900c09faf164fbce2d480456f50eea6e25f9c848" - -[[package]] -name = "remove_dir_all" -version = "0.5.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acd125665422973a33ac9d3dd2df85edad0f4ae9b00dafb1a05e43a9f5ef8e7" -dependencies = [ - "winapi", + "hashbrown 0.16.1", + "thiserror", ] [[package]] name = "rusqlite" -version = "0.27.0" +version = "0.40.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85127183a999f7db96d1a976a309eebbfb6ea3b0b400ddd8340190129de6eb7a" +checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3" dependencies = [ "bitflags", "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", - "memchr", "smallvec", + "sqlite-wasm-rs", ] [[package]] -name = "ryu" -version = "1.0.12" +name = "rustix" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b4b9743ed687d4b4bcedf9ff5eaa7398495ae14e61cba0a295704edbc7decde" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] [[package]] -name = "same-file" -version = "1.0.6" +name = "rustversion" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" -dependencies = [ - "winapi-util", -] +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "serde" -version = "1.0.185" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be9b6f69f1dfd54c3b568ffa45c310d6973a5e5148fd40cf515acaf38cf5bc31" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", +] [[package]] -name = "serde_cbor" -version = "0.11.2" +name = "serde_core" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2bef2ebfde456fb76bbcf9f59315333decc4fda0b2b44b420243c11e0f5ec1f5" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ - "half", - "serde", + "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.136" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08597e7152fcd306f41838ed3e37be9eaeed2b61c42e2117266a554fab4662f9" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", @@ -618,65 +363,59 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.109" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cb0652c533506ad7a2e353cce269330d6afd8bdfb6d75e0ace5b35aacbd7b9e9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", - "ryu", + "memchr", "serde", + "serde_core", + "zmij", ] [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "sqlite-vs-doublets" -version = "0.1.0" +version = "0.2.0" dependencies = [ - "bumpalo", - "criterion", "doublets", - "fastrand", - "getrandom", - "itoa", - "once_cell", - "proc-macro2", - "quote", - "rayon", - "rayon-core", - "regex", - "regex-automata", - "regex-syntax", "rusqlite", - "ryu", - "same-file", - "serde_derive", - "syn", - "tempfile", - "unicode-width", - "walkdir", - "winapi-util", + "serde_json", +] + +[[package]] +name = "sqlite-wasm-rs" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" +dependencies = [ + "cc", + "js-sys", + "rsqlite-vfs", + "wasm-bindgen", ] [[package]] name = "syn" -version = "1.0.86" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8a65b3f4ffa0092e9887669db0eae07941f023991ab58ea44da8fe8e2d511c6b" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", - "unicode-xid", + "unicode-ident", ] [[package]] @@ -687,41 +426,31 @@ checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" [[package]] name = "tempfile" -version = "3.3.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cdb1ef4eaeeaddc8fbd371e5017057064af0911902ef36b39801f67cc6d79e4" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "cfg-if", "fastrand", - "libc", - "redox_syscall", - "remove_dir_all", - "winapi", -] - -[[package]] -name = "textwrap" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" -dependencies = [ - "unicode-width", + "getrandom", + "once_cell", + "rustix", + "windows-sys", ] [[package]] name = "thiserror" -version = "1.0.39" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5ab016db510546d856297882807df8da66a16fb8c4101cb8b30054b0d5b2d9c" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "1.0.39" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5420d42e90af0c38c3290abcca25b9b3bdf379fc9f55c528f53a269d9c9a267e" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", @@ -729,26 +458,10 @@ dependencies = [ ] [[package]] -name = "tinytemplate" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" -dependencies = [ - "serde", - "serde_json", -] - -[[package]] -name = "unicode-width" -version = "0.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c0edd1e5b14653f783770bce4a4dabb4a5108a5370a5f5d8cfe8710c361f6c8b" - -[[package]] -name = "unicode-xid" -version = "0.2.6" +name = "unicode-ident" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "vcpkg" @@ -756,59 +469,24 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - -[[package]] -name = "walkdir" -version = "2.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "808cf2735cd4b6866113f648b791c6adc5714537bc222d9347bb203386ffda56" -dependencies = [ - "same-file", - "winapi", - "winapi-util", -] - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - [[package]] name = "wasm-bindgen" -version = "0.2.84" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "31f8dcbc21f30d9b8f2ea926ecb58f6b91192c17e9d33594b3df58b2007ca53b" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", - "wasm-bindgen-macro", -] - -[[package]] -name = "wasm-bindgen-backend" -version = "0.2.84" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95ce90fd5bcc06af55a641a86428ee4229e44e07033963a2290a8e241607ccb9" -dependencies = [ - "bumpalo", - "log", "once_cell", - "proc-macro2", - "quote", - "syn", + "rustversion", + "wasm-bindgen-macro", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-macro" -version = "0.2.84" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c21f77c0bedc37fd5dc21f897894a5ca01e7bb159884559461862ae90c0b4c5" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -816,60 +494,43 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.84" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2aff81306fcac3c7515ad4e177f521b5c9a15f2b08f4e32d823066102f35a5f6" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ + "bumpalo", "proc-macro2", "quote", "syn", - "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.84" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0046fef7e28c3804e5e38bfa31ea2a0f73905319b677e57ebe37e49358989b5d" - -[[package]] -name = "web-sys" -version = "0.3.61" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e33b99f4b23ba3eec1a53ac264e35a755f00e966e0065077d6027c0f575b0b97" -dependencies = [ - "js-sys", - "wasm-bindgen", -] - -[[package]] -name = "winapi" -version = "0.3.9" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", + "unicode-ident", ] [[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" +name = "windows-link" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] -name = "winapi-util" -version = "0.1.5" +name = "windows-sys" +version = "0.61.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70ec6ce85bb158151cae5e5c87f95a8e97d2c0c4b001223f33a334e3ce5de178" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" dependencies = [ - "winapi", + "windows-link", ] [[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" +name = "zmij" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 463bfc8..f117b96 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -1,42 +1,14 @@ [package] name = "sqlite-vs-doublets" -version = "0.1.0" -edition = "2021" +version = "0.2.0" +edition = "2024" license = "Unlicense" -description = "Benchmark comparing SQLite vs Doublets performance for basic CRUD operations" - -[[bench]] -name = "bench" -harness = false +description = "Compares SQLite and Doublets as storage for links and for objects" [dependencies] -rusqlite = { version = "=0.27.0", features = ["bundled"] } -doublets = { git = "https://github.com/linksplatform/doublets-rs.git", rev = "5522d91c536654934b7181829f6efb570fb8bb44" } -once_cell = "1.14" - -[dev-dependencies] -criterion = { version = "=0.3.6", default-features = false } - -# Pin dependencies for compatibility with nightly-2022-08-22 (rustc 1.65) -serde_derive = "=1.0.136" -proc-macro2 = "=1.0.36" -quote = "=1.0.15" -syn = "=1.0.86" -bumpalo = "=3.11.1" -rayon = "=1.6.1" -rayon-core = "=1.10.2" -itoa = "=1.0.5" -ryu = "=1.0.12" -unicode-width = "=0.1.10" -tempfile = "=3.3.0" -fastrand = "=1.8.0" -getrandom = "=0.2.8" -winapi-util = "=0.1.5" -walkdir = "=2.3.2" -same-file = "=1.0.6" -regex = "=1.7.1" -regex-automata = "=0.1.10" -regex-syntax = "=0.6.28" +doublets = "0.5.0" +rusqlite = { version = "0.40.2", features = ["bundled", "fallible_uint"] } +serde_json = "1.0" [profile.release] lto = true diff --git a/rust/benches/bench.rs b/rust/benches/bench.rs deleted file mode 100644 index b5e0f47..0000000 --- a/rust/benches/bench.rs +++ /dev/null @@ -1,29 +0,0 @@ -//! SQLite versus Doublets benchmark suite. -//! -//! Preparation is performed by `benchmarks::measure` outside the measured -//! region. Every operation is run against all six storage variants. - -#![feature(allocator_api)] - -mod benchmarks; - -use criterion::{criterion_group, criterion_main}; - -criterion_group! { - name = benches; - config = benchmarks::configure_criterion(); - targets = - benchmarks::links::create, - benchmarks::links::update, - benchmarks::links::delete, - benchmarks::each::all, - benchmarks::each::identity, - benchmarks::each::concrete, - benchmarks::each::outgoing, - benchmarks::each::incoming, - benchmarks::objects::create, - benchmarks::objects::read, - benchmarks::objects::delete, -} - -criterion_main!(benches); diff --git a/rust/benches/benchmarks/each.rs b/rust/benches/benchmarks/each.rs deleted file mode 100644 index 626ac20..0000000 --- a/rust/benches/benchmarks/each.rs +++ /dev/null @@ -1,51 +0,0 @@ -//! Link enumeration and restriction benchmarks. - -use criterion::Criterion; -use sqlite_vs_doublets::{ - fill_background, fill_benchmarked, Links, BACKGROUND_LINK_COUNT, BENCHMARK_LINK_COUNT, -}; - -macro_rules! query_benchmark { - ($function:ident, $group:literal, $query:expr) => { - pub fn $function(criterion: &mut Criterion) { - benchmark_operation!( - criterion, - $group, - *BENCHMARK_LINK_COUNT, - |storage| { - let background = fill_background(storage, *BACKGROUND_LINK_COUNT); - fill_benchmarked(storage, &background, *BENCHMARK_LINK_COUNT) - }, - $query, - ); - } - }; -} - -query_benchmark!(all, "query_all", |storage, _created| { - criterion::black_box(storage.query_all()); -}); - -query_benchmark!(identity, "query_by_id", |storage, created| { - for link in created { - criterion::black_box(storage.query_by_id(link.id)); - } -}); - -query_benchmark!(concrete, "query_by_source_target", |storage, created| { - for link in created { - criterion::black_box(storage.query_by_source_target(link.source, link.target)); - } -}); - -query_benchmark!(outgoing, "query_by_source", |storage, created| { - for link in created { - criterion::black_box(storage.query_by_source(link.source)); - } -}); - -query_benchmark!(incoming, "query_by_target", |storage, created| { - for link in created { - criterion::black_box(storage.query_by_target(link.target)); - } -}); diff --git a/rust/benches/benchmarks/links.rs b/rust/benches/benchmarks/links.rs deleted file mode 100644 index 6fe6815..0000000 --- a/rust/benches/benchmarks/links.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Create, update and delete benchmarks. - -use criterion::Criterion; -use sqlite_vs_doublets::{ - fill_background, fill_benchmarked, Links, BACKGROUND_LINK_COUNT, BENCHMARK_LINK_COUNT, -}; - -pub fn create(criterion: &mut Criterion) { - benchmark_operation!( - criterion, - "create", - *BENCHMARK_LINK_COUNT, - |storage| fill_background(storage, *BACKGROUND_LINK_COUNT), - |storage, background| { - let created = fill_benchmarked(storage, &background, *BENCHMARK_LINK_COUNT); - criterion::black_box(created); - }, - ); -} - -pub fn update(criterion: &mut Criterion) { - benchmark_operation!( - criterion, - "update", - *BENCHMARK_LINK_COUNT, - |storage| { - let background = fill_background(storage, *BACKGROUND_LINK_COUNT); - let created = fill_benchmarked(storage, &background, *BENCHMARK_LINK_COUNT); - let update_sources = fill_background(storage, *BENCHMARK_LINK_COUNT); - (background, created, update_sources) - }, - |storage, (background, created, update_sources)| { - for (index, link) in created.into_iter().enumerate() { - // A dedicated source makes the new doublet unique. Updating to - // an existing pair is not a valid Doublets operation. - storage.update(link.id, update_sources[index], background[0]); - } - }, - ); -} - -pub fn delete(criterion: &mut Criterion) { - benchmark_operation!( - criterion, - "delete", - *BENCHMARK_LINK_COUNT, - |storage| { - let background = fill_background(storage, *BACKGROUND_LINK_COUNT); - fill_benchmarked(storage, &background, *BENCHMARK_LINK_COUNT) - }, - |storage, created| { - for link in created { - storage.delete(link.id); - } - }, - ); -} diff --git a/rust/benches/benchmarks/mod.rs b/rust/benches/benchmarks/mod.rs deleted file mode 100644 index e884829..0000000 --- a/rust/benches/benchmarks/mod.rs +++ /dev/null @@ -1,157 +0,0 @@ -//! Shared infrastructure of the benchmark suite. -//! -//! Every operation is measured on the same six subjects, so the modules below -//! only describe *what* is prepared and *what* is measured, and the -//! [`benchmark_operation`] macro takes care of running it everywhere: -//! -//! | Subject | Database | Memory | -//! |-------------------------------|----------|---------------| -//! | `SQLite_Memory` | SQLite | volatile | -//! | `SQLite_File` | SQLite | non-volatile | -//! | `Doublets_United_Volatile` | Doublets | volatile | -//! | `Doublets_United_NonVolatile` | Doublets | non-volatile | -//! | `Doublets_Split_Volatile` | Doublets | volatile | -//! | `Doublets_Split_NonVolatile` | Doublets | non-volatile | -//! -//! The data of an iteration is prepared *outside* of the measured region with -//! [`Criterion::iter_custom`](criterion::Bencher::iter_custom), so that the -//! reported time is the time of the operation itself and not the time of -//! filling the storage. - -use criterion::{Bencher, Criterion}; -use sqlite_vs_doublets::{Benched, Links, Objects}; -use std::{ - env, - ops::DerefMut, - time::{Duration, Instant}, -}; - -/// Prepares the storage of every iteration and measures `operation` on it. -/// -/// `prepare` runs on an empty (forked) storage and its result is handed to -/// `operation`, which is the only part of the iteration that is timed. Dropping -/// the fork empties the storage again for the next iteration. -pub fn measure(bencher: &mut Bencher<'_>, mut prepare: P, mut operation: O) -where - B: Benched + DerefMut, - B::Target: Links + Objects + Sized, - P: FnMut(&mut B::Target) -> S, - O: FnMut(&mut B::Target, S), -{ - let mut benched = B::setup(()); - bencher.iter_custom(|iterations| { - let mut elapsed = Duration::ZERO; - for _ in 0..iterations { - let mut fork = benched.fork(); - let storage = &mut **fork; - let prepared = prepare(storage); - - let start = Instant::now(); - operation(storage, prepared); - elapsed += start.elapsed(); - } - elapsed - }); -} - -/// Measures one operation on one subject. -macro_rules! benchmark_subject { - ($group:expr, $count:expr, $name:literal, $subject:ty, $prepare:expr, $operation:expr) => { - $group.bench_with_input( - criterion::BenchmarkId::new($name, $count), - &$count, - |bencher, _| { - crate::benchmarks::measure::<$subject, _, _, _>(bencher, $prepare, $operation) - }, - ); - }; -} - -/// Measures one operation on every subject of the comparison. -/// -/// `$prepare` and `$operation` are repeated for each subject, so that both -/// closures are inferred against the storage type of that subject. -macro_rules! benchmark_operation { - ($criterion:expr, $name:literal, $count:expr, $prepare:expr, $operation:expr $(,)?) => {{ - let count = $count; - let mut group = $criterion.benchmark_group($name); - group.throughput(criterion::Throughput::Elements(count as u64)); - benchmark_subject!( - group, - count, - "SQLite_Memory", - sqlite_vs_doublets::benched::SqliteMemoryBenched, - $prepare, - $operation - ); - benchmark_subject!( - group, - count, - "SQLite_File", - sqlite_vs_doublets::benched::SqliteFileBenched, - $prepare, - $operation - ); - benchmark_subject!( - group, - count, - "Doublets_United_Volatile", - sqlite_vs_doublets::benched::DoubletsUnitedVolatileBenched, - $prepare, - $operation - ); - benchmark_subject!( - group, - count, - "Doublets_United_NonVolatile", - sqlite_vs_doublets::benched::DoubletsUnitedNonVolatileBenched, - $prepare, - $operation - ); - benchmark_subject!( - group, - count, - "Doublets_Split_Volatile", - sqlite_vs_doublets::benched::DoubletsSplitVolatileBenched, - $prepare, - $operation - ); - benchmark_subject!( - group, - count, - "Doublets_Split_NonVolatile", - sqlite_vs_doublets::benched::DoubletsSplitNonVolatileBenched, - $prepare, - $operation - ); - group.finish(); - }}; -} - -pub mod each; -pub mod links; -pub mod objects; - -fn env_value(name: &str, default: T) -> T { - env::var(name) - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(default) -} - -/// Criterion configuration of the suite. -/// -/// The defaults keep a pull request run short; the full run of the `main` -/// branch raises them through the environment. -pub fn configure_criterion() -> Criterion { - Criterion::default() - .sample_size(env_value("BENCHMARK_SAMPLE_SIZE", 10usize)) - .measurement_time(Duration::from_secs(env_value( - "BENCHMARK_MEASUREMENT_SECONDS", - 1u64, - ))) - .warm_up_time(Duration::from_millis(env_value( - "BENCHMARK_WARM_UP_MILLISECONDS", - 500u64, - ))) -} diff --git a/rust/benches/benchmarks/objects.rs b/rust/benches/benchmarks/objects.rs deleted file mode 100644 index c3b29a8..0000000 --- a/rust/benches/benchmarks/objects.rs +++ /dev/null @@ -1,44 +0,0 @@ -//! Object-like (blog post) benchmarks. - -use criterion::Criterion; -use sqlite_vs_doublets::{generate_posts, Objects, BENCHMARK_OBJECT_COUNT}; - -pub fn create(criterion: &mut Criterion) { - benchmark_operation!( - criterion, - "objects_create", - *BENCHMARK_OBJECT_COUNT, - |_storage| generate_posts(*BENCHMARK_OBJECT_COUNT), - |storage, posts| { - criterion::black_box(storage.create_posts(&posts)); - }, - ); -} - -pub fn read(criterion: &mut Criterion) { - benchmark_operation!( - criterion, - "objects_read", - *BENCHMARK_OBJECT_COUNT, - |storage| { - let posts = generate_posts(*BENCHMARK_OBJECT_COUNT); - storage.create_posts(&posts); - }, - |storage, _prepared| { - criterion::black_box(storage.read_posts()); - }, - ); -} - -pub fn delete(criterion: &mut Criterion) { - benchmark_operation!( - criterion, - "objects_delete", - *BENCHMARK_OBJECT_COUNT, - |storage| { - let posts = generate_posts(*BENCHMARK_OBJECT_COUNT); - storage.create_posts(&posts); - }, - |storage, _prepared| storage.delete_posts(), - ); -} diff --git a/rust/examples/inspect_storage.rs b/rust/examples/inspect_storage.rs deleted file mode 100644 index 0e87403..0000000 --- a/rust/examples/inspect_storage.rs +++ /dev/null @@ -1,76 +0,0 @@ -//! Prints what every benchmarked storage contains after a benchmark iteration. -//! -//! Useful to check by hand that all six subjects of the benchmarks behave the -//! same way — the assumption the whole comparison is based on. -//! -//! ```text -//! cargo run --release --example inspect_storage -//! ``` - -#![feature(allocator_api)] - -use sqlite_vs_doublets::benched::{ - DoubletsSplitNonVolatileBenched, DoubletsSplitVolatileBenched, - DoubletsUnitedNonVolatileBenched, DoubletsUnitedVolatileBenched, SqliteFileBenched, - SqliteMemoryBenched, -}; -use sqlite_vs_doublets::{ - fill_background, fill_benchmarked, generate_posts, Benched, Links, Objects, -}; -use std::ops::DerefMut; - -const BACKGROUND: usize = 100; -const BENCHMARKED: usize = 100; -const POSTS: usize = 10; - -fn inspect(name: &str, subject: &mut B) -where - B: Benched + DerefMut, - B::Target: Links + Objects + Sized, -{ - let mut fork = subject.fork(); - let storage = &mut **fork; - - let background = fill_background(storage, BACKGROUND); - let created = fill_benchmarked(storage, &background, BENCHMARKED); - let sample = created[created.len() / 2]; - let posts = generate_posts(POSTS); - storage.create_posts(&posts); - - println!("{name}:"); - println!(" links {}", storage.count()); - println!( - " by identity {:?}", - storage.query_by_id(sample.id) - ); - let outgoing = storage.query_by_source(sample.source).len(); - println!(" outgoing of the sample {outgoing}"); - let incoming = storage.query_by_target(sample.target).len(); - println!(" incoming of the sample {incoming}"); - println!(" blog posts {}", storage.count_posts()); - println!( - " first blog post {:?}", - storage.read_posts().first() - ); -} - -fn main() { - inspect("SQLite_Memory", &mut SqliteMemoryBenched::setup(())); - inspect("SQLite_File", &mut SqliteFileBenched::setup(())); - inspect( - "Doublets_United_Volatile", - &mut DoubletsUnitedVolatileBenched::setup(()), - ); - inspect( - "Doublets_United_NonVolatile", - &mut DoubletsUnitedNonVolatileBenched::setup(()), - ); - inspect( - "Doublets_Split_Volatile", - &mut DoubletsSplitVolatileBenched::setup(()), - ); - inspect( - "Doublets_Split_NonVolatile", - &mut DoubletsSplitNonVolatileBenched::setup(()), - ); -} diff --git a/rust/out.py b/rust/out.py deleted file mode 100644 index 440e743..0000000 --- a/rust/out.py +++ /dev/null @@ -1,98 +0,0 @@ -#!/usr/bin/env python3 -"""Parse Criterion bencher output and publish the Rust benchmark report.""" - -import argparse -import os -import re -import sys - -REPOSITORY_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.insert(0, os.path.join(REPOSITORY_ROOT, "scripts")) - -import benchmark_report as report # noqa: E402 - - -# Criterion writes the beginning and end of a bencher record separately. Error -# text can therefore occur between ``test ...`` and ``bench: ...``. Stop at the -# next test record so an aborted benchmark cannot borrow its successor's value. -BENCHER_PATTERN = re.compile( - r"test\s+(\w+)/(\w+)/(\d+)\s+\.\.\.\s*" - r"(?:(?!\btest\s)[\s\S])*?" - r"bench:\s+([\d,]+)\s+ns/iter" -) - - -def parse_text(content): - """Parse bencher text into ``{operation: {variant: ns_per_iteration}}``.""" - results = report.empty_results() - for match in BENCHER_PATTERN.finditer(content): - operation, variant, _size, nanoseconds = match.groups() - if operation in results: - results[operation][variant] = int(nanoseconds.replace(",", "")) - return results - - -def parse_results(path): - if not os.path.exists(path): - return report.empty_results() - with open(path, "r", encoding="utf-8") as handle: - return parse_text(handle.read()) - - -def parse_args(argv): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("input", nargs="?", default="out.txt") - parser.add_argument("--results", default="results.md") - parser.add_argument("--readme", action="append", default=[]) - parser.add_argument("--docs-dir") - parser.add_argument("--output-dir", default="") - return parser.parse_args(argv) - - -def main(argv=None): - args = parse_args(argv if argv is not None else sys.argv[1:]) - results = parse_results(args.input) - if not report.has_any_results(results): - print(f"No benchmark data found in {args.input}") - print(report.report_input_excerpt(args.input)) - return 1 - - missing = report.missing_measurements(results) - if missing: - print("Benchmark output is incomplete; missing:") - print("\n".join(missing)) - return 1 - - provenance = report.build_provenance(language="Rust") - section = report.render_results_section(results, provenance) - section += ( - "\n\n![Rust benchmark comparison](docs/benchmarks/bench_rust.png)" - "\n\n![Rust benchmark comparison, logarithmic scale]" - "(docs/benchmarks/bench_rust_log_scale.png)" - ) - with open(args.results, "w", encoding="utf-8") as handle: - handle.write(section + "\n") - print(report.format_results_table(results)) - print(f"Generated {args.results}") - - charts = report.generate_charts( - results, - "bench_rust", - "Benchmark Comparison: SQLite vs Doublets (Rust)", - args.output_dir, - ) - report.copy_charts(charts, args.docs_dir) - - for readme in args.readme: - changed = report.update_markers( - readme, - section, - report.RUST_START_MARKER, - report.RUST_END_MARKER, - ) - print(f"{'Updated' if changed else 'No changes needed in'} {readme}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/rust/results.md b/rust/results.md deleted file mode 100644 index 2e81ed8..0000000 --- a/rust/results.md +++ /dev/null @@ -1,19 +0,0 @@ -_Generated 2026-09-19 18:46 UTC for Rust by [GitHub Actions run 35459522519](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/runs/35459522519) — 1000 benchmarked links, 3000 background links, 1000 objects._ - -| Operation | Doublets United Volatile | Doublets United NonVolatile | Doublets Split Volatile | Doublets Split NonVolatile | SQLite Memory | SQLite File | -|---------------------|--------------------------|-----------------------------|-------------------------|----------------------------|---------------|-------------| -| Create | 141747 (40.7x faster) | 143659 (40.2x faster) | 69524 (83.0x faster) | 69702 (82.8x faster) | 5772002 | 993908545 | -| Update | 283809 (29.6x faster) | 277912 (30.2x faster) | 63882 (131.3x faster) | 72899 (115.1x faster) | 8390541 | 1067410860 | -| Delete | 168149 (26.8x faster) | 166496 (27.1x faster) | 77711 (58.0x faster) | 78217 (57.6x faster) | 4509130 | 977528491 | -| Each All | 31671 (15.9x faster) | 31638 (15.9x faster) | 34426 (14.6x faster) | 34487 (14.6x faster) | 504031 | 595624 | -| Each Identity | 2132 (1269.3x faster) | 2136 (1266.9x faster) | 2130 (1270.5x faster) | 2127 (1272.3x faster) | 2706180 | 10678850 | -| Each Concrete | 124784 (34.7x faster) | 120544 (36.0x faster) | 45647 (95.0x faster) | 43793 (99.0x faster) | 4335748 | 12668554 | -| Each Outgoing | 210057 (18.1x faster) | 208064 (18.2x faster) | 44112 (86.0x faster) | 45302 (83.7x faster) | 3792959 | 12033519 | -| Each Incoming | 212266 (19.3x faster) | 210393 (19.4x faster) | 50873 (80.4x faster) | 46396 (88.1x faster) | 4089215 | 12477405 | -| Objects Create List | 8759735 (10.5x slower) | 8759735 (10.5x slower) | 5388349 (6.5x slower) | 5436404 (6.5x slower) | 832974 | 2098461 | -| Objects Read List | 5889568 (18.5x slower) | 5585959 (17.6x slower) | 5393697 (17.0x slower) | 4889083 (15.4x slower) | 317868 | 343282 | -| Objects Delete List | 2709133 (75.1x slower) | 2667861 (74.0x slower) | 1083231 (30.0x slower) | 1106546 (30.7x slower) | 36069 | 992614 | - -![Rust benchmark comparison](docs/benchmarks/bench_rust.png) - -![Rust benchmark comparison, logarithmic scale](docs/benchmarks/bench_rust_log_scale.png) diff --git a/rust/rust-toolchain.toml b/rust/rust-toolchain.toml deleted file mode 100644 index 33c06c9..0000000 --- a/rust/rust-toolchain.toml +++ /dev/null @@ -1,3 +0,0 @@ -[toolchain] -channel = "nightly-2022-08-22" -components = ["rustfmt", "clippy"] diff --git a/rust/rustfmt.toml b/rust/rustfmt.toml index 79f8a99..f8d084c 100644 --- a/rust/rustfmt.toml +++ b/rust/rustfmt.toml @@ -1,2 +1,2 @@ -edition = "2021" +edition = "2024" tab_spaces = 4 diff --git a/rust/src/benched/doublets_benched.rs b/rust/src/benched/doublets_benched.rs deleted file mode 100644 index 8777b4e..0000000 --- a/rust/src/benched/doublets_benched.rs +++ /dev/null @@ -1,190 +0,0 @@ -//! Benched implementations for Doublets - -use crate::doublets_impl::{ - create_split_non_volatile, create_split_volatile, create_united_non_volatile, - create_united_volatile, DoubletsLinks, DoubletsSplitNonVolatile, DoubletsSplitVolatile, - DoubletsUnitedNonVolatile, DoubletsUnitedVolatile, -}; -use crate::{temp_path, Benched, Fork, Links}; -use std::path::PathBuf; - -/// Benched implementation for Doublets United (unit) store with volatile storage -pub struct DoubletsUnitedVolatileBenched { - links: DoubletsLinks, -} - -impl Benched for DoubletsUnitedVolatileBenched { - type Builder = (); - - fn setup(_builder: Self::Builder) -> Self { - Self { - links: create_united_volatile(), - } - } - - fn fork(&mut self) -> Fork { - Fork::new(self) - } - - unsafe fn unfork(&mut self) { - self.links.delete_all(); - } -} - -impl std::ops::Deref for DoubletsUnitedVolatileBenched { - type Target = DoubletsLinks; - - fn deref(&self) -> &Self::Target { - &self.links - } -} - -impl std::ops::DerefMut for DoubletsUnitedVolatileBenched { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.links - } -} - -/// Benched implementation for Doublets Split store with volatile storage -pub struct DoubletsSplitVolatileBenched { - links: DoubletsLinks, -} - -impl Benched for DoubletsSplitVolatileBenched { - type Builder = (); - - fn setup(_builder: Self::Builder) -> Self { - Self { - links: create_split_volatile(), - } - } - - fn fork(&mut self) -> Fork { - Fork::new(self) - } - - unsafe fn unfork(&mut self) { - self.links.delete_all(); - } -} - -impl std::ops::Deref for DoubletsSplitVolatileBenched { - type Target = DoubletsLinks; - - fn deref(&self) -> &Self::Target { - &self.links - } -} - -impl std::ops::DerefMut for DoubletsSplitVolatileBenched { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.links - } -} - -/// Benched implementation for Doublets United (unit) store with non-volatile storage -/// -/// The links are stored in a memory-mapped file inside the temporary directory -/// of the machine, which is removed when the benchmark subject is dropped. -pub struct DoubletsUnitedNonVolatileBenched { - links: DoubletsLinks, - path: PathBuf, -} - -impl Benched for DoubletsUnitedNonVolatileBenched { - type Builder = (); - - fn setup(_builder: Self::Builder) -> Self { - let path = temp_path("united.links"); - // Start from an empty store even if a previous run left a file. - let _ = std::fs::remove_file(&path); - Self { - links: create_united_non_volatile(&path), - path, - } - } - - fn fork(&mut self) -> Fork { - Fork::new(self) - } - - unsafe fn unfork(&mut self) { - self.links.delete_all(); - } -} - -impl std::ops::Deref for DoubletsUnitedNonVolatileBenched { - type Target = DoubletsLinks; - - fn deref(&self) -> &Self::Target { - &self.links - } -} - -impl std::ops::DerefMut for DoubletsUnitedNonVolatileBenched { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.links - } -} - -impl Drop for DoubletsUnitedNonVolatileBenched { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.path); - } -} - -/// Benched implementation for Doublets Split store with non-volatile storage -/// -/// The data and the index are stored in two memory-mapped files inside the -/// temporary directory of the machine, both removed when the benchmark subject -/// is dropped. -pub struct DoubletsSplitNonVolatileBenched { - links: DoubletsLinks, - data_path: PathBuf, - index_path: PathBuf, -} - -impl Benched for DoubletsSplitNonVolatileBenched { - type Builder = (); - - fn setup(_builder: Self::Builder) -> Self { - let data_path = temp_path("split.data.links"); - let index_path = temp_path("split.index.links"); - let _ = std::fs::remove_file(&data_path); - let _ = std::fs::remove_file(&index_path); - Self { - links: create_split_non_volatile(&data_path, &index_path), - data_path, - index_path, - } - } - - fn fork(&mut self) -> Fork { - Fork::new(self) - } - - unsafe fn unfork(&mut self) { - self.links.delete_all(); - } -} - -impl std::ops::Deref for DoubletsSplitNonVolatileBenched { - type Target = DoubletsLinks; - - fn deref(&self) -> &Self::Target { - &self.links - } -} - -impl std::ops::DerefMut for DoubletsSplitNonVolatileBenched { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.links - } -} - -impl Drop for DoubletsSplitNonVolatileBenched { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.data_path); - let _ = std::fs::remove_file(&self.index_path); - } -} diff --git a/rust/src/benched/mod.rs b/rust/src/benched/mod.rs deleted file mode 100644 index 3e5be02..0000000 --- a/rust/src/benched/mod.rs +++ /dev/null @@ -1,27 +0,0 @@ -//! Benched trait and implementations for SQLite and Doublets - -mod doublets_benched; -mod sqlite_benched; - -pub use doublets_benched::*; -pub use sqlite_benched::*; - -use crate::Fork; - -/// Trait for types that can be benchmarked with setup/teardown lifecycle -pub trait Benched: Sized { - /// The builder type used to construct this benched type - type Builder; - - /// Setup the benchmark environment - fn setup(builder: Self::Builder) -> Self; - - /// Create a fork for an isolated benchmark iteration - fn fork(&mut self) -> Fork; - - /// Clean up after a benchmark iteration - /// - /// # Safety - /// This should only be called by Fork's Drop implementation - unsafe fn unfork(&mut self); -} diff --git a/rust/src/benched/sqlite_benched.rs b/rust/src/benched/sqlite_benched.rs deleted file mode 100644 index 6e1b8c1..0000000 --- a/rust/src/benched/sqlite_benched.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Benched implementation for SQLite - -use crate::sqlite_impl::SqliteLinks; -use crate::{temp_path, Benched, Fork}; -use std::path::PathBuf; - -/// Benched implementation for SQLite with in-memory database -pub struct SqliteMemoryBenched { - links: SqliteLinks, -} - -impl Benched for SqliteMemoryBenched { - type Builder = (); - - fn setup(_builder: Self::Builder) -> Self { - Self { - links: SqliteLinks::new_memory(), - } - } - - fn fork(&mut self) -> Fork { - Fork::new(self) - } - - unsafe fn unfork(&mut self) { - self.links.reset(); - } -} - -impl std::ops::Deref for SqliteMemoryBenched { - type Target = SqliteLinks; - - fn deref(&self) -> &Self::Target { - &self.links - } -} - -impl std::ops::DerefMut for SqliteMemoryBenched { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.links - } -} - -/// Benched implementation for SQLite with a file based database -/// -/// The file lives in the temporary directory of the machine and is removed when -/// the benchmark subject is dropped. -pub struct SqliteFileBenched { - links: SqliteLinks, - path: PathBuf, -} - -impl Benched for SqliteFileBenched { - type Builder = (); - - fn setup(_builder: Self::Builder) -> Self { - let path = temp_path("sqlite.db"); - // Start from an empty database even if a previous run left a file. - let _ = std::fs::remove_file(&path); - Self { - links: SqliteLinks::new_file(&path), - path, - } - } - - fn fork(&mut self) -> Fork { - Fork::new(self) - } - - unsafe fn unfork(&mut self) { - self.links.reset(); - } -} - -impl std::ops::Deref for SqliteFileBenched { - type Target = SqliteLinks; - - fn deref(&self) -> &Self::Target { - &self.links - } -} - -impl std::ops::DerefMut for SqliteFileBenched { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.links - } -} - -impl Drop for SqliteFileBenched { - fn drop(&mut self) { - let _ = std::fs::remove_file(&self.path); - } -} diff --git a/rust/src/dataset.rs b/rust/src/dataset.rs new file mode 100644 index 0000000..0220ee1 --- /dev/null +++ b/rust/src/dataset.rs @@ -0,0 +1,99 @@ +//! Deterministic data shared by every storage and by the C# implementation. + +const GOLDEN_GAMMA: u64 = 0x9E37_79B9_7F4A_7C15; + +pub fn splitmix64(x: u64) -> u64 { + let mut z = x.wrapping_add(GOLDEN_GAMMA); + z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9); + z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB); + z ^ (z >> 31) +} + +/// The `(from, to)` pair of the link with id `i`: both reference already created ids +/// and `from + to == i + 1`, so every pair is unique. +pub fn link(i: u64) -> (u64, u64) { + let from = 1 + splitmix64(2 * i) % i; + (from, i + 1 - from) +} + +/// Order-sensitive mix of two numbers; summing mixes detects records that are lost, +/// duplicated or associated with the wrong id. +pub fn mix(a: u64, b: u64) -> u64 { + (a.wrapping_mul(GOLDEN_GAMMA) ^ b).wrapping_mul(GOLDEN_GAMMA) +} + +pub fn link_checksum(id: u64, from: u64, to: u64) -> u64 { + mix(mix(id, from), to) +} + +pub fn links_checksum(n: u64, swapped: bool) -> u64 { + (1..=n) + .map(|i| { + let (from, to) = link(i); + if swapped { + link_checksum(i, to, from) + } else { + link_checksum(i, from, to) + } + }) + .fold(0, u64::wrapping_add) +} + +/// Visits `1..=n` once each in a scattered order, so point operations do not hit +/// neighbouring records one after another. +pub fn scattered(n: u64) -> impl Iterator { + let mut stride = GOLDEN_GAMMA % n.max(1); + while gcd(stride, n) != 1 { + stride += 1; + } + (0..n).map(move |k| 1 + (k * stride) % n) +} + +fn gcd(a: u64, b: u64) -> u64 { + if b == 0 { a } else { gcd(b, a % b) } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BlogPost { + pub title: String, + pub content: String, + /// Seconds since the Unix epoch. + pub publication_date: u64, +} + +impl BlogPost { + pub fn checksum(&self) -> u64 { + let bytes = |text: &str| text.bytes().map(u64::from).sum::(); + bytes(&self.title) + bytes(&self.content) + self.publication_date + } +} + +const SEPTEMBER_2020: u64 = 1_600_000_000; +const THIRTY_DAYS: u64 = 30 * 24 * 60 * 60; + +const PARAGRAPHS: [&str; 5] = [ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis malesuada blandit mauris nec bibendum. Phasellus feugiat vehicula mauris et aliquet. Integer et gravida velit, in rutrum leo. Duis pretium, nunc ac posuere porttitor, augue sapien commodo tortor, nec consequat lorem eros ultricies odio. Aliquam varius congue ex nec viverra. Pellentesque eu velit tellus. Donec ac luctus nisi. Curabitur dignissim sodales mauris eu semper. Ut pretium lorem nulla, sit amet auctor arcu placerat vitae. Quisque lacinia dolor et consectetur fermentum. Nam ac orci vitae nulla aliquam tempor ac a nibh. Ut ac tincidunt lacus. Morbi vitae felis lorem.", + "Curabitur tincidunt nibh sit amet finibus dictum. Suspendisse aliquet arcu non rutrum ultrices. Integer ullamcorper mauris sit amet nibh aliquam, et tempor turpis hendrerit. In molestie elit et mauris rutrum, non auctor ligula ultricies. Vestibulum dignissim mauris finibus libero interdum hendrerit. Nunc vitae ipsum porttitor, egestas magna ut, sagittis sem. Donec euismod ac tortor vel porta. Vivamus convallis, ex at vestibulum rutrum, velit purus venenatis metus, sit amet aliquam sapien nibh quis elit. Aenean id neque a orci sodales venenatis. Integer ut orci ligula. Interdum et malesuada fames ac ante ipsum primis in faucibus. Praesent molestie dolor non lobortis ornare. Duis quis nisl sollicitudin, accumsan ante sed, eleifend velit. Maecenas maximus sed ante nec auctor.", + "Donec vitae felis lectus. Aenean velit sapien, porttitor ut feugiat a, consectetur et risus. Proin ac viverra sem. Nullam sagittis ex tortor, eu pellentesque tellus efficitur at. Nunc non egestas leo. Nam sed suscipit neque. Nam sodales vel neque eget eleifend. Vivamus in condimentum elit, consectetur commodo ex. Suspendisse rutrum, sapien efficitur cursus sodales, dolor orci pulvinar mauris, eu fringilla leo ex id leo. Interdum et malesuada fames ac ante ipsum primis in faucibus. Proin rhoncus sapien massa, molestie vestibulum augue hendrerit nec. Aliquam malesuada varius sapien id accumsan. Duis blandit aliquet felis, nec pellentesque lacus tincidunt et. Cras sed ligula vel nisl laoreet sagittis. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Praesent tristique a neque aliquet aliquam.", + "Aliquam sed egestas felis. Maecenas sollicitudin nisl in sapien posuere vulputate. Suspendisse eleifend sem magna, interdum consectetur augue venenatis at. Vivamus ornare orci vel orci sodales maximus. Donec ultricies felis ac nulla fermentum gravida. Phasellus vulputate turpis odio, a varius nibh luctus et. Aliquam tincidunt, metus ut congue porttitor, nibh dui ullamcorper quam, a eleifend elit ipsum sit amet quam. Aenean venenatis mollis interdum. Nunc cursus ex sit amet enim lacinia hendrerit. Nullam at libero iaculis, consectetur velit in, porta sem. Ut mattis ut ex in imperdiet. Maecenas pellentesque sit amet dui eget vehicula. Sed posuere, arcu pretium convallis tincidunt, turpis leo dignissim felis, non euismod diam magna a risus. Suspendisse a arcu nec turpis pulvinar ullamcorper. Nunc iaculis malesuada elit eu pretium. Aenean a neque a sapien tincidunt faucibus.", + "Ut a eleifend augue, eget posuere augue. Proin purus neque, pretium condimentum ipsum ut, venenatis tincidunt nunc. In vitae odio in justo pharetra tincidunt. Maecenas vel tellus interdum, suscipit tellus sit amet, cursus justo. Mauris sollicitudin euismod molestie. Cras eros nisi, molestie vel elementum ut, consequat ac nunc. In consectetur nulla vitae interdum elementum. Praesent faucibus magna et iaculis congue. Curabitur convallis cursus porttitor. Praesent hendrerit justo ut sem convallis sollicitudin eu at odio.", +]; + +pub fn blog_post(i: u64) -> BlogPost { + BlogPost { + title: format!("Blog post {i}"), + content: PARAGRAPHS[(i % 5) as usize].to_owned(), + publication_date: SEPTEMBER_2020 + splitmix64(i) % THIRTY_DAYS, + } +} + +pub fn blog_posts_checksum(n: u64) -> u64 { + (1..=n).map(|i| blog_post(i).checksum()).sum() +} + +/// Like [`blog_posts_checksum`], but also checks that the `i`-th created id returns the `i`-th post. +pub fn blog_posts_by_number_checksum(n: u64) -> u64 { + (1..=n) + .map(|i| mix(i, blog_post(i).checksum())) + .fold(0, u64::wrapping_add) +} diff --git a/rust/src/doublets_impl.rs b/rust/src/doublets_impl.rs deleted file mode 100644 index 458f0e8..0000000 --- a/rust/src/doublets_impl.rs +++ /dev/null @@ -1,492 +0,0 @@ -//! Doublets storage implementation for links - -use crate::{BlogPost, Link, Links, Objects}; -use doublets::{ - mem::{Alloc, FileMapped}, - split::{self, DataPart, IndexPart}, - unit::{self, LinkPart}, - Doublets, DoubletsExt, -}; -use std::{alloc::Global, collections::HashMap, fs::OpenOptions, path::Path}; - -/// Type alias for Doublets united (unit) store with volatile (in-memory) storage. -/// Each link is stored as a contiguous unit containing (id, source, target). -pub type DoubletsUnitedVolatile = unit::Store, Global>>; - -/// Type alias for Doublets split store with volatile (in-memory) storage. -/// Separates data and index into different memory regions for better cache efficiency. -pub type DoubletsSplitVolatile = - split::Store, Global>, Alloc, Global>>; - -/// Type alias for Doublets united (unit) store with non-volatile (file-mapped) storage. -/// Same layout as [`DoubletsUnitedVolatile`], but the links live in a memory-mapped file. -pub type DoubletsUnitedNonVolatile = unit::Store>>; - -/// Type alias for Doublets split store with non-volatile (file-mapped) storage. -/// Same layout as [`DoubletsSplitVolatile`], but data and index live in two memory-mapped files. -pub type DoubletsSplitNonVolatile = - split::Store>, FileMapped>>; - -/// Opens (creating it when missing) a file and maps it as Doublets memory. -pub fn map_file>(path: P) -> FileMapped { - let file = OpenOptions::new() - .read(true) - .write(true) - .create(true) - .open(path) - .expect("Failed to open the links file"); - FileMapped::new(file).expect("Failed to map the links file") -} - -/// Markers of the object like structures stored as links. -/// -/// The markers mirror the ones of the C# `DoubletsDbContext`: a marker for the -/// blog post itself and one marker per property. -#[derive(Debug, Clone, Copy)] -struct Markers { - blog_post: usize, - title: usize, - content: usize, - publication_date_time: usize, - empty_string: usize, -} - -/// State needed to store object like structures as links. -/// -/// Doublets has no notion of a string, so every string is stored as a sequence -/// of links: every distinct character becomes a point link (a "symbol"), and a -/// string becomes a left fold of `(sequence, symbol)` links. Identical strings -/// (and identical prefixes) are therefore stored exactly once, which is how the -/// C# benchmark stores unicode sequences as well. -#[derive(Default)] -struct ObjectsState { - markers: Option, - symbols: HashMap, - characters: HashMap, -} - -impl ObjectsState { - fn clear(&mut self) { - self.markers = None; - self.symbols.clear(); - self.characters.clear(); - } -} - -/// Wrapper to adapt doublets::Doublets to our Links trait -pub struct DoubletsLinks { - store: S, - objects: ObjectsState, -} - -impl DoubletsLinks { - pub fn new(store: S) -> Self { - Self { - store, - objects: ObjectsState::default(), - } - } - - pub fn into_inner(self) -> S { - self.store - } -} - -impl + DoubletsExt> Links for DoubletsLinks { - fn create(&mut self, source: u64, target: u64) -> u64 { - // `create_by` passes its argument as a *restriction* of the query, and - // the memory stores ignore it — it always creates an empty link. The - // source and the target are only assigned by the update that follows, - // which is exactly what `create_link` does (the equivalent of - // `CreateAndUpdate` of the C# `Platform.Data.Doublets`). - self.store - .create_link(source as usize, target as usize) - .expect("Failed to create link") as u64 - } - - fn create_point(&mut self) -> u64 { - self.store.create_point().expect("Failed to create point") as u64 - } - - fn update(&mut self, id: u64, source: u64, target: u64) { - self.store - .update(id as usize, source as usize, target as usize) - .expect("Failed to update link"); - } - - fn delete(&mut self, id: u64) { - self.store - .delete(id as usize) - .expect("Failed to delete link"); - } - - fn delete_all(&mut self) { - // The markers and the symbols are links too, they are gone as well. - self.objects.clear(); - let any = self.store.constants().any; - let ids: Vec = self - .store - .each_iter([any, any, any]) - .map(|link| link.index) - .collect(); - for id in ids { - let _ = self.store.delete(id); - } - } - - fn query_all(&self) -> Vec { - let any = self.store.constants().any; - self.store - .each_iter([any, any, any]) - .map(|link| Link::new(link.index as u64, link.source as u64, link.target as u64)) - .collect() - } - - fn query_by_id(&self, id: u64) -> Option { - self.store - .get_link(id as usize) - .map(|link| Link::new(link.index as u64, link.source as u64, link.target as u64)) - } - - fn query_by_source(&self, source: u64) -> Vec { - let any = self.store.constants().any; - self.store - .each_iter([any, source as usize, any]) - .map(|link| Link::new(link.index as u64, link.source as u64, link.target as u64)) - .collect() - } - - fn query_by_target(&self, target: u64) -> Vec { - let any = self.store.constants().any; - self.store - .each_iter([any, any, target as usize]) - .map(|link| Link::new(link.index as u64, link.source as u64, link.target as u64)) - .collect() - } - - fn query_by_source_target(&self, source: u64, target: u64) -> Vec { - let any = self.store.constants().any; - self.store - .each_iter([any, source as usize, target as usize]) - .map(|link| Link::new(link.index as u64, link.source as u64, link.target as u64)) - .collect() - } - - fn count(&self) -> usize { - self.store.count() - } -} - -impl + DoubletsExt> DoubletsLinks { - /// Returns the markers of the object like structures, creating them on first use. - fn markers(&mut self) -> Markers { - if let Some(markers) = self.objects.markers { - return markers; - } - let markers = Markers { - blog_post: self.point(), - title: self.point(), - content: self.point(), - publication_date_time: self.point(), - empty_string: self.point(), - }; - self.objects.markers = Some(markers); - markers - } - - fn point(&mut self) -> usize { - self.store - .create_point() - .expect("Failed to create a marker") - } - - /// Returns the point link representing `character`, creating it on first use. - fn symbol(&mut self, character: char) -> usize { - if let Some(&symbol) = self.objects.symbols.get(&character) { - return symbol; - } - let symbol = self.point(); - self.objects.symbols.insert(character, symbol); - self.objects.characters.insert(symbol, character); - symbol - } - - /// Stores `text` as a sequence of links and returns the link of the sequence. - fn create_sequence(&mut self, text: &str) -> usize { - let mut sequence: Option = None; - for character in text.chars() { - let symbol = self.symbol(character); - sequence = Some(match sequence { - None => symbol, - Some(previous) => self - .store - .get_or_create(previous, symbol) - .expect("Failed to create a sequence link"), - }); - } - sequence.unwrap_or_else(|| self.markers().empty_string) - } - - /// Restores the string stored as the sequence of links `sequence`. - fn read_sequence(&self, sequence: usize) -> String { - if Some(sequence) == self.objects.markers.map(|markers| markers.empty_string) { - return String::new(); - } - let mut characters = Vec::new(); - let mut current = sequence; - loop { - if let Some(&character) = self.objects.characters.get(¤t) { - characters.push(character); - break; - } - match self.store.get_link(current) { - Some(link) => { - match self.objects.characters.get(&link.target) { - Some(&character) => characters.push(character), - // Not a sequence of this store, stop instead of looping. - None => break, - } - current = link.source; - } - None => break, - } - } - characters.reverse(); - characters.into_iter().collect() - } - - /// Sets the value of a property of an object, as `PropertiesOperator` does in C#. - fn set_property(&mut self, object: usize, marker: usize, value: usize) { - let property = self - .store - .get_or_create(object, marker) - .expect("Failed to create a property link"); - self.store - .get_or_create(property, value) - .expect("Failed to create a property value link"); - } - - /// Returns the value of a property of an object. - fn property(&self, object: usize, marker: usize) -> Option { - let any = self.store.constants().any; - let property = self.store.search(object, marker)?; - self.store - .find([any, property, any]) - .map(|link| link.target) - } -} - -impl + DoubletsExt> Objects for DoubletsLinks { - fn create_posts(&mut self, posts: &[BlogPost]) -> Vec { - let markers = self.markers(); - let mut ids = Vec::with_capacity(posts.len()); - for post in posts { - let title = self.create_sequence(&post.title); - let content = self.create_sequence(&post.content); - let date = self.create_sequence(&post.publication_date_time.to_string()); - - // The blog post link is `(blog post marker, itself)`, exactly like - // `CreateAndUpdate(_blogPostMarker, Constants.Itself)` in C#. - let object = self.store.create().expect("Failed to create a blog post"); - self.store - .update(object, markers.blog_post, object) - .expect("Failed to mark a blog post"); - - self.set_property(object, markers.title, title); - self.set_property(object, markers.content, content); - self.set_property(object, markers.publication_date_time, date); - ids.push(object as u64); - } - ids - } - - fn read_posts(&self) -> Vec { - let markers = match self.objects.markers { - Some(markers) => markers, - None => return Vec::new(), - }; - self.objects_of(markers) - .into_iter() - .map(|object| { - let title = self - .property(object, markers.title) - .map(|value| self.read_sequence(value)) - .unwrap_or_default(); - let content = self - .property(object, markers.content) - .map(|value| self.read_sequence(value)) - .unwrap_or_default(); - let date = self - .property(object, markers.publication_date_time) - .map(|value| self.read_sequence(value)) - .unwrap_or_default(); - BlogPost { - id: object as u64, - title, - content, - publication_date_time: date.parse().unwrap_or_default(), - } - }) - .collect() - } - - fn delete_posts(&mut self) { - let markers = match self.objects.markers { - Some(markers) => markers, - None => return, - }; - let any = self.store.constants().any; - for object in self.objects_of(markers) { - let properties: Vec = self - .store - .each_iter([any, object, any]) - .map(|link| link.index) - .filter(|&index| index != object) - .collect(); - for property in properties { - let values: Vec = self - .store - .each_iter([any, property, any]) - .map(|link| link.index) - .filter(|&index| index != property) - .collect(); - for value in values { - let _ = self.store.delete(value); - } - let _ = self.store.delete(property); - } - let _ = self.store.delete(object); - } - } - - fn count_posts(&self) -> usize { - match self.objects.markers { - Some(markers) => self.objects_of(markers).len(), - None => 0, - } - } -} - -impl + DoubletsExt> DoubletsLinks { - /// Links of all stored blog posts: `(blog post marker, itself)` links. - fn objects_of(&self, markers: Markers) -> Vec { - let any = self.store.constants().any; - self.store - .each_iter([any, markers.blog_post, any]) - .map(|link| link.index) - .filter(|&index| index != markers.blog_post) - .collect() - } -} - -/// Create a new in-memory doublets united store -pub fn create_united_volatile() -> DoubletsLinks { - let mem = Alloc::new(Global); - let store = DoubletsUnitedVolatile::new(mem).expect("Failed to create doublets store"); - DoubletsLinks::new(store) -} - -/// Create a new in-memory doublets split store -pub fn create_split_volatile() -> DoubletsLinks { - let data_mem = Alloc::new(Global); - let index_mem = Alloc::new(Global); - let store = - DoubletsSplitVolatile::new(data_mem, index_mem).expect("Failed to create doublets store"); - DoubletsLinks::new(store) -} - -/// Create a new file-mapped doublets united store -pub fn create_united_non_volatile>( - path: P, -) -> DoubletsLinks { - let mem = map_file(path); - let store = DoubletsUnitedNonVolatile::new(mem).expect("Failed to create doublets store"); - DoubletsLinks::new(store) -} - -/// Create a new file-mapped doublets split store -pub fn create_split_non_volatile, Q: AsRef>( - data_path: P, - index_path: Q, -) -> DoubletsLinks { - let data_mem = map_file(data_path); - let index_mem = map_file(index_path); - let store = DoubletsSplitNonVolatile::new(data_mem, index_mem) - .expect("Failed to create doublets store"); - DoubletsLinks::new(store) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_create_and_query_united() { - let mut db = create_united_volatile(); - let id = db.create_point(); - assert_eq!(id, 1); - - let link = db.query_by_id(id).unwrap(); - assert_eq!(link.source, id); - assert_eq!(link.target, id); - } - - #[test] - fn test_create_and_query_split() { - let mut db = create_split_volatile(); - let id = db.create_point(); - assert_eq!(id, 1); - - let link = db.query_by_id(id).unwrap(); - assert_eq!(link.source, id); - assert_eq!(link.target, id); - } - - /// Regression test: `create` used to call `create_by([source, target])`, - /// which the memory stores read as a restriction and ignore, so every - /// benchmarked link was created empty instead of connecting two links. - #[test] - fn test_create_assigns_source_and_target() { - let mut db = create_united_volatile(); - let first = db.create_point(); - let second = db.create_point(); - let id = db.create(first, second); - - let link = db.query_by_id(id).unwrap(); - assert_eq!(link.source, first); - assert_eq!(link.target, second); - assert_eq!(db.query_by_source(first).len(), 2); - assert_eq!(db.query_by_target(second).len(), 2); - } - - #[test] - fn test_update() { - let mut db = create_united_volatile(); - let id = db.create(1, 2); - db.update(id, 3, 4); - - let link = db.query_by_id(id).unwrap(); - assert_eq!(link.source, 3); - assert_eq!(link.target, 4); - } - - #[test] - fn test_delete() { - let mut db = create_united_volatile(); - let id = db.create_point(); - db.delete(id); - assert!(db.query_by_id(id).is_none()); - } - - #[test] - fn test_query_by_source() { - let mut db = create_united_volatile(); - let id1 = db.create_point(); - let id2 = db.create_point(); - db.update(id1, id1, id2); - db.update(id2, id1, id1); - - let links = db.query_by_source(id1); - assert_eq!(links.len(), 2); - } -} diff --git a/rust/src/exclusive.rs b/rust/src/exclusive.rs deleted file mode 100644 index 727f428..0000000 --- a/rust/src/exclusive.rs +++ /dev/null @@ -1,46 +0,0 @@ -//! Thread-safe exclusive access wrapper - -use std::cell::UnsafeCell; -use std::ops::{Deref, DerefMut}; - -/// A wrapper that provides mutable access through an immutable reference. -/// This is useful for benchmark scenarios where we need interior mutability. -pub struct Exclusive { - inner: UnsafeCell, -} - -impl Exclusive { - /// Creates a new Exclusive wrapper - pub fn new(value: T) -> Self { - Self { - inner: UnsafeCell::new(value), - } - } - - /// Gets a mutable reference to the inner value - /// - /// # Safety - /// The caller must ensure that no other references exist to the inner value. - #[allow(clippy::mut_from_ref)] - pub fn get_mut(&self) -> &mut T { - unsafe { &mut *self.inner.get() } - } -} - -impl Deref for Exclusive { - type Target = T; - - fn deref(&self) -> &Self::Target { - unsafe { &*self.inner.get() } - } -} - -impl DerefMut for Exclusive { - fn deref_mut(&mut self) -> &mut Self::Target { - unsafe { &mut *self.inner.get() } - } -} - -// SAFETY: We ensure exclusive access through the API design -unsafe impl Send for Exclusive {} -unsafe impl Sync for Exclusive {} diff --git a/rust/src/fork.rs b/rust/src/fork.rs deleted file mode 100644 index ba14355..0000000 --- a/rust/src/fork.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! Fork mechanism for isolated benchmark runs - -use crate::Benched; -use std::ops::{Deref, DerefMut}; - -/// A fork that provides an isolated environment for benchmark iterations. -/// When dropped, it calls unfork() to clean up the state. -pub struct Fork<'a, B: Benched> { - benched: &'a mut B, -} - -impl<'a, B: Benched> Fork<'a, B> { - /// Creates a new Fork from a Benched implementation - pub fn new(benched: &'a mut B) -> Self { - Self { benched } - } -} - -impl<'a, B: Benched> Deref for Fork<'a, B> { - type Target = B; - - fn deref(&self) -> &Self::Target { - self.benched - } -} - -impl<'a, B: Benched> DerefMut for Fork<'a, B> { - fn deref_mut(&mut self) -> &mut Self::Target { - self.benched - } -} - -impl<'a, B: Benched> Drop for Fork<'a, B> { - fn drop(&mut self) { - // SAFETY: We ensure the benched is properly cleaned up - unsafe { - self.benched.unfork(); - } - } -} diff --git a/rust/src/harness.rs b/rust/src/harness.rs new file mode 100644 index 0000000..7ae0654 --- /dev/null +++ b/rust/src/harness.rs @@ -0,0 +1,519 @@ +//! Runs every storage through the same lifecycle on a fresh store per repetition: +//! a timed batch per operation, each validated against the expected count and checksum. + +use crate::{ + dataset::{self, blog_post, link, mix, scattered}, + links::{DoubletsLinks, Link, LinksStorage, SqliteLinks}, + objects::{BlogPostsStorage, DoubletsBlogPosts, SqliteBlogPosts}, +}; +use doublets::{ + data::LinkReference, + mem::{FileMapped, Global}, + split, unit, +}; +use serde_json::{Value, json}; +use std::{ + fs, + path::{Path, PathBuf}, + time::{Duration, Instant}, +}; + +pub const LINKS_VARIANTS: [&str; 6] = [ + "SQLite_Memory", + "SQLite_File", + "Doublets_United_Volatile", + "Doublets_United_NonVolatile", + "Doublets_Split_Volatile", + "Doublets_Split_NonVolatile", +]; + +pub const OBJECTS_VARIANTS: [&str; 10] = [ + "SQLite_Memory", + "SQLite_File", + "Doublets_United_Volatile_Cached", + "Doublets_United_Volatile_Uncached", + "Doublets_United_NonVolatile_Cached", + "Doublets_United_NonVolatile_Uncached", + "Doublets_Split_Volatile_Cached", + "Doublets_Split_Volatile_Uncached", + "Doublets_Split_NonVolatile_Cached", + "Doublets_Split_NonVolatile_Uncached", +]; + +pub const WARM_UP_SIZE: u64 = 10_000; + +type Timings = Vec<(&'static str, Duration)>; + +/// Count and order-sensitive checksum of the records an operation has seen. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Tally { + pub count: u64, + pub checksum: u64, +} + +impl Tally { + fn add(&mut self, checksum: u64) { + self.count += 1; + self.checksum = self.checksum.wrapping_add(checksum); + } + + fn add_link(&mut self, link: Link) { + self.add(dataset::link_checksum( + uint(link.id), + uint(link.from), + uint(link.to), + )); + } + + fn of(count: u64, checksum: u64) -> Self { + Self { count, checksum } + } +} + +fn timed( + timings: &mut Timings, + operation: &'static str, + expected: Tally, + work: impl FnOnce() -> Tally, +) { + let start = Instant::now(); + let actual = work(); + timings.push((operation, start.elapsed())); + assert_eq!(actual, expected, "{operation} returned unexpected records"); +} + +fn int(value: u64) -> T { + T::try_from(value).unwrap() +} + +fn uint(value: T) -> u64 { + value.try_into().unwrap() +} + +pub fn links_lifecycle>( + storage: &mut S, + n: u64, + created: &mut dyn FnMut(), +) -> Timings { + let expected = Tally::of(n, dataset::links_checksum(n, false)); + let mut timings = Timings::new(); + + timed(&mut timings, "create", expected, || { + storage.transaction(|links| { + let mut tally = Tally::default(); + for i in 1..=n { + let (from, to) = link(i); + let id = links.create(int(from), int(to)); + tally.add_link(Link { + id, + from: int(from), + to: int(to), + }); + } + tally + }) + }); + created(); + + timed(&mut timings, "query_all", expected, || { + storage.transaction(|links| { + let mut tally = Tally::default(); + links.each(|link| tally.add_link(link)); + tally + }) + }); + timed(&mut timings, "query_by_id", expected, || { + storage.transaction(|links| { + let mut tally = Tally::default(); + for id in scattered(n) { + tally.add_link(links.get(int(id)).unwrap()); + } + tally + }) + }); + timed(&mut timings, "query_by_from_to", expected, || { + storage.transaction(|links| { + let mut tally = Tally::default(); + for i in scattered(n) { + let (from, to) = link(i); + let id = links.search(int(from), int(to)).unwrap(); + tally.add_link(Link { + id, + from: int(from), + to: int(to), + }); + } + tally + }) + }); + timed(&mut timings, "query_by_from", expected, || { + storage.transaction(|links| { + let mut tally = Tally::default(); + for from in scattered(n) { + links.each_with_from(int(from), |link| tally.add_link(link)); + } + tally + }) + }); + timed(&mut timings, "query_by_to", expected, || { + storage.transaction(|links| { + let mut tally = Tally::default(); + for to in scattered(n) { + links.each_with_to(int(to), |link| tally.add_link(link)); + } + tally + }) + }); + + let swapped = Tally::of(n, dataset::links_checksum(n, true)); + timed(&mut timings, "update", swapped, || { + storage.transaction(|links| { + let mut tally = Tally::default(); + for id in scattered(n) { + let (from, to) = link(id); + links.update(int(id), int(to), int(from)); + tally.add(dataset::link_checksum(id, to, from)); + } + tally + }) + }); + let mut stored = Tally::default(); + storage.each(|link| stored.add_link(link)); + assert_eq!(stored, swapped, "update did not swap every link"); + + timed(&mut timings, "delete", Tally::of(n, 0), || { + storage.transaction(|links| { + let mut tally = Tally::default(); + for id in scattered(n) { + links.delete(int(id)); + tally.add(0); + } + tally + }) + }); + assert_eq!(storage.count(), 0, "delete left links behind"); + timings +} + +pub fn objects_lifecycle>( + storage: &mut S, + n: u64, + created: &mut dyn FnMut(), +) -> Timings { + let expected = Tally::of(n, dataset::blog_posts_checksum(n)); + let mut timings = Timings::new(); + let mut ids = Vec::with_capacity(n as usize); + + timed(&mut timings, "create", expected, || { + storage.transaction(|posts| { + let mut tally = Tally::default(); + for i in 1..=n { + let post = blog_post(i); + ids.push(posts.create(&post)); + tally.add(post.checksum()); + } + tally + }) + }); + created(); + + timed(&mut timings, "read_all", expected, || { + storage.transaction(|posts| { + let mut tally = Tally::default(); + posts.each(|_, post| tally.add(post.checksum())); + tally + }) + }); + timed( + &mut timings, + "read_by_id", + Tally::of(n, dataset::blog_posts_by_number_checksum(n)), + || { + storage.transaction(|posts| { + let mut tally = Tally::default(); + for i in scattered(n) { + let post = posts.get(ids[i as usize - 1]).unwrap(); + tally.add(mix(i, post.checksum())); + } + tally + }) + }, + ); + timed(&mut timings, "delete", Tally::of(n, 0), || { + storage.transaction(|posts| { + let mut tally = Tally::default(); + for i in scattered(n) { + posts.delete(ids[i as usize - 1]); + tally.add(0); + } + tally + }) + }); + assert_eq!(storage.count(), 0, "delete left blog posts behind"); + timings +} + +pub struct Measurement { + pub variant: &'static str, + pub file_bytes: Option, + pub operations: Vec<(&'static str, Vec)>, +} + +impl Measurement { + pub fn to_json(&self) -> Value { + let operations: serde_json::Map = self + .operations + .iter() + .map(|(operation, samples)| { + let mut sorted = samples.clone(); + sorted.sort_by(f64::total_cmp); + let middle = sorted.len() / 2; + let median = if sorted.len() % 2 == 0 { + (sorted[middle - 1] + sorted[middle]) / 2.0 + } else { + sorted[middle] + }; + let summary = json!({ + "median_ns": median, + "min_ns": sorted[0], + "max_ns": sorted[sorted.len() - 1], + "samples_ns": samples, + }); + (operation.to_string(), summary) + }) + .collect(); + json!({ "variant": self.variant, "file_bytes": self.file_bytes, "operations": operations }) + } +} + +fn directory_bytes(directory: &Path) -> u64 { + fs::read_dir(directory) + .unwrap() + .map(|entry| entry.unwrap().metadata().unwrap().len()) + .sum() +} + +/// Times `lifecycle` on `repetitions` fresh stores opened in empty directories, after one discarded warm-up. +fn measure( + variant: &'static str, + n: u64, + repetitions: usize, + directory: &Path, + open: impl Fn(&Path) -> S, + lifecycle: fn(&mut S, u64, &mut dyn FnMut()) -> Timings, +) -> Measurement { + let mut measurement = Measurement { + variant, + file_bytes: None, + operations: Vec::new(), + }; + for repetition in 0..=repetitions { + let size = if repetition == 0 { + n.min(WARM_UP_SIZE) + } else { + n + }; + let workspace = directory.join(format!("{variant}-{repetition}")); + fs::create_dir_all(&workspace).unwrap(); + let mut storage = open(&workspace); + let mut file_bytes = 0; + let timings = lifecycle(&mut storage, size, &mut || { + file_bytes = directory_bytes(&workspace) + }); + drop(storage); + fs::remove_dir_all(&workspace).unwrap(); + if repetition == 0 { + continue; + } + eprintln!("{variant} #{repetition}: {}", summary(&timings, n)); + measurement.file_bytes = (file_bytes > 0).then_some(file_bytes); + for (operation, elapsed) in timings { + let ns_per_operation = elapsed.as_nanos() as f64 / n as f64; + match measurement + .operations + .iter_mut() + .find(|(name, _)| *name == operation) + { + Some((_, samples)) => samples.push(ns_per_operation), + None => measurement + .operations + .push((operation, vec![ns_per_operation])), + } + } + } + measurement +} + +fn summary(timings: &Timings, n: u64) -> String { + timings + .iter() + .map(|(operation, elapsed)| { + format!( + "{operation} {:.0} ns/op", + elapsed.as_nanos() as f64 / n as f64 + ) + }) + .collect::>() + .join(", ") +} + +fn file(directory: &Path, name: &str) -> PathBuf { + directory.join(name) +} + +fn mapped

(directory: &Path, name: &str) -> FileMapped

{ + FileMapped::from_path(file(directory, name)).unwrap() +} + +pub fn measure_links( + variant: &'static str, + n: u64, + repetitions: usize, + directory: &Path, +) -> Measurement { + match variant { + "SQLite_Memory" => measure( + variant, + n, + repetitions, + directory, + |_| SqliteLinks::::in_memory(), + links_lifecycle, + ), + "SQLite_File" => measure( + variant, + n, + repetitions, + directory, + |dir| SqliteLinks::::open(file(dir, "links.db")), + links_lifecycle, + ), + "Doublets_United_Volatile" => measure( + variant, + n, + repetitions, + directory, + |_| DoubletsLinks::new(unit::Store::::new(Global::new()).unwrap()), + links_lifecycle, + ), + "Doublets_United_NonVolatile" => measure( + variant, + n, + repetitions, + directory, + |dir| DoubletsLinks::new(unit::Store::::new(mapped(dir, "links.links")).unwrap()), + links_lifecycle, + ), + "Doublets_Split_Volatile" => measure( + variant, + n, + repetitions, + directory, + |_| { + DoubletsLinks::new( + split::Store::::new(Global::new(), Global::new()).unwrap(), + ) + }, + links_lifecycle, + ), + "Doublets_Split_NonVolatile" => measure( + variant, + n, + repetitions, + directory, + |dir| { + DoubletsLinks::new( + split::Store::::new( + mapped(dir, "data.links"), + mapped(dir, "index.links"), + ) + .unwrap(), + ) + }, + links_lifecycle, + ), + _ => panic!("unknown links variant {variant}"), + } +} + +pub fn measure_objects( + variant: &'static str, + n: u64, + repetitions: usize, + directory: &Path, +) -> Measurement { + let cached = variant.ends_with("_Cached"); + match variant + .trim_end_matches("_Cached") + .trim_end_matches("_Uncached") + { + "SQLite_Memory" => measure( + variant, + n, + repetitions, + directory, + |_| SqliteBlogPosts::::in_memory(), + objects_lifecycle, + ), + "SQLite_File" => measure( + variant, + n, + repetitions, + directory, + |dir| SqliteBlogPosts::::open(file(dir, "blog_posts.db")), + objects_lifecycle, + ), + "Doublets_United_Volatile" => measure( + variant, + n, + repetitions, + directory, + |_| DoubletsBlogPosts::new(unit::Store::::new(Global::new()).unwrap(), cached), + objects_lifecycle, + ), + "Doublets_United_NonVolatile" => measure( + variant, + n, + repetitions, + directory, + |dir| { + DoubletsBlogPosts::new( + unit::Store::::new(mapped(dir, "links.links")).unwrap(), + cached, + ) + }, + objects_lifecycle, + ), + "Doublets_Split_Volatile" => measure( + variant, + n, + repetitions, + directory, + |_| { + DoubletsBlogPosts::new( + split::Store::::new(Global::new(), Global::new()).unwrap(), + cached, + ) + }, + objects_lifecycle, + ), + "Doublets_Split_NonVolatile" => measure( + variant, + n, + repetitions, + directory, + |dir| { + DoubletsBlogPosts::new( + split::Store::::new( + mapped(dir, "data.links"), + mapped(dir, "index.links"), + ) + .unwrap(), + cached, + ) + }, + objects_lifecycle, + ), + _ => panic!("unknown objects variant {variant}"), + } +} diff --git a/rust/src/lib.rs b/rust/src/lib.rs index 8db569f..d62a0b9 100644 --- a/rust/src/lib.rs +++ b/rust/src/lib.rs @@ -1,153 +1,4 @@ -//! SQLite vs Doublets benchmark library -//! -//! This library provides implementations for benchmarking SQLite and Doublets -//! storage systems on basic CRUD operations with links. - -#![feature(allocator_api)] - -pub mod benched; -pub mod doublets_impl; -pub mod exclusive; -pub mod fork; +pub mod dataset; +pub mod harness; +pub mod links; pub mod objects; -pub mod sqlite_impl; - -pub use benched::Benched; -pub use exclusive::Exclusive; -pub use fork::Fork; -pub use objects::{generate_posts, BlogPost, Objects, BENCHMARK_OBJECT_COUNT}; - -use once_cell::sync::Lazy; -use std::{ - env, - path::PathBuf, - process, - sync::atomic::{AtomicUsize, Ordering}, -}; - -/// Number of links to use for benchmarking -pub static BENCHMARK_LINK_COUNT: Lazy = Lazy::new(|| { - env::var("BENCHMARK_LINK_COUNT") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(1000) -}); - -/// Number of background links to create before benchmarking -pub static BACKGROUND_LINK_COUNT: Lazy = Lazy::new(|| { - env::var("BACKGROUND_LINK_COUNT") - .ok() - .and_then(|s| s.parse().ok()) - .unwrap_or(3000) -}); - -/// Returns a unique path inside the temporary directory of the machine. -/// -/// The non-volatile (file backed) benchmark subjects store their data there, so -/// that parallel runs of the benchmarks never share a file, and so that nothing -/// is left in the working directory. -pub fn temp_path(name: &str) -> PathBuf { - static COUNTER: AtomicUsize = AtomicUsize::new(0); - let unique = COUNTER.fetch_add(1, Ordering::Relaxed); - env::temp_dir().join(format!( - "sqlite-vs-doublets-{}-{}-{}", - process::id(), - unique, - name - )) -} - -/// A link structure representing a doublet (source -> target relationship) -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct Link { - pub id: u64, - pub source: u64, - pub target: u64, -} - -impl Link { - pub fn new(id: u64, source: u64, target: u64) -> Self { - Self { id, source, target } - } -} - -/// Trait for database operations on links -pub trait Links { - /// Create a new link and return its ID - fn create(&mut self, source: u64, target: u64) -> u64; - - /// Create a point link (self-referencing link) - fn create_point(&mut self) -> u64 { - let id = self.create(0, 0); - self.update(id, id, id); - id - } - - /// Update an existing link - fn update(&mut self, id: u64, source: u64, target: u64); - - /// Delete a link by ID - fn delete(&mut self, id: u64); - - /// Delete all links - fn delete_all(&mut self); - - /// Query all links - fn query_all(&self) -> Vec; - - /// Query a link by ID - fn query_by_id(&self, id: u64) -> Option; - - /// Query links by source - fn query_by_source(&self, source: u64) -> Vec; - - /// Query links by target - fn query_by_target(&self, target: u64) -> Vec; - - /// Query links by source and target - fn query_by_source_target(&self, source: u64, target: u64) -> Vec; - - /// Count all links - fn count(&self) -> usize; -} - -/// Creates `count` background point links and returns their identifiers. -/// -/// Background links make every benchmark run against a non-empty storage, so -/// that queries have to search through unrelated data, as they would in a real -/// application. -pub fn fill_background(links: &mut impl Links, count: usize) -> Vec { - (0..count).map(|_| links.create_point()).collect() -} - -/// Creates `count` links between the given background links. -/// -/// Link number `i` connects `background[i % len]` to a background link further -/// down the list, so that every created doublet is unique (Doublets stores -/// every doublet exactly once) while sources and targets are shared by several -/// links — which is what makes the outgoing and incoming queries meaningful. -pub fn fill_benchmarked(links: &mut impl Links, background: &[u64], count: usize) -> Vec { - assert!( - !background.is_empty(), - "background links are required to create benchmarked links" - ); - let len = background.len(); - (0..count) - .map(|index| { - let source = background[index % len]; - let target = background[(index % len + 1 + index / len) % len]; - let id = links.create(source, target); - Link::new(id, source, target) - }) - .collect() -} - -/// Macro for running benchmarks with proper setup and teardown -#[macro_export] -macro_rules! bench { - ($name:expr, $benched:expr, $op:expr) => {{ - use $crate::Benched; - let mut fork = $benched.fork(); - $op(&mut *fork); - }}; -} diff --git a/rust/src/links.rs b/rust/src/links.rs new file mode 100644 index 0000000..42fb072 --- /dev/null +++ b/rust/src/links.rs @@ -0,0 +1,253 @@ +use doublets::{ + Doublets, + data::{Flow, LinkReference}, +}; +use rusqlite::{Connection, OptionalExtension, params}; +use std::{marker::PhantomData, path::Path}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct Link { + pub id: T, + pub from: T, + pub to: T, +} + +pub trait LinksStorage { + fn create(&mut self, from: T, to: T) -> T; + fn update(&mut self, id: T, from: T, to: T); + fn delete(&mut self, id: T); + fn get(&self, id: T) -> Option>; + fn search(&self, from: T, to: T) -> Option; + fn each(&self, visit: impl FnMut(Link)); + fn each_with_from(&self, from: T, visit: impl FnMut(Link)); + fn each_with_to(&self, to: T, visit: impl FnMut(Link)); + fn count(&self) -> u64; + + fn transaction(&mut self, work: impl FnOnce(&mut Self) -> R) -> R { + work(self) + } +} + +pub struct SqliteLinks { + connection: Connection, + id_type: PhantomData, +} + +impl SqliteLinks { + pub fn open(path: impl AsRef) -> Self { + Self::new(Connection::open(path).unwrap()) + } + + pub fn in_memory() -> Self { + Self::new(Connection::open_in_memory().unwrap()) + } + + fn new(connection: Connection) -> Self { + connection + .execute_batch( + r#" + CREATE TABLE links (id INTEGER PRIMARY KEY, "from" INTEGER NOT NULL, "to" INTEGER NOT NULL); + CREATE INDEX links_from_to ON links ("from", "to"); + CREATE INDEX links_to_from ON links ("to", "from"); + "#, + ) + .unwrap(); + Self { + connection, + id_type: PhantomData, + } + } + + fn each_where( + &self, + sql: &str, + parameters: impl rusqlite::Params, + mut visit: impl FnMut(Link), + ) { + let mut statement = self.connection.prepare_cached(sql).unwrap(); + let mut rows = statement.query(parameters).unwrap(); + while let Some(row) = rows.next().unwrap() { + visit(Link { + id: id(row.get(0).unwrap()), + from: id(row.get(1).unwrap()), + to: id(row.get(2).unwrap()), + }); + } + } +} + +fn sql(id: T) -> i64 { + id.try_into().unwrap() +} + +fn id(sql: i64) -> T { + T::try_from(sql).unwrap() +} + +impl LinksStorage for SqliteLinks { + fn create(&mut self, from: T, to: T) -> T { + let mut statement = self + .connection + .prepare_cached(r#"INSERT INTO links ("from", "to") VALUES (?1, ?2)"#) + .unwrap(); + statement.execute(params![sql(from), sql(to)]).unwrap(); + id(self.connection.last_insert_rowid()) + } + + fn update(&mut self, link: T, from: T, to: T) { + let mut statement = self + .connection + .prepare_cached(r#"UPDATE links SET "from" = ?2, "to" = ?3 WHERE id = ?1"#) + .unwrap(); + statement + .execute(params![sql(link), sql(from), sql(to)]) + .unwrap(); + } + + fn delete(&mut self, link: T) { + let mut statement = self + .connection + .prepare_cached("DELETE FROM links WHERE id = ?1") + .unwrap(); + statement.execute([sql(link)]).unwrap(); + } + + fn get(&self, link: T) -> Option> { + let mut statement = self + .connection + .prepare_cached(r#"SELECT "from", "to" FROM links WHERE id = ?1"#) + .unwrap(); + statement + .query_row([sql(link)], |row| { + Ok(Link { + id: link, + from: id(row.get(0)?), + to: id(row.get(1)?), + }) + }) + .optional() + .unwrap() + } + + fn search(&self, from: T, to: T) -> Option { + let mut statement = self + .connection + .prepare_cached(r#"SELECT id FROM links WHERE "from" = ?1 AND "to" = ?2"#) + .unwrap(); + statement + .query_row([sql(from), sql(to)], |row| row.get(0).map(id)) + .optional() + .unwrap() + } + + fn each(&self, visit: impl FnMut(Link)) { + self.each_where(r#"SELECT id, "from", "to" FROM links"#, [], visit); + } + + fn each_with_from(&self, from: T, visit: impl FnMut(Link)) { + self.each_where( + r#"SELECT id, "from", "to" FROM links WHERE "from" = ?1"#, + [sql(from)], + visit, + ); + } + + fn each_with_to(&self, to: T, visit: impl FnMut(Link)) { + self.each_where( + r#"SELECT id, "from", "to" FROM links WHERE "to" = ?1"#, + [sql(to)], + visit, + ); + } + + fn count(&self) -> u64 { + self.connection + .query_row("SELECT COUNT(*) FROM links", [], |row| row.get(0)) + .unwrap() + } + + fn transaction(&mut self, work: impl FnOnce(&mut Self) -> R) -> R { + self.connection.execute_batch("BEGIN").unwrap(); + let result = work(self); + self.connection.execute_batch("COMMIT").unwrap(); + result + } +} + +fn link(link: doublets::Link) -> Link { + Link { + id: link.index, + from: link.source, + to: link.target, + } +} + +pub struct DoubletsLinks { + store: D, + id_type: PhantomData, +} + +impl> DoubletsLinks { + pub fn new(store: D) -> Self { + Self { + store, + id_type: PhantomData, + } + } +} + +impl> LinksStorage for DoubletsLinks { + fn create(&mut self, from: T, to: T) -> T { + self.store.create_link(from, to).unwrap() + } + + fn update(&mut self, id: T, from: T, to: T) { + // doublets 0.5.0 split stores lose a link updated to reference itself unless it starts + // from the empty state `create_link` uses, see experiments/split_store_delete + if from == id || to == id { + self.store + .update(id, T::from_byte(0), T::from_byte(0)) + .unwrap(); + } + self.store.update(id, from, to).unwrap(); + } + + fn delete(&mut self, id: T) { + self.store.delete(id).unwrap(); + } + + fn get(&self, id: T) -> Option> { + self.store.get_link(id).map(link) + } + + fn search(&self, from: T, to: T) -> Option { + self.store.search(from, to) + } + + fn each(&self, mut visit: impl FnMut(Link)) { + self.store.each(|found| { + visit(link(found)); + Flow::Continue + }); + } + + fn each_with_from(&self, from: T, mut visit: impl FnMut(Link)) { + let any = self.store.constants().any; + self.store.each_by([any, from, any], |found| { + visit(link(found)); + Flow::Continue + }); + } + + fn each_with_to(&self, to: T, mut visit: impl FnMut(Link)) { + let any = self.store.constants().any; + self.store.each_by([any, any, to], |found| { + visit(link(found)); + Flow::Continue + }); + } + + fn count(&self) -> u64 { + self.store.count().try_into().unwrap() + } +} diff --git a/rust/src/main.rs b/rust/src/main.rs new file mode 100644 index 0000000..39d8943 --- /dev/null +++ b/rust/src/main.rs @@ -0,0 +1,92 @@ +//! Usage: sqlite-vs-doublets <32|64> [--work N | --repetitions N] +//! [--variants A,B] [--directory DIR] [--output FILE] +//! +//! Repetitions default to `work / size` (at least one), so every size does about the same total work. + +use doublets::data::LinkReference; +use serde_json::json; +use sqlite_vs_doublets::harness::{self, LINKS_VARIANTS, Measurement, OBJECTS_VARIANTS}; +use std::{collections::HashMap, env, fs, path::Path}; + +const DEFAULT_WORK: u64 = 10_000_000; + +fn main() { + let arguments: Vec = env::args().skip(1).collect(); + let [category, bits, size] = [0, 1, 2].map(|index| { + arguments + .get(index) + .expect("missing positional argument") + .as_str() + }); + let options: HashMap<&str, &str> = arguments[3..] + .chunks(2) + .map(|pair| (pair[0].trim_start_matches("--"), pair[1].as_str())) + .collect(); + let size: u64 = size.replace('_', "").parse().unwrap(); + let work: u64 = options + .get("work") + .map_or(DEFAULT_WORK, |work| work.replace('_', "").parse().unwrap()); + let repetitions: usize = options + .get("repetitions") + .map_or((work / size).max(1) as usize, |count| { + count.parse().unwrap() + }); + let all_variants: &[&'static str] = if category == "links" { + &LINKS_VARIANTS + } else { + &OBJECTS_VARIANTS + }; + let variants: Vec<&'static str> = match options.get("variants") { + Some(names) => names + .split(',') + .map(|name| { + *all_variants + .iter() + .find(|known| **known == name) + .expect(name) + }) + .collect(), + None => all_variants.to_vec(), + }; + let directory = options + .get("directory") + .map_or_else(env::temp_dir, |directory| directory.into()); + + let measurements: Vec = match bits { + "32" => measure::(category, &variants, size, repetitions, &directory), + "64" => measure::(category, &variants, size, repetitions, &directory), + _ => panic!("bits must be 32 or 64"), + }; + let report = json!({ + "language": "Rust", + "category": category, + "bits": bits.parse::().unwrap(), + "size": size, + "repetitions": repetitions, + "warm_up_size": size.min(harness::WARM_UP_SIZE), + "sqlite_version": rusqlite::version(), + "results": measurements.iter().map(Measurement::to_json).collect::>(), + }); + let report = serde_json::to_string_pretty(&report).unwrap(); + match options.get("output") { + Some(path) => fs::write(path, report).unwrap(), + None => println!("{report}"), + } +} + +fn measure( + category: &str, + variants: &[&'static str], + size: u64, + repetitions: usize, + directory: &Path, +) -> Vec { + variants + .iter() + .map(|variant| match category { + "links" => harness::measure_links::(variant, size, repetitions, directory), + "objects" => harness::measure_objects::(variant, size, repetitions, directory), + _ => panic!("category must be links or objects"), + }) + .collect() +} diff --git a/rust/src/objects.rs b/rust/src/objects.rs index 894d3c4..01ce094 100644 --- a/rust/src/objects.rs +++ b/rust/src/objects.rs @@ -1,164 +1,341 @@ -//! Object-like structures (blog posts) shared by both storages. -//! -//! The C# part of this repository compares SQLite and Doublets on an object -//! like structure — a blog post with a title, a content and a publication date. -//! This module provides the same model for the Rust benchmarks, so that both -//! languages measure the same three operations: -//! -//! | Operation | Meaning | -//! |-----------------------|-----------------------------------------------| -//! | `Objects Create List` | save a list of blog posts into empty storage | -//! | `Objects Read List` | read every stored blog post back | -//! | `Objects Delete List` | delete every stored blog post | -//! -//! The generated data matches `csharp/Model/BlogPosts.cs`: the title is -//! `Blog post {n}`, the content is one of five lorem ipsum paragraphs and the -//! publication date is within the last 30 days. Generation is deterministic -//! (a small xorshift generator seeded by [`OBJECT_SEED`]) so that every run and -//! every storage is benchmarked on exactly the same data. - -use once_cell::sync::Lazy; -use std::env; - -/// Number of blog posts used by the object benchmarks. -/// -/// Can be overridden with the `BENCHMARK_OBJECT_COUNT` environment variable, -/// which the CI workflow lowers for pull request runs. -pub static BENCHMARK_OBJECT_COUNT: Lazy = Lazy::new(|| { - env::var("BENCHMARK_OBJECT_COUNT") - .ok() - .and_then(|value| value.parse().ok()) - .unwrap_or(1000) -}); - -/// Seed of the deterministic generator of blog posts. -pub const OBJECT_SEED: u64 = 0x5148_5157_2D31_3235; - -/// Lorem ipsum paragraphs used as blog post contents. -/// -/// The same five paragraphs are used by `csharp/Model/BlogPosts.cs`. -pub const CONTENTS: [&str; 5] = [ - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis malesuada blandit mauris nec bibendum.", - "Curabitur tincidunt nibh sit amet finibus dictum. Suspendisse aliquet arcu non rutrum ultrices.", - "Donec vitae felis lectus. Aenean velit sapien, porttitor ut feugiat a, consectetur et risus.", - "Aliquam sed egestas felis. Maecenas sollicitudin nisl in sapien posuere vulputate.", - "Ut a eleifend augue, eget posuere augue. Proin purus neque, pretium condimentum ipsum ut.", -]; - -/// An object like structure: a blog post. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct BlogPost { - /// Storage assigned identifier, `0` before the post is saved. - pub id: u64, - /// Unique title of the post. - pub title: String, - /// Body of the post. - pub content: String, - /// Publication date as a Unix timestamp in seconds. - pub publication_date_time: i64, +use crate::dataset::BlogPost; +use doublets::{ + Doublets, + data::{AddrToRaw, Flow, LinkReference, RawToAddr}, +}; +use rusqlite::{Connection, OptionalExtension, params}; +use std::{collections::HashMap, marker::PhantomData, path::Path}; + +pub trait BlogPostsStorage { + fn create(&mut self, post: &BlogPost) -> T; + fn get(&mut self, id: T) -> Option; + fn each(&mut self, visit: impl FnMut(T, BlogPost)); + fn delete(&mut self, id: T); + fn count(&mut self) -> u64; + + fn transaction(&mut self, work: impl FnOnce(&mut Self) -> R) -> R { + work(self) + } +} + +pub struct SqliteBlogPosts { + connection: Connection, + id_type: PhantomData, } -impl BlogPost { - /// Creates a not yet stored blog post. - pub fn new( - title: impl Into, - content: impl Into, - publication_date_time: i64, - ) -> Self { +impl SqliteBlogPosts { + pub fn open(path: impl AsRef) -> Self { + Self::new(Connection::open(path).unwrap()) + } + + pub fn in_memory() -> Self { + Self::new(Connection::open_in_memory().unwrap()) + } + + fn new(connection: Connection) -> Self { + connection + .execute_batch( + "CREATE TABLE blog_posts (id INTEGER PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, publication_date INTEGER NOT NULL)", + ) + .unwrap(); Self { - id: 0, - title: title.into(), - content: content.into(), - publication_date_time, + connection, + id_type: PhantomData, } } } -/// Deterministic xorshift64* generator, so every benchmark sees the same data. -struct Generator(u64); - -impl Generator { - fn next(&mut self) -> u64 { - let mut state = self.0; - state ^= state << 13; - state ^= state >> 7; - state ^= state << 17; - self.0 = state; - state - } +fn blog_post(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(BlogPost { + title: row.get(0)?, + content: row.get(1)?, + publication_date: row.get(2)?, + }) } -/// Generates `count` blog posts, mirroring `BlogPosts.GenerateData` of the C# benchmark. -pub fn generate_posts(count: usize) -> Vec { - // A fixed "now" keeps the generated data stable between runs. - const NOW: i64 = 1_700_000_000; - const SECONDS_IN_30_DAYS: i64 = 30 * 24 * 60 * 60; - - let mut generator = Generator(OBJECT_SEED); - (0..count) - .map(|index| { - let content = CONTENTS[(generator.next() % CONTENTS.len() as u64) as usize]; - let age = (generator.next() % SECONDS_IN_30_DAYS as u64) as i64; - BlogPost::new(format!("Blog post {}", index + 1), content, NOW - age) - }) - .collect() +fn sql(id: T) -> i64 { + id.try_into().unwrap() } -/// Storage of object like structures. -/// -/// Implemented natively by every benchmarked backend: SQLite stores blog posts -/// as rows of a table, Doublets stores them as links (with strings represented -/// as sequences of links). -pub trait Objects { - /// Saves every post of the list, returns the assigned identifiers. - fn create_posts(&mut self, posts: &[BlogPost]) -> Vec; +impl BlogPostsStorage for SqliteBlogPosts { + fn create(&mut self, post: &BlogPost) -> T { + let mut statement = self + .connection + .prepare_cached( + "INSERT INTO blog_posts (title, content, publication_date) VALUES (?1, ?2, ?3)", + ) + .unwrap(); + statement + .execute(params![post.title, post.content, post.publication_date]) + .unwrap(); + T::try_from(self.connection.last_insert_rowid()).unwrap() + } + + fn get(&mut self, id: T) -> Option { + let mut statement = self + .connection + .prepare_cached("SELECT title, content, publication_date FROM blog_posts WHERE id = ?1") + .unwrap(); + statement + .query_row([sql(id)], blog_post) + .optional() + .unwrap() + } - /// Reads every stored post back. - fn read_posts(&self) -> Vec; + fn each(&mut self, mut visit: impl FnMut(T, BlogPost)) { + let mut statement = self + .connection + .prepare_cached("SELECT title, content, publication_date, id FROM blog_posts") + .unwrap(); + let mut rows = statement.query([]).unwrap(); + while let Some(row) = rows.next().unwrap() { + visit( + T::try_from(row.get::<_, i64>(3).unwrap()).unwrap(), + blog_post(row).unwrap(), + ); + } + } - /// Deletes every stored post. - fn delete_posts(&mut self); + fn delete(&mut self, id: T) { + let mut statement = self + .connection + .prepare_cached("DELETE FROM blog_posts WHERE id = ?1") + .unwrap(); + statement.execute([sql(id)]).unwrap(); + } + + fn count(&mut self) -> u64 { + self.connection + .query_row("SELECT COUNT(*) FROM blog_posts", [], |row| row.get(0)) + .unwrap() + } - /// Number of stored posts. - fn count_posts(&self) -> usize; + fn transaction(&mut self, work: impl FnOnce(&mut Self) -> R) -> R { + self.connection.execute_batch("BEGIN").unwrap(); + let result = work(self); + self.connection.execute_batch("COMMIT").unwrap(); + result + } +} + +/// Stores each blog post as a `(blog_post, itself)` link with `(post, property) -> value` properties, +/// strings as balanced-variant sequences of Unicode symbols and dates as raw numbers, +/// using the same layout as `Platform.Data.Doublets.Sequences` in C#. +pub struct DoubletsBlogPosts { + links: D, + unicode_symbol: T, + unicode_sequence: T, + title: T, + content: T, + publication_date: T, + blog_post: T, + cache: Option>, } -#[cfg(test)] -mod tests { - use super::*; +#[derive(Default)] +struct SequencesCache { + sequences: HashMap, + strings: HashMap, +} - #[test] - fn generated_data_is_deterministic() { - assert_eq!(generate_posts(16), generate_posts(16)); +impl> DoubletsBlogPosts { + pub fn new(mut links: D, cache_sequences: bool) -> Self { + let meaning_root = links.create_point().unwrap(); + let mut marker = || { + let marker = links.create().unwrap(); + links.update(marker, meaning_root, marker).unwrap() + }; + Self { + unicode_symbol: marker(), + unicode_sequence: marker(), + title: marker(), + content: marker(), + publication_date: marker(), + blog_post: marker(), + cache: cache_sequences.then(SequencesCache::default), + links, + } } - #[test] - fn generated_titles_match_the_csharp_benchmark() { - let posts = generate_posts(3); - assert_eq!(posts[0].title, "Blog post 1"); - assert_eq!(posts[2].title, "Blog post 3"); + fn sequence(&mut self, string: &str) -> T { + if let Some(sequence) = self + .cache + .as_ref() + .and_then(|cache| cache.sequences.get(string)) + { + return *sequence; + } + if string.is_empty() { + return self.unicode_sequence; + } + let symbols: Vec = string + .encode_utf16() + .map(|char| { + self.links + .get_or_create( + AddrToRaw.convert(T::try_from(char).unwrap()), + self.unicode_symbol, + ) + .unwrap() + }) + .collect(); + let balanced = self.balanced_variant(symbols); + let sequence = self + .links + .get_or_create(balanced, self.unicode_sequence) + .unwrap(); + if let Some(cache) = &mut self.cache { + cache.sequences.insert(string.to_owned(), sequence); + } + sequence } - #[test] - fn generated_contents_are_taken_from_the_lorem_ipsum_paragraphs() { - for post in generate_posts(64) { - assert!(CONTENTS.contains(&post.content.as_str())); + fn balanced_variant(&mut self, mut layer: Vec) -> T { + while layer.len() > 1 { + layer = layer + .chunks(2) + .map(|pair| match *pair { + [source, target] => self.links.get_or_create(source, target).unwrap(), + [last] => last, + _ => unreachable!(), + }) + .collect(); } + layer[0] } - #[test] - fn generated_dates_are_within_the_last_30_days() { - let posts = generate_posts(64); - let newest = posts - .iter() - .map(|post| post.publication_date_time) - .max() + fn string(&mut self, sequence: T) -> String { + if let Some(string) = self + .cache + .as_ref() + .and_then(|cache| cache.strings.get(&sequence)) + { + return string.clone(); + } + if sequence == self.unicode_sequence { + return String::new(); + } + let mut utf16 = Vec::new(); + self.walk(self.source(sequence), |symbol| { + utf16.push(RawToAddr.convert(self.source(symbol)).try_into().unwrap()); + }); + let string = String::from_utf16(&utf16).unwrap(); + if let Some(cache) = &mut self.cache { + cache.strings.insert(sequence, string.clone()); + } + string + } + + /// Visits the symbols of a balanced sequence from left to right, like `RightSequenceWalker` in C#. + fn walk(&self, sequence: T, mut visit: impl FnMut(T)) { + let is_symbol = |link| self.target(link) == self.unicode_symbol; + let mut stack = Vec::new(); + let mut element = sequence; + if is_symbol(element) { + return visit(element); + } + loop { + if is_symbol(element) { + let Some(pair) = stack.pop() else { break }; + let (source, target) = (self.source(pair), self.target(pair)); + for part in [source, target] { + if is_symbol(part) { + visit(part); + } + } + element = target; + } else { + stack.push(element); + element = self.source(element); + } + } + } + + fn source(&self, link: T) -> T { + self.links.get_link(link).unwrap().source + } + + fn target(&self, link: T) -> T { + self.links.get_link(link).unwrap().target + } + + fn set_property(&mut self, object: T, property: T, value: T) { + let object_property = self.links.get_or_create(object, property).unwrap(); + let any = self.links.constants().any; + self.links + .delete_query_with([any, object_property, any], |_, _| Flow::Continue) .unwrap(); - let oldest = posts - .iter() - .map(|post| post.publication_date_time) - .min() + self.links.get_or_create(object_property, value).unwrap(); + } + + fn property(&self, object: T, property: T) -> Option { + let object_property = self.links.search(object, property)?; + let any = self.links.constants().any; + self.links + .single([any, object_property, any]) + .map(|value| value.target) + } + + fn delete_property(&mut self, object: T, property: T) { + let object_property = self.links.search(object, property).unwrap(); + let any = self.links.constants().any; + let value = self.links.single([any, object_property, any]).unwrap(); + self.links.delete(value.index).unwrap(); + self.links.delete(object_property).unwrap(); + } +} + +impl> BlogPostsStorage for DoubletsBlogPosts { + fn create(&mut self, post: &BlogPost) -> T { + let blog_post = self.links.create().unwrap(); + self.links + .update(blog_post, self.blog_post, blog_post) .unwrap(); - assert!(newest - oldest <= 30 * 24 * 60 * 60); + let title = self.sequence(&post.title); + self.set_property(blog_post, self.title, title); + let content = self.sequence(&post.content); + self.set_property(blog_post, self.content, content); + let publication_date = AddrToRaw.convert(T::try_from(post.publication_date).unwrap()); + self.set_property(blog_post, self.publication_date, publication_date); + blog_post + } + + fn get(&mut self, id: T) -> Option { + let title = self.property(id, self.title)?; + let content = self.property(id, self.content)?; + let publication_date = self.property(id, self.publication_date)?; + Some(BlogPost { + title: self.string(title), + content: self.string(content), + publication_date: RawToAddr.convert(publication_date).try_into().unwrap(), + }) + } + + fn each(&mut self, mut visit: impl FnMut(T, BlogPost)) { + let any = self.links.constants().any; + let mut ids = Vec::new(); + self.links.each_by([any, self.blog_post, any], |post| { + ids.push(post.index); + Flow::Continue + }); + for id in ids { + let post = self.get(id).unwrap(); + visit(id, post); + } + } + + fn delete(&mut self, id: T) { + for property in [self.title, self.content, self.publication_date] { + self.delete_property(id, property); + } + self.links.delete(id).unwrap(); + } + + fn count(&mut self) -> u64 { + let any = self.links.constants().any; + self.links + .count_by([any, self.blog_post, any]) + .try_into() + .unwrap() } } diff --git a/rust/src/sqlite_impl.rs b/rust/src/sqlite_impl.rs deleted file mode 100644 index b4022a0..0000000 --- a/rust/src/sqlite_impl.rs +++ /dev/null @@ -1,339 +0,0 @@ -//! SQLite implementation for links storage - -use crate::{BlogPost, Link, Links, Objects}; -use rusqlite::{params, Connection}; -use std::path::Path; - -/// SQLite-based links storage -pub struct SqliteLinks { - conn: Connection, - next_id: u64, -} - -impl SqliteLinks { - /// Create a new SQLite links storage with in-memory database - pub fn new_memory() -> Self { - let conn = Connection::open_in_memory().expect("Failed to open in-memory SQLite database"); - Self::init(conn) - } - - /// Create a new SQLite links storage with file-based database - pub fn new_file>(path: P) -> Self { - let conn = Connection::open(path).expect("Failed to open SQLite database file"); - Self::init(conn) - } - - fn init(conn: Connection) -> Self { - Self::create_schema(&conn); - - // Get the max ID to continue from - let next_id: u64 = conn - .query_row("SELECT COALESCE(MAX(id), 0) + 1 FROM links", [], |row| { - row.get(0) - }) - .unwrap_or(1); - - Self { conn, next_id } - } - - /// Creates the tables and the indexes used by the benchmarks. - /// - /// `links` stores the link related benchmark data, `blog_posts` stores the - /// object like structures — the same shape the C# benchmark stores through - /// Entity Framework Core (a primary key, a unique title, a content and a - /// publication date). - fn create_schema(conn: &Connection) { - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS links ( - id INTEGER PRIMARY KEY, - source INTEGER NOT NULL, - target INTEGER NOT NULL - ); - CREATE INDEX IF NOT EXISTS idx_source ON links(source); - CREATE INDEX IF NOT EXISTS idx_target ON links(target); - CREATE INDEX IF NOT EXISTS idx_source_target ON links(source, target); - CREATE TABLE IF NOT EXISTS blog_posts ( - id INTEGER PRIMARY KEY, - title TEXT NOT NULL, - content TEXT NOT NULL, - publication_date_time INTEGER NOT NULL - ); - CREATE UNIQUE INDEX IF NOT EXISTS idx_blog_posts_title ON blog_posts(title);", - ) - .expect("Failed to create the benchmark schema"); - } - - /// Drop all tables and recreate them - pub fn reset(&mut self) { - self.conn - .execute_batch( - "DROP TABLE IF EXISTS links; - DROP TABLE IF EXISTS blog_posts;", - ) - .expect("Failed to drop the benchmark tables"); - Self::create_schema(&self.conn); - self.next_id = 1; - } -} - -impl Links for SqliteLinks { - fn create(&mut self, source: u64, target: u64) -> u64 { - let id = self.next_id; - self.conn - .execute( - "INSERT INTO links (id, source, target) VALUES (?1, ?2, ?3)", - params![id as i64, source as i64, target as i64], - ) - .expect("Failed to insert link"); - self.next_id += 1; - id - } - - fn update(&mut self, id: u64, source: u64, target: u64) { - self.conn - .execute( - "UPDATE links SET source = ?1, target = ?2 WHERE id = ?3", - params![source as i64, target as i64, id as i64], - ) - .expect("Failed to update link"); - } - - fn delete(&mut self, id: u64) { - self.conn - .execute("DELETE FROM links WHERE id = ?1", params![id as i64]) - .expect("Failed to delete link"); - } - - fn delete_all(&mut self) { - self.conn - .execute("DELETE FROM links", []) - .expect("Failed to delete all links"); - self.next_id = 1; - } - - fn query_all(&self) -> Vec { - let mut stmt = self - .conn - .prepare("SELECT id, source, target FROM links") - .expect("Failed to prepare query"); - - stmt.query_map([], |row| { - Ok(Link { - id: row.get::<_, i64>(0)? as u64, - source: row.get::<_, i64>(1)? as u64, - target: row.get::<_, i64>(2)? as u64, - }) - }) - .expect("Failed to query links") - .filter_map(|r| r.ok()) - .collect() - } - - fn query_by_id(&self, id: u64) -> Option { - self.conn - .query_row( - "SELECT id, source, target FROM links WHERE id = ?1", - params![id as i64], - |row| { - Ok(Link { - id: row.get::<_, i64>(0)? as u64, - source: row.get::<_, i64>(1)? as u64, - target: row.get::<_, i64>(2)? as u64, - }) - }, - ) - .ok() - } - - fn query_by_source(&self, source: u64) -> Vec { - let mut stmt = self - .conn - .prepare("SELECT id, source, target FROM links WHERE source = ?1") - .expect("Failed to prepare query"); - - stmt.query_map(params![source as i64], |row| { - Ok(Link { - id: row.get::<_, i64>(0)? as u64, - source: row.get::<_, i64>(1)? as u64, - target: row.get::<_, i64>(2)? as u64, - }) - }) - .expect("Failed to query links") - .filter_map(|r| r.ok()) - .collect() - } - - fn query_by_target(&self, target: u64) -> Vec { - let mut stmt = self - .conn - .prepare("SELECT id, source, target FROM links WHERE target = ?1") - .expect("Failed to prepare query"); - - stmt.query_map(params![target as i64], |row| { - Ok(Link { - id: row.get::<_, i64>(0)? as u64, - source: row.get::<_, i64>(1)? as u64, - target: row.get::<_, i64>(2)? as u64, - }) - }) - .expect("Failed to query links") - .filter_map(|r| r.ok()) - .collect() - } - - fn query_by_source_target(&self, source: u64, target: u64) -> Vec { - let mut stmt = self - .conn - .prepare("SELECT id, source, target FROM links WHERE source = ?1 AND target = ?2") - .expect("Failed to prepare query"); - - stmt.query_map(params![source as i64, target as i64], |row| { - Ok(Link { - id: row.get::<_, i64>(0)? as u64, - source: row.get::<_, i64>(1)? as u64, - target: row.get::<_, i64>(2)? as u64, - }) - }) - .expect("Failed to query links") - .filter_map(|r| r.ok()) - .collect() - } - - fn count(&self) -> usize { - self.conn - .query_row("SELECT COUNT(*) FROM links", [], |row| row.get::<_, i64>(0)) - .unwrap_or(0) as usize - } -} - -impl Objects for SqliteLinks { - fn create_posts(&mut self, posts: &[BlogPost]) -> Vec { - let transaction = self - .conn - .unchecked_transaction() - .expect("Failed to start a transaction"); - let mut ids = Vec::with_capacity(posts.len()); - { - let mut statement = transaction - .prepare( - "INSERT INTO blog_posts (title, content, publication_date_time) - VALUES (?1, ?2, ?3)", - ) - .expect("Failed to prepare the blog post insert"); - for post in posts { - statement - .execute(params![ - post.title, - post.content, - post.publication_date_time - ]) - .expect("Failed to insert a blog post"); - ids.push(transaction.last_insert_rowid() as u64); - } - } - transaction.commit().expect("Failed to commit blog posts"); - ids - } - - fn read_posts(&self) -> Vec { - let mut statement = self - .conn - .prepare("SELECT id, title, content, publication_date_time FROM blog_posts") - .expect("Failed to prepare the blog post query"); - - statement - .query_map([], |row| { - Ok(BlogPost { - id: row.get::<_, i64>(0)? as u64, - title: row.get(1)?, - content: row.get(2)?, - publication_date_time: row.get(3)?, - }) - }) - .expect("Failed to query blog posts") - .filter_map(|post| post.ok()) - .collect() - } - - fn delete_posts(&mut self) { - self.conn - .execute("DELETE FROM blog_posts", []) - .expect("Failed to delete blog posts"); - } - - fn count_posts(&self) -> usize { - self.conn - .query_row("SELECT COUNT(*) FROM blog_posts", [], |row| { - row.get::<_, i64>(0) - }) - .unwrap_or(0) as usize - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_create_and_query() { - let mut db = SqliteLinks::new_memory(); - let id = db.create(1, 2); - assert_eq!(id, 1); - - let link = db.query_by_id(id).unwrap(); - assert_eq!(link.source, 1); - assert_eq!(link.target, 2); - } - - #[test] - fn test_create_point() { - let mut db = SqliteLinks::new_memory(); - let id = db.create_point(); - assert_eq!(id, 1); - - let link = db.query_by_id(id).unwrap(); - assert_eq!(link.source, id); - assert_eq!(link.target, id); - } - - #[test] - fn test_update() { - let mut db = SqliteLinks::new_memory(); - let id = db.create(1, 2); - db.update(id, 3, 4); - - let link = db.query_by_id(id).unwrap(); - assert_eq!(link.source, 3); - assert_eq!(link.target, 4); - } - - #[test] - fn test_delete() { - let mut db = SqliteLinks::new_memory(); - let id = db.create(1, 2); - db.delete(id); - assert!(db.query_by_id(id).is_none()); - } - - #[test] - fn test_query_by_source() { - let mut db = SqliteLinks::new_memory(); - db.create(1, 2); - db.create(1, 3); - db.create(2, 3); - - let links = db.query_by_source(1); - assert_eq!(links.len(), 2); - } - - #[test] - fn test_query_by_target() { - let mut db = SqliteLinks::new_memory(); - db.create(1, 3); - db.create(2, 3); - db.create(2, 4); - - let links = db.query_by_target(3); - assert_eq!(links.len(), 2); - } -} diff --git a/rust/test_out.py b/rust/test_out.py deleted file mode 100644 index 0b27574..0000000 --- a/rust/test_out.py +++ /dev/null @@ -1,103 +0,0 @@ -#!/usr/bin/env python3 -"""Regression tests for the Criterion result parser and Rust report CLI.""" - -import os -import tempfile -import unittest - -import out - - -def complete_output(): - lines = ["Gnuplot not found, using plotters backend"] - value = 1_000 - for operation, _label in out.report.OPERATIONS: - for variant, _variant_label, _color in out.report.VARIANTS: - lines.append( - f"test {operation}/{variant}/10 ... bench: {value:,} ns/iter (+/- 10)" - ) - value += 100 - return "\n".join(lines) + "\n" - - -class ParserTests(unittest.TestCase): - def test_parses_every_operation_and_variant(self): - results = out.parse_text(complete_output()) - - self.assertFalse(out.report.missing_measurements(results)) - self.assertEqual(results["create"]["Doublets_United_Volatile"], 1_000) - - def test_accepts_error_text_inside_a_record(self): - results = out.parse_text( - "test create/SQLite_Memory/10 ... Criterion.rs ERROR: stale cache\n" - "bench: 12,345 ns/iter (+/- 10)\n" - ) - - self.assertEqual(results["create"]["SQLite_Memory"], 12_345) - - def test_does_not_borrow_the_next_record_measurement(self): - results = out.parse_text( - "test create/SQLite_Memory/10 ...\n" - "test create/SQLite_File/10 ... bench: 42 ns/iter (+/- 1)\n" - ) - - self.assertNotIn("SQLite_Memory", results["create"]) - self.assertEqual(results["create"]["SQLite_File"], 42) - - -class MainTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.input = os.path.join(self.temporary.name, "out.txt") - self.results = os.path.join(self.temporary.name, "results.md") - self.readme = os.path.join(self.temporary.name, "README.md") - - def write(self, path, content): - with open(path, "w", encoding="utf-8") as handle: - handle.write(content) - - def test_generates_results_and_updates_each_readme(self): - self.write(self.input, complete_output()) - self.write( - self.readme, - "# Results\n\n" - f"{out.report.RUST_START_MARKER}\nold\n" - f"{out.report.RUST_END_MARKER}\n", - ) - - result = out.main( - [ - self.input, - "--results", - self.results, - "--readme", - self.readme, - "--output-dir", - self.temporary.name, - ] - ) - - self.assertEqual(result, 0) - with open(self.results, "r", encoding="utf-8") as handle: - self.assertIn("Objects Delete List", handle.read()) - with open(self.readme, "r", encoding="utf-8") as handle: - self.assertIn("Doublets Split NonVolatile", handle.read()) - - def test_rejects_partial_output(self): - self.write( - self.input, - "test create/SQLite_Memory/10 ... bench: 42 ns/iter (+/- 1)\n", - ) - - self.assertEqual(out.main([self.input, "--results", self.results]), 1) - self.assertFalse(os.path.exists(self.results)) - - def test_empty_input_reports_failure(self): - self.write(self.input, "") - - self.assertEqual(out.main([self.input, "--results", self.results]), 1) - - -if __name__ == "__main__": - unittest.main() diff --git a/rust/tests/storages.rs b/rust/tests/storages.rs index 9ec3854..8106b4b 100644 --- a/rust/tests/storages.rs +++ b/rust/tests/storages.rs @@ -1,172 +1,161 @@ -//! Behaviour tests shared by every benchmarked storage. -//! -//! The benchmarks only make sense when every variant implements exactly the -//! same semantics, so the same checks are executed against all six subjects: -//! SQLite (in-memory and file based) and Doublets (united and split stores, -//! each of them volatile and non-volatile). - -#![feature(allocator_api)] - -use sqlite_vs_doublets::benched::{ - DoubletsSplitNonVolatileBenched, DoubletsSplitVolatileBenched, - DoubletsUnitedNonVolatileBenched, DoubletsUnitedVolatileBenched, SqliteFileBenched, - SqliteMemoryBenched, -}; +use doublets::{data::LinkReference, mem::Global, split, unit}; use sqlite_vs_doublets::{ - fill_background, fill_benchmarked, generate_posts, Benched, Links, Objects, + dataset::{BlogPost, blog_post, link, scattered}, + harness::{self, LINKS_VARIANTS, OBJECTS_VARIANTS}, + links::{DoubletsLinks, Link, LinksStorage, SqliteLinks}, + objects::{BlogPostsStorage, DoubletsBlogPosts, SqliteBlogPosts}, }; -use std::ops::DerefMut; - -const BACKGROUND: usize = 16; -const BENCHMARKED: usize = 32; -const POSTS: usize = 8; - -/// Checks the link related operations benchmarked by `benches/benchmarks`. -fn check_links(subject: &mut B) -where - B: Benched + DerefMut, - B::Target: Links + Sized, -{ - { - let mut fork = subject.fork(); - let links = &mut **fork; +use std::collections::HashSet; - let background = fill_background(links, BACKGROUND); - assert_eq!(background.len(), BACKGROUND); - let created = fill_benchmarked(links, &background, BENCHMARKED); - assert_eq!(created.len(), BENCHMARKED); - assert_eq!(links.count(), BACKGROUND + BENCHMARKED); - assert_eq!(links.query_all().len(), BACKGROUND + BENCHMARKED); +const SIZE: u64 = 1_000; - // Every created doublet has to be unique, otherwise Doublets would - // return an already existing link instead of creating a new one. - let mut doublets: Vec<(u64, u64)> = created.iter().map(|l| (l.source, l.target)).collect(); - doublets.sort_unstable(); - doublets.dedup(); - assert_eq!(doublets.len(), BENCHMARKED); - - let sample = created[BENCHMARKED / 2]; - assert_eq!(links.query_by_id(sample.id), Some(sample)); - - let all = links.query_all(); - let outgoing = all.iter().filter(|l| l.source == sample.source).count(); - assert_eq!(links.query_by_source(sample.source).len(), outgoing); - let incoming = all.iter().filter(|l| l.target == sample.target).count(); - assert_eq!(links.query_by_target(sample.target).len(), incoming); - let concrete = all - .iter() - .filter(|l| l.source == sample.source && l.target == sample.target) - .count(); - let queried = links.query_by_source_target(sample.source, sample.target); - assert_eq!(queried.len(), concrete); - - // Updating to a doublet that is not stored yet, as the update benchmark does. - let unused = links.create_point(); - links.update(sample.id, unused, background[0]); - let updated = links - .query_by_id(sample.id) - .expect("updated link is missing"); - assert_eq!((updated.source, updated.target), (unused, background[0])); +#[test] +fn links_are_unique_and_reference_created_ids() { + let links: HashSet<_> = (1..=SIZE).map(link).collect(); + assert_eq!(links.len(), SIZE as usize); + for i in 1..=SIZE { + let (from, to) = link(i); + assert!((1..=i).contains(&from) && (1..=i).contains(&to)); + } +} - let before = links.count(); - links.delete(sample.id); - assert_eq!(links.count(), before - 1); - assert!(links.query_by_id(sample.id).is_none()); +#[test] +fn scattered_visits_every_id_once() { + for n in [1, 2, 3, 10, 1_000, 1_024, 65_536] { + let mut ids: Vec = scattered(n).collect(); + ids.sort(); + assert_eq!(ids, (1..=n).collect::>()); + } +} - links.delete_all(); - assert_eq!(links.count(), 0); - assert!(links.query_all().is_empty()); +#[test] +fn every_links_variant_passes_the_lifecycle() { + let directory = tempdir("links"); + for variant in LINKS_VARIANTS { + harness::measure_links::(variant, SIZE, 1, &directory); + harness::measure_links::(variant, SIZE, 1, &directory); } +} - // Dropping the fork has to restore the empty state for the next iteration. - assert_eq!(subject.count(), 0); - assert!(subject.query_all().is_empty()); +#[test] +fn every_objects_variant_passes_the_lifecycle() { + let directory = tempdir("objects"); + for variant in OBJECTS_VARIANTS { + harness::measure_objects::(variant, SIZE / 10, 1, &directory); + harness::measure_objects::(variant, SIZE / 10, 1, &directory); + } } -/// Checks the object like (blog post) operations benchmarked by `benches/benchmarks/objects`. -fn check_objects(subject: &mut B) -where - B: Benched + DerefMut, - B::Target: Objects + Links + Sized, -{ - let expected = generate_posts(POSTS); - { - let mut fork = subject.fork(); - let storage = &mut **fork; +#[test] +fn split_store_keeps_links_updated_to_reference_themselves() { + let mut links = + DoubletsLinks::new(split::Store::::new(Global::new(), Global::new()).unwrap()); + let point = links.create(1, 1); + let link = links.create(point, 2); + links.update(point, point, point); + links.update(link, link, point); + assert_eq!( + links.get(link), + Some(Link { + id: link, + from: link, + to: point + }) + ); + links.delete(link); + links.delete(point); + assert_eq!(links.count(), 0); +} - let ids = storage.create_posts(&expected); - assert_eq!(ids.len(), POSTS); - assert_eq!(storage.count_posts(), POSTS); +#[test] +#[should_panic(expected = "query_all returned unexpected records")] +fn lifecycle_rejects_a_storage_that_loses_records() { + struct Lossy(SqliteLinks); - let mut read = storage.read_posts(); - read.sort_by(|left, right| left.title.cmp(&right.title)); - let mut expected = expected.clone(); - expected.sort_by(|left, right| left.title.cmp(&right.title)); - assert_eq!(read.len(), expected.len()); - for (read, expected) in read.iter().zip(expected.iter()) { - assert_eq!(read.title, expected.title); - assert_eq!(read.content, expected.content); - assert_eq!(read.publication_date_time, expected.publication_date_time); + impl LinksStorage for Lossy { + fn create(&mut self, from: u32, to: u32) -> u32 { + self.0.create(from, to) + } + fn update(&mut self, id: u32, from: u32, to: u32) { + self.0.update(id, from, to) + } + fn delete(&mut self, id: u32) { + self.0.delete(id) + } + fn get(&self, id: u32) -> Option> { + self.0.get(id) + } + fn search(&self, from: u32, to: u32) -> Option { + self.0.search(from, to) + } + fn each(&self, visit: impl FnMut(Link)) { + self.0.each_with_from(1, visit) + } + fn each_with_from(&self, from: u32, visit: impl FnMut(Link)) { + self.0.each_with_from(from, visit) + } + fn each_with_to(&self, to: u32, visit: impl FnMut(Link)) { + self.0.each_with_to(to, visit) + } + fn count(&self) -> u64 { + self.0.count() } - - storage.delete_posts(); - assert_eq!(storage.count_posts(), 0); - assert!(storage.read_posts().is_empty()); } - assert_eq!(subject.count_posts(), 0); + harness::links_lifecycle(&mut Lossy(SqliteLinks::in_memory()), SIZE, &mut || {}); } -/// Objects and links live in the same storage, they must not disturb each other. -fn check_objects_and_links(subject: &mut B) -where - B: Benched + DerefMut, - B::Target: Objects + Links + Sized, -{ - let mut fork = subject.fork(); - let storage = &mut **fork; - - let background = fill_background(storage, BACKGROUND); - fill_benchmarked(storage, &background, BENCHMARKED); - let posts = generate_posts(POSTS); - storage.create_posts(&posts); - - assert_eq!(storage.count_posts(), POSTS); - assert_eq!(storage.read_posts().len(), POSTS); - - storage.delete_posts(); - assert_eq!(storage.count_posts(), 0); +fn round_trip(mut posts: impl BlogPostsStorage) { + let mut inputs: Vec = (1..=20).map(blog_post).collect(); + inputs.push(BlogPost { + title: "Ünïcödé 🌍 ✓".into(), + content: "a".into(), + publication_date: 0, + }); + inputs.push(BlogPost { + title: String::new(), + content: "ab".into(), + publication_date: u32::MAX as u64 / 2, + }); + let ids: Vec = inputs.iter().map(|post| posts.create(post)).collect(); + for (id, post) in ids.iter().zip(&inputs) { + assert_eq!(posts.get(*id).as_ref(), Some(post)); + } + let mut stored = Vec::new(); + posts.each(|id, post| stored.push((id, post))); + stored.sort_by_key(|(id, _)| *id); + assert_eq!(stored, ids.into_iter().zip(inputs).collect::>()); } -macro_rules! storage_tests { - ($module:ident, $benched:ty) => { - mod $module { - use super::*; - - #[test] - fn links() { - check_links(&mut <$benched>::setup(())); - } - - #[test] - fn objects() { - check_objects(&mut <$benched>::setup(())); - } - - #[test] - fn objects_and_links() { - check_objects_and_links(&mut <$benched>::setup(())); - } - } - }; +#[test] +fn every_objects_storage_returns_the_stored_posts() { + round_trip::(SqliteBlogPosts::in_memory()); + round_trip::(SqliteBlogPosts::in_memory()); + for cached in [false, true] { + round_trip(DoubletsBlogPosts::new( + unit::Store::::new(Global::new()).unwrap(), + cached, + )); + round_trip(DoubletsBlogPosts::new( + unit::Store::::new(Global::new()).unwrap(), + cached, + )); + round_trip(DoubletsBlogPosts::new( + split::Store::::new(Global::new(), Global::new()).unwrap(), + cached, + )); + round_trip(DoubletsBlogPosts::new( + split::Store::::new(Global::new(), Global::new()).unwrap(), + cached, + )); + } } -storage_tests!(sqlite_memory, SqliteMemoryBenched); -storage_tests!(sqlite_file, SqliteFileBenched); -storage_tests!(doublets_united_volatile, DoubletsUnitedVolatileBenched); -storage_tests!( - doublets_united_non_volatile, - DoubletsUnitedNonVolatileBenched -); -storage_tests!(doublets_split_volatile, DoubletsSplitVolatileBenched); -storage_tests!(doublets_split_non_volatile, DoubletsSplitNonVolatileBenched); +fn tempdir(name: &str) -> std::path::PathBuf { + let directory = std::env::temp_dir().join(format!( + "sqlite-vs-doublets-test-{name}-{}", + std::process::id() + )); + std::fs::create_dir_all(&directory).unwrap(); + directory +} From 29ec139538dab708e2096317b617c47378e0059d Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 4 Oct 2026 17:40:19 +0000 Subject: [PATCH 03/25] Rewrite C# benchmarks on net10.0 with the same lifecycle as Rust Replace the legacy BenchmarkDotNet project with a small CLI and an xunit v3 test project. Both languages now share the dataset, the validated lifecycle, the variant names and the JSON results format, for 32 and 64 bit ids. Doublets links are deleted with the Platform.Data.Doublets overload that resets them first, and objects stores enable external references for raw numbers. --- .gitignore | 4 + csharp/Benchmarks.cs | 55 --- csharp/Comparisons.SQLiteVSDoublets.csproj | 34 -- csharp/Comparisons.SQLiteVSDoublets.sln | 25 -- csharp/Doublets/DoubletsDbContext.cs | 414 ------------------ csharp/Doublets/DoubletsTestRun.cs | 117 ----- csharp/Links/DoubletsLinksStorage.cs | 78 ---- csharp/Links/ILinksStorage.cs | 27 -- csharp/Links/LinkRecord.cs | 3 - csharp/Links/LinksData.cs | 38 -- csharp/Links/LinksStorageFactory.cs | 55 --- csharp/Links/SQLiteLinksStorage.cs | 150 ------- csharp/LinksBenchmarks.cs | 149 ------- csharp/LinksSelfTest.cs | 58 --- csharp/Model/BlogPost.cs | 62 --- csharp/Model/BlogPosts.cs | 68 --- csharp/Program.cs | 101 ----- csharp/SQLite/SQLiteDbContext.cs | 85 ---- csharp/SQLite/SQLiteTestRun.cs | 81 ---- .../SQLiteVSDoublets.Tests.csproj | 20 + .../SQLiteVSDoublets.Tests/StoragesTests.cs | 111 +++++ csharp/SQLiteVSDoublets.slnx | 4 + csharp/SQLiteVSDoublets/Dataset.cs | 119 +++++ csharp/SQLiteVSDoublets/Harness.cs | 342 +++++++++++++++ csharp/SQLiteVSDoublets/Links.cs | 134 ++++++ csharp/SQLiteVSDoublets/Objects.cs | 189 ++++++++ csharp/SQLiteVSDoublets/Program.cs | 61 +++ csharp/SQLiteVSDoublets/SQLiteStorage.cs | 62 +++ .../SQLiteVSDoublets/SQLiteVSDoublets.csproj | 20 + csharp/SizeAfterCreationColumn.cs | 194 -------- csharp/TestRun.cs | 137 ------ csharp/TestRunResults.cs | 119 ----- csharp/global.json | 5 +- csharp/out.py | 119 ----- csharp/results.md | 16 - csharp/test_out.py | 122 ------ 36 files changed, 1070 insertions(+), 2308 deletions(-) delete mode 100644 csharp/Benchmarks.cs delete mode 100644 csharp/Comparisons.SQLiteVSDoublets.csproj delete mode 100644 csharp/Comparisons.SQLiteVSDoublets.sln delete mode 100644 csharp/Doublets/DoubletsDbContext.cs delete mode 100644 csharp/Doublets/DoubletsTestRun.cs delete mode 100644 csharp/Links/DoubletsLinksStorage.cs delete mode 100644 csharp/Links/ILinksStorage.cs delete mode 100644 csharp/Links/LinkRecord.cs delete mode 100644 csharp/Links/LinksData.cs delete mode 100644 csharp/Links/LinksStorageFactory.cs delete mode 100644 csharp/Links/SQLiteLinksStorage.cs delete mode 100644 csharp/LinksBenchmarks.cs delete mode 100644 csharp/LinksSelfTest.cs delete mode 100644 csharp/Model/BlogPost.cs delete mode 100644 csharp/Model/BlogPosts.cs delete mode 100644 csharp/Program.cs delete mode 100644 csharp/SQLite/SQLiteDbContext.cs delete mode 100644 csharp/SQLite/SQLiteTestRun.cs create mode 100644 csharp/SQLiteVSDoublets.Tests/SQLiteVSDoublets.Tests.csproj create mode 100644 csharp/SQLiteVSDoublets.Tests/StoragesTests.cs create mode 100644 csharp/SQLiteVSDoublets.slnx create mode 100644 csharp/SQLiteVSDoublets/Dataset.cs create mode 100644 csharp/SQLiteVSDoublets/Harness.cs create mode 100644 csharp/SQLiteVSDoublets/Links.cs create mode 100644 csharp/SQLiteVSDoublets/Objects.cs create mode 100644 csharp/SQLiteVSDoublets/Program.cs create mode 100644 csharp/SQLiteVSDoublets/SQLiteStorage.cs create mode 100644 csharp/SQLiteVSDoublets/SQLiteVSDoublets.csproj delete mode 100644 csharp/SizeAfterCreationColumn.cs delete mode 100644 csharp/TestRun.cs delete mode 100644 csharp/TestRunResults.cs delete mode 100644 csharp/out.py delete mode 100644 csharp/results.md delete mode 100644 csharp/test_out.py diff --git a/.gitignore b/.gitignore index 4bdaf41..e5c9ab8 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,7 @@ __pycache__/ # Rust build output target/ + +# .NET build output +bin/ +obj/ diff --git a/csharp/Benchmarks.cs b/csharp/Benchmarks.cs deleted file mode 100644 index ee78467..0000000 --- a/csharp/Benchmarks.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System.IO; -using BenchmarkDotNet.Attributes; -using BenchmarkDotNet.Configs; -using Comparisons.SQLiteVSDoublets.Model; -using Comparisons.SQLiteVSDoublets.SQLite; -using Comparisons.SQLiteVSDoublets.Doublets; - -namespace Comparisons.SQLiteVSDoublets -{ - [SimpleJob] - [MemoryDiagnoser] - [WarmupCount(2)] - [IterationCount(1)] - [Config(typeof(Config))] - public class Benchmarks - { - private class Config : ManualConfig - { - public Config() => AddColumn(new SizeAfterCreationColumn()); - } - - [Params(1000, 10000, 100000)] - public int N; - private SQLiteTestRun _sqliteTestRun = null!; - private DoubletsTestRun _doubletsTestRun = null!; - - [GlobalSetup] - public void Setup() - { - BlogPosts.GenerateData(N); - _sqliteTestRun = new SQLiteTestRun("test.db"); - _doubletsTestRun = new DoubletsTestRun("test.links"); - } - - [Benchmark] - public void SQLite() => _sqliteTestRun.Run(); - - [IterationCleanup(Target = "SQLite")] - public void SQLiteOutput() - { - Directory.CreateDirectory(SizeAfterCreationColumn.DbSizeOutputFolder); - File.WriteAllText(Path.Combine(SizeAfterCreationColumn.DbSizeOutputFolder, $"disk-size.sqlite.{N}.txt"), _sqliteTestRun.Results.DbSizeAfterCreation.ToString()); - } - - [Benchmark] - public void Doublets() => _doubletsTestRun.Run(); - - [IterationCleanup(Target = "Doublets")] - public void DoubletsOutput() - { - Directory.CreateDirectory(SizeAfterCreationColumn.DbSizeOutputFolder); - File.WriteAllText(Path.Combine(SizeAfterCreationColumn.DbSizeOutputFolder, $"disk-size.doublets.{N}.txt"), _doubletsTestRun.Results.DbSizeAfterCreation.ToString()); - } - } -} diff --git a/csharp/Comparisons.SQLiteVSDoublets.csproj b/csharp/Comparisons.SQLiteVSDoublets.csproj deleted file mode 100644 index 58d6b82..0000000 --- a/csharp/Comparisons.SQLiteVSDoublets.csproj +++ /dev/null @@ -1,34 +0,0 @@ - - - - Exe - net8.0 - enable - - $(NoWarn);NETSDK1206 - - - - - - - - - - - - - - - - - - - - - - - diff --git a/csharp/Comparisons.SQLiteVSDoublets.sln b/csharp/Comparisons.SQLiteVSDoublets.sln deleted file mode 100644 index 98c6043..0000000 --- a/csharp/Comparisons.SQLiteVSDoublets.sln +++ /dev/null @@ -1,25 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio Version 16 - VisualStudioVersion = 16.0.29123.88 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Comparisons.SQLiteVSDoublets", "Comparisons.SQLiteVSDoublets.csproj", "{2B2D5048-3A81-4ED5-91CD-32744C970875}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - Release|Any CPU = Release|Any CPU - EndGlobalSection - GlobalSection(ProjectConfigurationPlatforms) = postSolution - {2B2D5048-3A81-4ED5-91CD-32744C970875}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {2B2D5048-3A81-4ED5-91CD-32744C970875}.Debug|Any CPU.Build.0 = Debug|Any CPU - {2B2D5048-3A81-4ED5-91CD-32744C970875}.Release|Any CPU.ActiveCfg = Release|Any CPU - {2B2D5048-3A81-4ED5-91CD-32744C970875}.Release|Any CPU.Build.0 = Release|Any CPU - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection - GlobalSection(ExtensibilityGlobals) = postSolution - SolutionGuid = {A6D1A7DB-8A3E-4789-8F89-8A343C96CCBD} - EndGlobalSection -EndGlobal diff --git a/csharp/Doublets/DoubletsDbContext.cs b/csharp/Doublets/DoubletsDbContext.cs deleted file mode 100644 index b1b7ecd..0000000 --- a/csharp/Doublets/DoubletsDbContext.cs +++ /dev/null @@ -1,414 +0,0 @@ -using System.Collections.Generic; -using System.Linq; -using Platform.Disposables; -using Platform.Collections.Lists; -using Platform.Collections.Stacks; -using Platform.Converters; -using Platform.Memory; -using Platform.Data; -using Platform.Data.Numbers.Raw; -using Platform.Data.Doublets; -using Platform.Data.Doublets.Decorators; -using Platform.Data.Doublets.PropertyOperators; -using Platform.Data.Doublets.CriterionMatchers; -using Platform.Data.Doublets.Sequences; -using Platform.Data.Doublets.Sequences.Unicode; -using Platform.Data.Doublets.Sequences.Time; -using Platform.Data.Doublets.Sequences.Walkers; -using Platform.Data.Doublets.Sequences.Converters; -using Platform.Data.Doublets.Sequences.CriterionMatchers; -using Platform.Data.Doublets.Sequences.Numbers.Raw; -using Platform.Data.Doublets.Memory.Split.Generic; -using Comparisons.SQLiteVSDoublets.Model; -using TLinkAddress = System.UInt32; - -namespace Comparisons.SQLiteVSDoublets.Doublets -{ - ///

- /// - /// Represents the doublets db context. - /// - /// - /// - /// - public class DoubletsDbContext : DisposableBase - { - /// - /// - /// The meaning root. - /// - /// - /// - private readonly TLinkAddress _meaningRoot; - /// - /// - /// The unicode symbol marker. - /// - /// - /// - private readonly TLinkAddress _unicodeSymbolMarker; - /// - /// - /// The unicode sequence marker. - /// - /// - /// - private readonly TLinkAddress _unicodeSequenceMarker; - /// - /// - /// The title property marker. - /// - /// - /// - private readonly TLinkAddress _titlePropertyMarker; - /// - /// - /// The content property marker. - /// - /// - /// - private readonly TLinkAddress _contentPropertyMarker; - /// - /// - /// The publication date time property marker. - /// - /// - /// - private readonly TLinkAddress _publicationDateTimePropertyMarker; - /// - /// - /// The blog post marker. - /// - /// - /// - private readonly TLinkAddress _blogPostMarker; - /// - /// - /// The default link property operator. - /// - /// - /// - private readonly PropertiesOperator _defaultLinkPropertyOperator; - /// - /// - /// The number to address converter. - /// - /// - /// - private readonly RawNumberToAddressConverter _numberToAddressConverter; - /// - /// - /// The address to number converter. - /// - /// - /// - private readonly AddressToRawNumberConverter _addressToNumberConverter; - /// - /// - /// The long raw number to date time converter. - /// - /// - /// - private readonly LongRawNumberSequenceToDateTimeConverter _longRawNumberToDateTimeConverter; - /// - /// - /// The date time to long raw number converter. - /// - /// - /// - private readonly DateTimeToLongRawNumberSequenceConverter _dateTimeToLongRawNumberConverter; - /// - /// - /// The string to unicode sequence converter. - /// - /// - /// - private readonly IConverter _stringToUnicodeSequenceConverter; - /// - /// - /// The unicode sequence to string converter. - /// - /// - /// - private readonly IConverter _unicodeSequenceToStringConverter; - /// - /// - /// The disposable links. - /// - /// - /// - private readonly ILinks _disposableLinks; - /// - /// - /// The links. - /// - /// - /// - private readonly ILinks _links; - - /// - /// - /// Initializes a new instance. - /// - /// - /// - /// - /// A data db filename. - /// - /// - /// - /// A index db filename. - /// - /// - public DoubletsDbContext(string dataDBFilename, string indexDBFilename) - { - var dataMemory = new FileMappedResizableDirectMemory(dataDBFilename); - var indexMemory = new FileMappedResizableDirectMemory(indexDBFilename); - - var linksConstants = new LinksConstants(enableExternalReferencesSupport: true); - - // Init the links storage - _disposableLinks = new SplitMemoryLinks(dataMemory, indexMemory); - _links = _disposableLinks; - - // Set up constant links (markers, aka mapped links) - TLinkAddress currentMappingLinkIndex = 1; - _meaningRoot = GetOrCreateMeaningRoot(currentMappingLinkIndex++); - _unicodeSymbolMarker = GetOrCreateNextMapping(currentMappingLinkIndex++); - _unicodeSequenceMarker = GetOrCreateNextMapping(currentMappingLinkIndex++); - _titlePropertyMarker = GetOrCreateNextMapping(currentMappingLinkIndex++); - _contentPropertyMarker = GetOrCreateNextMapping(currentMappingLinkIndex++); - _publicationDateTimePropertyMarker = GetOrCreateNextMapping(currentMappingLinkIndex++); - _blogPostMarker = GetOrCreateNextMapping(currentMappingLinkIndex++); - - // Create properties operator that is able to control reading and writing properties for any link (object) - _defaultLinkPropertyOperator = new PropertiesOperator(_links); - - // Create converters that are able to convert link's address (UInt64 value) to a raw number represented with another UInt64 value and back - _numberToAddressConverter = new RawNumberToAddressConverter(); - _addressToNumberConverter = new AddressToRawNumberConverter(); - - // Create converters for dates - _longRawNumberToDateTimeConverter = new LongRawNumberSequenceToDateTimeConverter(new LongRawNumberSequenceToNumberConverter(_links, _numberToAddressConverter)); - _dateTimeToLongRawNumberConverter = new DateTimeToLongRawNumberSequenceConverter(new NumberToLongRawNumberSequenceConverter(_links, _addressToNumberConverter)); - - // Create converters that are able to convert string to unicode sequence stored as link and back - var balancedVariantConverter = new BalancedVariantConverter(_links); - var unicodeSymbolCriterionMatcher = new TargetMatcher(_links, _unicodeSymbolMarker); - var unicodeSequenceCriterionMatcher = new TargetMatcher(_links, _unicodeSequenceMarker); - var charToUnicodeSymbolConverter = new CharToUnicodeSymbolConverter(_links, _addressToNumberConverter, _unicodeSymbolMarker); - var unicodeSymbolToCharConverter = new UnicodeSymbolToCharConverter(_links, _numberToAddressConverter, unicodeSymbolCriterionMatcher); - var sequenceWalker = new RightSequenceWalker(_links, new DefaultStack(), unicodeSymbolCriterionMatcher.IsMatched); - _stringToUnicodeSequenceConverter = new CachingConverterDecorator(new StringToUnicodeSequenceConverter(_links, charToUnicodeSymbolConverter, balancedVariantConverter, _unicodeSequenceMarker)); - _unicodeSequenceToStringConverter = new CachingConverterDecorator(new UnicodeSequenceToStringConverter(_links, unicodeSequenceCriterionMatcher, sequenceWalker, unicodeSymbolToCharConverter, _unicodeSequenceMarker)); - } - - /// - /// - /// Gets the or create meaning root using the specified meaning root index. - /// - /// - /// - /// - /// The meaning root index. - /// - /// - /// - /// The link address - /// - /// - private TLinkAddress GetOrCreateMeaningRoot(TLinkAddress meaningRootIndex) => _links.Exists(meaningRootIndex) ? meaningRootIndex : _links.CreatePoint(); - - /// - /// - /// Gets the or create next mapping using the specified current mapping index. - /// - /// - /// - /// - /// The current mapping index. - /// - /// - /// - /// The link address - /// - /// - private TLinkAddress GetOrCreateNextMapping(TLinkAddress currentMappingIndex) => _links.Exists(currentMappingIndex) ? currentMappingIndex : _links.CreateAndUpdate(_meaningRoot, _links.Constants.Itself); - - /// - /// - /// Converts the to string using the specified sequence. - /// - /// - /// - /// - /// The sequence. - /// - /// - /// - /// The string - /// - /// - public string ConvertToString(TLinkAddress sequence) => _unicodeSequenceToStringConverter.Convert(sequence); - - /// - /// - /// Converts the to sequence using the specified string. - /// - /// - /// - /// - /// The string. - /// - /// - /// - /// The link address - /// - /// - public TLinkAddress ConvertToSequence(string @string) => _stringToUnicodeSequenceConverter.Convert(@string); - - /// - /// - /// Gets the blog posts value. - /// - /// - /// - public IList BlogPosts => GetBlogPosts(); - - /// - /// - /// Gets the blog posts. - /// - /// - /// - /// - /// A list of blog post - /// - /// - public IList GetBlogPosts() - { - var list = new List>(); - var listFiller = new ListFiller, TLinkAddress>(list, _links.Constants.Continue); - // Load all links that match the query: (any: _blogPostMarker any) it means, link with any address, _blogPostMarker as source and any link as target. - // All links that match this query are BlogPosts. - var any = _links.Constants.Any; - var query = new Link(any, _blogPostMarker, any); - _links.Each(element => listFiller.AddAndReturnConstant(element!), query); - return list.Select(LoadBlogPost).ToList(); - } - - /// - /// - /// Loads the blog post using the specified post link. - /// - /// - /// - /// - /// The post link. - /// - /// - /// - /// The blog post - /// - /// - public BlogPost LoadBlogPost(IList postLink) => LoadBlogPost(postLink[_links.Constants.IndexPart]); - - /// - /// - /// Loads the blog post using the specified post link. - /// - /// - /// - /// - /// The post link. - /// - /// - /// - /// The blog post. - /// - /// - public BlogPost LoadBlogPost(TLinkAddress postLink) - { - var blogPost = new BlogPost(); - - blogPost.Id = (int)postLink; - - // Load Title property value from the links storage - var titleSequence = _defaultLinkPropertyOperator.GetValue(postLink, _titlePropertyMarker); - blogPost.Title = ConvertToString(titleSequence); - - // Load Content property value from the links storage - var contentSequence = _defaultLinkPropertyOperator.GetValue(postLink, _contentPropertyMarker); - blogPost.Content = ConvertToString(contentSequence); - - // Load PublicationDateTime property value from the links storage - blogPost.PublicationDateTime = _longRawNumberToDateTimeConverter.Convert(_defaultLinkPropertyOperator.GetValue(postLink, _publicationDateTimePropertyMarker)); - - return blogPost; - } - - /// - /// - /// Saves the blog post using the specified post. - /// - /// - /// - /// - /// The post. - /// - /// - /// - /// The new post link. - /// - /// - public TLinkAddress SaveBlogPost(BlogPost post) - { - var newPostLink = _links.CreateAndUpdate(_blogPostMarker, _links.Constants.Itself); - - // Save Title property value to the links storage - _defaultLinkPropertyOperator.SetValue(newPostLink, _titlePropertyMarker, ConvertToSequence(post.Title)); - - // Save Content property value to the links storage - _defaultLinkPropertyOperator.SetValue(newPostLink, _contentPropertyMarker, ConvertToSequence(post.Content)); - - // Save PublicationDateTime property value to the links storage - _defaultLinkPropertyOperator.SetValue(newPostLink, _publicationDateTimePropertyMarker, _dateTimeToLongRawNumberConverter.Convert(post.PublicationDateTime)); - - return newPostLink; - } - - /// - /// - /// Deletes the link. - /// - /// - /// - /// - /// The link. - /// - /// - public void Delete(TLinkAddress link) => _links.Delete(link); - - /// - /// - /// Disposes the manual. - /// - /// - /// - /// - /// The manual. - /// - /// - /// - /// The was disposed. - /// - /// - protected override void Dispose(bool manual, bool wasDisposed) - { - if (!wasDisposed) - { - _disposableLinks.DisposeIfPossible(); - } - } - } -} diff --git a/csharp/Doublets/DoubletsTestRun.cs b/csharp/Doublets/DoubletsTestRun.cs deleted file mode 100644 index f00e11d..0000000 --- a/csharp/Doublets/DoubletsTestRun.cs +++ /dev/null @@ -1,117 +0,0 @@ -using System.IO; -using Platform.IO; -using Comparisons.SQLiteVSDoublets.Model; - -namespace Comparisons.SQLiteVSDoublets.Doublets -{ - /// - /// - /// Represents the doublets test run. - /// - /// - /// - /// - public class DoubletsTestRun : TestRun - { - /// - /// - /// Gets the db index filename value. - /// - /// - /// - public string DbIndexFilename { get; } - - /// - /// - /// Initializes a new instance. - /// - /// - /// - /// - /// A db filename. - /// - /// - public DoubletsTestRun(string dbFilename) : base(dbFilename) => DbIndexFilename = $"{Path.GetFileNameWithoutExtension(dbFilename)}.links.index"; - - /// - /// - /// Prepares this instance. - /// - /// - /// - public override void Prepare() - { - using var dbContext = new DoubletsDbContext(DbFilename, DbIndexFilename); - } - - /// - /// - /// Creates the list. - /// - /// - /// - public override void CreateList() - { - using var dbContext = new DoubletsDbContext(DbFilename, DbIndexFilename); - foreach (var blogPost in BlogPosts.List) - { - dbContext.SaveBlogPost(blogPost); - } - } - - /// - /// - /// Reads the list. - /// - /// - /// - public override void ReadList() - { - using var dbContext = new DoubletsDbContext(DbFilename, DbIndexFilename); - foreach (var blogPost in dbContext.BlogPosts) - { - ReadBlogPosts.Add(blogPost); - } - } - - /// - /// - /// Deletes the list. - /// - /// - /// - public override void DeleteList() - { - using var dbContext = new DoubletsDbContext(DbFilename, DbIndexFilename); - var blogPostsToDelete = dbContext.BlogPosts; - foreach (var blogPost in blogPostsToDelete) - { - dbContext.Delete((uint)blogPost.Id); - } - } - - /// - /// - /// Deletes the database. - /// - /// - /// - protected override void DeleteDatabase() - { - File.Delete(DbFilename); - File.Delete(DbIndexFilename); - } - - /// - /// - /// Gets the database size in bytes. - /// - /// - /// - /// - /// The long - /// - /// - protected override long GetDatabaseSizeInBytes() => FileHelpers.GetSize(DbFilename) + FileHelpers.GetSize(DbIndexFilename); - } -} diff --git a/csharp/Links/DoubletsLinksStorage.cs b/csharp/Links/DoubletsLinksStorage.cs deleted file mode 100644 index e899390..0000000 --- a/csharp/Links/DoubletsLinksStorage.cs +++ /dev/null @@ -1,78 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using Platform.Data.Doublets; -using Platform.Disposables; - -namespace Comparisons.SQLiteVSDoublets.Links; - -public sealed class DoubletsLinksStorage : ILinksStorage -{ - private readonly ILinks _links; - private readonly IReadOnlyList _paths; - - public DoubletsLinksStorage(ILinks links, params string[] paths) - { - _links = links; - _paths = paths; - } - - public uint Create(uint source, uint target) => _links.CreateAndUpdate(source, target); - - public uint CreatePoint() => _links.CreatePoint(); - - public void Update(uint id, uint source, uint target) => _links.Update(id, source, target); - - public void Delete(uint id) => Platform.Data.ILinksExtensions.Delete(_links, id); - - public IReadOnlyList QueryAll() - { - var any = _links.Constants.Any; - return Convert(_links.All(any, any, any)); - } - - public LinkRecord? QueryById(uint id) - { - var any = _links.Constants.Any; - var result = _links.All(id, any, any); - return result.Count == 0 || result[0] is null ? null : Convert(result[0]!); - } - - public IReadOnlyList QueryBySourceTarget(uint source, uint target) - { - var any = _links.Constants.Any; - return Convert(_links.All(any, source, target)); - } - - public IReadOnlyList QueryBySource(uint source) - { - var any = _links.Constants.Any; - return Convert(_links.All(any, source, any)); - } - - public IReadOnlyList QueryByTarget(uint target) - { - var any = _links.Constants.Any; - return Convert(_links.All(any, any, target)); - } - - public int Count => QueryAll().Count; - - public void Dispose() - { - _links.DisposeIfPossible(); - foreach (var path in _paths.Where(File.Exists)) - { - File.Delete(path); - } - } - - private LinkRecord Convert(IList link) => new( - _links.GetIndex(link), - _links.GetSource(link), - _links.GetTarget(link)); - - private List Convert(IList?> links) => - links.Where(link => link is not null).Select(link => Convert(link!)).ToList(); -} diff --git a/csharp/Links/ILinksStorage.cs b/csharp/Links/ILinksStorage.cs deleted file mode 100644 index 606f5a7..0000000 --- a/csharp/Links/ILinksStorage.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Comparisons.SQLiteVSDoublets.Links; - -public interface ILinksStorage : IDisposable -{ - uint Create(uint source, uint target); - - uint CreatePoint(); - - void Update(uint id, uint source, uint target); - - void Delete(uint id); - - IReadOnlyList QueryAll(); - - LinkRecord? QueryById(uint id); - - IReadOnlyList QueryBySourceTarget(uint source, uint target); - - IReadOnlyList QueryBySource(uint source); - - IReadOnlyList QueryByTarget(uint target); - - int Count { get; } -} diff --git a/csharp/Links/LinkRecord.cs b/csharp/Links/LinkRecord.cs deleted file mode 100644 index 3b75d83..0000000 --- a/csharp/Links/LinkRecord.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace Comparisons.SQLiteVSDoublets.Links; - -public readonly record struct LinkRecord(uint Id, uint Source, uint Target); diff --git a/csharp/Links/LinksData.cs b/csharp/Links/LinksData.cs deleted file mode 100644 index 4bf84d2..0000000 --- a/csharp/Links/LinksData.cs +++ /dev/null @@ -1,38 +0,0 @@ -using System; -using System.Collections.Generic; - -namespace Comparisons.SQLiteVSDoublets.Links; - -public static class LinksData -{ - public static uint[] FillBackground(ILinksStorage storage, int count) - { - var ids = new uint[count]; - for (var index = 0; index < count; index++) - { - ids[index] = storage.CreatePoint(); - } - return ids; - } - - public static LinkRecord[] FillBenchmarked( - ILinksStorage storage, - IReadOnlyList background, - int count) - { - if (background.Count == 0) - { - throw new ArgumentException("Background links are required.", nameof(background)); - } - - var links = new LinkRecord[count]; - for (var index = 0; index < count; index++) - { - var source = background[index % background.Count]; - var target = background[((index % background.Count) + 1 + (index / background.Count)) - % background.Count]; - links[index] = new LinkRecord(storage.Create(source, target), source, target); - } - return links; - } -} diff --git a/csharp/Links/LinksStorageFactory.cs b/csharp/Links/LinksStorageFactory.cs deleted file mode 100644 index d6bad6a..0000000 --- a/csharp/Links/LinksStorageFactory.cs +++ /dev/null @@ -1,55 +0,0 @@ -using System; -using System.IO; -using Platform.Data.Doublets; -using Platform.Data.Doublets.Memory.Split.Generic; -using Platform.Data.Doublets.Memory.United.Generic; -using Platform.Memory; - -namespace Comparisons.SQLiteVSDoublets.Links; - -public enum LinksVariant -{ - SQLite_Memory, - SQLite_File, - Doublets_United_Volatile, - Doublets_United_NonVolatile, - Doublets_Split_Volatile, - Doublets_Split_NonVolatile, -} - -public static class LinksStorageFactory -{ - public static ILinksStorage Create(LinksVariant variant) => variant switch - { - LinksVariant.SQLite_Memory => new SQLiteLinksStorage(inMemory: true), - LinksVariant.SQLite_File => new SQLiteLinksStorage(inMemory: false), - LinksVariant.Doublets_United_Volatile => new DoubletsLinksStorage( - new UnitedMemoryLinks(new HeapResizableDirectMemory())), - LinksVariant.Doublets_United_NonVolatile => CreateUnitedFile(), - LinksVariant.Doublets_Split_Volatile => new DoubletsLinksStorage( - new SplitMemoryLinks( - new HeapResizableDirectMemory(), - new HeapResizableDirectMemory())), - LinksVariant.Doublets_Split_NonVolatile => CreateSplitFile(), - _ => throw new ArgumentOutOfRangeException(nameof(variant), variant, null), - }; - - private static DoubletsLinksStorage CreateUnitedFile() - { - var path = TemporaryPath("united.links"); - ILinks links = new UnitedMemoryLinks(path); - return new DoubletsLinksStorage(links, path); - } - - private static DoubletsLinksStorage CreateSplitFile() - { - var dataPath = TemporaryPath("split.data.links"); - var indexPath = TemporaryPath("split.index.links"); - ILinks links = new SplitMemoryLinks(dataPath, indexPath); - return new DoubletsLinksStorage(links, dataPath, indexPath); - } - - private static string TemporaryPath(string suffix) => Path.Combine( - Path.GetTempPath(), - $"sqlite-vs-doublets-{Guid.NewGuid():N}-{suffix}"); -} diff --git a/csharp/Links/SQLiteLinksStorage.cs b/csharp/Links/SQLiteLinksStorage.cs deleted file mode 100644 index f46a53b..0000000 --- a/csharp/Links/SQLiteLinksStorage.cs +++ /dev/null @@ -1,150 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using Microsoft.Data.Sqlite; - -namespace Comparisons.SQLiteVSDoublets.Links; - -public sealed class SQLiteLinksStorage : ILinksStorage -{ - private readonly SqliteConnection _connection; - private readonly string? _path; - private uint _nextId = 1; - - public SQLiteLinksStorage(bool inMemory) - { - _path = inMemory - ? null - : Path.Combine(Path.GetTempPath(), $"sqlite-vs-doublets-{Guid.NewGuid():N}.db"); - var dataSource = _path ?? ":memory:"; - var connectionString = new SqliteConnectionStringBuilder - { - DataSource = dataSource, - // A pooled native connection keeps temporary database files open - // after Dispose, which prevents their immediate removal on Windows. - Pooling = false, - }.ToString(); - _connection = new SqliteConnection(connectionString); - _connection.Open(); - using var command = _connection.CreateCommand(); - command.CommandText = """ - CREATE TABLE links ( - id INTEGER PRIMARY KEY, - source INTEGER NOT NULL, - target INTEGER NOT NULL - ); - CREATE INDEX idx_source ON links(source); - CREATE INDEX idx_target ON links(target); - CREATE INDEX idx_source_target ON links(source, target); - """; - command.ExecuteNonQuery(); - } - - public uint Create(uint source, uint target) - { - var id = _nextId++; - using var command = Command( - "INSERT INTO links (id, source, target) VALUES ($id, $source, $target)", - ("$id", id), - ("$source", source), - ("$target", target)); - command.ExecuteNonQuery(); - return id; - } - - public uint CreatePoint() - { - var id = Create(0, 0); - Update(id, id, id); - return id; - } - - public void Update(uint id, uint source, uint target) - { - using var command = Command( - "UPDATE links SET source = $source, target = $target WHERE id = $id", - ("$source", source), - ("$target", target), - ("$id", id)); - command.ExecuteNonQuery(); - } - - public void Delete(uint id) - { - using var command = Command("DELETE FROM links WHERE id = $id", ("$id", id)); - command.ExecuteNonQuery(); - } - - public IReadOnlyList QueryAll() => Query( - "SELECT id, source, target FROM links"); - - public LinkRecord? QueryById(uint id) - { - var links = Query( - "SELECT id, source, target FROM links WHERE id = $id", - ("$id", id)); - return links.Count == 0 ? null : links[0]; - } - - public IReadOnlyList QueryBySourceTarget(uint source, uint target) => Query( - "SELECT id, source, target FROM links WHERE source = $source AND target = $target", - ("$source", source), - ("$target", target)); - - public IReadOnlyList QueryBySource(uint source) => Query( - "SELECT id, source, target FROM links WHERE source = $source", - ("$source", source)); - - public IReadOnlyList QueryByTarget(uint target) => Query( - "SELECT id, source, target FROM links WHERE target = $target", - ("$target", target)); - - public int Count - { - get - { - using var command = _connection.CreateCommand(); - command.CommandText = "SELECT COUNT(*) FROM links"; - return Convert.ToInt32(command.ExecuteScalar()); - } - } - - public void Dispose() - { - _connection.Dispose(); - if (_path is not null) - { - File.Delete(_path); - } - } - - private SqliteCommand Command( - string sql, - params (string Name, uint Value)[] parameters) - { - var command = _connection.CreateCommand(); - command.CommandText = sql; - foreach (var parameter in parameters) - { - command.Parameters.AddWithValue(parameter.Name, parameter.Value); - } - return command; - } - - private List Query( - string sql, - params (string Name, uint Value)[] parameters) - { - using var command = Command(sql, parameters); - using var reader = command.ExecuteReader(); - var result = new List(); - while (reader.Read()) - { - result.Add(new LinkRecord( - checked((uint)reader.GetInt64(0)), - checked((uint)reader.GetInt64(1)), - checked((uint)reader.GetInt64(2)))); - } - return result; - } -} diff --git a/csharp/LinksBenchmarks.cs b/csharp/LinksBenchmarks.cs deleted file mode 100644 index bf3545f..0000000 --- a/csharp/LinksBenchmarks.cs +++ /dev/null @@ -1,149 +0,0 @@ -using System; -using System.Collections.Generic; -using BenchmarkDotNet.Attributes; -using Comparisons.SQLiteVSDoublets.Links; - -namespace Comparisons.SQLiteVSDoublets; - -[MemoryDiagnoser] -public class LinksBenchmarks -{ - private ILinksStorage _storage = null!; - private uint[] _background = null!; - private uint[] _updateSources = null!; - private LinkRecord[] _links = null!; - - [ParamsAllValues] - public LinksVariant Variant { get; set; } - - [ParamsSource(nameof(LinkCounts))] - public int N { get; set; } - - public IEnumerable LinkCounts => new[] { EnvironmentValue("BENCHMARK_LINK_COUNT", 1000) }; - - [IterationSetup(Target = nameof(Create))] - public void SetupCreate() => Setup(createBenchmarked: false); - - [IterationSetup(Target = nameof(Update))] - public void SetupUpdate() - { - Setup(createBenchmarked: true); - _updateSources = LinksData.FillBackground(_storage, N); - } - - [IterationSetup(Target = nameof(Delete))] - public void SetupDelete() => Setup(createBenchmarked: true); - - [IterationSetup(Target = nameof(EachAll))] - public void SetupEachAll() => Setup(createBenchmarked: true); - - [IterationSetup(Target = nameof(EachIdentity))] - public void SetupEachIdentity() => Setup(createBenchmarked: true); - - [IterationSetup(Target = nameof(EachConcrete))] - public void SetupEachConcrete() => Setup(createBenchmarked: true); - - [IterationSetup(Target = nameof(EachOutgoing))] - public void SetupEachOutgoing() => Setup(createBenchmarked: true); - - [IterationSetup(Target = nameof(EachIncoming))] - public void SetupEachIncoming() => Setup(createBenchmarked: true); - - [IterationCleanup] - public void Cleanup() => _storage.Dispose(); - - [Benchmark] - public uint Create() - { - var created = LinksData.FillBenchmarked(_storage, _background, N); - return created[^1].Id; - } - - [Benchmark] - public uint Update() - { - var last = 0U; - for (var index = 0; index < _links.Length; index++) - { - _storage.Update(_links[index].Id, _updateSources[index], _background[0]); - last = _links[index].Id; - } - return last; - } - - [Benchmark] - public uint Delete() - { - var last = 0U; - foreach (var link in _links) - { - _storage.Delete(link.Id); - last = link.Id; - } - return last; - } - - [Benchmark] - public int EachAll() => _storage.QueryAll().Count; - - [Benchmark] - public ulong EachIdentity() - { - var checksum = 0UL; - foreach (var link in _links) - { - checksum += _storage.QueryById(link.Id)?.Id ?? 0; - } - return checksum; - } - - [Benchmark] - public int EachConcrete() - { - var count = 0; - foreach (var link in _links) - { - count += _storage.QueryBySourceTarget(link.Source, link.Target).Count; - } - return count; - } - - [Benchmark] - public int EachOutgoing() - { - var count = 0; - foreach (var link in _links) - { - count += _storage.QueryBySource(link.Source).Count; - } - return count; - } - - [Benchmark] - public int EachIncoming() - { - var count = 0; - foreach (var link in _links) - { - count += _storage.QueryByTarget(link.Target).Count; - } - return count; - } - - private void Setup(bool createBenchmarked) - { - _storage = LinksStorageFactory.Create(Variant); - _background = LinksData.FillBackground( - _storage, - EnvironmentValue("BACKGROUND_LINK_COUNT", 3000)); - _links = createBenchmarked - ? LinksData.FillBenchmarked(_storage, _background, N) - : Array.Empty(); - _updateSources = Array.Empty(); - } - - private static int EnvironmentValue(string name, int fallback) => - int.TryParse(Environment.GetEnvironmentVariable(name), out var value) && value > 0 - ? value - : fallback; -} diff --git a/csharp/LinksSelfTest.cs b/csharp/LinksSelfTest.cs deleted file mode 100644 index 4ef627d..0000000 --- a/csharp/LinksSelfTest.cs +++ /dev/null @@ -1,58 +0,0 @@ -using System; -using System.Linq; -using Comparisons.SQLiteVSDoublets.Links; - -namespace Comparisons.SQLiteVSDoublets; - -public static class LinksSelfTest -{ - public static void Run() - { - foreach (var variant in Enum.GetValues()) - { - Check(variant); - Console.WriteLine($"{variant}: passed"); - } - } - - private static void Check(LinksVariant variant) - { - using var storage = LinksStorageFactory.Create(variant); - var background = LinksData.FillBackground(storage, 8); - var created = LinksData.FillBenchmarked(storage, background, 16); - Ensure(storage.Count == 24, $"{variant}: unexpected link count"); - Ensure(storage.QueryAll().Count == 24, $"{variant}: query all failed"); - - var sample = created[created.Length / 2]; - Ensure(storage.QueryById(sample.Id) == sample, $"{variant}: identity query failed"); - Ensure( - storage.QueryBySourceTarget(sample.Source, sample.Target).Contains(sample), - $"{variant}: concrete query failed"); - Ensure( - storage.QueryBySource(sample.Source).Contains(sample), - $"{variant}: outgoing query failed"); - Ensure( - storage.QueryByTarget(sample.Target).Contains(sample), - $"{variant}: incoming query failed"); - - var unused = storage.CreatePoint(); - storage.Update(sample.Id, unused, background[0]); - var updated = storage.QueryById(sample.Id); - Ensure( - updated is { Source: var source, Target: var target } - && source == unused - && target == background[0], - $"{variant}: update failed"); - - storage.Delete(sample.Id); - Ensure(storage.QueryById(sample.Id) is null, $"{variant}: delete failed"); - } - - private static void Ensure(bool condition, string message) - { - if (!condition) - { - throw new InvalidOperationException(message); - } - } -} diff --git a/csharp/Model/BlogPost.cs b/csharp/Model/BlogPost.cs deleted file mode 100644 index 16ad446..0000000 --- a/csharp/Model/BlogPost.cs +++ /dev/null @@ -1,62 +0,0 @@ -using System; -using System.ComponentModel.DataAnnotations; - -namespace Comparisons.SQLiteVSDoublets.Model -{ - /// - /// - /// Represents the blog post. - /// - /// - /// - public class BlogPost - { - /// - /// - /// Gets or sets the id value. - /// - /// - /// - [Key] - public int Id { get; set; } - - /// - /// - /// Gets or sets the title value. - /// - /// - /// - [Required] - public string Title { get; set; } = string.Empty; - - /// - /// - /// Gets or sets the content value. - /// - /// - /// - [Required] - public string Content { get; set; } = string.Empty; - - /// - /// - /// Gets or sets the publication date time value. - /// - /// - /// - [Required] - public DateTime PublicationDateTime { get; set; } - - /// - /// - /// Returns the string. - /// - /// - /// - /// - /// The string - /// - /// - public override string ToString() => $"ID={Id}\tTitle={Title}\tContent=\tPublicationDateTime={PublicationDateTime}"; - } -} diff --git a/csharp/Model/BlogPosts.cs b/csharp/Model/BlogPosts.cs deleted file mode 100644 index 39d577e..0000000 --- a/csharp/Model/BlogPosts.cs +++ /dev/null @@ -1,68 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Collections.ObjectModel; - -namespace Comparisons.SQLiteVSDoublets.Model -{ - /// - /// - /// Represents the blog posts. - /// - /// - /// - public static class BlogPosts - { - /// - /// - /// Gets or sets the list value. - /// - /// - /// - public static IReadOnlyList List { get; private set; } - - /// - /// - /// Initializes a new instance. - /// - /// - /// - static BlogPosts() - { - List = new List(); - } - - /// - /// - /// Generates the data using the specified number of records. - /// - /// - /// - /// - /// The number of records. - /// - /// - static public void GenerateData(int numberOfRecords) - { - var contentStrings = new string[] - { - "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis malesuada blandit mauris nec bibendum. Phasellus feugiat vehicula mauris et aliquet. Integer et gravida velit, in rutrum leo. Duis pretium, nunc ac posuere porttitor, augue sapien commodo tortor, nec consequat lorem eros ultricies odio. Aliquam varius congue ex nec viverra. Pellentesque eu velit tellus. Donec ac luctus nisi. Curabitur dignissim sodales mauris eu semper. Ut pretium lorem nulla, sit amet auctor arcu placerat vitae. Quisque lacinia dolor et consectetur fermentum. Nam ac orci vitae nulla aliquam tempor ac a nibh. Ut ac tincidunt lacus. Morbi vitae felis lorem.", - "Curabitur tincidunt nibh sit amet finibus dictum. Suspendisse aliquet arcu non rutrum ultrices. Integer ullamcorper mauris sit amet nibh aliquam, et tempor turpis hendrerit. In molestie elit et mauris rutrum, non auctor ligula ultricies. Vestibulum dignissim mauris finibus libero interdum hendrerit. Nunc vitae ipsum porttitor, egestas magna ut, sagittis sem. Donec euismod ac tortor vel porta. Vivamus convallis, ex at vestibulum rutrum, velit purus venenatis metus, sit amet aliquam sapien nibh quis elit. Aenean id neque a orci sodales venenatis. Integer ut orci ligula. Interdum et malesuada fames ac ante ipsum primis in faucibus. Praesent molestie dolor non lobortis ornare. Duis quis nisl sollicitudin, accumsan ante sed, eleifend velit. Maecenas maximus sed ante nec auctor.", - "Donec vitae felis lectus. Aenean velit sapien, porttitor ut feugiat a, consectetur et risus. Proin ac viverra sem. Nullam sagittis ex tortor, eu pellentesque tellus efficitur at. Nunc non egestas leo. Nam sed suscipit neque. Nam sodales vel neque eget eleifend. Vivamus in condimentum elit, consectetur commodo ex. Suspendisse rutrum, sapien efficitur cursus sodales, dolor orci pulvinar mauris, eu fringilla leo ex id leo. Interdum et malesuada fames ac ante ipsum primis in faucibus. Proin rhoncus sapien massa, molestie vestibulum augue hendrerit nec. Aliquam malesuada varius sapien id accumsan. Duis blandit aliquet felis, nec pellentesque lacus tincidunt et. Cras sed ligula vel nisl laoreet sagittis. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Praesent tristique a neque aliquet aliquam.", - "Aliquam sed egestas felis. Maecenas sollicitudin nisl in sapien posuere vulputate. Suspendisse eleifend sem magna, interdum consectetur augue venenatis at. Vivamus ornare orci vel orci sodales maximus. Donec ultricies felis ac nulla fermentum gravida. Phasellus vulputate turpis odio, a varius nibh luctus et. Aliquam tincidunt, metus ut congue porttitor, nibh dui ullamcorper quam, a eleifend elit ipsum sit amet quam. Aenean venenatis mollis interdum. Nunc cursus ex sit amet enim lacinia hendrerit. Nullam at libero iaculis, consectetur velit in, porta sem. Ut mattis ut ex in imperdiet. Maecenas pellentesque sit amet dui eget vehicula. Sed posuere, arcu pretium convallis tincidunt, turpis leo dignissim felis, non euismod diam magna a risus. Suspendisse a arcu nec turpis pulvinar ullamcorper. Nunc iaculis malesuada elit eu pretium. Aenean a neque a sapien tincidunt faucibus.", - "Ut a eleifend augue, eget posuere augue. Proin purus neque, pretium condimentum ipsum ut, venenatis tincidunt nunc. In vitae odio in justo pharetra tincidunt. Maecenas vel tellus interdum, suscipit tellus sit amet, cursus justo. Mauris sollicitudin euismod molestie. Cras eros nisi, molestie vel elementum ut, consequat ac nunc. In consectetur nulla vitae interdum elementum. Praesent faucibus magna et iaculis congue. Curabitur convallis cursus porttitor. Praesent hendrerit justo ut sem convallis sollicitudin eu at odio." - }; - var blogPosts = new BlogPost[numberOfRecords]; - Random random = new Random(); - for (int i = 0; i < numberOfRecords; i++) - { - blogPosts[i] = new BlogPost() - { - Title = $"Blog post {i + 1}", - Content = contentStrings[random.Next(0, 5)], - PublicationDateTime = DateTime.UtcNow.AddDays(random.NextDouble() * -30) - }; - } - List = new ReadOnlyCollection(blogPosts); - } - } -} diff --git a/csharp/Program.cs b/csharp/Program.cs deleted file mode 100644 index 7b376eb..0000000 --- a/csharp/Program.cs +++ /dev/null @@ -1,101 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using BenchmarkDotNet.Configs; -using BenchmarkDotNet.Exporters; -using BenchmarkDotNet.Exporters.Json; -using BenchmarkDotNet.Jobs; -using BenchmarkDotNet.Reports; -using BenchmarkDotNet.Running; -using Comparisons.SQLiteVSDoublets.Doublets; -using Comparisons.SQLiteVSDoublets.Model; -using Comparisons.SQLiteVSDoublets.SQLite; - -namespace Comparisons.SQLiteVSDoublets; - -internal static class Program -{ - private static void Main(string[] args) - { - if (args.Length == 1 && args[0] == "--self-test") - { - LinksSelfTest.Run(); - return; - } - - if (args.Length == 1 && args[0] == "--manual-object-test") - { - RunManualObjectTest(); - return; - } - - if (args.Length == 0) - { - // Preserve the original entry point for the object-like benchmark. - BenchmarkRunner.Run(); - return; - } - - BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, BenchmarkConfig()); - } - - private static void RunManualObjectTest() - { - const int numberOfTestRuns = 1; - const int numberOfRecordsPerTestRun = 1; - BlogPosts.GenerateData(numberOfRecordsPerTestRun); - var sqliteTestRuns = new List(); - var doubletsTestRuns = new List(); - for (var index = 0; index < numberOfTestRuns; index++) - { - var sqliteTestRun = new SQLiteTestRun("test.db"); - sqliteTestRun.Run(); - sqliteTestRuns.Add(sqliteTestRun); - var doubletsTestRun = new DoubletsTestRun("test.links"); - doubletsTestRun.Run(); - doubletsTestRuns.Add(doubletsTestRun); - } - Console.WriteLine("SQLite results:"); - Console.WriteLine(GetResultsAverage(sqliteTestRuns)); - Console.WriteLine("Doublets results:"); - Console.WriteLine(GetResultsAverage(doubletsTestRuns)); - } - - private static TestRunResults GetResultsAverage(IEnumerable testRuns) => new() - { - PrepareTime = new TimeSpan( - (long)testRuns.Select(run => run.Results.PrepareTime.Ticks).Average()), - DbSizeAfterPrepare = (long)testRuns.Select( - run => run.Results.DbSizeAfterPrepare).Average(), - ListCreationTime = new TimeSpan( - (long)testRuns.Select(run => run.Results.ListCreationTime.Ticks).Average()), - DbSizeAfterCreation = (long)testRuns.Select( - run => run.Results.DbSizeAfterCreation).Average(), - ListReadingTime = new TimeSpan( - (long)testRuns.Select(run => run.Results.ListReadingTime.Ticks).Average()), - DbSizeAfterReading = (long)testRuns.Select( - run => run.Results.DbSizeAfterReading).Average(), - ListDeletionTime = new TimeSpan( - (long)testRuns.Select(run => run.Results.ListDeletionTime.Ticks).Average()), - DbSizeAfterDeletion = (long)testRuns.Select( - run => run.Results.DbSizeAfterDeletion).Average(), - }; - - private static IConfig BenchmarkConfig() - { - var warmups = EnvironmentValue("BENCHMARK_WARMUP_COUNT", 1); - var iterations = EnvironmentValue("BENCHMARK_ITERATION_COUNT", 3); - var job = Job.Default - .WithWarmupCount(warmups) - .WithIterationCount(iterations); - return ManualConfig.Create(DefaultConfig.Instance) - .AddJob(job) - .AddExporter(JsonExporter.Full) - .WithSummaryStyle(SummaryStyle.Default.WithMaxParameterColumnWidth(40)); - } - - private static int EnvironmentValue(string name, int fallback) => - int.TryParse(Environment.GetEnvironmentVariable(name), out var value) && value > 0 - ? value - : fallback; -} diff --git a/csharp/SQLite/SQLiteDbContext.cs b/csharp/SQLite/SQLiteDbContext.cs deleted file mode 100644 index fdf6a74..0000000 --- a/csharp/SQLite/SQLiteDbContext.cs +++ /dev/null @@ -1,85 +0,0 @@ -using System.Reflection; -using Microsoft.EntityFrameworkCore; -using Comparisons.SQLiteVSDoublets.Model; - -namespace Comparisons.SQLiteVSDoublets.SQLite -{ - /// - /// - /// Represents the sq lite db context. - /// - /// - /// - /// - public class SQLiteDbContext : DbContext - { - /// - /// - /// The db filename. - /// - /// - /// - private readonly string _dbFilename; - - /// - /// - /// Gets or sets the blog posts value. - /// - /// - /// - public DbSet BlogPosts { get; set; } - - /// - /// - /// Initializes a new instance. - /// - /// - /// - /// - /// A db filename. - /// - /// - public SQLiteDbContext(string dbFilename) => _dbFilename = dbFilename; - - /// - /// - /// Ons the configuring using the specified options builder. - /// - /// - /// - /// - /// The options builder. - /// - /// - protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) - { - optionsBuilder.UseSqlite($"Filename={_dbFilename}", options => - { - options.MigrationsAssembly(Assembly.GetExecutingAssembly().FullName); - }); - base.OnConfiguring(optionsBuilder); - } - - /// - /// - /// Ons the model creating using the specified model builder. - /// - /// - /// - /// - /// The model builder. - /// - /// - protected override void OnModelCreating(ModelBuilder modelBuilder) - { - modelBuilder.Entity().ToTable("BlogPosts", "test"); - modelBuilder.Entity(entity => - { - entity.HasKey(e => e.Id); - entity.HasIndex(e => e.Title).IsUnique(); - entity.Property(e => e.PublicationDateTime).HasDefaultValueSql("CURRENT_TIMESTAMP"); - }); - base.OnModelCreating(modelBuilder); - } - } -} diff --git a/csharp/SQLite/SQLiteTestRun.cs b/csharp/SQLite/SQLiteTestRun.cs deleted file mode 100644 index bfd7697..0000000 --- a/csharp/SQLite/SQLiteTestRun.cs +++ /dev/null @@ -1,81 +0,0 @@ -using System.Linq; -using Comparisons.SQLiteVSDoublets.Model; - -namespace Comparisons.SQLiteVSDoublets.SQLite -{ - /// - /// - /// Represents the sq lite test run. - /// - /// - /// - /// - public class SQLiteTestRun : TestRun - { - /// - /// - /// Initializes a new instance. - /// - /// - /// - /// - /// A db filename. - /// - /// - public SQLiteTestRun(string dbFilename) : base(dbFilename) { } - - /// - /// - /// Prepares this instance. - /// - /// - /// - public override void Prepare() - { - using var dbContext = new SQLiteDbContext(DbFilename); - dbContext.Database.EnsureCreated(); - } - - /// - /// - /// Creates the list. - /// - /// - /// - public override void CreateList() - { - using var dbContext = new SQLiteDbContext(DbFilename); - dbContext.BlogPosts.AddRange(BlogPosts.List); - dbContext.SaveChanges(); - } - - /// - /// - /// Reads the list. - /// - /// - /// - public override void ReadList() - { - using var dbContext = new SQLiteDbContext(DbFilename); - foreach (var blogPost in dbContext.BlogPosts) - { - ReadBlogPosts.Add(blogPost); - } - } - - /// - /// - /// Deletes the list. - /// - /// - /// - public override void DeleteList() - { - using var dbContext = new SQLiteDbContext(DbFilename); - var blogPostsToDelete = dbContext.BlogPosts.ToList(); - dbContext.BlogPosts.RemoveRange(blogPostsToDelete); - dbContext.SaveChanges(); - } - } -} diff --git a/csharp/SQLiteVSDoublets.Tests/SQLiteVSDoublets.Tests.csproj b/csharp/SQLiteVSDoublets.Tests/SQLiteVSDoublets.Tests.csproj new file mode 100644 index 0000000..23debdc --- /dev/null +++ b/csharp/SQLiteVSDoublets.Tests/SQLiteVSDoublets.Tests.csproj @@ -0,0 +1,20 @@ + + + + net10.0 + enable + enable + Comparisons.SQLiteVSDoublets.Tests + true + Exe + + + + + + + + + + + diff --git a/csharp/SQLiteVSDoublets.Tests/StoragesTests.cs b/csharp/SQLiteVSDoublets.Tests/StoragesTests.cs new file mode 100644 index 0000000..c898bd9 --- /dev/null +++ b/csharp/SQLiteVSDoublets.Tests/StoragesTests.cs @@ -0,0 +1,111 @@ +using Platform.Memory; +using Xunit; +using static Comparisons.SQLiteVSDoublets.Dataset; + +namespace Comparisons.SQLiteVSDoublets.Tests; + +public class StoragesTests +{ + private const ulong Size = 1_000; + + [Fact] + public void LinksAreUniqueAndReferenceCreatedIds() + { + var links = new HashSet<(ulong, ulong)>(); + for (ulong i = 1; i <= Size; i++) + { + var (from, to) = Link(i); + Assert.InRange(from, 1UL, i); + Assert.InRange(to, 1UL, i); + links.Add((from, to)); + } + Assert.Equal((int)Size, links.Count); + } + + [Theory] + [InlineData(1UL), InlineData(2UL), InlineData(3UL), InlineData(10UL), InlineData(1_000UL), InlineData(1_024UL), InlineData(65_536UL)] + public void ScatteredVisitsEveryIdOnce(ulong n) => + Assert.Equal(Enumerable.Range(1, (int)n).Select(i => (ulong)i), Scattered(n).Order()); + + public static TheoryData LinksVariants => [.. Harness.LinksVariants]; + + public static TheoryData ObjectsVariants => [.. Harness.ObjectsVariants]; + + [Theory, MemberData(nameof(LinksVariants))] + public void EveryLinksVariantPassesTheLifecycle(string variant) + { + var directory = Directory.CreateTempSubdirectory().FullName; + Harness.MeasureLinks(variant, Size, 1, directory); + Harness.MeasureLinks(variant, Size, 1, directory); + } + + [Theory, MemberData(nameof(ObjectsVariants))] + public void EveryObjectsVariantPassesTheLifecycle(string variant) + { + var directory = Directory.CreateTempSubdirectory().FullName; + Harness.MeasureObjects(variant, Size / 10, 1, directory); + Harness.MeasureObjects(variant, Size / 10, 1, directory); + } + + [Fact] + public void SplitStoreKeepsLinksUpdatedToReferenceThemselves() + { + using var links = new DoubletsLinks(Harness.Split(new HeapResizableDirectMemory(), new HeapResizableDirectMemory())); + var point = links.Create(1, 1); + var link = links.Create(point, 2); + links.Update(point, point, point); + links.Update(link, link, point); + Assert.Equal(new Link(link, link, point), links.Get(link)); + links.Delete(link); + links.Delete(point); + Assert.Equal(0UL, links.Count()); + } + + private sealed class Lossy(SQLiteLinks links) : ILinksStorage + { + public uint Create(uint from, uint to) => links.Create(from, to); + public void Update(uint id, uint from, uint to) => links.Update(id, from, to); + public void Delete(uint id) => links.Delete(id); + public Link? Get(uint id) => links.Get(id); + public uint? Search(uint from, uint to) => links.Search(from, to); + public void Each(Action> visit) => links.EachWithFrom(1, visit); + public void EachWithFrom(uint from, Action> visit) => links.EachWithFrom(from, visit); + public void EachWithTo(uint to, Action> visit) => links.EachWithTo(to, visit); + public ulong Count() => links.Count(); + public void Dispose() => links.Dispose(); + } + + [Fact] + public void LifecycleRejectsAStorageThatLosesRecords() + { + using var lossy = new Lossy(SQLiteLinks.InMemory()); + var error = Assert.Throws(() => Harness.LinksLifecycle(lossy, Size, () => { })); + Assert.StartsWith("query_all returned unexpected records", error.Message); + } + + private static void RoundTrip(IBlogPostsStorage posts) where T : struct + { + using var _ = posts; + var inputs = Enumerable.Range(1, 20).Select(i => BlogPost((ulong)i)).ToList(); + inputs.Add(new BlogPost("Ünïcödé 🌍 ✓", "a", 0)); + inputs.Add(new BlogPost("", "ab", uint.MaxValue / 2)); + var ids = inputs.Select(posts.Create).ToList(); + Assert.Equal(inputs, ids.Select(id => posts.Get(id))); + var stored = new List<(T, BlogPost)>(); + posts.Each((id, post) => stored.Add((id, post))); + Assert.Equal(ids.Zip(inputs), stored.OrderBy(entry => entry.Item1)); + ids.ForEach(posts.Delete); + Assert.Equal(0UL, posts.Count()); + } + + [Theory, InlineData(false), InlineData(true)] + public void EveryObjectsStorageReturnsTheStoredPosts(bool cached) + { + RoundTrip(SQLiteBlogPosts.InMemory()); + RoundTrip(SQLiteBlogPosts.InMemory()); + RoundTrip(new DoubletsBlogPosts(Harness.United(new HeapResizableDirectMemory(), external: true), cached)); + RoundTrip(new DoubletsBlogPosts(Harness.United(new HeapResizableDirectMemory(), external: true), cached)); + RoundTrip(new DoubletsBlogPosts(Harness.Split(new HeapResizableDirectMemory(), new HeapResizableDirectMemory(), external: true), cached)); + RoundTrip(new DoubletsBlogPosts(Harness.Split(new HeapResizableDirectMemory(), new HeapResizableDirectMemory(), external: true), cached)); + } +} diff --git a/csharp/SQLiteVSDoublets.slnx b/csharp/SQLiteVSDoublets.slnx new file mode 100644 index 0000000..2ab0b4a --- /dev/null +++ b/csharp/SQLiteVSDoublets.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/csharp/SQLiteVSDoublets/Dataset.cs b/csharp/SQLiteVSDoublets/Dataset.cs new file mode 100644 index 0000000..061e229 --- /dev/null +++ b/csharp/SQLiteVSDoublets/Dataset.cs @@ -0,0 +1,119 @@ +using System.Text; + +namespace Comparisons.SQLiteVSDoublets; + +/// Deterministic data shared by every storage and by the Rust implementation. +public static class Dataset +{ + private const ulong GoldenGamma = 0x9E37_79B9_7F4A_7C15; + + public static ulong SplitMix64(ulong x) + { + var z = x + GoldenGamma; + z = (z ^ (z >> 30)) * 0xBF58_476D_1CE4_E5B9; + z = (z ^ (z >> 27)) * 0x94D0_49BB_1331_11EB; + return z ^ (z >> 31); + } + + /// + /// The (From, To) pair of the link with id : both reference already created ids + /// and From + To == i + 1, so every pair is unique. + /// + public static (ulong From, ulong To) Link(ulong i) + { + var from = 1 + SplitMix64(2 * i) % i; + return (from, i + 1 - from); + } + + /// + /// Order-sensitive mix of two numbers; summing mixes detects records that are lost, + /// duplicated or associated with the wrong id. + /// + public static ulong Mix(ulong a, ulong b) => ((a * GoldenGamma) ^ b) * GoldenGamma; + + public static ulong LinkChecksum(ulong id, ulong from, ulong to) => Mix(Mix(id, from), to); + + public static ulong LinksChecksum(ulong n, bool swapped) + { + ulong checksum = 0; + for (ulong i = 1; i <= n; i++) + { + var (from, to) = Link(i); + checksum += swapped ? LinkChecksum(i, to, from) : LinkChecksum(i, from, to); + } + return checksum; + } + + /// + /// Visits 1..=n once each in a scattered order, so point operations do not hit + /// neighbouring records one after another. + /// + public static IEnumerable Scattered(ulong n) + { + var stride = GoldenGamma % Math.Max(n, 1); + while (Gcd(stride, n) != 1) + { + stride++; + } + for (ulong k = 0; k < n; k++) + { + yield return 1 + k * stride % n; + } + } + + private static ulong Gcd(ulong a, ulong b) => b == 0 ? a : Gcd(b, a % b); + + private const ulong September2020 = 1_600_000_000; + private const ulong ThirtyDays = 30 * 24 * 60 * 60; + + private static readonly string[] Paragraphs = + [ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis malesuada blandit mauris nec bibendum. Phasellus feugiat vehicula mauris et aliquet. Integer et gravida velit, in rutrum leo. Duis pretium, nunc ac posuere porttitor, augue sapien commodo tortor, nec consequat lorem eros ultricies odio. Aliquam varius congue ex nec viverra. Pellentesque eu velit tellus. Donec ac luctus nisi. Curabitur dignissim sodales mauris eu semper. Ut pretium lorem nulla, sit amet auctor arcu placerat vitae. Quisque lacinia dolor et consectetur fermentum. Nam ac orci vitae nulla aliquam tempor ac a nibh. Ut ac tincidunt lacus. Morbi vitae felis lorem.", + "Curabitur tincidunt nibh sit amet finibus dictum. Suspendisse aliquet arcu non rutrum ultrices. Integer ullamcorper mauris sit amet nibh aliquam, et tempor turpis hendrerit. In molestie elit et mauris rutrum, non auctor ligula ultricies. Vestibulum dignissim mauris finibus libero interdum hendrerit. Nunc vitae ipsum porttitor, egestas magna ut, sagittis sem. Donec euismod ac tortor vel porta. Vivamus convallis, ex at vestibulum rutrum, velit purus venenatis metus, sit amet aliquam sapien nibh quis elit. Aenean id neque a orci sodales venenatis. Integer ut orci ligula. Interdum et malesuada fames ac ante ipsum primis in faucibus. Praesent molestie dolor non lobortis ornare. Duis quis nisl sollicitudin, accumsan ante sed, eleifend velit. Maecenas maximus sed ante nec auctor.", + "Donec vitae felis lectus. Aenean velit sapien, porttitor ut feugiat a, consectetur et risus. Proin ac viverra sem. Nullam sagittis ex tortor, eu pellentesque tellus efficitur at. Nunc non egestas leo. Nam sed suscipit neque. Nam sodales vel neque eget eleifend. Vivamus in condimentum elit, consectetur commodo ex. Suspendisse rutrum, sapien efficitur cursus sodales, dolor orci pulvinar mauris, eu fringilla leo ex id leo. Interdum et malesuada fames ac ante ipsum primis in faucibus. Proin rhoncus sapien massa, molestie vestibulum augue hendrerit nec. Aliquam malesuada varius sapien id accumsan. Duis blandit aliquet felis, nec pellentesque lacus tincidunt et. Cras sed ligula vel nisl laoreet sagittis. Vestibulum ante ipsum primis in faucibus orci luctus et ultrices posuere cubilia Curae; Praesent tristique a neque aliquet aliquam.", + "Aliquam sed egestas felis. Maecenas sollicitudin nisl in sapien posuere vulputate. Suspendisse eleifend sem magna, interdum consectetur augue venenatis at. Vivamus ornare orci vel orci sodales maximus. Donec ultricies felis ac nulla fermentum gravida. Phasellus vulputate turpis odio, a varius nibh luctus et. Aliquam tincidunt, metus ut congue porttitor, nibh dui ullamcorper quam, a eleifend elit ipsum sit amet quam. Aenean venenatis mollis interdum. Nunc cursus ex sit amet enim lacinia hendrerit. Nullam at libero iaculis, consectetur velit in, porta sem. Ut mattis ut ex in imperdiet. Maecenas pellentesque sit amet dui eget vehicula. Sed posuere, arcu pretium convallis tincidunt, turpis leo dignissim felis, non euismod diam magna a risus. Suspendisse a arcu nec turpis pulvinar ullamcorper. Nunc iaculis malesuada elit eu pretium. Aenean a neque a sapien tincidunt faucibus.", + "Ut a eleifend augue, eget posuere augue. Proin purus neque, pretium condimentum ipsum ut, venenatis tincidunt nunc. In vitae odio in justo pharetra tincidunt. Maecenas vel tellus interdum, suscipit tellus sit amet, cursus justo. Mauris sollicitudin euismod molestie. Cras eros nisi, molestie vel elementum ut, consequat ac nunc. In consectetur nulla vitae interdum elementum. Praesent faucibus magna et iaculis congue. Curabitur convallis cursus porttitor. Praesent hendrerit justo ut sem convallis sollicitudin eu at odio.", + ]; + + public static BlogPost BlogPost(ulong i) => + new($"Blog post {i}", Paragraphs[i % 5], September2020 + SplitMix64(i) % ThirtyDays); + + public static ulong BlogPostsChecksum(ulong n) + { + ulong checksum = 0; + for (ulong i = 1; i <= n; i++) + { + checksum += BlogPost(i).Checksum; + } + return checksum; + } + + /// + /// Like , but also checks that the i-th created id returns the i-th post. + /// + public static ulong BlogPostsByNumberChecksum(ulong n) + { + ulong checksum = 0; + for (ulong i = 1; i <= n; i++) + { + checksum += Mix(i, BlogPost(i).Checksum); + } + return checksum; + } +} + +/// Seconds since the Unix epoch. +public sealed record BlogPost(string Title, string Content, ulong PublicationDate) +{ + public ulong Checksum => Bytes(Title) + Bytes(Content) + PublicationDate; + + private static ulong Bytes(string text) + { + ulong sum = 0; + foreach (var b in Encoding.UTF8.GetBytes(text)) + { + sum += b; + } + return sum; + } +} diff --git a/csharp/SQLiteVSDoublets/Harness.cs b/csharp/SQLiteVSDoublets/Harness.cs new file mode 100644 index 0000000..98a3088 --- /dev/null +++ b/csharp/SQLiteVSDoublets/Harness.cs @@ -0,0 +1,342 @@ +using System.Diagnostics; +using System.Numerics; +using System.Text.Json.Nodes; +using Platform.Data; +using Platform.Data.Doublets; +using Platform.Data.Doublets.Memory; +using Platform.Data.Doublets.Memory.Split.Generic; +using Platform.Data.Doublets.Memory.United.Generic; +using Platform.Memory; +using static Comparisons.SQLiteVSDoublets.Dataset; + +namespace Comparisons.SQLiteVSDoublets; + +/// +/// Runs every storage through the same lifecycle on a fresh store per repetition: +/// a timed batch per operation, each validated against the expected count and checksum. +/// +public static class Harness +{ + public static readonly string[] LinksVariants = + [ + "SQLite_Memory", + "SQLite_File", + "Doublets_United_Volatile", + "Doublets_United_NonVolatile", + "Doublets_Split_Volatile", + "Doublets_Split_NonVolatile", + ]; + + public static readonly string[] ObjectsVariants = + [ + "SQLite_Memory", + "SQLite_File", + "Doublets_United_Volatile_Cached", + "Doublets_United_Volatile_Uncached", + "Doublets_United_NonVolatile_Cached", + "Doublets_United_NonVolatile_Uncached", + "Doublets_Split_Volatile_Cached", + "Doublets_Split_Volatile_Uncached", + "Doublets_Split_NonVolatile_Cached", + "Doublets_Split_NonVolatile_Uncached", + ]; + + public const ulong WarmUpSize = 10_000; + + /// Count and order-sensitive checksum of the records an operation has seen. + public record struct Tally(ulong Count, ulong Checksum) + { + public void Add(ulong checksum) + { + Count++; + Checksum += checksum; + } + + public void AddLink(Link link) where T : IBinaryInteger => + Add(LinkChecksum(ulong.CreateChecked(link.Id), ulong.CreateChecked(link.From), ulong.CreateChecked(link.To))); + } + + private static void Timed(List<(string, TimeSpan)> timings, string operation, Tally expected, Func work) + { + var stopwatch = Stopwatch.StartNew(); + var actual = work(); + timings.Add((operation, stopwatch.Elapsed)); + Check(actual == expected, $"{operation} returned unexpected records: {actual} instead of {expected}"); + } + + private static void Check(bool condition, string message) + { + if (!condition) + { + throw new InvalidOperationException(message); + } + } + + public static List<(string, TimeSpan)> LinksLifecycle(ILinksStorage storage, ulong n, Action created) + where T : struct, IBinaryInteger + { + static T Int(ulong value) => T.CreateChecked(value); + var expected = new Tally(n, LinksChecksum(n, false)); + var timings = new List<(string, TimeSpan)>(); + + Timed(timings, "create", expected, () => storage.Transaction(() => + { + var tally = new Tally(); + for (ulong i = 1; i <= n; i++) + { + var (from, to) = Link(i); + var id = storage.Create(Int(from), Int(to)); + tally.AddLink(new Link(id, Int(from), Int(to))); + } + return tally; + })); + created(); + + Timed(timings, "query_all", expected, () => storage.Transaction(() => + { + var tally = new Tally(); + storage.Each(link => tally.AddLink(link)); + return tally; + })); + Timed(timings, "query_by_id", expected, () => storage.Transaction(() => + { + var tally = new Tally(); + foreach (var id in Scattered(n)) + { + tally.AddLink(storage.Get(Int(id))!.Value); + } + return tally; + })); + Timed(timings, "query_by_from_to", expected, () => storage.Transaction(() => + { + var tally = new Tally(); + foreach (var i in Scattered(n)) + { + var (from, to) = Link(i); + var id = storage.Search(Int(from), Int(to))!.Value; + tally.AddLink(new Link(id, Int(from), Int(to))); + } + return tally; + })); + Timed(timings, "query_by_from", expected, () => storage.Transaction(() => + { + var tally = new Tally(); + foreach (var from in Scattered(n)) + { + storage.EachWithFrom(Int(from), link => tally.AddLink(link)); + } + return tally; + })); + Timed(timings, "query_by_to", expected, () => storage.Transaction(() => + { + var tally = new Tally(); + foreach (var to in Scattered(n)) + { + storage.EachWithTo(Int(to), link => tally.AddLink(link)); + } + return tally; + })); + + var swapped = new Tally(n, LinksChecksum(n, true)); + Timed(timings, "update", swapped, () => storage.Transaction(() => + { + var tally = new Tally(); + foreach (var id in Scattered(n)) + { + var (from, to) = Link(id); + storage.Update(Int(id), Int(to), Int(from)); + tally.Add(LinkChecksum(id, to, from)); + } + return tally; + })); + var stored = new Tally(); + storage.Each(link => stored.AddLink(link)); + Check(stored == swapped, "update did not swap every link"); + + Timed(timings, "delete", new Tally(n, 0), () => storage.Transaction(() => + { + var tally = new Tally(); + foreach (var id in Scattered(n)) + { + storage.Delete(Int(id)); + tally.Add(0); + } + return tally; + })); + Check(storage.Count() == 0, "delete left links behind"); + return timings; + } + + public static List<(string, TimeSpan)> ObjectsLifecycle(IBlogPostsStorage storage, ulong n, Action created) + where T : struct, IBinaryInteger + { + var expected = new Tally(n, BlogPostsChecksum(n)); + var timings = new List<(string, TimeSpan)>(); + var ids = new List((int)n); + + Timed(timings, "create", expected, () => storage.Transaction(() => + { + var tally = new Tally(); + for (ulong i = 1; i <= n; i++) + { + var post = BlogPost(i); + ids.Add(storage.Create(post)); + tally.Add(post.Checksum); + } + return tally; + })); + created(); + + Timed(timings, "read_all", expected, () => storage.Transaction(() => + { + var tally = new Tally(); + storage.Each((_, post) => tally.Add(post.Checksum)); + return tally; + })); + Timed(timings, "read_by_id", new Tally(n, BlogPostsByNumberChecksum(n)), () => storage.Transaction(() => + { + var tally = new Tally(); + foreach (var i in Scattered(n)) + { + var post = storage.Get(ids[(int)i - 1])!; + tally.Add(Mix(i, post.Checksum)); + } + return tally; + })); + Timed(timings, "delete", new Tally(n, 0), () => storage.Transaction(() => + { + var tally = new Tally(); + foreach (var i in Scattered(n)) + { + storage.Delete(ids[(int)i - 1]); + tally.Add(0); + } + return tally; + })); + Check(storage.Count() == 0, "delete left blog posts behind"); + return timings; + } + + public sealed record Measurement(string Variant, ulong? FileBytes, List<(string Operation, List Samples)> Operations) + { + public JsonObject ToJson() + { + var operations = new JsonObject(); + foreach (var (operation, samples) in Operations) + { + var sorted = samples.Order().ToList(); + var middle = sorted.Count / 2; + var median = sorted.Count % 2 == 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle]; + operations[operation] = new JsonObject + { + ["median_ns"] = median, + ["min_ns"] = sorted[0], + ["max_ns"] = sorted[^1], + ["samples_ns"] = new JsonArray(samples.Select(sample => (JsonNode)sample).ToArray()), + }; + } + return new JsonObject { ["variant"] = Variant, ["file_bytes"] = FileBytes, ["operations"] = operations }; + } + } + + private static ulong DirectoryBytes(string directory) => + (ulong)new DirectoryInfo(directory).GetFiles().Sum(file => file.Length); + + /// + /// Times on fresh stores opened in empty directories, + /// after one discarded warm-up. + /// + private static Measurement Measure( + string variant, + ulong n, + int repetitions, + string directory, + Func open, + Func> lifecycle) + where TStorage : IDisposable + { + var measurement = new Measurement(variant, null, []); + for (var repetition = 0; repetition <= repetitions; repetition++) + { + var size = repetition == 0 ? Math.Min(n, WarmUpSize) : n; + var workspace = Path.Combine(directory, $"{variant}-{repetition}"); + Directory.CreateDirectory(workspace); + GC.Collect(); + GC.WaitForPendingFinalizers(); + ulong fileBytes = 0; + List<(string, TimeSpan)> timings; + using (var storage = open(workspace)) + { + timings = lifecycle(storage, size, () => fileBytes = DirectoryBytes(workspace)); + } + Directory.Delete(workspace, true); + if (repetition == 0) + { + continue; + } + Console.Error.WriteLine($"{variant} #{repetition}: {Summary(timings, n)}"); + measurement = measurement with { FileBytes = fileBytes > 0 ? fileBytes : null }; + foreach (var (operation, elapsed) in timings) + { + var nsPerOperation = elapsed.TotalNanoseconds / n; + var index = measurement.Operations.FindIndex(entry => entry.Operation == operation); + if (index < 0) + { + measurement.Operations.Add((operation, [nsPerOperation])); + } + else + { + measurement.Operations[index].Samples.Add(nsPerOperation); + } + } + } + return measurement; + } + + private static string Summary(List<(string Operation, TimeSpan Elapsed)> timings, ulong n) => + string.Join(", ", timings.Select(timing => $"{timing.Operation} {timing.Elapsed.TotalNanoseconds / n:F0} ns/op")); + + private static string File(string directory, string name) => Path.Combine(directory, name); + + private static FileMappedResizableDirectMemory Mapped(string directory, string name) => new(File(directory, name)); + + /// Objects store numbers and Unicode symbols as raw numbers, which are external references. + public static ILinks United(IResizableDirectMemory memory, bool external = false) where T : struct, IBinaryInteger, IUnsignedNumber, IMinMaxValue => + new UnitedMemoryLinks(memory, UnitedMemoryLinks.DefaultLinksSizeStep, new LinksConstants(external), IndexTreeType.Default); + + public static ILinks Split(IResizableDirectMemory data, IResizableDirectMemory index, bool external = false) where T : struct, IBinaryInteger, IUnsignedNumber, IMinMaxValue => + new SplitMemoryLinks(data, index, SplitMemoryLinks.DefaultLinksSizeStep, new LinksConstants(external)); + + public static Measurement MeasureLinks(string variant, ulong n, int repetitions, string directory) + where T : struct, IBinaryInteger, IUnsignedNumber, IMinMaxValue + { + Func> open = variant switch + { + "SQLite_Memory" => _ => SQLiteLinks.InMemory(), + "SQLite_File" => dir => SQLiteLinks.Open(File(dir, "links.db")), + "Doublets_United_Volatile" => _ => new DoubletsLinks(United(new HeapResizableDirectMemory())), + "Doublets_United_NonVolatile" => dir => new DoubletsLinks(United(Mapped(dir, "links.links"))), + "Doublets_Split_Volatile" => _ => new DoubletsLinks(Split(new HeapResizableDirectMemory(), new HeapResizableDirectMemory())), + "Doublets_Split_NonVolatile" => dir => new DoubletsLinks(Split(Mapped(dir, "data.links"), Mapped(dir, "index.links"))), + _ => throw new ArgumentException($"unknown links variant {variant}"), + }; + return Measure(variant, n, repetitions, directory, open, LinksLifecycle); + } + + public static Measurement MeasureObjects(string variant, ulong n, int repetitions, string directory) + where T : struct, IBinaryInteger, IUnsignedNumber, IMinMaxValue + { + var cached = variant.EndsWith("_Cached"); + Func> open = variant.Replace("_Uncached", "").Replace("_Cached", "") switch + { + "SQLite_Memory" => _ => SQLiteBlogPosts.InMemory(), + "SQLite_File" => dir => SQLiteBlogPosts.Open(File(dir, "blog_posts.db")), + "Doublets_United_Volatile" => _ => new DoubletsBlogPosts(United(new HeapResizableDirectMemory(), external: true), cached), + "Doublets_United_NonVolatile" => dir => new DoubletsBlogPosts(United(Mapped(dir, "links.links"), external: true), cached), + "Doublets_Split_Volatile" => _ => new DoubletsBlogPosts(Split(new HeapResizableDirectMemory(), new HeapResizableDirectMemory(), external: true), cached), + "Doublets_Split_NonVolatile" => dir => new DoubletsBlogPosts(Split(Mapped(dir, "data.links"), Mapped(dir, "index.links"), external: true), cached), + _ => throw new ArgumentException($"unknown objects variant {variant}"), + }; + return Measure(variant, n, repetitions, directory, open, ObjectsLifecycle); + } +} diff --git a/csharp/SQLiteVSDoublets/Links.cs b/csharp/SQLiteVSDoublets/Links.cs new file mode 100644 index 0000000..ff51559 --- /dev/null +++ b/csharp/SQLiteVSDoublets/Links.cs @@ -0,0 +1,134 @@ +using System.Numerics; +using Microsoft.Data.Sqlite; +using Platform.Data; +using Platform.Data.Doublets; + +namespace Comparisons.SQLiteVSDoublets; + +public readonly record struct Link(T Id, T From, T To); + +public interface ILinksStorage : IDisposable where T : struct +{ + T Create(T from, T to); + void Update(T id, T from, T to); + void Delete(T id); + Link? Get(T id); + T? Search(T from, T to); + void Each(Action> visit); + void EachWithFrom(T from, Action> visit); + void EachWithTo(T to, Action> visit); + ulong Count(); + + TResult Transaction(Func work) => work(); +} + +public sealed class SQLiteLinks : SQLiteStorage, ILinksStorage where T : struct, IBinaryInteger +{ + private static readonly (string, SqliteType) IdParameter = ("$id", SqliteType.Integer); + private static readonly (string, SqliteType) FromParameter = ("$from", SqliteType.Integer); + private static readonly (string, SqliteType) ToParameter = ("$to", SqliteType.Integer); + + private readonly SqliteCommand _create; + private readonly SqliteCommand _update; + private readonly SqliteCommand _delete; + private readonly SqliteCommand _get; + private readonly SqliteCommand _search; + private readonly SqliteCommand _each; + private readonly SqliteCommand _eachWithFrom; + private readonly SqliteCommand _eachWithTo; + + public static SQLiteLinks Open(string path) => new(path); + + public static SQLiteLinks InMemory() => new(":memory:"); + + private SQLiteLinks(string path) : base(path, "links", """ + CREATE TABLE links (id INTEGER PRIMARY KEY, "from" INTEGER NOT NULL, "to" INTEGER NOT NULL); + CREATE INDEX links_from_to ON links ("from", "to"); + CREATE INDEX links_to_from ON links ("to", "from"); + """) + { + _create = Command("""INSERT INTO links ("from", "to") VALUES ($from, $to) RETURNING id""", FromParameter, ToParameter); + _update = Command("""UPDATE links SET "from" = $from, "to" = $to WHERE id = $id""", IdParameter, FromParameter, ToParameter); + _delete = Command("DELETE FROM links WHERE id = $id", IdParameter); + _get = Command("""SELECT "from", "to" FROM links WHERE id = $id""", IdParameter); + _search = Command("""SELECT id FROM links WHERE "from" = $from AND "to" = $to""", FromParameter, ToParameter); + _each = Command("""SELECT id, "from", "to" FROM links"""); + _eachWithFrom = Command("""SELECT id, "from", "to" FROM links WHERE "from" = $from""", FromParameter); + _eachWithTo = Command("""SELECT id, "from", "to" FROM links WHERE "to" = $to""", ToParameter); + } + + private static T Id(long sql) => T.CreateChecked(sql); + + private static SqliteCommand With(SqliteCommand command, params ReadOnlySpan values) + { + for (var i = 0; i < values.Length; i++) + { + command.Parameters[i].Value = long.CreateChecked(values[i]); + } + return command; + } + + public T Create(T from, T to) => Id((long)With(_create, from, to).ExecuteScalar()!); + + public void Update(T id, T from, T to) => With(_update, id, from, to).ExecuteNonQuery(); + + public void Delete(T id) => With(_delete, id).ExecuteNonQuery(); + + public Link? Get(T id) + { + using var reader = With(_get, id).ExecuteReader(); + return reader.Read() ? new Link(id, Id(reader.GetInt64(0)), Id(reader.GetInt64(1))) : null; + } + + public T? Search(T from, T to) => With(_search, from, to).ExecuteScalar() is long id ? Id(id) : null; + + public void Each(Action> visit) => Read(_each, visit); + + public void EachWithFrom(T from, Action> visit) => Read(With(_eachWithFrom, from), visit); + + public void EachWithTo(T to, Action> visit) => Read(With(_eachWithTo, to), visit); + + private static void Read(SqliteCommand command, Action> visit) + { + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + visit(new Link(Id(reader.GetInt64(0)), Id(reader.GetInt64(1)), Id(reader.GetInt64(2)))); + } + } +} + +public sealed class DoubletsLinks(ILinks links) : ILinksStorage + where T : struct, IUnsignedNumber, IComparisonOperators +{ + private readonly T _any = links.Constants.Any; + private readonly T _continue = links.Constants.Continue; + + public T Create(T from, T to) => links.CreateAndUpdate(from, to); + + public void Update(T id, T from, T to) => links.Update(id, from, to); + + public void Delete(T id) => links.Delete(id, handler: null); + + public Link? Get(T id) => links.Exists(id) ? Link(links.GetLink(id)!) : null; + + public T? Search(T from, T to) => links.SearchOrDefault(from, to) is var id && id != T.Zero ? id : null; + + public void Each(Action> visit) => Each(visit, _any, _any, _any); + + public void EachWithFrom(T from, Action> visit) => Each(visit, _any, from, _any); + + public void EachWithTo(T to, Action> visit) => Each(visit, _any, _any, to); + + private void Each(Action> visit, params T[] query) => links.Each(link => + { + visit(Link(link!)); + return _continue; + }, query); + + private static Link Link(IList link) => new(link[0], link[1], link[2]); + + public ulong Count() => ulong.CreateChecked(links.Count()); + + public void Dispose() => (links as IDisposable)?.Dispose(); +} diff --git a/csharp/SQLiteVSDoublets/Objects.cs b/csharp/SQLiteVSDoublets/Objects.cs new file mode 100644 index 0000000..4c81700 --- /dev/null +++ b/csharp/SQLiteVSDoublets/Objects.cs @@ -0,0 +1,189 @@ +using System.Numerics; +using Microsoft.Data.Sqlite; +using Platform.Collections.Stacks; +using Platform.Converters; +using Platform.Data; +using Platform.Data.Doublets; +using Platform.Data.Doublets.CriterionMatchers; +using Platform.Data.Doublets.PropertyOperators; +using Platform.Data.Doublets.Sequences.Converters; +using Platform.Data.Doublets.Sequences.Unicode; +using Platform.Data.Doublets.Sequences.Walkers; +using Platform.Data.Numbers.Raw; + +namespace Comparisons.SQLiteVSDoublets; + +public interface IBlogPostsStorage : IDisposable where T : struct +{ + T Create(BlogPost post); + BlogPost? Get(T id); + void Each(Action visit); + void Delete(T id); + ulong Count(); + + TResult Transaction(Func work) => work(); +} + +public sealed class SQLiteBlogPosts : SQLiteStorage, IBlogPostsStorage where T : struct, IBinaryInteger +{ + private readonly SqliteCommand _create; + private readonly SqliteCommand _get; + private readonly SqliteCommand _each; + private readonly SqliteCommand _delete; + + public static SQLiteBlogPosts Open(string path) => new(path); + + public static SQLiteBlogPosts InMemory() => new(":memory:"); + + private SQLiteBlogPosts(string path) : base(path, "blog_posts", + "CREATE TABLE blog_posts (id INTEGER PRIMARY KEY, title TEXT NOT NULL, content TEXT NOT NULL, publication_date INTEGER NOT NULL)") + { + _create = Command( + "INSERT INTO blog_posts (title, content, publication_date) VALUES ($title, $content, $publication_date) RETURNING id", + ("$title", SqliteType.Text), ("$content", SqliteType.Text), ("$publication_date", SqliteType.Integer)); + _get = Command("SELECT title, content, publication_date FROM blog_posts WHERE id = $id", ("$id", SqliteType.Integer)); + _each = Command("SELECT title, content, publication_date, id FROM blog_posts"); + _delete = Command("DELETE FROM blog_posts WHERE id = $id", ("$id", SqliteType.Integer)); + } + + private static BlogPost BlogPost(SqliteDataReader reader) => + new(reader.GetString(0), reader.GetString(1), (ulong)reader.GetInt64(2)); + + public T Create(BlogPost post) + { + _create.Parameters[0].Value = post.Title; + _create.Parameters[1].Value = post.Content; + _create.Parameters[2].Value = (long)post.PublicationDate; + return T.CreateChecked((long)_create.ExecuteScalar()!); + } + + public BlogPost? Get(T id) + { + _get.Parameters[0].Value = long.CreateChecked(id); + using var reader = _get.ExecuteReader(); + return reader.Read() ? BlogPost(reader) : null; + } + + public void Each(Action visit) + { + using var reader = _each.ExecuteReader(); + while (reader.Read()) + { + visit(T.CreateChecked(reader.GetInt64(3)), BlogPost(reader)); + } + } + + public void Delete(T id) + { + _delete.Parameters[0].Value = long.CreateChecked(id); + _delete.ExecuteNonQuery(); + } +} + +/// +/// Stores each blog post as a (blog_post, itself) link with (post, property) -> value properties, +/// strings as balanced-variant sequences of Unicode symbols and dates as raw numbers. +/// +public sealed class DoubletsBlogPosts : IBlogPostsStorage + where T : struct, IUnsignedNumber, IComparisonOperators +{ + private readonly ILinks _links; + private readonly T _any; + private readonly T _unicodeSequence; + private readonly T _title; + private readonly T _content; + private readonly T _publicationDate; + private readonly T _blogPost; + private readonly PropertiesOperator _properties; + private readonly AddressToRawNumberConverter _addressToNumber = new(); + private readonly RawNumberToAddressConverter _numberToAddress = new(); + private readonly IConverter _stringToSequence; + private readonly IConverter _sequenceToString; + + public DoubletsBlogPosts(ILinks links, bool cacheSequences) + { + _links = links; + _any = links.Constants.Any; + var meaningRoot = links.CreatePoint(); + T Marker() + { + var marker = links.Create(); + return links.Update(marker, meaningRoot, marker); + } + var unicodeSymbol = Marker(); + _unicodeSequence = Marker(); + _title = Marker(); + _content = Marker(); + _publicationDate = Marker(); + _blogPost = Marker(); + _properties = new PropertiesOperator(links); + + var unicodeSymbolMatcher = new TargetMatcher(links, unicodeSymbol); + IConverter stringToSequence = new StringToUnicodeSequenceConverter( + links, + new CharToUnicodeSymbolConverter(links, _addressToNumber, unicodeSymbol), + new BalancedVariantConverter(links), + _unicodeSequence); + IConverter sequenceToString = new UnicodeSequenceToStringConverter( + links, + new RightSequenceWalker(links, new DefaultStack(), unicodeSymbolMatcher.IsMatched), + new UnicodeSymbolToCharConverter(links, _numberToAddress, unicodeSymbolMatcher), + _unicodeSequence); + _stringToSequence = cacheSequences ? new CachingConverterDecorator(stringToSequence) : stringToSequence; + _sequenceToString = cacheSequences ? new CachingConverterDecorator(sequenceToString) : sequenceToString; + } + + public T Create(BlogPost post) + { + var blogPost = _links.Create(); + _links.Update(blogPost, _blogPost, blogPost); + _properties.SetValue(blogPost, _title, _stringToSequence.Convert(post.Title)); + _properties.SetValue(blogPost, _content, _stringToSequence.Convert(post.Content)); + _properties.SetValue(blogPost, _publicationDate, _addressToNumber.Convert(T.CreateChecked(post.PublicationDate))); + return blogPost; + } + + public BlogPost? Get(T id) + { + var title = _properties.GetValue(id, _title); + var content = _properties.GetValue(id, _content); + var publicationDate = _properties.GetValue(id, _publicationDate); + if (title == T.Zero || content == T.Zero || publicationDate == T.Zero) + { + return null; + } + return new BlogPost( + _sequenceToString.Convert(title), + _sequenceToString.Convert(content), + ulong.CreateChecked(_numberToAddress.Convert(publicationDate))); + } + + public void Each(Action visit) + { + var ids = new List(); + _links.Each(post => + { + ids.Add(post![0]); + return _links.Constants.Continue; + }, _any, _blogPost, _any); + foreach (var id in ids) + { + visit(id, Get(id)!); + } + } + + public void Delete(T id) + { + foreach (var property in (ReadOnlySpan)[_title, _content, _publicationDate]) + { + var objectProperty = _links.SearchOrDefault(id, property); + _links.Delete(_links.SingleOrDefault([_any, objectProperty, _any])![0], handler: null); + _links.Delete(objectProperty, handler: null); + } + _links.Delete(id, handler: null); + } + + public ulong Count() => ulong.CreateChecked(_links.Count(_any, _blogPost, _any)); + + public void Dispose() => (_links as IDisposable)?.Dispose(); +} diff --git a/csharp/SQLiteVSDoublets/Program.cs b/csharp/SQLiteVSDoublets/Program.cs new file mode 100644 index 0000000..02855c6 --- /dev/null +++ b/csharp/SQLiteVSDoublets/Program.cs @@ -0,0 +1,61 @@ +// Usage: sqlite-vs-doublets <32|64> [--work N | --repetitions N] +// [--variants A,B] [--directory DIR] [--output FILE] +// +// Repetitions default to `work / size` clamped to 1..=MaxRepetitions, so smaller sizes get more, +// but not endless, repetitions. + +using System.Text.Json; +using System.Text.Json.Nodes; +using Comparisons.SQLiteVSDoublets; +using Microsoft.Data.Sqlite; + +const ulong DefaultWork = 3_000_000; +const ulong MaxRepetitions = 10; + +var (category, bits, sizeText) = (args[0], args[1], args[2]); +var options = args[3..].Chunk(2).ToDictionary(pair => pair[0].TrimStart('-'), pair => pair[1]); +static ulong Number(string text) => ulong.Parse(text.Replace("_", "")); +var size = Number(sizeText); +var work = options.TryGetValue("work", out var workText) ? Number(workText) : DefaultWork; +var repetitions = options.TryGetValue("repetitions", out var count) + ? int.Parse(count) + : (int)Math.Clamp(work / size, 1, MaxRepetitions); +var allVariants = category == "links" ? Harness.LinksVariants : Harness.ObjectsVariants; +var variants = options.TryGetValue("variants", out var names) + ? names.Split(',').Select(name => allVariants.Contains(name) ? name : throw new ArgumentException(name)).ToArray() + : allVariants; +var directory = options.GetValueOrDefault("directory") ?? Path.GetTempPath(); + +var measurements = variants.Select(variant => (category, bits) switch +{ + ("links", "32") => Harness.MeasureLinks(variant, size, repetitions, directory), + ("links", "64") => Harness.MeasureLinks(variant, size, repetitions, directory), + ("objects", "32") => Harness.MeasureObjects(variant, size, repetitions, directory), + ("objects", "64") => Harness.MeasureObjects(variant, size, repetitions, directory), + _ => throw new ArgumentException("category must be links or objects and bits must be 32 or 64"), +}).ToList(); + +using var connection = new SqliteConnection("Data Source=:memory:"); +connection.Open(); +using var version = connection.CreateCommand(); +version.CommandText = "SELECT sqlite_version()"; +var report = new JsonObject +{ + ["language"] = "C#", + ["category"] = category, + ["bits"] = int.Parse(bits), + ["size"] = size, + ["repetitions"] = repetitions, + ["warm_up_size"] = Math.Min(size, Harness.WarmUpSize), + ["sqlite_version"] = (string)version.ExecuteScalar()!, + ["results"] = new JsonArray(measurements.Select(measurement => (JsonNode)measurement.ToJson()).ToArray()), +}; +var json = report.ToJsonString(new JsonSerializerOptions { WriteIndented = true }); +if (options.TryGetValue("output", out var output)) +{ + File.WriteAllText(output, json); +} +else +{ + Console.WriteLine(json); +} diff --git a/csharp/SQLiteVSDoublets/SQLiteStorage.cs b/csharp/SQLiteVSDoublets/SQLiteStorage.cs new file mode 100644 index 0000000..a02bcae --- /dev/null +++ b/csharp/SQLiteVSDoublets/SQLiteStorage.cs @@ -0,0 +1,62 @@ +using Microsoft.Data.Sqlite; + +namespace Comparisons.SQLiteVSDoublets; + +/// A connection with reusable prepared commands that all join the current transaction. +public abstract class SQLiteStorage : IDisposable +{ + private readonly SqliteConnection _connection; + private readonly List _commands = []; + private readonly string _table; + + protected SQLiteStorage(string path, string table, string schema) + { + _connection = new SqliteConnection($"Data Source={path};Pooling=False"); + _connection.Open(); + _table = table; + using var create = _connection.CreateCommand(); + create.CommandText = schema; + create.ExecuteNonQuery(); + } + + protected SqliteCommand Command(string sql, params (string Name, SqliteType Type)[] parameters) + { + var command = _connection.CreateCommand(); + command.CommandText = sql; + foreach (var (name, type) in parameters) + { + command.Parameters.Add(name, type); + } + _commands.Add(command); + return command; + } + + public ulong Count() + { + using var count = _connection.CreateCommand(); + count.CommandText = $"SELECT COUNT(*) FROM {_table}"; + return (ulong)(long)count.ExecuteScalar()!; + } + + public TResult Transaction(Func work) + { + using var transaction = _connection.BeginTransaction(); + _commands.ForEach(command => command.Transaction = transaction); + try + { + var result = work(); + transaction.Commit(); + return result; + } + finally + { + _commands.ForEach(command => command.Transaction = null); + } + } + + public void Dispose() + { + _commands.ForEach(command => command.Dispose()); + _connection.Dispose(); + } +} diff --git a/csharp/SQLiteVSDoublets/SQLiteVSDoublets.csproj b/csharp/SQLiteVSDoublets/SQLiteVSDoublets.csproj new file mode 100644 index 0000000..fd447ae --- /dev/null +++ b/csharp/SQLiteVSDoublets/SQLiteVSDoublets.csproj @@ -0,0 +1,20 @@ + + + + Exe + net10.0 + enable + enable + Comparisons.SQLiteVSDoublets + sqlite-vs-doublets + true + true + + + + + + + + + diff --git a/csharp/SizeAfterCreationColumn.cs b/csharp/SizeAfterCreationColumn.cs deleted file mode 100644 index 4f107b8..0000000 --- a/csharp/SizeAfterCreationColumn.cs +++ /dev/null @@ -1,194 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using BenchmarkDotNet.Columns; -using BenchmarkDotNet.Reports; -using BenchmarkDotNet.Running; - -namespace Comparisons.SQLiteVSDoublets -{ - /// - /// - /// Represents the size after creation column. - /// - /// - /// - /// - public class SizeAfterCreationColumn : IColumn - { - /// - /// - /// The sq lite vs doublets. - /// - /// - /// - public static readonly string DbSizeOutputFolder = Path.Combine(Path.GetTempPath(), nameof(Comparisons), nameof(SQLiteVSDoublets)); - - /// - /// - /// Gets the id value. - /// - /// - /// - public string Id => nameof(SizeAfterCreationColumn); - - /// - /// - /// Gets the column name value. - /// - /// - /// - public string ColumnName => "SizeAfterCreation"; - - /// - /// - /// Gets the legend value. - /// - /// - /// - public string Legend => "Allocated memory on disk after all records are created (1KB = 1024B)"; - - /// - /// - /// Gets the unit type value. - /// - /// - /// - public UnitType UnitType => UnitType.Size; - - /// - /// - /// Gets the always show value. - /// - /// - /// - public bool AlwaysShow => true; - - /// - /// - /// Gets the category value. - /// - /// - /// - public ColumnCategory Category => ColumnCategory.Metric; - - /// - /// - /// Gets the priority in category value. - /// - /// - /// - public int PriorityInCategory => 0; - - /// - /// - /// Gets the is numeric value. - /// - /// - /// - public bool IsNumeric => true; - - /// - /// - /// Determines whether this instance is available. - /// - /// - /// - /// - /// The summary. - /// - /// - /// - /// The bool - /// - /// - public bool IsAvailable(Summary summary) => true; - - /// - /// - /// Determines whether this instance is default. - /// - /// - /// - /// - /// The summary. - /// - /// - /// - /// The benchmark case. - /// - /// - /// - /// The bool - /// - /// - public bool IsDefault(Summary summary, BenchmarkCase benchmarkCase) => false; - - /// - /// - /// Gets the value using the specified summary. - /// - /// - /// - /// - /// The summary. - /// - /// - /// - /// The benchmark case. - /// - /// - /// - /// The string - /// - /// - public string GetValue(Summary summary, BenchmarkCase benchmarkCase) => GetValue(summary, benchmarkCase, SummaryStyle.Default); - - /// - /// - /// Gets the value using the specified summary. - /// - /// - /// - /// - /// The summary. - /// - /// - /// - /// The benchmark case. - /// - /// - /// - /// The style. - /// - /// - /// - /// The string - /// - /// - public string GetValue(Summary summary, BenchmarkCase benchmarkCase, SummaryStyle style) - { - var benchmarkName = benchmarkCase.Descriptor.WorkloadMethod.Name.ToLower(); - var parameter = benchmarkCase.Parameters.Items.FirstOrDefault(x => x.Name == "N"); - if (parameter == null) - { - return "no parameter"; - } - var N = Convert.ToInt32(parameter.Value); - var filename = Path.Combine(DbSizeOutputFolder, $"disk-size.{benchmarkName}.{N}.txt"); - return File.Exists(filename) ? File.ReadAllText(filename) : "no file"; - } - - /// - /// - /// Returns the string. - /// - /// - /// - /// - /// The string - /// - /// - public override string ToString() => ColumnName; - } -} diff --git a/csharp/TestRun.cs b/csharp/TestRun.cs deleted file mode 100644 index f022af6..0000000 --- a/csharp/TestRun.cs +++ /dev/null @@ -1,137 +0,0 @@ -using System.IO; -using System.Collections.Generic; -using Platform.IO; -using Platform.Diagnostics; -using Comparisons.SQLiteVSDoublets.Model; - -namespace Comparisons.SQLiteVSDoublets -{ - /// - /// - /// Represents the test run. - /// - /// - /// - public abstract class TestRun - { - /// - /// - /// Gets the db filename value. - /// - /// - /// - public string DbFilename { get; } - /// - /// - /// Gets the results value. - /// - /// - /// - public TestRunResults Results { get; } - /// - /// - /// Gets the read blog posts value. - /// - /// - /// - public List ReadBlogPosts { get; } - - /// - /// - /// Initializes a new instance. - /// - /// - /// - /// - /// A db filename. - /// - /// - protected TestRun(string dbFilename) - { - DbFilename = dbFilename; - Results = new TestRunResults(); - ReadBlogPosts = new List(); - } - - /// - /// - /// Runs this instance. - /// - /// - /// - public void Run() - { - if (File.Exists(DbFilename)) - { - DeleteDatabase(); - } - Results.PrepareTime = Performance.Measure(Prepare); - Results.DbSizeAfterPrepare = GetDatabaseSizeInBytes(); - Results.ListCreationTime = Performance.Measure(CreateList); - Results.DbSizeAfterCreation = GetDatabaseSizeInBytes(); - Results.ListReadingTime = Performance.Measure(ReadList); - Results.DbSizeAfterReading = GetDatabaseSizeInBytes(); - Results.ListDeletionTime = Performance.Measure(DeleteList); - Results.DbSizeAfterDeletion = GetDatabaseSizeInBytes(); - DeleteDatabase(); - } - - /// - /// - /// Gets the database size in bytes. - /// - /// - /// - /// - /// The long - /// - /// - protected virtual long GetDatabaseSizeInBytes() - { - return FileHelpers.GetSize(DbFilename); - } - - /// - /// - /// Deletes the database. - /// - /// - /// - protected virtual void DeleteDatabase() - { - File.Delete(DbFilename); - } - - /// - /// - /// Prepares this instance. - /// - /// - /// - public abstract void Prepare(); - - /// - /// - /// Creates the list. - /// - /// - /// - public abstract void CreateList(); - - /// - /// - /// Reads the list. - /// - /// - /// - public abstract void ReadList(); - - /// - /// - /// Deletes the list. - /// - /// - /// - public abstract void DeleteList(); - } -} diff --git a/csharp/TestRunResults.cs b/csharp/TestRunResults.cs deleted file mode 100644 index b802712..0000000 --- a/csharp/TestRunResults.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using System.Text; - -namespace Comparisons.SQLiteVSDoublets -{ - /// - /// - /// Represents the test run results. - /// - /// - /// - public class TestRunResults - { - /// - /// - /// Gets or sets the prepare time value. - /// - /// - /// - public TimeSpan PrepareTime { get; set; } - /// - /// - /// Gets or sets the db size after prepare value. - /// - /// - /// - public long DbSizeAfterPrepare { get; set; } - /// - /// - /// Gets or sets the list creation time value. - /// - /// - /// - public TimeSpan ListCreationTime { get; set; } - /// - /// - /// Gets or sets the db size after creation value. - /// - /// - /// - public long DbSizeAfterCreation { get; set; } - /// - /// - /// Gets or sets the list reading time value. - /// - /// - /// - public TimeSpan ListReadingTime { get; set; } - /// - /// - /// Gets or sets the db size after reading value. - /// - /// - /// - public long DbSizeAfterReading { get; set; } - /// - /// - /// Gets or sets the list deletion time value. - /// - /// - /// - public TimeSpan ListDeletionTime { get; set; } - /// - /// - /// Gets or sets the db size after deletion value. - /// - /// - /// - public long DbSizeAfterDeletion { get; set; } - - /// - /// - /// Returns the string. - /// - /// - /// - /// - /// The string - /// - /// - public override string ToString() - { - var sb = new StringBuilder(); - if (DbSizeAfterPrepare != 0) - { - sb.AppendLine($"Prepare execution time: {PrepareTime}, db size after prepare: {DbSizeAfterPrepare}."); - } - else - { - sb.AppendLine($"Prepare execution time: {PrepareTime}."); - } - if (DbSizeAfterCreation != 0) - { - sb.AppendLine($"Create list execution time: {ListCreationTime}, db size after list creation: {DbSizeAfterCreation}."); - } - else - { - sb.AppendLine($"Create list execution time: {ListCreationTime}."); - } - if (DbSizeAfterReading != 0) - { - sb.AppendLine($"Read list execution time: {ListReadingTime}, db size after list reading: {DbSizeAfterReading}."); - } - else - { - sb.AppendLine($"Read list execution time: {ListReadingTime}."); - } - if (DbSizeAfterDeletion != 0) - { - sb.AppendLine($"Delete list execution time: {ListDeletionTime}, db size after list deletion: {DbSizeAfterDeletion}."); - } - else - { - sb.AppendLine($"Delete list execution time: {ListDeletionTime}."); - } - return sb.ToString(); - } - } -} diff --git a/csharp/global.json b/csharp/global.json index 4c4c3ae..df32a2e 100644 --- a/csharp/global.json +++ b/csharp/global.json @@ -1,7 +1,10 @@ { "sdk": { - "version": "8.0.100", + "version": "10.0.100", "rollForward": "latestFeature", "allowPrerelease": false + }, + "test": { + "runner": "Microsoft.Testing.Platform" } } diff --git a/csharp/out.py b/csharp/out.py deleted file mode 100644 index 2a9eac4..0000000 --- a/csharp/out.py +++ /dev/null @@ -1,119 +0,0 @@ -#!/usr/bin/env python3 -"""Parse BenchmarkDotNet JSON and publish the C# link benchmark report.""" - -import argparse -import json -import os -import sys -from urllib.parse import parse_qs - -REPOSITORY_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) -sys.path.insert(0, os.path.join(REPOSITORY_ROOT, "scripts")) - -import benchmark_report as report # noqa: E402 - - -METHODS = { - "Create": "create", - "Update": "update", - "Delete": "delete", - "EachAll": "query_all", - "EachIdentity": "query_by_id", - "EachConcrete": "query_by_source_target", - "EachOutgoing": "query_by_source", - "EachIncoming": "query_by_target", -} - - -def parse_document(document): - """Parse a BenchmarkDotNet full JSON export into shared report results.""" - results = report.empty_results(report.LINK_OPERATIONS) - for benchmark in document.get("Benchmarks", []): - operation = METHODS.get(benchmark.get("Method")) - parameters = parse_qs(benchmark.get("Parameters", "")) - variant = parameters.get("Variant", [None])[0] - mean = benchmark.get("Statistics", {}).get("Mean") - if operation in results and variant and isinstance(mean, (int, float)) and mean > 0: - results[operation][variant] = round(mean) - return results - - -def parse_results(path): - if not os.path.exists(path): - return report.empty_results(report.LINK_OPERATIONS) - with open(path, "r", encoding="utf-8") as handle: - return parse_document(json.load(handle)) - - -def parse_args(argv): - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("input", help="BenchmarkDotNet *-report-full.json file") - parser.add_argument("--results", default="results.md") - parser.add_argument("--readme", action="append", default=[]) - parser.add_argument("--docs-dir") - parser.add_argument("--output-dir", default="") - return parser.parse_args(argv) - - -def main(argv=None): - args = parse_args(argv if argv is not None else sys.argv[1:]) - try: - results = parse_results(args.input) - except (json.JSONDecodeError, OSError) as error: - print(f"Cannot parse {args.input}: {error}") - print(report.report_input_excerpt(args.input)) - return 1 - - if not report.has_any_results(results): - print(f"No benchmark data found in {args.input}") - print(report.report_input_excerpt(args.input)) - return 1 - - missing = report.missing_measurements( - results, - report.LINK_OPERATIONS, - report.VARIANTS, - ) - if missing: - print("Benchmark output is incomplete; missing:") - print("\n".join(missing)) - return 1 - - provenance = report.build_provenance(language="C#", object_count=False) - section = report.render_results_section( - results, - provenance, - operations=report.LINK_OPERATIONS, - ) - section += ( - "\n\n![C# benchmark comparison](docs/benchmarks/bench_csharp.png)" - "\n\n![C# benchmark comparison, logarithmic scale]" - "(docs/benchmarks/bench_csharp_log_scale.png)" - ) - with open(args.results, "w", encoding="utf-8") as handle: - handle.write(section + "\n") - print(report.format_results_table(results, operations=report.LINK_OPERATIONS)) - print(f"Generated {args.results}") - - charts = report.generate_charts( - results, - "bench_csharp", - "Benchmark Comparison: SQLite vs Doublets (C#)", - args.output_dir, - operations=report.LINK_OPERATIONS, - ) - report.copy_charts(charts, args.docs_dir) - - for readme in args.readme: - changed = report.update_markers( - readme, - section, - report.CSHARP_START_MARKER, - report.CSHARP_END_MARKER, - ) - print(f"{'Updated' if changed else 'No changes needed in'} {readme}") - return 0 - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/csharp/results.md b/csharp/results.md deleted file mode 100644 index 93a6c7d..0000000 --- a/csharp/results.md +++ /dev/null @@ -1,16 +0,0 @@ -_Generated 2026-09-19 18:13 UTC for C# by [GitHub Actions run 35459522587](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/runs/35459522587) — 1000 benchmarked links, 3000 background links._ - -| Operation | Doublets United Volatile | Doublets United NonVolatile | Doublets Split Volatile | Doublets Split NonVolatile | SQLite Memory | SQLite File | -|---------------|--------------------------|-----------------------------|-------------------------|----------------------------|---------------|-------------| -| Create | 2414327 (3.7x faster) | 2512520 (3.5x faster) | 867200 (10.2x faster) | 896999 (9.8x faster) | 8818051 | 2294476668 | -| Update | 4566053 (2.3x faster) | 4670899 (2.3x faster) | 1541871 (7.0x faster) | 1534119 (7.0x faster) | 10720700 | 2733072615 | -| Delete | 484008 (14.3x faster) | 465524 (14.8x faster) | 523910 (13.2x faster) | 521322 (13.3x faster) | 6907869 | 1682008343 | -| Each All | 437356 (3.7x faster) | 470885 (3.4x faster) | 420518 (3.8x faster) | 397666 (4.0x faster) | 2113072 | 1598861 | -| Each Identity | 430962 (11.6x faster) | 411173 (12.1x faster) | 403128 (12.4x faster) | 385531 (13.0x faster) | 4995738 | 12044311 | -| Each Concrete | 1363130 (5.2x faster) | 1408600 (5.1x faster) | 647769 (11.0x faster) | 917509 (7.8x faster) | 7134655 | 13622920 | -| Each Outgoing | 1160157 (5.3x faster) | 1180175 (5.3x faster) | 862790 (7.2x faster) | 830070 (7.5x faster) | 6199124 | 12923172 | -| Each Incoming | 1139559 (5.7x faster) | 1169325 (5.6x faster) | 796694 (8.2x faster) | 776158 (8.4x faster) | 6533836 | 13510164 | - -![C# benchmark comparison](docs/benchmarks/bench_csharp.png) - -![C# benchmark comparison, logarithmic scale](docs/benchmarks/bench_csharp_log_scale.png) diff --git a/csharp/test_out.py b/csharp/test_out.py deleted file mode 100644 index 0311234..0000000 --- a/csharp/test_out.py +++ /dev/null @@ -1,122 +0,0 @@ -#!/usr/bin/env python3 -"""Regression tests for the BenchmarkDotNet JSON report pipeline.""" - -import json -import os -import tempfile -import unittest - -import out - - -def complete_document(): - benchmarks = [] - mean = 1_000.4 - for method in out.METHODS: - for variant, _label, _color in out.report.VARIANTS: - benchmarks.append( - { - "Method": method, - "Parameters": f"N=10&Variant={variant}", - "Statistics": {"Mean": mean}, - } - ) - mean += 100 - return {"Title": "test", "Benchmarks": benchmarks} - - -class ParserTests(unittest.TestCase): - def test_parses_every_operation_and_variant(self): - results = out.parse_document(complete_document()) - - self.assertFalse( - out.report.missing_measurements( - results, - out.report.LINK_OPERATIONS, - out.report.VARIANTS, - ) - ) - self.assertEqual(results["create"]["Doublets_United_Volatile"], 1_000) - - def test_ignores_legacy_object_benchmarks(self): - document = complete_document() - document["Benchmarks"].append( - { - "Method": "SQLite", - "Parameters": "N=10", - "Statistics": {"Mean": 42}, - } - ) - - self.assertEqual(out.parse_document(document), out.parse_document(complete_document())) - - def test_ignores_failed_benchmarks_without_statistics(self): - results = out.parse_document( - { - "Benchmarks": [ - { - "Method": "Create", - "Parameters": "N=10&Variant=SQLite_Memory", - } - ] - } - ) - - self.assertFalse(out.report.has_any_results(results)) - - -class MainTests(unittest.TestCase): - def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.input = os.path.join(self.temporary.name, "results.json") - self.results = os.path.join(self.temporary.name, "results.md") - self.readme = os.path.join(self.temporary.name, "README.md") - - def write_json(self, document): - with open(self.input, "w", encoding="utf-8") as handle: - json.dump(document, handle) - - def test_generates_results_and_updates_readme(self): - self.write_json(complete_document()) - with open(self.readme, "w", encoding="utf-8") as handle: - handle.write( - f"{out.report.CSHARP_START_MARKER}\nold\n" - f"{out.report.CSHARP_END_MARKER}\n" - ) - - result = out.main( - [ - self.input, - "--results", - self.results, - "--readme", - self.readme, - "--output-dir", - self.temporary.name, - ] - ) - - self.assertEqual(result, 0) - with open(self.results, "r", encoding="utf-8") as handle: - self.assertIn("Each Concrete", handle.read()) - with open(self.readme, "r", encoding="utf-8") as handle: - self.assertIn("SQLite File", handle.read()) - - def test_rejects_incomplete_results(self): - document = complete_document() - document["Benchmarks"].pop() - self.write_json(document) - - self.assertEqual(out.main([self.input, "--results", self.results]), 1) - self.assertFalse(os.path.exists(self.results)) - - def test_rejects_malformed_json(self): - with open(self.input, "w", encoding="utf-8") as handle: - handle.write("not json") - - self.assertEqual(out.main([self.input, "--results", self.results]), 1) - - -if __name__ == "__main__": - unittest.main() From 4f8d0c5ab15e0115d6b07aaac77e5d99e76a67cb Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 4 Oct 2026 17:40:24 +0000 Subject: [PATCH 04/25] Rust: external references for objects stores, clamp repetitions to 1..=10 Raw numbers stored by the objects layout are external references, as in C#. Repetitions default to 3M / size clamped to 1..=10 in both languages, and experiments/sizing times one repetition of each variant to plan CI jobs. --- experiments/sizing/run.sh | 13 +++++++++++++ rust/src/harness.rs | 26 +++++++++++++++++++++----- rust/src/main.rs | 8 +++++--- rust/tests/storages.rs | 26 +++++++++++++++++++++----- 4 files changed, 60 insertions(+), 13 deletions(-) create mode 100755 experiments/sizing/run.sh diff --git a/experiments/sizing/run.sh b/experiments/sizing/run.sh new file mode 100755 index 0000000..af22fd5 --- /dev/null +++ b/experiments/sizing/run.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash +# Times one repetition of every variant to plan sizes and CI job splits. +# Usage: experiments/sizing/run.sh <32|64> [extra args] +set -euo pipefail +category=$1 bits=$2 size=$3 +shift 3 +binary="$(dirname "$0")/../../rust/target/release/sqlite-vs-doublets" +log="/tmp/sizing-$category-$bits-$size.log" +start=$(date +%s) +"$binary" "$category" "$bits" "$size" --repetitions 1 --directory "/tmp/sizing-$category-$bits-$size" \ + --output "/tmp/sizing-$category-$bits-$size.json" "$@" > "$log" 2>&1 +echo "$category $bits $size: $(( $(date +%s) - start )) s" +grep '#1' "$log" diff --git a/rust/src/harness.rs b/rust/src/harness.rs index 7ae0654..bacaac5 100644 --- a/rust/src/harness.rs +++ b/rust/src/harness.rs @@ -7,7 +7,7 @@ use crate::{ objects::{BlogPostsStorage, DoubletsBlogPosts, SqliteBlogPosts}, }; use doublets::{ - data::LinkReference, + data::{LinkReference, LinksConstants}, mem::{FileMapped, Global}, split, unit, }; @@ -468,7 +468,13 @@ pub fn measure_objects( n, repetitions, directory, - |_| DoubletsBlogPosts::new(unit::Store::::new(Global::new()).unwrap(), cached), + |_| { + DoubletsBlogPosts::new( + unit::Store::::with_constants(Global::new(), LinksConstants::external()) + .unwrap(), + cached, + ) + }, objects_lifecycle, ), "Doublets_United_NonVolatile" => measure( @@ -478,7 +484,11 @@ pub fn measure_objects( directory, |dir| { DoubletsBlogPosts::new( - unit::Store::::new(mapped(dir, "links.links")).unwrap(), + unit::Store::::with_constants( + mapped(dir, "links.links"), + LinksConstants::external(), + ) + .unwrap(), cached, ) }, @@ -491,7 +501,12 @@ pub fn measure_objects( directory, |_| { DoubletsBlogPosts::new( - split::Store::::new(Global::new(), Global::new()).unwrap(), + split::Store::::with_constants( + Global::new(), + Global::new(), + LinksConstants::external(), + ) + .unwrap(), cached, ) }, @@ -504,9 +519,10 @@ pub fn measure_objects( directory, |dir| { DoubletsBlogPosts::new( - split::Store::::new( + split::Store::::with_constants( mapped(dir, "data.links"), mapped(dir, "index.links"), + LinksConstants::external(), ) .unwrap(), cached, diff --git a/rust/src/main.rs b/rust/src/main.rs index 39d8943..c4f248f 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -1,14 +1,16 @@ //! Usage: sqlite-vs-doublets <32|64> [--work N | --repetitions N] //! [--variants A,B] [--directory DIR] [--output FILE] //! -//! Repetitions default to `work / size` (at least one), so every size does about the same total work. +//! Repetitions default to `work / size` clamped to `1..=MAX_REPETITIONS`, so smaller sizes get more, +//! but not endless, repetitions. use doublets::data::LinkReference; use serde_json::json; use sqlite_vs_doublets::harness::{self, LINKS_VARIANTS, Measurement, OBJECTS_VARIANTS}; use std::{collections::HashMap, env, fs, path::Path}; -const DEFAULT_WORK: u64 = 10_000_000; +const DEFAULT_WORK: u64 = 3_000_000; +const MAX_REPETITIONS: u64 = 10; fn main() { let arguments: Vec = env::args().skip(1).collect(); @@ -28,7 +30,7 @@ fn main() { .map_or(DEFAULT_WORK, |work| work.replace('_', "").parse().unwrap()); let repetitions: usize = options .get("repetitions") - .map_or((work / size).max(1) as usize, |count| { + .map_or((work / size).clamp(1, MAX_REPETITIONS) as usize, |count| { count.parse().unwrap() }); let all_variants: &[&'static str] = if category == "links" { diff --git a/rust/tests/storages.rs b/rust/tests/storages.rs index 8106b4b..d3242fa 100644 --- a/rust/tests/storages.rs +++ b/rust/tests/storages.rs @@ -1,4 +1,8 @@ -use doublets::{data::LinkReference, mem::Global, split, unit}; +use doublets::{ + data::{LinkReference, LinksConstants}, + mem::Global, + split, unit, +}; use sqlite_vs_doublets::{ dataset::{BlogPost, blog_post, link, scattered}, harness::{self, LINKS_VARIANTS, OBJECTS_VARIANTS}, @@ -133,19 +137,31 @@ fn every_objects_storage_returns_the_stored_posts() { round_trip::(SqliteBlogPosts::in_memory()); for cached in [false, true] { round_trip(DoubletsBlogPosts::new( - unit::Store::::new(Global::new()).unwrap(), + unit::Store::::with_constants(Global::new(), LinksConstants::external()) + .unwrap(), cached, )); round_trip(DoubletsBlogPosts::new( - unit::Store::::new(Global::new()).unwrap(), + unit::Store::::with_constants(Global::new(), LinksConstants::external()) + .unwrap(), cached, )); round_trip(DoubletsBlogPosts::new( - split::Store::::new(Global::new(), Global::new()).unwrap(), + split::Store::::with_constants( + Global::new(), + Global::new(), + LinksConstants::external(), + ) + .unwrap(), cached, )); round_trip(DoubletsBlogPosts::new( - split::Store::::new(Global::new(), Global::new()).unwrap(), + split::Store::::with_constants( + Global::new(), + Global::new(), + LinksConstants::external(), + ) + .unwrap(), cached, )); } From ab531107ad10c9a4e36fa762065c8ed669d634c8 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 4 Oct 2026 18:13:46 +0000 Subject: [PATCH 05/25] C#: AVL index trees for the united store; delete and tree experiments The united store's size balanced trees degenerate in the objects workload, which made every blog post creation linear in the number of stored posts. experiments/csharp_objects_profile measures it per tree type and experiments/csharp_tree_delete shows why links are deleted with the resetting Platform.Data.Doublets overload. --- csharp/SQLiteVSDoublets/Harness.cs | 7 +- experiments/csharp_objects_profile/Program.cs | 83 +++++++++++++++++++ .../csharp_objects_profile.csproj | 11 +++ experiments/csharp_tree_delete/Program.cs | 61 ++++++++++++++ .../csharp_tree_delete.csproj | 11 +++ 5 files changed, 172 insertions(+), 1 deletion(-) create mode 100644 experiments/csharp_objects_profile/Program.cs create mode 100644 experiments/csharp_objects_profile/csharp_objects_profile.csproj create mode 100644 experiments/csharp_tree_delete/Program.cs create mode 100644 experiments/csharp_tree_delete/csharp_tree_delete.csproj diff --git a/csharp/SQLiteVSDoublets/Harness.cs b/csharp/SQLiteVSDoublets/Harness.cs index 98a3088..a3a7394 100644 --- a/csharp/SQLiteVSDoublets/Harness.cs +++ b/csharp/SQLiteVSDoublets/Harness.cs @@ -301,8 +301,13 @@ private static string Summary(List<(string Operation, TimeSpan Elapsed)> timings private static FileMappedResizableDirectMemory Mapped(string directory, string name) => new(File(directory, name)); /// Objects store numbers and Unicode symbols as raw numbers, which are external references. + /// + /// The size balanced index trees (the default) degenerate when many links share a source or a target, + /// which makes every blog post creation linear in the number of stored posts; + /// the AVL trees stay logarithmic (see experiments/csharp_objects_profile). + /// public static ILinks United(IResizableDirectMemory memory, bool external = false) where T : struct, IBinaryInteger, IUnsignedNumber, IMinMaxValue => - new UnitedMemoryLinks(memory, UnitedMemoryLinks.DefaultLinksSizeStep, new LinksConstants(external), IndexTreeType.Default); + new UnitedMemoryLinks(memory, UnitedMemoryLinks.DefaultLinksSizeStep, new LinksConstants(external), IndexTreeType.SizedAndThreadedAVLBalancedTree); public static ILinks Split(IResizableDirectMemory data, IResizableDirectMemory index, bool external = false) where T : struct, IBinaryInteger, IUnsignedNumber, IMinMaxValue => new SplitMemoryLinks(data, index, SplitMemoryLinks.DefaultLinksSizeStep, new LinksConstants(external)); diff --git a/experiments/csharp_objects_profile/Program.cs b/experiments/csharp_objects_profile/Program.cs new file mode 100644 index 0000000..efffaf6 --- /dev/null +++ b/experiments/csharp_objects_profile/Program.cs @@ -0,0 +1,83 @@ +// Profiles which ILinks calls make C# Doublets blog post creation grow with the number of stored posts. +// Usage: dotnet run -c Release -- [united index tree type] [internal] +// Wraps the store in a decorator that counts and times Each, Count, Create, Update and Delete calls and +// prints them per created post for the first and the last tenth of the posts. +// Results with Platform.Data.Doublets 0.18.1 and 10,000 posts: only the update that attaches a post to its +// type marker grows, so the united store's size balanced trees degenerate when thousands of links share a source. +// Mean time of that update for posts 1..1,000 -> 9,001..10,000: +// united Default 27 µs -> 322 µs +// united SizeBalancedTree 82 µs -> 369 µs +// united RecursionlessSizeBalancedTree 47 µs -> 742 µs +// united SizedAndThreadedAVLBalancedTree 22 µs -> 2.2 µs +// split 0.9 µs -> 0.2 µs +// `internal` (no external references) degenerates the same way (Default: 50 µs -> 584 µs), and plain hubs +// without the rest of the blog post links do not, so the benchmarks use the AVL trees for the united store. +using System.Diagnostics; +using Comparisons.SQLiteVSDoublets; +using Platform.Data; +using Platform.Data.Doublets; +using Platform.Data.Doublets.Memory; +using Platform.Data.Doublets.Memory.United.Generic; +using Platform.Delegates; +using Platform.Memory; + +var kind = args[0]; +var n = int.Parse(args[1]); +Console.WriteLine($"tree types: {string.Join(", ", Enum.GetNames())}"); +ILinks store = kind == "united" + ? new UnitedMemoryLinks(new HeapResizableDirectMemory(), UnitedMemoryLinks.DefaultLinksSizeStep, + new LinksConstants(args.Length <= 3 || args[3] != "internal"), args.Length > 2 ? Enum.Parse(args[2]) : IndexTreeType.Default) + : Harness.Split(new HeapResizableDirectMemory(), new HeapResizableDirectMemory(), external: true); +var profiled = new Profiled(store); +var posts = new DoubletsBlogPosts(profiled, cacheSequences: true); +var tenth = n / 10; +for (var i = 1; i <= n; i++) +{ + if (i == 1 || i == n - tenth + 1) + { + profiled.Reset(); + } + posts.Create(Dataset.BlogPost((ulong)i)); + if (i == tenth || i == n) + { + Console.WriteLine($"{kind} posts {i - tenth + 1}..{i}: {profiled.Report(tenth)}"); + } +} + +sealed class Profiled(ILinks links) : ILinks +{ + private readonly Dictionary _calls = []; + + public LinksConstants Constants => links.Constants; + + public void Reset() => _calls.Clear(); + + public string Report(int posts) => string.Join(", ", _calls.Select(call => + $"{call.Key} {(double)call.Value.Calls / posts:F1} calls {call.Value.Time.TotalNanoseconds / posts:F0} ns")); + + private TResult Timed(string name, Func work) + { + var stopwatch = Stopwatch.StartNew(); + var result = work(); + var (calls, time) = _calls.GetValueOrDefault(name); + _calls[name] = (calls + 1, time + stopwatch.Elapsed); + return result; + } + + private static string Shape(IList? restriction, ulong any) => + restriction is null ? "null" : string.Concat(restriction.Select(value => value == any ? "*" : "v")); + + public ulong Count(IList? restriction) => Timed($"Count[{Shape(restriction, Constants.Any)}]", () => links.Count(restriction)); + + public ulong Each(IList? restriction, ReadHandler? handler) => + Timed($"Each[{Shape(restriction, Constants.Any)}]", () => links.Each(restriction, handler)); + + public ulong Create(IList? substitution, WriteHandler? handler) => Timed("Create", () => links.Create(substitution, handler)); + + public ulong Update(IList? restriction, IList? substitution, WriteHandler? handler) => + Timed($"Update[{Kind(substitution![1])},{Kind(substitution[2])}]", () => links.Update(restriction, substitution, handler)); + + private string Kind(ulong address) => Constants.IsExternalReference(address) ? "raw" : address <= 7 ? $"marker{address}" : "link"; + + public ulong Delete(IList? restriction, WriteHandler? handler) => Timed("Delete", () => links.Delete(restriction, handler)); +} diff --git a/experiments/csharp_objects_profile/csharp_objects_profile.csproj b/experiments/csharp_objects_profile/csharp_objects_profile.csproj new file mode 100644 index 0000000..77a69f8 --- /dev/null +++ b/experiments/csharp_objects_profile/csharp_objects_profile.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + diff --git a/experiments/csharp_tree_delete/Program.cs b/experiments/csharp_tree_delete/Program.cs new file mode 100644 index 0000000..52638a4 --- /dev/null +++ b/experiments/csharp_tree_delete/Program.cs @@ -0,0 +1,61 @@ +// Shows why the C# benchmarks delete links with `Platform.Data.Doublets.ILinksExtensions.Delete(links, id, handler)`. +// The `links.Delete(id)` overload from Platform.Data frees the link without detaching it from the source and +// target trees, so the trees keep a stale entry that soon hides other links from `SearchOrDefault`. +// The Platform.Data.Doublets overload resets the link to (0, 0) first, which detaches it. +// Usage: dotnet run -c Release [-- --self] [-- --reset] +// --self also creates links that reference themselves; --reset deletes with the resetting overload. +// Results with Platform.Data.Doublets 0.18.1 (random stores of 3..151 links, every unreferenced link deleted): +// (no flags) united: fails at 7 links, split: fails at 13 links +// --self united: fails at 8 links, split: fails at 10 links +// --reset united and split: no failure up to 151 links +// --self --reset united and split: no failure up to 151 links +using Platform.Data; +using Platform.Data.Doublets; +using Platform.Data.Doublets.Memory.Split.Generic; +using Platform.Data.Doublets.Memory.United.Generic; +using Platform.Memory; + +var self = args.Contains("--self"); +var reset = args.Contains("--reset"); +foreach (var kind in new[] { "united", "split" }) +{ + var failure = Enumerable.Range(3, 149).Select(n => Run(kind, n)).FirstOrDefault(failure => failure is not null); + Console.WriteLine($"{kind}: {failure ?? "no failure up to 151 links"}"); +} + +string? Run(string kind, int n) +{ + ILinks links = kind == "united" + ? new UnitedMemoryLinks(new HeapResizableDirectMemory()) + : new SplitMemoryLinks(new HeapResizableDirectMemory(), new HeapResizableDirectMemory()); + // Every store preallocates its memory, so it must be released before the next run. + using var disposable = (IDisposable)links; + var random = new Random(n); + // Two points, so that new (source, target) pairs exist without --self. + var created = new List<(ulong Id, ulong Source, ulong Target)> { (links.CreatePoint(), 1, 1), (links.CreatePoint(), 2, 2) }; + while (created.Count < n) + { + var next = created[^1].Id + 1; + var source = self && random.Next(4) == 0 ? next : created[random.Next(created.Count)].Id; + var target = created[random.Next(created.Count)].Id; + if (links.SearchOrDefault(source, target) == 0) + { + created.Add((links.CreateAndUpdate(source, target), source, target)); + } + } + var alive = created.ToDictionary(link => link.Id); + foreach (var victim in created.OrderBy(_ => random.Next())) + { + if (alive.Values.Any(link => link.Id != victim.Id && (link.Source == victim.Id || link.Target == victim.Id))) + { + continue; + } + if (reset) links.Delete(victim.Id, handler: null); else links.Delete(victim.Id); + alive.Remove(victim.Id); + foreach (var link in alive.Values.Where(link => links.SearchOrDefault(link.Source, link.Target) != link.Id)) + { + return $"{n} links {string.Join(" ", created.Select(l => $"({l.Id}: {l.Source} {l.Target})"))}; after deleting {victim.Id} search({link.Source}, {link.Target}) = {links.SearchOrDefault(link.Source, link.Target)} instead of {link.Id}"; + } + } + return null; +} diff --git a/experiments/csharp_tree_delete/csharp_tree_delete.csproj b/experiments/csharp_tree_delete/csharp_tree_delete.csproj new file mode 100644 index 0000000..77a69f8 --- /dev/null +++ b/experiments/csharp_tree_delete/csharp_tree_delete.csproj @@ -0,0 +1,11 @@ + + + Exe + net10.0 + enable + enable + + + + + From 40e676babcbe202d5b486fadba85428e283412b6 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 4 Oct 2026 18:14:50 +0000 Subject: [PATCH 06/25] Report script: README sections in the category/language/bits/size hierarchy One JSON report per (category, language, bits, size); Doublets are compared with SQLite of the same durability, and overlapping ranges or medians within 5% are reported as the same. --- scripts/benchmark_report.py | 598 +++++++++++++++---------------- scripts/requirements.txt | 1 + scripts/test_benchmark_report.py | 231 +++++++----- 3 files changed, 443 insertions(+), 387 deletions(-) create mode 100644 scripts/requirements.txt diff --git a/scripts/benchmark_report.py b/scripts/benchmark_report.py index a37e98b..64b160a 100644 --- a/scripts/benchmark_report.py +++ b/scripts/benchmark_report.py @@ -1,318 +1,304 @@ #!/usr/bin/env python3 -"""Shared benchmark reporting helpers for the SQLite vs Doublets comparison.""" +"""Turns the benchmark JSON reports into the README results sections and charts. -# The language-specific pipelines parse their own benchmark formats, then use -# this module for consistent Markdown tables, speedup annotations, linear and -# logarithmic charts, and in-place result-section updates. The report format -# follows the sibling LinksPlatform database comparisons. +Usage: benchmark_report.py RESULTS_DIR [--readme README.md] [--readme README.ru.md] [--charts DIR] -import os -import re -import shutil -from datetime import datetime, timezone - -try: - import matplotlib - - matplotlib.use("Agg") - import matplotlib.pyplot as plt - import numpy as np - - HAS_MATPLOTLIB = True -except ImportError: # pragma: no cover - exercised only without matplotlib - print("Warning: matplotlib/numpy not installed, skipping chart generation") - HAS_MATPLOTLIB = False - -RUST_START_MARKER = "" -RUST_END_MARKER = "" -CSHARP_START_MARKER = "" -CSHARP_END_MARKER = "" - -# Operations shared by every language of this comparison. The first element of -# each pair is the identifier used by the benchmark runner, the second one is -# the label used in reports. -LINK_OPERATIONS = ( - ("create", "Create"), - ("update", "Update"), - ("delete", "Delete"), - ("query_all", "Each All"), - ("query_by_id", "Each Identity"), - ("query_by_source_target", "Each Concrete"), - ("query_by_source", "Each Outgoing"), - ("query_by_target", "Each Incoming"), -) - -# Object-like structures (blog posts), the operations the C# comparison has -# been built around from the beginning. -OBJECT_OPERATIONS = ( - ("objects_create", "Objects Create List"), - ("objects_read", "Objects Read List"), - ("objects_delete", "Objects Delete List"), -) - -OPERATIONS = LINK_OPERATIONS + OBJECT_OPERATIONS - -# Benchmarked backends: identifier, label and chart color. -DOUBLETS_VARIANTS = ( - ("Doublets_United_Volatile", "Doublets United Volatile", "salmon"), - ("Doublets_United_NonVolatile", "Doublets United NonVolatile", "red"), - ("Doublets_Split_Volatile", "Doublets Split Volatile", "lightgreen"), - ("Doublets_Split_NonVolatile", "Doublets Split NonVolatile", "green"), -) - -SQLITE_VARIANTS = ( - ("SQLite_Memory", "SQLite Memory", "lightblue"), - ("SQLite_File", "SQLite File", "royalblue"), -) - -VARIANTS = DOUBLETS_VARIANTS + SQLITE_VARIANTS - -# Doublets cells are annotated relative to the fastest SQLite measurement of -# the same operation, the same way Comparisons.Neo4jVSDoublets annotates -# against the fastest of the two Neo4j modes. -BASELINES = tuple(key for key, _label, _color in SQLITE_VARIANTS) - - -def empty_results(operations=OPERATIONS): - """Build an empty ``{operation: {variant: nanoseconds}}`` mapping.""" - return {op: {} for op, _label in operations} - - -def has_any_results(results): - """Return ``True`` when at least one measurement was parsed.""" - return any(measurements for measurements in results.values()) - - -def missing_measurements(results, operations=OPERATIONS, variants=VARIANTS): - """List expected ``operation/variant`` pairs absent from parsed output.""" - missing = [] - for operation, _operation_label in operations: - measured = results.get(operation, {}) - for variant, _variant_label, _color in variants: - if not measured.get(variant): - missing.append(f"{operation}/{variant}") - return missing - - -def baseline_of(measurements, baselines=BASELINES): - """Fastest baseline (SQLite) measurement of a single operation, 0 if none.""" - values = [measurements.get(key, 0) for key in baselines] - values = [value for value in values if value] - return min(values) if values else 0 - - -def format_speedup(value, baseline): - """Annotate ``value`` with how it compares to the ``baseline`` measurement.""" - if not value: - return "N/A" - if not baseline: - return f"{value}" - if value <= baseline: - return f"{value} ({baseline / value:.1f}x faster)" - return f"{value} ({value / baseline:.1f}x slower)" - - -def format_results_table(results, operations=OPERATIONS, variants=VARIANTS, baselines=BASELINES): - """Render the Markdown results table (all numbers in nanoseconds).""" - labels = [label for _key, label, _color in variants] - cells_by_variant = [] - for key, _label, _color in variants: - column = [] - for op, _op_label in operations: - measurements = results.get(op, {}) - value = measurements.get(key, 0) - if key in baselines: - column.append(str(value) if value else "N/A") - else: - column.append(format_speedup(value, baseline_of(measurements, baselines))) - cells_by_variant.append(column) - - widths = [ - max(len(label), *(len(cell) for cell in column)) - for label, column in zip(labels, cells_by_variant) - ] - operation_width = max(len("Operation"), *(len(label) for _key, label in operations)) - - header = "| " + "Operation".ljust(operation_width) + " | " - header += " | ".join(label.ljust(width) for label, width in zip(labels, widths)) - header += " |" - separator = "|" + "-" * (operation_width + 2) - separator += "".join("|" + "-" * (width + 2) for width in widths) + "|" - - lines = [header, separator] - for index, (_op, op_label) in enumerate(operations): - row = "| " + op_label.ljust(operation_width) + " | " - row += " | ".join( - column[index].ljust(width) for column, width in zip(cells_by_variant, widths) - ) - row += " |" - lines.append(row) +Every JSON report (written by `rust` and `csharp` with `--output`) is one table: a language, +a category (links or objects), an address/id space (32 or 64 bit) and a size, measured on one machine. +The README section between the markers is regenerated in the hierarchy +category -> language -> bits -> size, and charts are written per category, language and bits. +""" +import argparse +import json +import re +from pathlib import Path + +START_MARKER = "" +END_MARKER = "" + +CATEGORIES = ("links", "objects") +NOISE = 0.05 +LANGUAGES = ("Rust", "C#") +BITS = (32, 64) + +OPERATIONS = { + "links": ( + "create", + "query_all", + "query_by_id", + "query_by_from_to", + "query_by_from", + "query_by_to", + "update", + "delete", + ), + "objects": ("create", "read_all", "read_by_id", "delete"), +} + + +def russian_plural(count, one, few, many): + if count % 10 == 1 and count % 100 != 11: + return one + return few if 2 <= count % 10 <= 4 and not 12 <= count % 100 <= 14 else many + + +TEXT = { + "en": { + "category": { + "links": "Doublets vs SQLite as storage for links", + "objects": "Doublets vs SQLite as storage for objects", + }, + "language": "{language} doublets vs SQLite", + "bits": "{bits} bit address/id space benchmarks", + "size": {"links": "{size} links", "objects": "{size} blog posts"}, + "operations": { + "create": "Create", + "query_all": "Read all", + "query_by_id": "Read by id", + "query_by_from_to": "Search (from, to)", + "query_by_from": "Read by from", + "query_by_to": "Read by to", + "update": "Update", + "delete": "Delete", + "read_all": "Read all", + "read_by_id": "Read by id", + }, + "storage": "Storage", + "file_size": "File size", + "faster": "{ratio}× faster", + "slower": "{ratio}× slower", + "same": "≈ same", + "provenance": "_{repetitions} after a warm-up, median time per operation. SQLite {sqlite}, {machine}, {source}._", + "repetitions": lambda count: f"{count} repetition" + ("" if count == 1 else "s"), + "machine": "unknown machine", + "local": "a local run", + "run": "[GitHub Actions run]({url}) on {date}", + "missing": "_No results yet._", + "chart": "{language} doublets vs SQLite, {bits} bit, {category}", + "nouns": {"links": "links", "objects": "objects"}, + }, + "ru": { + "category": { + "links": "Дуплеты против SQLite как хранилище связей", + "objects": "Дуплеты против SQLite как хранилище объектов", + }, + "language": "Дуплеты на {language} против SQLite", + "bits": "Тесты с {bits}-битным пространством адресов/идентификаторов", + "size": {"links": "{size} связей", "objects": "{size} записей блога"}, + "operations": { + "create": "Создание", + "query_all": "Чтение всех", + "query_by_id": "Чтение по id", + "query_by_from_to": "Поиск (from, to)", + "query_by_from": "Чтение по from", + "query_by_to": "Чтение по to", + "update": "Обновление", + "delete": "Удаление", + "read_all": "Чтение всех", + "read_by_id": "Чтение по id", + }, + "storage": "Хранилище", + "file_size": "Размер файлов", + "faster": "в {ratio}× быстрее", + "slower": "в {ratio}× медленнее", + "same": "≈ так же", + "provenance": "_{repetitions} после прогрева, медианное время одной операции. SQLite {sqlite}, {machine}, {source}._", + "repetitions": lambda count: f"{count} повтор" + russian_plural(count, "", "а", "ов"), + "machine": "неизвестная машина", + "local": "локальный запуск", + "run": "[запуск GitHub Actions]({url}) от {date}", + "missing": "_Результатов пока нет._", + "chart": "Дуплеты на {language} против SQLite, {bits} бит, {category}", + "nouns": {"links": "связи", "objects": "объекты"}, + }, +} + + +def load(directory): + """All reports in `directory`, keyed by (category, language, bits, size).""" + reports = {} + for path in sorted(Path(directory).glob("*.json")): + report = json.loads(path.read_text(encoding="utf-8")) + key = (report["category"], report["language"], report["bits"], report["size"]) + if key in reports: + raise ValueError(f"{path} duplicates the results of {key}") + reports[key] = report + return reports + + +def baseline(variant): + """Doublets are compared with SQLite of the same durability: volatile with memory, non-volatile with file.""" + if variant.startswith("SQLite"): + return None + return "SQLite_File" if "_NonVolatile" in variant else "SQLite_Memory" + + +def duration(nanoseconds): + for unit, scale in (("s", 1e9), ("ms", 1e6), ("µs", 1e3)): + if nanoseconds >= scale: + return f"{nanoseconds / scale:.3g} {unit}" + return f"{nanoseconds:.3g} ns" + + +def size(count): + return f"{count:,}" + + +def file_size(count): + return "—" if count is None else f"{count / 2**20:.1f} MiB" + + +def comparison(measured, reference, text): + """How `measured` compares with `reference`. + + Overlapping sample ranges and medians within NOISE of each other (single samples have no range) + are not called a difference. + """ + overlapping = measured["min_ns"] <= reference["max_ns"] and reference["min_ns"] <= measured["max_ns"] + ratio = max(measured["median_ns"], reference["median_ns"]) / min(measured["median_ns"], reference["median_ns"]) + if overlapping or ratio < 1 + NOISE: + return text["same"] + if measured["median_ns"] <= reference["median_ns"]: + return text["faster"].format(ratio=f"{reference['median_ns'] / measured['median_ns']:.3g}") + return text["slower"].format(ratio=f"{measured['median_ns'] / reference['median_ns']:.3g}") + + +def table(report, text): + operations = OPERATIONS[report["category"]] + by_variant = {result["variant"]: result for result in report["results"]} + header = [text["storage"], *(text["operations"][operation] for operation in operations), text["file_size"]] + lines = ["| " + " | ".join(header) + " |", "|" + "---|" + "---:|" * (len(header) - 1)] + for variant, result in by_variant.items(): + reference = by_variant.get(baseline(variant)) + cells = [variant.replace("_", " ")] + for operation in operations: + measured = result["operations"][operation] + cell = duration(measured["median_ns"]) + if reference is not None: + cell += f" ({comparison(measured, reference['operations'][operation], text)})" + cells.append(cell) + cells.append(file_size(result["file_bytes"])) + lines.append("| " + " | ".join(cells) + " |") return "\n".join(lines) -def build_provenance( - benchmark_links=None, - background_links=None, - object_count=None, - generated_at=None, - language=None, -): - """Describe how and when the committed results were produced.""" - benchmark_links = benchmark_links or os.environ.get("BENCHMARK_LINK_COUNT", "1000") - background_links = background_links or os.environ.get("BACKGROUND_LINK_COUNT", "3000") - if object_count is not False: - object_count = object_count or os.environ.get("BENCHMARK_OBJECT_COUNT", "1000") - generated_at = generated_at or datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC") - - source = "a local benchmark run" - repository = os.environ.get("GITHUB_REPOSITORY") - run_id = os.environ.get("GITHUB_RUN_ID") - if repository and run_id: - source = ( - f"[GitHub Actions run {run_id}]" - f"(https://github.com/{repository}/actions/runs/{run_id})" - ) - - prefix = f"_Generated {generated_at}" - if language: - prefix += f" for {language}" - quantities = ( - f"{benchmark_links} benchmarked links, " - f"{background_links} background links" +def provenance(report, text): + run = report.get("run_url") + return text["provenance"].format( + repetitions=text["repetitions"](report["repetitions"]), + sqlite=report["sqlite_version"], + machine=report.get("machine") or text["machine"], + source=text["run"].format(url=run, date=report.get("date")) if run else text["local"], ) - if object_count is not False: - quantities += f", {object_count} objects" - return f"{prefix} by {source} — {quantities}._" -def render_results_section(results, provenance=None, **table_options): - """Render a results section: provenance line plus the results table.""" - provenance = provenance if provenance is not None else build_provenance() - return f"{provenance}\n\n{format_results_table(results, **table_options)}" - - -def update_markers(path, section, start_marker, end_marker): - """Replace a marked Markdown section and report whether it changed.""" - with open(path, "r", encoding="utf-8") as handle: - document = handle.read() +def chart_name(category, language, bits): + return f"{category}-{language.lower().replace('#', 'sharp')}-{bits}.png" + + +def section(reports, language_code, charts_link): + """The Markdown results section in the category -> language -> bits -> size hierarchy.""" + text = TEXT[language_code] + lines = [] + for category in CATEGORIES: + lines += [f"## {text['category'][category]}", ""] + for language in LANGUAGES: + lines += [f"### {text['language'].format(language=language)}", ""] + for bits in BITS: + lines += [f"#### {text['bits'].format(bits=bits)}", ""] + sizes = sorted(key[3] for key in reports if key[:3] == (category, language, bits)) + if not sizes: + lines += [text["missing"], ""] + continue + for count in sizes: + report = reports[(category, language, bits, count)] + lines += [ + f"##### {text['size'][category].format(size=size(count))}", + "", + provenance(report, text), + "", + table(report, text), + "", + ] + if charts_link is not None: + name = chart_name(category, language, bits) + title = text["chart"].format(language=language, bits=bits, category=text["nouns"][category]) + lines += [f"![{title}]({charts_link}/{name})", ""] + return "\n".join(lines).rstrip() + "\n" + + +def replace_section(document, generated): + pattern = re.compile(re.escape(START_MARKER) + ".*?" + re.escape(END_MARKER), re.DOTALL) + if not pattern.search(document): + raise ValueError(f"{START_MARKER} ... {END_MARKER} markers are missing") + return pattern.sub(lambda _: f"{START_MARKER}\n{generated}{END_MARKER}", document) + + +def charts(reports, directory): + """One logarithmic bar chart per category, language and bits, with a panel per size.""" + import matplotlib - if start_marker not in document or end_marker not in document: - raise ValueError(f"{path} does not contain the {start_marker} / {end_marker} markers") + matplotlib.use("Agg") + import matplotlib.pyplot as plt - pattern = re.compile( - re.escape(start_marker) + r".*?" + re.escape(end_marker), - re.DOTALL, - ) - replacement = f"{start_marker}\n{section}\n{end_marker}" - updated = pattern.sub(lambda _match: replacement, document, count=1) - - if updated == document: - return False - - with open(path, "w", encoding="utf-8") as handle: - handle.write(updated) - return True - - -def _series(results, variant, operations): - """Measurements of one variant across all operations, 0 when missing.""" - return [results.get(op, {}).get(variant, 0) for op, _label in operations] - - -def _ensure_min_visible(values, minimum): - """Keep non-zero bars at least ``minimum`` wide so they stay visible.""" - return [max(value, minimum) if value > 0 else 0 for value in values] - - -def _plot(results, path, title, log_scale, operations, variants): - positions = np.arange(len(operations)) - width = 0.8 / len(variants) - figure, axes = plt.subplots(figsize=(12, 8)) - - series = {key: _series(results, key, operations) for key, _label, _color in variants} - - if log_scale: - plotted = series - else: - # On a linear scale Doublets bars are invisible next to SQLite, so give - # every non-zero measurement a minimum visible width (~0.5% of the - # maximum), matching the sibling benchmark charts. - all_values = [value for values in series.values() for value in values] - max_value = max(all_values) if all_values else 1 - minimum = max_value * 0.005 - plotted = {key: _ensure_min_visible(values, minimum) for key, values in series.items()} - - offset_base = (len(variants) - 1) / 2 - for index, (key, label, color) in enumerate(variants): - offset = (index - offset_base) * width - axes.barh(positions + offset, plotted[key], width, label=label, color=color) - - axes.set_xlabel("Time (ns) – log scale" if log_scale else "Time (ns)") - axes.set_title(title) - axes.set_yticks(positions) - axes.set_yticklabels([label for _op, label in operations]) - if log_scale: - axes.set_xscale("log") - axes.legend() - figure.tight_layout() - - directory = os.path.dirname(path) - if directory: - os.makedirs(directory, exist_ok=True) - figure.savefig(path) - plt.close(figure) - print(f"Generated {path}") - - -def generate_charts( - results, - prefix, - title, - output_dir="", - operations=OPERATIONS, - variants=VARIANTS, -): - """Generate linear/log charts and return their paths when available.""" - if not HAS_MATPLOTLIB: - return [] - - linear = os.path.join(output_dir, f"{prefix}.png") if output_dir else f"{prefix}.png" - logarithmic = ( - os.path.join(output_dir, f"{prefix}_log_scale.png") - if output_dir - else f"{prefix}_log_scale.png" - ) - _plot(results, linear, title, False, operations, variants) - _plot(results, logarithmic, title, True, operations, variants) - return [linear, logarithmic] - - -def copy_charts(charts, docs_dir): - """Copy generated charts into the documentation directory.""" - if not docs_dir: - return [] - os.makedirs(docs_dir, exist_ok=True) - copied = [] - for chart in charts: - if os.path.exists(chart): - destination = os.path.join(docs_dir, os.path.basename(chart)) - shutil.copyfile(chart, destination) - copied.append(destination) - print(f"Copied {chart} -> {destination}") - return copied - - -def report_input_excerpt(path, lines=20): - """Describe the tail of ``path`` so an unparsable run can be diagnosed.""" - if not os.path.exists(path): - return f"{path} does not exist" - - with open(path, "r", encoding="utf-8") as handle: - content = handle.read() - - if not content.strip(): - return f"{path} is empty" - - tail = content.splitlines()[-lines:] - return "\n".join([f"Last {len(tail)} line(s) of {path}:", *tail]) + directory.mkdir(parents=True, exist_ok=True) + written = [] + for category in CATEGORIES: + operations = OPERATIONS[category] + labels = [TEXT["en"]["operations"][operation] for operation in operations] + for language in LANGUAGES: + for bits in BITS: + sizes = sorted(key[3] for key in reports if key[:3] == (category, language, bits)) + if not sizes: + continue + figure, axes = plt.subplots( + 1, len(sizes), figsize=(6 * len(sizes), 5), squeeze=False, sharey=False + ) + for axis, count in zip(axes[0], sizes): + results = reports[(category, language, bits, count)]["results"] + width = 0.8 / len(results) + for index, result in enumerate(results): + axis.bar( + [position + index * width for position in range(len(operations))], + [result["operations"][operation]["median_ns"] for operation in operations], + width, + label=result["variant"].replace("_", " "), + color=plt.get_cmap("tab10")(index % 10), + ) + axis.set_yscale("log") + axis.set_title(TEXT["en"]["size"][category].format(size=size(count))) + axis.set_ylabel("median ns per operation (log scale)") + axis.set_xticks([position + 0.4 - width / 2 for position in range(len(operations))]) + axis.set_xticklabels(labels, rotation=30, ha="right") + axis.grid(axis="y", which="major", alpha=0.3) + axes[0][0].legend(fontsize="small") + figure.suptitle(TEXT["en"]["chart"].format(language=language, bits=bits, category=category)) + figure.tight_layout() + path = directory / chart_name(category, language, bits) + figure.savefig(path, dpi=80) + plt.close(figure) + written.append(path) + return written + + +def main(arguments=None): + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument("results", type=Path, help="directory with the JSON reports") + parser.add_argument("--readme", type=Path, action="append", default=[], help="README to update in place") + parser.add_argument("--charts", type=Path, help="directory for the charts, relative to the READMEs") + options = parser.parse_args(arguments) + reports = load(options.results) + if options.charts is not None: + charts(reports, options.charts) + for readme in options.readme: + language_code = "ru" if readme.name.endswith(".ru.md") else "en" + link = None if options.charts is None else options.charts.resolve().relative_to(readme.resolve().parent).as_posix() + document = readme.read_text(encoding="utf-8") + readme.write_text(replace_section(document, section(reports, language_code, link)), encoding="utf-8") + if not options.readme: + print(section(reports, "en", None)) + + +if __name__ == "__main__": + main() diff --git a/scripts/requirements.txt b/scripts/requirements.txt new file mode 100644 index 0000000..71363ac --- /dev/null +++ b/scripts/requirements.txt @@ -0,0 +1 @@ +matplotlib==3.11.2 diff --git a/scripts/test_benchmark_report.py b/scripts/test_benchmark_report.py index 11c170e..5e3c1cf 100644 --- a/scripts/test_benchmark_report.py +++ b/scripts/test_benchmark_report.py @@ -1,95 +1,164 @@ #!/usr/bin/env python3 -"""Tests for shared result formatting and Markdown publication helpers.""" +"""Tests for benchmark_report.py: run with `python -m unittest discover -s scripts`.""" -import os +import importlib.util +import json import tempfile import unittest +from pathlib import Path import benchmark_report as report -class ResultTests(unittest.TestCase): - def test_fastest_sqlite_variant_is_the_baseline(self): - self.assertEqual( - report.baseline_of({"SQLite_Memory": 200, "SQLite_File": 100}), 100 - ) - - def test_formats_faster_and_slower_measurements(self): - self.assertEqual(report.format_speedup(100, 1_000), "100 (10.0x faster)") - self.assertEqual(report.format_speedup(1_000, 100), "1000 (10.0x slower)") - - def test_reports_every_missing_measurement(self): - results = report.empty_results([("create", "Create")]) - - missing = report.missing_measurements( - results, - [("create", "Create")], - [("SQLite_Memory", "SQLite Memory", "blue")], - ) - - self.assertEqual(missing, ["create/SQLite_Memory"]) - - -class MarkerTests(unittest.TestCase): +def measurement(median, low=None, high=None): + low = median if low is None else low + high = median if high is None else high + return {"median_ns": median, "min_ns": low, "max_ns": high, "samples_ns": [low, median, high]} + + +def results(category, variants, file_bytes=None): + return { + "category": category, + "results": [ + { + "variant": variant, + "file_bytes": file_bytes, + "operations": {operation: measurement(median) for operation in report.OPERATIONS[category]}, + } + for variant, median in variants.items() + ], + } + + +def sample(category="links", language="Rust", bits=64, size=100_000, **extra): + data = results(category, {"SQLite_Memory": 1000, "SQLite_File": 2000, "Doublets_United_Volatile": 100}) + data.update(language=language, bits=bits, size=size, repetitions=3, sqlite_version="3.53.2", **extra) + return data + + +class FormattingTests(unittest.TestCase): + def test_durations_keep_three_significant_digits(self): + self.assertEqual(report.duration(999), "999 ns") + self.assertEqual(report.duration(1_234), "1.23 µs") + self.assertEqual(report.duration(45_600_000), "45.6 ms") + self.assertEqual(report.duration(2e9), "2 s") + + def test_sizes(self): + self.assertEqual(report.size(10_000_000), "10,000,000") + self.assertEqual(report.file_size(64 * 2**20), "64.0 MiB") + self.assertEqual(report.file_size(None), "—") + + def test_russian_plurals(self): + repetitions = report.TEXT["ru"]["repetitions"] + self.assertEqual([repetitions(count) for count in (1, 3, 5, 11, 21)], + ["1 повтор", "3 повтора", "5 повторов", "11 повторов", "21 повтор"]) + + +class ComparisonTests(unittest.TestCase): + text = report.TEXT["en"] + + def test_doublets_are_compared_with_sqlite_of_the_same_durability(self): + self.assertEqual(report.baseline("Doublets_Split_Volatile"), "SQLite_Memory") + self.assertEqual(report.baseline("Doublets_United_NonVolatile_Cached"), "SQLite_File") + self.assertIsNone(report.baseline("SQLite_File")) + + def test_clear_differences(self): + self.assertEqual(report.comparison(measurement(100), measurement(1000), self.text), "10× faster") + self.assertEqual(report.comparison(measurement(2500), measurement(1000), self.text), "2.5× slower") + + def test_overlapping_ranges_are_not_a_difference(self): + self.assertEqual(report.comparison(measurement(100, 90, 300), measurement(250, 200, 260), self.text), "≈ same") + + def test_single_samples_within_noise_are_not_a_difference(self): + self.assertEqual(report.comparison(measurement(1040), measurement(1000), self.text), "≈ same") + self.assertEqual(report.comparison(measurement(1100), measurement(1000), self.text), "1.1× slower") + + def test_table_compares_every_operation_with_the_baseline(self): + table = report.table(sample(), self.text).splitlines() + self.assertEqual(len(table), 2 + 3) + self.assertEqual(table[0].count("|"), len(report.OPERATIONS["links"]) + 3) + self.assertNotIn("faster", table[2]) + self.assertEqual(table[4].count("10× faster"), len(report.OPERATIONS["links"])) + + +class SectionTests(unittest.TestCase): + def test_hierarchy_is_category_language_bits_size(self): + reports = { + ("links", "Rust", 64, 1_000_000): sample(size=1_000_000), + ("links", "Rust", 64, 100_000): sample(), + ("objects", "C#", 32, 100_000): sample("objects", "C#", 32), + } + headings = [line for line in report.section(reports, "en", "docs").splitlines() if line.startswith("#")] + self.assertEqual(headings, [ + "## Doublets vs SQLite as storage for links", + "### Rust doublets vs SQLite", + "#### 32 bit address/id space benchmarks", + "#### 64 bit address/id space benchmarks", + "##### 100,000 links", + "##### 1,000,000 links", + "### C# doublets vs SQLite", + "#### 32 bit address/id space benchmarks", + "#### 64 bit address/id space benchmarks", + "## Doublets vs SQLite as storage for objects", + "### Rust doublets vs SQLite", + "#### 32 bit address/id space benchmarks", + "#### 64 bit address/id space benchmarks", + "### C# doublets vs SQLite", + "#### 32 bit address/id space benchmarks", + "##### 100,000 blog posts", + "#### 64 bit address/id space benchmarks", + ]) + + def test_missing_groups_and_provenance(self): + url = "https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/runs/1" + reports = {("links", "Rust", 64, 100_000): sample(run_url=url, date="2026-10-04", machine="ubuntu-24.04")} + text = report.section(reports, "en", None) + self.assertEqual(text.count("_No results yet._"), 7) + self.assertIn(f"_3 repetitions after a warm-up, median time per operation. SQLite 3.53.2, ubuntu-24.04, " + f"[GitHub Actions run]({url}) on 2026-10-04._", text) + self.assertNotIn("![", text) + + def test_charts_are_linked_per_category_language_and_bits(self): + text = report.section({("objects", "C#", 32, 100_000): sample("objects", "C#", 32)}, "ru", "docs/benchmarks") + self.assertIn("(docs/benchmarks/objects-csharp-32.png)", text) + self.assertIn("## Дуплеты против SQLite как хранилище объектов", text) + self.assertIn("локальный запуск", text) + + +class DocumentTests(unittest.TestCase): def setUp(self): - self.temporary = tempfile.TemporaryDirectory() - self.addCleanup(self.temporary.cleanup) - self.path = os.path.join(self.temporary.name, "README.md") - - def write(self, content): - with open(self.path, "w", encoding="utf-8") as handle: - handle.write(content) - - def read(self): - with open(self.path, "r", encoding="utf-8") as handle: - return handle.read() - - def test_updates_only_the_selected_language(self): - self.write( - f"{report.RUST_START_MARKER}\nold rust\n{report.RUST_END_MARKER}\n" - f"{report.CSHARP_START_MARKER}\nold csharp\n{report.CSHARP_END_MARKER}\n" - ) - - report.update_markers( - self.path, - r"new rust \g<0> $1", - report.RUST_START_MARKER, - report.RUST_END_MARKER, - ) - - self.assertIn(r"new rust \g<0> $1", self.read()) - self.assertIn("old csharp", self.read()) - - def test_update_is_idempotent(self): - self.write( - f"{report.RUST_START_MARKER}\nold\n{report.RUST_END_MARKER}\n" - ) - report.update_markers( - self.path, - "new", - report.RUST_START_MARKER, - report.RUST_END_MARKER, - ) - - self.assertFalse( - report.update_markers( - self.path, - "new", - report.RUST_START_MARKER, - report.RUST_END_MARKER, - ) - ) - - def test_missing_markers_raise(self): - self.write("# No results\n") + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + self.root = Path(temporary.name) + + def test_only_the_marked_section_is_replaced(self): + document = f"before\n{report.START_MARKER}\nold\n{report.END_MARKER}\nafter\n" + self.assertEqual(report.replace_section(document, "new\n"), + f"before\n{report.START_MARKER}\nnew\n{report.END_MARKER}\nafter\n") + with self.assertRaises(ValueError): + report.replace_section("no markers", "new\n") + def test_duplicate_reports_are_rejected(self): + for name in ("a.json", "b.json"): + (self.root / name).write_text(json.dumps(sample()), encoding="utf-8") with self.assertRaises(ValueError): - report.update_markers( - self.path, - "new", - report.RUST_START_MARKER, - report.RUST_END_MARKER, - ) + report.load(self.root) + + @unittest.skipUnless(importlib.util.find_spec("matplotlib"), "matplotlib is not installed") + def test_main_updates_readmes_and_writes_charts(self): + results_directory = self.root / "results" + results_directory.mkdir() + for index, data in enumerate((sample(), sample(size=1_000_000), sample("objects", "C#", 32))): + (results_directory / f"{index}.json").write_text(json.dumps(data), encoding="utf-8") + for name in ("README.md", "README.ru.md"): + (self.root / name).write_text(f"intro\n{report.START_MARKER}\n{report.END_MARKER}\n", encoding="utf-8") + report.main([str(results_directory), "--readme", str(self.root / "README.md"), + "--readme", str(self.root / "README.ru.md"), "--charts", str(self.root / "docs")]) + self.assertEqual(sorted(path.name for path in (self.root / "docs").iterdir()), + ["links-rust-64.png", "objects-csharp-32.png"]) + english = (self.root / "README.md").read_text(encoding="utf-8") + self.assertIn("![Rust doublets vs SQLite, 64 bit, links](docs/links-rust-64.png)", english) + self.assertIn("Дуплеты на C# против SQLite", (self.root / "README.ru.md").read_text(encoding="utf-8")) if __name__ == "__main__": From 105f9a652366a711dba5f262b500222d420d8939 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 4 Oct 2026 18:18:12 +0000 Subject: [PATCH 07/25] Less default work for objects: one blog post takes hundreds of links Repetitions are work / size clamped to 1..=10; objects now use 500,000 posts of work (5 repetitions at 100,000 posts, 1 at 1,000,000), so that the slowest C# objects table still fits a 6 hour CI job. --- csharp/SQLiteVSDoublets/Program.cs | 7 ++++--- rust/src/main.rs | 12 +++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/csharp/SQLiteVSDoublets/Program.cs b/csharp/SQLiteVSDoublets/Program.cs index 02855c6..9f32014 100644 --- a/csharp/SQLiteVSDoublets/Program.cs +++ b/csharp/SQLiteVSDoublets/Program.cs @@ -2,21 +2,22 @@ // [--variants A,B] [--directory DIR] [--output FILE] // // Repetitions default to `work / size` clamped to 1..=MaxRepetitions, so smaller sizes get more, -// but not endless, repetitions. +// but not endless, repetitions. Objects get less work, because one blog post takes hundreds of links. using System.Text.Json; using System.Text.Json.Nodes; using Comparisons.SQLiteVSDoublets; using Microsoft.Data.Sqlite; -const ulong DefaultWork = 3_000_000; +const ulong LinksWork = 3_000_000; +const ulong ObjectsWork = 500_000; const ulong MaxRepetitions = 10; var (category, bits, sizeText) = (args[0], args[1], args[2]); var options = args[3..].Chunk(2).ToDictionary(pair => pair[0].TrimStart('-'), pair => pair[1]); static ulong Number(string text) => ulong.Parse(text.Replace("_", "")); var size = Number(sizeText); -var work = options.TryGetValue("work", out var workText) ? Number(workText) : DefaultWork; +var work = options.TryGetValue("work", out var workText) ? Number(workText) : category == "links" ? LinksWork : ObjectsWork; var repetitions = options.TryGetValue("repetitions", out var count) ? int.Parse(count) : (int)Math.Clamp(work / size, 1, MaxRepetitions); diff --git a/rust/src/main.rs b/rust/src/main.rs index c4f248f..596bc91 100644 --- a/rust/src/main.rs +++ b/rust/src/main.rs @@ -2,14 +2,15 @@ //! [--variants A,B] [--directory DIR] [--output FILE] //! //! Repetitions default to `work / size` clamped to `1..=MAX_REPETITIONS`, so smaller sizes get more, -//! but not endless, repetitions. +//! but not endless, repetitions. Objects get less work, because one blog post takes hundreds of links. use doublets::data::LinkReference; use serde_json::json; use sqlite_vs_doublets::harness::{self, LINKS_VARIANTS, Measurement, OBJECTS_VARIANTS}; use std::{collections::HashMap, env, fs, path::Path}; -const DEFAULT_WORK: u64 = 3_000_000; +const LINKS_WORK: u64 = 3_000_000; +const OBJECTS_WORK: u64 = 500_000; const MAX_REPETITIONS: u64 = 10; fn main() { @@ -25,9 +26,14 @@ fn main() { .map(|pair| (pair[0].trim_start_matches("--"), pair[1].as_str())) .collect(); let size: u64 = size.replace('_', "").parse().unwrap(); + let default_work = if category == "links" { + LINKS_WORK + } else { + OBJECTS_WORK + }; let work: u64 = options .get("work") - .map_or(DEFAULT_WORK, |work| work.replace('_', "").parse().unwrap()); + .map_or(default_work, |work| work.replace('_', "").parse().unwrap()); let repetitions: usize = options .get("repetitions") .map_or((work / size).clamp(1, MAX_REPETITIONS) as usize, |count| { From 4d83c843ff9818cd05e04e1f229a8700972a5bd6 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 4 Oct 2026 18:21:28 +0000 Subject: [PATCH 08/25] CI: stable Rust and .NET 10 checks, benchmark matrix that publishes README results; dependabot for cargo and actions --- .github/dependabot.yml | 11 +- .github/workflows/CI.yml | 198 --------------------------- .github/workflows/benchmarks.yml | 130 ++++++++++++++++++ .github/workflows/csharp.yml | 33 +++++ .github/workflows/rust.yml | 224 +++---------------------------- 5 files changed, 188 insertions(+), 408 deletions(-) delete mode 100644 .github/workflows/CI.yml create mode 100644 .github/workflows/benchmarks.yml create mode 100644 .github/workflows/csharp.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index aec207e..f87be70 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -1,7 +1,16 @@ version: 2 updates: - package-ecosystem: nuget - directory: "/" + directory: "/csharp" + schedule: + interval: daily + open-pull-requests-limit: 10 +- package-ecosystem: cargo + directory: "/rust" schedule: interval: daily open-pull-requests-limit: 10 +- package-ecosystem: github-actions + directory: "/" + schedule: + interval: weekly diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml deleted file mode 100644 index a3c0931..0000000 --- a/.github/workflows/CI.yml +++ /dev/null @@ -1,198 +0,0 @@ -name: CI - -on: - push: - branches: [main] - paths: - - 'csharp/**' - - 'scripts/**' - - '.github/workflows/CI.yml' - pull_request: - branches: [main] - paths: - - 'csharp/**' - - 'scripts/**' - - '.github/workflows/CI.yml' - -defaults: - run: - working-directory: csharp - -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} - -jobs: - test: - name: Test (${{ matrix.os }}) - runs-on: ${{ matrix.os }} - timeout-minutes: 30 - strategy: - fail-fast: false - matrix: - os: [ubuntu-latest, macos-latest, windows-latest] - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.0.x' - - name: Build - run: dotnet build Comparisons.SQLiteVSDoublets.sln -c Release --warnaserror - - name: Verify every storage variant - run: dotnet run -c Release --no-build -- --self-test - - results-pipeline: - name: Results pipeline tests - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Install chart dependencies - run: python -m pip install matplotlib numpy - working-directory: . - - name: Test shared and C# reporting - run: | - python -m unittest discover -s scripts -p 'test_*.py' -v - python -m unittest discover -s csharp -p 'test_*.py' -v - working-directory: . - - benchmark-pr: - name: Benchmark (PR validation) - if: github.event_name == 'pull_request' - needs: [test, results-pipeline] - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.0.x' - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Install chart dependencies - run: python -m pip install matplotlib numpy - working-directory: . - - name: Build benchmark - run: dotnet build Comparisons.SQLiteVSDoublets.sln -c Release - - name: Run reduced-scale benchmark - env: - BENCHMARK_LINK_COUNT: 10 - BACKGROUND_LINK_COUNT: 30 - BENCHMARK_WARMUP_COUNT: 1 - BENCHMARK_ITERATION_COUNT: 1 - run: >- - dotnet run -c Release --no-build -- - --filter '*LinksBenchmarks*' > benchmark.log 2>&1 - - name: Generate table and charts - env: - BENCHMARK_LINK_COUNT: 10 - BACKGROUND_LINK_COUNT: 30 - run: | - mkdir -p artifacts - report_path=$(find BenchmarkDotNet.Artifacts/results -name '*LinksBenchmarks-report-full.json' -print -quit) - test -n "$report_path" - python out.py "$report_path" \ - --results artifacts/results.md \ - --output-dir artifacts \ - --docs-dir artifacts/docs - - name: Add results to job summary - run: | - { - echo '## C# benchmark (reduced-scale validation)' - echo - cat artifacts/results.md - echo - echo '_These numbers validate the pipeline; full results are published from `main`._' - } >> "$GITHUB_STEP_SUMMARY" - - name: Upload diagnostic and report artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: csharp-benchmark-pr - path: | - csharp/benchmark.log - csharp/BenchmarkDotNet.Artifacts/results/ - csharp/artifacts/ - - benchmark: - name: Benchmark (full) - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: [test, results-pipeline] - runs-on: ubuntu-latest - timeout-minutes: 180 - concurrency: - group: benchmark-publication - cancel-in-progress: false - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: main - fetch-depth: 0 - - uses: actions/setup-dotnet@v4 - with: - dotnet-version: '8.0.x' - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Install chart dependencies - run: python -m pip install matplotlib numpy - working-directory: . - - name: Build benchmark - run: dotnet build Comparisons.SQLiteVSDoublets.sln -c Release - - name: Run full benchmark - env: - BENCHMARK_LINK_COUNT: 1000 - BACKGROUND_LINK_COUNT: 3000 - BENCHMARK_WARMUP_COUNT: 2 - BENCHMARK_ITERATION_COUNT: 5 - run: >- - dotnet run -c Release --no-build -- - --filter '*LinksBenchmarks*' > benchmark.log 2>&1 - - name: Generate and publish documentation - env: - BENCHMARK_LINK_COUNT: 1000 - BACKGROUND_LINK_COUNT: 3000 - run: | - report_path=$(find BenchmarkDotNet.Artifacts/results -name '*LinksBenchmarks-report-full.json' -print -quit) - test -n "$report_path" - python out.py "$report_path" \ - --results results.md \ - --readme ../README.md \ - --readme ../README.ru.md \ - --docs-dir ../docs/benchmarks - - name: Add results to job summary - run: | - { - echo '## C# benchmark (full scale)' - echo - cat results.md - } >> "$GITHUB_STEP_SUMMARY" - - name: Commit published results - working-directory: . - run: | - git config user.email 'linksplatform@gmail.com' - git config user.name 'LinksPlatformBencher' - git add README.md README.ru.md csharp/results.md docs/benchmarks/ - if git diff --staged --quiet; then - echo 'No benchmark documentation changed.' - else - git commit -m 'Update C# benchmark results [skip ci]' - git push origin HEAD:main - fi - - name: Upload diagnostic and report artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: csharp-benchmark-full - path: | - csharp/benchmark.log - csharp/BenchmarkDotNet.Artifacts/results/ - csharp/results.md - csharp/bench_csharp.png - csharp/bench_csharp_log_scale.png diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..2e81832 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,130 @@ +name: Benchmarks + +# Every job measures one README table (language, category, bits, size) with all its variants on one runner, +# so that the variants of a table are compared on the same machine. Pull requests run the whole pipeline +# on small sizes; pushes to main publish the results to README.md and README.ru.md. +on: + push: + branches: [main] + paths: ['rust/**', 'csharp/**', 'scripts/**', '.github/workflows/benchmarks.yml'] + pull_request: + paths: ['rust/**', 'csharp/**', 'scripts/**', '.github/workflows/benchmarks.yml'] + workflow_dispatch: + inputs: + sizes: + description: JSON array of sizes (objects skip 10,000,000) + default: '[100000, 1000000, 10000000]' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + +jobs: + report-tests: + name: Report script tests + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 + with: + python-version: '3.13' + - run: pip install -r scripts/requirements.txt + - run: python -m unittest discover -s scripts -v + + benchmark: + name: ${{ matrix.language }} ${{ matrix.category }} ${{ matrix.bits }} bit ${{ matrix.size }} + runs-on: ubuntu-latest + timeout-minutes: 355 + strategy: + fail-fast: false + matrix: + language: [rust, csharp] + category: [links, objects] + bits: [32, 64] + size: ${{ fromJSON(inputs.sizes || (github.event_name == 'pull_request' && '[1000]') || '[100000, 1000000, 10000000]') }} + exclude: + # One million blog posts already take hours in C#; ten millions would not fit into a job. + - category: objects + size: 10000000 + steps: + - uses: actions/checkout@v7 + - if: matrix.language == 'rust' + uses: dtolnay/rust-toolchain@stable + - if: matrix.language == 'rust' + uses: Swatinem/rust-cache@v2 + with: + workspaces: rust + - if: matrix.language == 'rust' + run: | + cargo build --release --manifest-path rust/Cargo.toml + echo "BENCHMARK=rust/target/release/sqlite-vs-doublets" >> "$GITHUB_ENV" + - if: matrix.language == 'csharp' + uses: actions/setup-dotnet@v6 + with: + global-json-file: csharp/global.json + - if: matrix.language == 'csharp' + run: | + dotnet build -c Release csharp/SQLiteVSDoublets + echo "BENCHMARK=dotnet csharp/SQLiteVSDoublets/bin/Release/net10.0/sqlite-vs-doublets.dll" >> "$GITHUB_ENV" + - name: Benchmark + run: | + name=${{ matrix.category }}-${{ matrix.language }}-${{ matrix.bits }}-${{ matrix.size }} + echo "NAME=$name" >> "$GITHUB_ENV" + $BENCHMARK ${{ matrix.category }} ${{ matrix.bits }} ${{ matrix.size }} \ + --directory "$RUNNER_TEMP/storage" --output "$RUNNER_TEMP/measured.json" + machine="$(. /etc/os-release && echo "$PRETTY_NAME"), $(lscpu | sed -n 's/^Model name: *//p' | head -1)" + machine="$machine, $(nproc) cores, $(awk '/^MemTotal/ { printf "%.0f", $2 / 1048576 }' /proc/meminfo) GiB" + mkdir results + jq --arg run_url "$GITHUB_SERVER_URL/$GITHUB_REPOSITORY/actions/runs/$GITHUB_RUN_ID" \ + --arg date "$(date -u +%Y-%m-%d)" --arg machine "$machine" \ + '. + {run_url: $run_url, date: $date, machine: $machine}' \ + "$RUNNER_TEMP/measured.json" > "results/$name.json" + - uses: actions/upload-artifact@v7 + with: + name: results-${{ env.NAME }} + path: results/${{ env.NAME }}.json + + report: + name: Report + needs: [report-tests, benchmark] + # Publishes the finished tables even if some jobs failed; the missing ones keep their previous results. + if: ${{ !cancelled() && needs.report-tests.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: write + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ github.event_name == 'pull_request' && github.sha || github.ref_name }} + - uses: actions/setup-python@v7 + with: + python-version: '3.13' + - run: pip install -r scripts/requirements.txt + - uses: actions/download-artifact@v8 + with: + pattern: results-* + merge-multiple: true + path: ${{ github.event_name == 'pull_request' && format('{0}/results', runner.temp) || 'docs/benchmarks/results' }} + - if: github.event_name == 'pull_request' + name: Check the report + run: python scripts/benchmark_report.py "$RUNNER_TEMP/results" >> "$GITHUB_STEP_SUMMARY" + - if: github.event_name != 'pull_request' + name: Publish the report + run: | + python scripts/benchmark_report.py docs/benchmarks/results \ + --readme README.md --readme README.ru.md --charts docs/benchmarks + git config user.email 'linksplatform@gmail.com' + git config user.name 'LinksPlatformBencher' + git add README.md README.ru.md docs/benchmarks + git diff --staged --quiet && exit 0 + git commit -m 'Update benchmark results [skip ci]' + for attempt in 1 2 3; do + git pull --rebase origin "$GITHUB_REF_NAME" && git push origin "HEAD:$GITHUB_REF_NAME" && exit 0 + sleep 10 + done + exit 1 diff --git a/.github/workflows/csharp.yml b/.github/workflows/csharp.yml new file mode 100644 index 0000000..cf693ae --- /dev/null +++ b/.github/workflows/csharp.yml @@ -0,0 +1,33 @@ +name: C# + +on: + push: + branches: [main] + paths: ['csharp/**', '.github/workflows/csharp.yml'] + pull_request: + paths: ['csharp/**', '.github/workflows/csharp.yml'] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +defaults: + run: + working-directory: csharp + +jobs: + test: + name: Test (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, macos-latest, windows-latest] + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-dotnet@v6 + with: + global-json-file: csharp/global.json + - run: dotnet build -c Release -warnaserror + - run: dotnet test --project SQLiteVSDoublets.Tests -c Release --no-build diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 0d5d0bb..2123a32 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -3,232 +3,38 @@ name: Rust on: push: branches: [main] - paths: - - 'rust/**' - - 'scripts/**' - - '.github/workflows/rust.yml' + paths: ['rust/**', '.github/workflows/rust.yml'] pull_request: - branches: [main] - paths: - - 'rust/**' - - 'scripts/**' - - '.github/workflows/rust.yml' + paths: ['rust/**', '.github/workflows/rust.yml'] -env: - CARGO_TERM_COLOR: always - RUST_BACKTRACE: 1 - RUST_TOOLCHAIN: nightly-2022-08-22 +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true defaults: run: working-directory: rust -concurrency: - group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} - cancel-in-progress: ${{ github.event_name == 'pull_request' }} +env: + CARGO_TERM_COLOR: always jobs: test: name: Test (${{ matrix.os }}) runs-on: ${{ matrix.os }} - timeout-minutes: 45 + timeout-minutes: 30 strategy: fail-fast: false matrix: os: [ubuntu-latest, macos-latest, windows-latest] steps: - - uses: actions/checkout@v4 - - - name: Setup pinned Rust nightly - uses: dtolnay/rust-toolchain@master + - uses: actions/checkout@v7 + - uses: dtolnay/rust-toolchain@stable with: - toolchain: ${{ env.RUST_TOOLCHAIN }} components: rustfmt, clippy - - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: rust -> target - cache-on-failure: 'true' - - - name: Check formatting - run: cargo fmt --all -- --check - - - name: Run Clippy - run: cargo clippy --lib --tests --benches -- -D warnings - - - name: Run unit and integration tests - run: cargo test --release --lib --tests - - # `cargo test --all-targets` executes Criterion binaries. Compile them - # explicitly so file-backed measurements are only run in bounded jobs. - - name: Compile benchmarks - run: cargo check --release --benches - - results-pipeline: - name: Results pipeline tests - runs-on: ubuntu-latest - timeout-minutes: 10 - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Install chart dependencies - run: python -m pip install matplotlib numpy - working-directory: . - - name: Test shared and Rust reporting - run: | - python -m unittest discover -s scripts -p 'test_*.py' -v - python -m unittest discover -s rust -p 'test_*.py' -v - working-directory: . - - benchmark-pr: - name: Benchmark (PR validation) - if: github.event_name == 'pull_request' - needs: [test, results-pipeline] - runs-on: ubuntu-latest - timeout-minutes: 45 - steps: - - uses: actions/checkout@v4 - - name: Setup pinned Rust nightly - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - components: rustfmt, clippy - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Install chart dependencies - run: python -m pip install matplotlib numpy - working-directory: . - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: rust -> target - cache-on-failure: 'true' - - name: Run reduced-scale benchmark - env: - BENCHMARK_LINK_COUNT: 10 - BACKGROUND_LINK_COUNT: 30 - BENCHMARK_OBJECT_COUNT: 5 - BENCHMARK_SAMPLE_SIZE: 10 - BENCHMARK_MEASUREMENT_SECONDS: 1 - BENCHMARK_WARM_UP_MILLISECONDS: 100 - run: | - set -o pipefail - rm -rf target/criterion - cargo bench --bench bench -- --output-format bencher | tee out.txt - - name: Generate table and charts - env: - BENCHMARK_LINK_COUNT: 10 - BACKGROUND_LINK_COUNT: 30 - BENCHMARK_OBJECT_COUNT: 5 - run: | - mkdir -p artifacts - python out.py out.txt \ - --results artifacts/results.md \ - --output-dir artifacts \ - --docs-dir artifacts/docs - - name: Add results to job summary - run: | - { - echo '## Rust benchmark (reduced-scale validation)' - echo - cat artifacts/results.md - echo - echo '_These numbers validate the pipeline; full results are published from `main`._' - } >> "$GITHUB_STEP_SUMMARY" - - name: Upload diagnostic and report artifacts - if: always() - uses: actions/upload-artifact@v4 - with: - name: rust-benchmark-pr - path: | - rust/out.txt - rust/artifacts/ - - benchmark: - name: Benchmark (full) - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - needs: [test, results-pipeline] - runs-on: ubuntu-latest - timeout-minutes: 180 - concurrency: - group: benchmark-publication - cancel-in-progress: false - permissions: - contents: write - steps: - - uses: actions/checkout@v4 - with: - ref: main - fetch-depth: 0 - - name: Setup pinned Rust nightly - uses: dtolnay/rust-toolchain@master - with: - toolchain: ${{ env.RUST_TOOLCHAIN }} - components: rustfmt, clippy - - uses: actions/setup-python@v5 - with: - python-version: '3.11' - - name: Install chart dependencies - run: python -m pip install matplotlib numpy - working-directory: . - - name: Cache Rust dependencies - uses: Swatinem/rust-cache@v2 - with: - workspaces: rust -> target - cache-on-failure: 'true' - - name: Run full benchmark - env: - BENCHMARK_LINK_COUNT: 1000 - BACKGROUND_LINK_COUNT: 3000 - BENCHMARK_OBJECT_COUNT: 1000 - BENCHMARK_SAMPLE_SIZE: 20 - BENCHMARK_MEASUREMENT_SECONDS: 3 - BENCHMARK_WARM_UP_MILLISECONDS: 1000 - run: | - set -o pipefail - rm -rf target/criterion - cargo bench --bench bench -- --output-format bencher | tee out.txt - - name: Generate and publish documentation - env: - BENCHMARK_LINK_COUNT: 1000 - BACKGROUND_LINK_COUNT: 3000 - BENCHMARK_OBJECT_COUNT: 1000 - run: | - python out.py out.txt \ - --results results.md \ - --readme ../README.md \ - --readme ../README.ru.md \ - --docs-dir ../docs/benchmarks - - name: Add results to job summary - run: | - { - echo '## Rust benchmark (full scale)' - echo - cat results.md - } >> "$GITHUB_STEP_SUMMARY" - - name: Commit published results - working-directory: . - run: | - git config user.email 'linksplatform@gmail.com' - git config user.name 'LinksPlatformBencher' - git add README.md README.ru.md rust/results.md docs/benchmarks/ - if git diff --staged --quiet; then - echo 'No benchmark documentation changed.' - else - git commit -m 'Update Rust benchmark results [skip ci]' - git push origin HEAD:main - fi - - name: Upload diagnostic and report artifacts - if: always() - uses: actions/upload-artifact@v4 + - uses: Swatinem/rust-cache@v2 with: - name: rust-benchmark-full - path: | - rust/out.txt - rust/results.md - rust/bench_rust.png - rust/bench_rust_log_scale.png + workspaces: rust + - run: cargo fmt --check + - run: cargo clippy --release --all-targets -- -D warnings + - run: cargo test --release From 48d7fa090e1a05ada32e9820cfdce6dcfe7efbb6 Mon Sep 17 00:00:00 2001 From: konard Date: Sun, 4 Oct 2026 18:25:29 +0000 Subject: [PATCH 09/25] README: methodology, local runs, notes and one generated results section; compare interquartile ranges --- README.md | 139 +++++++++++---------- README.ru.md | 139 +++++++++++---------- docs/benchmarks/bench_csharp.png | Bin 45558 -> 0 bytes docs/benchmarks/bench_csharp_log_scale.png | Bin 45987 -> 0 bytes docs/benchmarks/bench_rust.png | Bin 53665 -> 0 bytes docs/benchmarks/bench_rust_log_scale.png | Bin 55250 -> 0 bytes scripts/benchmark_report.py | 15 ++- scripts/test_benchmark_report.py | 19 ++- 8 files changed, 168 insertions(+), 144 deletions(-) delete mode 100644 docs/benchmarks/bench_csharp.png delete mode 100644 docs/benchmarks/bench_csharp_log_scale.png delete mode 100644 docs/benchmarks/bench_rust.png delete mode 100644 docs/benchmarks/bench_rust_log_scale.png diff --git a/README.md b/README.md index b5b2924..88f562d 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Actions Status](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/workflows/CI/badge.svg)](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions?workflow=CI) +[![Rust](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/workflows/rust.yml/badge.svg)](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/workflows/rust.yml) [![C#](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/workflows/csharp.yml/badge.svg)](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/workflows/csharp.yml) [![Benchmarks](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/workflows/benchmarks.yml/badge.svg)](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/workflows/benchmarks.yml) # Comparisons.SQLiteVSDoublets ([русская версия](README.ru.md)) @@ -8,84 +8,87 @@ Comparison of SQLite and LinksPlatform's Doublets (links) on basic embedded data Based on examples from https://github.com/FahaoTang/dotnetcore-examples and https://github.com/Konard/LinksPlatform -## Automated benchmark suite +## Benchmarks -The comparison runs the same link workload against SQLite in-memory and file -databases and four Doublets layouts: united/split and volatile/non-volatile. -It measures create, update, delete, enumerate all, and queries by identity, -concrete `(source, target)`, outgoing source, and incoming target. The Rust -suite also measures creation, reading, and deletion of object-like blog posts -on every storage variant. +Rust ([doublets](https://crates.io/crates/doublets) and [rusqlite](https://crates.io/crates/rusqlite) with bundled SQLite) and C# ([Platform.Data.Doublets](https://www.nuget.org/packages/Platform.Data.Doublets), [Platform.Data.Doublets.Sequences](https://www.nuget.org/packages/Platform.Data.Doublets.Sequences) and [Microsoft.Data.Sqlite](https://www.nuget.org/packages/Microsoft.Data.Sqlite)) run the same workloads on the same deterministic data, with 32 bit (`u32`/`uint`) and 64 bit (`u64`/`ulong`) ids: -Preparation and cleanup are outside the measured region. Pull requests run a -reduced-scale validation and preserve raw output, tables, and charts as -workflow artifacts. Full runs on `main` publish the tables and charts below -with a link to the producing workflow run. +- **Links**: SQLite stores the table `links(id INTEGER PRIMARY KEY, "from", "to")` with the indices `("from", "to")` and `("to", "from")`; Doublets stores the links themselves. The operations are create, read all, read by id, search by `(from, to)`, read by `from`, read by `to`, update (swap `from` and `to`) and delete. +- **Objects**: blog posts with a title, content and publication date. SQLite stores the table `blog_posts(id, title, content, publication_date)`; Doublets stores every post as links, with strings as sequences of Unicode symbols, like [Platform.Data.Doublets.Sequences](https://github.com/linksplatform/Data.Doublets.Sequences) does. The operations are create, read all, read by id and delete. Every Doublets store runs with and without a cache of the string sequences (`Cached`/`Uncached`). -Run the checks locally: +Storages: SQLite in memory and in a file; Doublets united (one array of links with index trees) and split (separate data and index arrays), each volatile (in memory) and non-volatile (in memory-mapped files). Doublets are compared with SQLite of the same durability: volatile with `SQLite Memory`, non-volatile with `SQLite File`. + +Every repetition runs on a fresh store in an empty directory, after one discarded warm-up repetition on up to 10,000 records. Each operation is one timed transaction over all records, point operations visit the records in a scattered order, and each result is checked against the expected count and order-sensitive checksum, so a storage that loses, duplicates or mixes up records fails the run instead of being reported as fast. Repetitions are `3,000,000 / size` for links and `500,000 / size` for objects, clamped to `1..=10`. The tables show the median time per operation; a difference is reported only if the interquartile ranges (the middle half) of the repetitions do not overlap and the medians differ by more than 5%, otherwise it is `≈ same`. The file size is measured after creation; Doublets files are preallocated memory-mapped files, so small stores show the preallocation size. + +Every table is measured by its own GitHub Actions job, with all variants on the same runner, by the [Benchmarks workflow](.github/workflows/benchmarks.yml): links with 100,000, 1,000,000 and 10,000,000 records and objects with 100,000 and 1,000,000 records (larger sizes do not fit into the 6 hour limit of a job). Pull requests check the whole pipeline on 1,000 records, and pushes to `main` update the results below. The workflow can also be started manually with other sizes. + +Run locally: ```bash -cd rust -cargo test --lib --tests -cargo check --benches -BENCHMARK_LINK_COUNT=10 BACKGROUND_LINK_COUNT=30 \ - BENCHMARK_OBJECT_COUNT=5 cargo bench --bench bench -- \ - --output-format bencher - -cd ../csharp -dotnet run -c Release -- --self-test -BENCHMARK_LINK_COUNT=10 BACKGROUND_LINK_COUNT=30 \ - dotnet run -c Release -- --filter '*LinksBenchmarks*' +mkdir -p results +cargo run --release --manifest-path rust/Cargo.toml -- links 64 100000 --output results/links-rust-64-100000.json +dotnet run -c Release --project csharp/SQLiteVSDoublets -- objects 32 1000 --variants SQLite_File,Doublets_Split_NonVolatile_Cached +python3 scripts/benchmark_report.py results # prints the tables, add --readme README.md --charts docs/benchmarks to update this file ``` -### C# link results +Notes: + +- The split stores of doublets 0.5.0 (Rust) lose a link that is updated to reference itself, so the benchmark updates a link to `(0, 0)` first, which is the state links are created in ([experiments/split_store_delete](experiments/split_store_delete)). +- In C#, links are deleted with `Delete(id, handler: null)`, which resets the link before deleting it; the bare `Delete(id)` leaves the link in the index trees, and later searches fail ([experiments/csharp_tree_delete](experiments/csharp_tree_delete)). +- The C# united stores use AVL index trees: the default size balanced trees degenerate when many links share a source or a target, which makes each blog post creation linear in the number of posts ([experiments/csharp_objects_profile](experiments/csharp_objects_profile)). +- Objects stores use external references for numbers and Unicode symbols, so raw values never collide with link ids. +- Reading a string without the cache walks its sequence link by link; in C# each `GetSource`/`GetTarget` call of `Platform.Data` allocates a handler and a list, which makes the uncached C# reads much slower than the Rust ones. + + +## Doublets vs SQLite as storage for links + +### Rust doublets vs SQLite + +#### 32 bit address/id space benchmarks + +_No results yet._ + +#### 64 bit address/id space benchmarks + +_No results yet._ + +### C# doublets vs SQLite + +#### 32 bit address/id space benchmarks + +_No results yet._ + +#### 64 bit address/id space benchmarks + +_No results yet._ + +## Doublets vs SQLite as storage for objects + +### Rust doublets vs SQLite + +#### 32 bit address/id space benchmarks - -_Generated 2026-09-19 18:13 UTC for C# by [GitHub Actions run 35459522587](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/runs/35459522587) — 1000 benchmarked links, 3000 background links._ +_No results yet._ -| Operation | Doublets United Volatile | Doublets United NonVolatile | Doublets Split Volatile | Doublets Split NonVolatile | SQLite Memory | SQLite File | -|---------------|--------------------------|-----------------------------|-------------------------|----------------------------|---------------|-------------| -| Create | 2414327 (3.7x faster) | 2512520 (3.5x faster) | 867200 (10.2x faster) | 896999 (9.8x faster) | 8818051 | 2294476668 | -| Update | 4566053 (2.3x faster) | 4670899 (2.3x faster) | 1541871 (7.0x faster) | 1534119 (7.0x faster) | 10720700 | 2733072615 | -| Delete | 484008 (14.3x faster) | 465524 (14.8x faster) | 523910 (13.2x faster) | 521322 (13.3x faster) | 6907869 | 1682008343 | -| Each All | 437356 (3.7x faster) | 470885 (3.4x faster) | 420518 (3.8x faster) | 397666 (4.0x faster) | 2113072 | 1598861 | -| Each Identity | 430962 (11.6x faster) | 411173 (12.1x faster) | 403128 (12.4x faster) | 385531 (13.0x faster) | 4995738 | 12044311 | -| Each Concrete | 1363130 (5.2x faster) | 1408600 (5.1x faster) | 647769 (11.0x faster) | 917509 (7.8x faster) | 7134655 | 13622920 | -| Each Outgoing | 1160157 (5.3x faster) | 1180175 (5.3x faster) | 862790 (7.2x faster) | 830070 (7.5x faster) | 6199124 | 12923172 | -| Each Incoming | 1139559 (5.7x faster) | 1169325 (5.6x faster) | 796694 (8.2x faster) | 776158 (8.4x faster) | 6533836 | 13510164 | +#### 64 bit address/id space benchmarks -![C# benchmark comparison](docs/benchmarks/bench_csharp.png) +_No results yet._ -![C# benchmark comparison, logarithmic scale](docs/benchmarks/bench_csharp_log_scale.png) - +### C# doublets vs SQLite -### Rust link and object results +#### 32 bit address/id space benchmarks - -_Generated 2026-09-19 18:46 UTC for Rust by [GitHub Actions run 35459522519](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/runs/35459522519) — 1000 benchmarked links, 3000 background links, 1000 objects._ +_No results yet._ -| Operation | Doublets United Volatile | Doublets United NonVolatile | Doublets Split Volatile | Doublets Split NonVolatile | SQLite Memory | SQLite File | -|---------------------|--------------------------|-----------------------------|-------------------------|----------------------------|---------------|-------------| -| Create | 141747 (40.7x faster) | 143659 (40.2x faster) | 69524 (83.0x faster) | 69702 (82.8x faster) | 5772002 | 993908545 | -| Update | 283809 (29.6x faster) | 277912 (30.2x faster) | 63882 (131.3x faster) | 72899 (115.1x faster) | 8390541 | 1067410860 | -| Delete | 168149 (26.8x faster) | 166496 (27.1x faster) | 77711 (58.0x faster) | 78217 (57.6x faster) | 4509130 | 977528491 | -| Each All | 31671 (15.9x faster) | 31638 (15.9x faster) | 34426 (14.6x faster) | 34487 (14.6x faster) | 504031 | 595624 | -| Each Identity | 2132 (1269.3x faster) | 2136 (1266.9x faster) | 2130 (1270.5x faster) | 2127 (1272.3x faster) | 2706180 | 10678850 | -| Each Concrete | 124784 (34.7x faster) | 120544 (36.0x faster) | 45647 (95.0x faster) | 43793 (99.0x faster) | 4335748 | 12668554 | -| Each Outgoing | 210057 (18.1x faster) | 208064 (18.2x faster) | 44112 (86.0x faster) | 45302 (83.7x faster) | 3792959 | 12033519 | -| Each Incoming | 212266 (19.3x faster) | 210393 (19.4x faster) | 50873 (80.4x faster) | 46396 (88.1x faster) | 4089215 | 12477405 | -| Objects Create List | 8759735 (10.5x slower) | 8759735 (10.5x slower) | 5388349 (6.5x slower) | 5436404 (6.5x slower) | 832974 | 2098461 | -| Objects Read List | 5889568 (18.5x slower) | 5585959 (17.6x slower) | 5393697 (17.0x slower) | 4889083 (15.4x slower) | 317868 | 343282 | -| Objects Delete List | 2709133 (75.1x slower) | 2667861 (74.0x slower) | 1083231 (30.0x slower) | 1106546 (30.7x slower) | 36069 | 992614 | +#### 64 bit address/id space benchmarks -![Rust benchmark comparison](docs/benchmarks/bench_rust.png) +_No results yet._ + -![Rust benchmark comparison, logarithmic scale](docs/benchmarks/bench_rust_log_scale.png) - +## Original comparison -The original C# object comparison and its historical results remain below. +The original C# object comparison and its historical results. -## SQLite +### SQLite ```C# using System.Linq; using Comparisons.SQLiteVSDoublets.Model; @@ -129,7 +132,7 @@ namespace Comparisons.SQLiteVSDoublets.SQLite } ``` -## Doublets +### Doublets ``` C# using System.IO; using Platform.IO; @@ -187,18 +190,18 @@ namespace Comparisons.SQLiteVSDoublets.Doublets } ``` -## [Result](https://www.icloud.com/keynote/0cYVNWkWD5RLU0k-XIBs3qWkA#Sqlite_vs_Doublets) +### [Result](https://www.icloud.com/keynote/0cYVNWkWD5RLU0k-XIBs3qWkA#Sqlite_vs_Doublets) -### Performance +#### Performance ![Image with result of performance comparison between SQLite and Doublets.](https://raw.githubusercontent.com/linksplatform/Documentation/master/doc/Examples/sqlite_vs_doublets_performance.png "Result of performance comparison between SQLite and Doublets") -### Disk usage +#### Disk usage ![Image with result of disk usage comparison between SQLite and Doublets.](https://raw.githubusercontent.com/linksplatform/Documentation/master/doc/Examples/sqlite_vs_doublets_disk_usage.png "Result of disk usage comparison between SQLite and Doublets") -### RAM usage +#### RAM usage ![Image with result of RAM usage comparison between SQLite and Doublets.](https://raw.githubusercontent.com/linksplatform/Documentation/master/doc/Examples/sqlite_vs_doublets_ram_usage.png "Result of RAM usage comparison between SQLite and Doublets") -### Source data +#### Source data ``` ini BenchmarkDotNet=v0.12.0, OS=Windows 10.0.18362 @@ -220,6 +223,6 @@ WarmupCount=2 | **SQLite** | **100000** | **35,853.8 ms** | **NA** | **680000.0000** | **151000.0000** | **-** | **3234.09 MB** | **90890240** | | Doublets | 100000 | 13,083.4 ms | NA | 3088000.0000 | 328000.0000 | - | 12356.33 MB | 64192256 | -## Conclusion +### Conclusion In this particular comparison, Doublets are faster and use less memory on disk, but this comes with the cost of additional use of RAM (Sqlite uses it less). diff --git a/README.ru.md b/README.ru.md index 312a64b..e9ed116 100644 --- a/README.ru.md +++ b/README.ru.md @@ -1,4 +1,4 @@ -[![Состояние сборки](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/workflows/CI/badge.svg)](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions?workflow=CI) +[![Rust](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/workflows/rust.yml/badge.svg)](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/workflows/rust.yml) [![C#](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/workflows/csharp.yml/badge.svg)](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/workflows/csharp.yml) [![Benchmarks](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/workflows/benchmarks.yml/badge.svg)](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/workflows/benchmarks.yml) # Comparisons.SQLiteVSDoublets ([english version](README.md)) @@ -8,84 +8,87 @@ Основано на примерах из https://github.com/FahaoTang/dotnetcore-examples и https://github.com/Konard/LinksPlatform -## Автоматизированный набор тестов производительности +## Тесты производительности -Одинаковая нагрузка со связями выполняется для SQLite в памяти и в файле, а -также для четырёх вариантов Дуплетов: объединённого/разделённого и -энергозависимого/энергонезависимого. Измеряются создание, обновление, удаление, -перечисление всех связей и запросы по идентификатору, конкретной паре -`(начало, конец)`, началу и концу. Набор на Rust также измеряет создание, -чтение и удаление объектоподобных записей блога во всех вариантах хранилищ. +Rust ([doublets](https://crates.io/crates/doublets) и [rusqlite](https://crates.io/crates/rusqlite) со встроенной SQLite) и C# ([Platform.Data.Doublets](https://www.nuget.org/packages/Platform.Data.Doublets), [Platform.Data.Doublets.Sequences](https://www.nuget.org/packages/Platform.Data.Doublets.Sequences) и [Microsoft.Data.Sqlite](https://www.nuget.org/packages/Microsoft.Data.Sqlite)) выполняют одинаковую нагрузку на одинаковых детерминированных данных с 32-битными (`u32`/`uint`) и 64-битными (`u64`/`ulong`) идентификаторами: -Подготовка и очистка данных не входят в измеряемый интервал. Для pull request -запускается сокращённая проверка, а исходный вывод, таблицы и диаграммы -сохраняются как артефакты workflow. Полные запуски в `main` публикуют таблицы и -диаграммы ниже вместе со ссылкой на создавший их запуск. +- **Связи**: SQLite хранит таблицу `links(id INTEGER PRIMARY KEY, "from", "to")` с индексами `("from", "to")` и `("to", "from")`; Дуплеты хранят сами связи. Операции: создание, чтение всех, чтение по id, поиск по `(from, to)`, чтение по `from`, чтение по `to`, обновление (обмен `from` и `to`) и удаление. +- **Объекты**: записи блога с заголовком, содержимым и датой публикации. SQLite хранит таблицу `blog_posts(id, title, content, publication_date)`; Дуплеты хранят каждую запись в виде связей, а строки — в виде последовательностей символов Юникода, как это делает [Platform.Data.Doublets.Sequences](https://github.com/linksplatform/Data.Doublets.Sequences). Операции: создание, чтение всех, чтение по id и удаление. Каждое хранилище Дуплетов проверяется с кэшем последовательностей строк и без него (`Cached`/`Uncached`). -Локальный запуск проверок: +Хранилища: SQLite в памяти и в файле; Дуплеты объединённые (один массив связей с деревьями индексов) и разделённые (отдельные массивы данных и индексов), каждое энергозависимое (в памяти) и энергонезависимое (в отображаемых в память файлах). Дуплеты сравниваются с SQLite той же надёжности хранения: энергозависимые с `SQLite Memory`, энергонезависимые с `SQLite File`. + +Каждый повтор выполняется на новом хранилище в пустой папке после одного отбрасываемого прогревочного повтора на не более чем 10 000 записей. Каждая операция — одна измеряемая транзакция по всем записям, точечные операции обходят записи в перемешанном порядке, а каждый результат сверяется с ожидаемым количеством и зависящей от порядка контрольной суммой, поэтому хранилище, которое теряет, дублирует или путает записи, завершает запуск ошибкой, а не попадает в отчёт как быстрое. Число повторов — `3 000 000 / размер` для связей и `500 000 / размер` для объектов, но от 1 до 10. В таблицах указано медианное время одной операции; разница указывается, только если межквартильные диапазоны (средняя половина) повторов не пересекаются и медианы отличаются больше чем на 5%, иначе пишется `≈ так же`. Размер файлов измеряется после создания; файлы Дуплетов — заранее выделенные отображаемые в память файлы, поэтому для небольших хранилищ показан размер предварительного выделения. + +Каждая таблица измеряется отдельной задачей GitHub Actions со всеми вариантами на одной машине в [workflow Benchmarks](.github/workflows/benchmarks.yml): связи на 100 000, 1 000 000 и 10 000 000 записей, объекты на 100 000 и 1 000 000 записей (большие размеры не укладываются в 6-часовое ограничение задачи). Pull request проверяют весь процесс на 1 000 записей, а push в `main` обновляет результаты ниже. Workflow можно запустить и вручную с другими размерами. + +Локальный запуск: ```bash -cd rust -cargo test --lib --tests -cargo check --benches -BENCHMARK_LINK_COUNT=10 BACKGROUND_LINK_COUNT=30 \ - BENCHMARK_OBJECT_COUNT=5 cargo bench --bench bench -- \ - --output-format bencher - -cd ../csharp -dotnet run -c Release -- --self-test -BENCHMARK_LINK_COUNT=10 BACKGROUND_LINK_COUNT=30 \ - dotnet run -c Release -- --filter '*LinksBenchmarks*' +mkdir -p results +cargo run --release --manifest-path rust/Cargo.toml -- links 64 100000 --output results/links-rust-64-100000.json +dotnet run -c Release --project csharp/SQLiteVSDoublets -- objects 32 1000 --variants SQLite_File,Doublets_Split_NonVolatile_Cached +python3 scripts/benchmark_report.py results # печатает таблицы, добавьте --readme README.ru.md --charts docs/benchmarks для обновления этого файла ``` -### Результаты операций со связями в C#-версии +Замечания: + +- Разделённые хранилища doublets 0.5.0 (Rust) теряют связь, обновлённую так, чтобы она ссылалась на саму себя, поэтому тест сначала обновляет связь до `(0, 0)` — состояния, в котором связи создаются ([experiments/split_store_delete](experiments/split_store_delete)). +- В C# связи удаляются через `Delete(id, handler: null)`, который сбрасывает связь перед удалением; простой `Delete(id)` оставляет связь в деревьях индексов, и последующий поиск ломается ([experiments/csharp_tree_delete](experiments/csharp_tree_delete)). +- Объединённые хранилища на C# используют АВЛ-деревья индексов: деревья по умолчанию, сбалансированные по размеру, вырождаются, когда много связей имеют общее начало или конец, и создание каждой записи блога становится линейным по числу записей ([experiments/csharp_objects_profile](experiments/csharp_objects_profile)). +- Хранилища объектов используют внешние ссылки для чисел и символов Юникода, поэтому исходные значения никогда не совпадают с идентификаторами связей. +- Чтение строки без кэша обходит её последовательность связь за связью; в C# каждый вызов `GetSource`/`GetTarget` из `Platform.Data` выделяет обработчик и список, поэтому чтение без кэша на C# значительно медленнее, чем на Rust. + + +## Дуплеты против SQLite как хранилище связей + +### Дуплеты на Rust против SQLite + +#### Тесты с 32-битным пространством адресов/идентификаторов + +_Результатов пока нет._ + +#### Тесты с 64-битным пространством адресов/идентификаторов + +_Результатов пока нет._ + +### Дуплеты на C# против SQLite + +#### Тесты с 32-битным пространством адресов/идентификаторов + +_Результатов пока нет._ + +#### Тесты с 64-битным пространством адресов/идентификаторов + +_Результатов пока нет._ + +## Дуплеты против SQLite как хранилище объектов + +### Дуплеты на Rust против SQLite + +#### Тесты с 32-битным пространством адресов/идентификаторов - -_Generated 2026-09-19 18:13 UTC for C# by [GitHub Actions run 35459522587](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/runs/35459522587) — 1000 benchmarked links, 3000 background links._ +_Результатов пока нет._ -| Operation | Doublets United Volatile | Doublets United NonVolatile | Doublets Split Volatile | Doublets Split NonVolatile | SQLite Memory | SQLite File | -|---------------|--------------------------|-----------------------------|-------------------------|----------------------------|---------------|-------------| -| Create | 2414327 (3.7x faster) | 2512520 (3.5x faster) | 867200 (10.2x faster) | 896999 (9.8x faster) | 8818051 | 2294476668 | -| Update | 4566053 (2.3x faster) | 4670899 (2.3x faster) | 1541871 (7.0x faster) | 1534119 (7.0x faster) | 10720700 | 2733072615 | -| Delete | 484008 (14.3x faster) | 465524 (14.8x faster) | 523910 (13.2x faster) | 521322 (13.3x faster) | 6907869 | 1682008343 | -| Each All | 437356 (3.7x faster) | 470885 (3.4x faster) | 420518 (3.8x faster) | 397666 (4.0x faster) | 2113072 | 1598861 | -| Each Identity | 430962 (11.6x faster) | 411173 (12.1x faster) | 403128 (12.4x faster) | 385531 (13.0x faster) | 4995738 | 12044311 | -| Each Concrete | 1363130 (5.2x faster) | 1408600 (5.1x faster) | 647769 (11.0x faster) | 917509 (7.8x faster) | 7134655 | 13622920 | -| Each Outgoing | 1160157 (5.3x faster) | 1180175 (5.3x faster) | 862790 (7.2x faster) | 830070 (7.5x faster) | 6199124 | 12923172 | -| Each Incoming | 1139559 (5.7x faster) | 1169325 (5.6x faster) | 796694 (8.2x faster) | 776158 (8.4x faster) | 6533836 | 13510164 | +#### Тесты с 64-битным пространством адресов/идентификаторов -![C# benchmark comparison](docs/benchmarks/bench_csharp.png) +_Результатов пока нет._ -![C# benchmark comparison, logarithmic scale](docs/benchmarks/bench_csharp_log_scale.png) - +### Дуплеты на C# против SQLite -### Результаты операций со связями и объектами на Rust +#### Тесты с 32-битным пространством адресов/идентификаторов - -_Generated 2026-09-19 18:46 UTC for Rust by [GitHub Actions run 35459522519](https://github.com/linksplatform/Comparisons.SQLiteVSDoublets/actions/runs/35459522519) — 1000 benchmarked links, 3000 background links, 1000 objects._ +_Результатов пока нет._ -| Operation | Doublets United Volatile | Doublets United NonVolatile | Doublets Split Volatile | Doublets Split NonVolatile | SQLite Memory | SQLite File | -|---------------------|--------------------------|-----------------------------|-------------------------|----------------------------|---------------|-------------| -| Create | 141747 (40.7x faster) | 143659 (40.2x faster) | 69524 (83.0x faster) | 69702 (82.8x faster) | 5772002 | 993908545 | -| Update | 283809 (29.6x faster) | 277912 (30.2x faster) | 63882 (131.3x faster) | 72899 (115.1x faster) | 8390541 | 1067410860 | -| Delete | 168149 (26.8x faster) | 166496 (27.1x faster) | 77711 (58.0x faster) | 78217 (57.6x faster) | 4509130 | 977528491 | -| Each All | 31671 (15.9x faster) | 31638 (15.9x faster) | 34426 (14.6x faster) | 34487 (14.6x faster) | 504031 | 595624 | -| Each Identity | 2132 (1269.3x faster) | 2136 (1266.9x faster) | 2130 (1270.5x faster) | 2127 (1272.3x faster) | 2706180 | 10678850 | -| Each Concrete | 124784 (34.7x faster) | 120544 (36.0x faster) | 45647 (95.0x faster) | 43793 (99.0x faster) | 4335748 | 12668554 | -| Each Outgoing | 210057 (18.1x faster) | 208064 (18.2x faster) | 44112 (86.0x faster) | 45302 (83.7x faster) | 3792959 | 12033519 | -| Each Incoming | 212266 (19.3x faster) | 210393 (19.4x faster) | 50873 (80.4x faster) | 46396 (88.1x faster) | 4089215 | 12477405 | -| Objects Create List | 8759735 (10.5x slower) | 8759735 (10.5x slower) | 5388349 (6.5x slower) | 5436404 (6.5x slower) | 832974 | 2098461 | -| Objects Read List | 5889568 (18.5x slower) | 5585959 (17.6x slower) | 5393697 (17.0x slower) | 4889083 (15.4x slower) | 317868 | 343282 | -| Objects Delete List | 2709133 (75.1x slower) | 2667861 (74.0x slower) | 1083231 (30.0x slower) | 1106546 (30.7x slower) | 36069 | 992614 | +#### Тесты с 64-битным пространством адресов/идентификаторов -![Rust benchmark comparison](docs/benchmarks/bench_rust.png) +_Результатов пока нет._ + -![Rust benchmark comparison, logarithmic scale](docs/benchmarks/bench_rust_log_scale.png) - +## Исходное сравнение -Исходное сравнение объектов на C# и его исторические результаты сохранены ниже. +Исходное сравнение объектов на C# и его исторические результаты. -## SQLite +### SQLite ```C# using System.Linq; using Comparisons.SQLiteVSDoublets.Model; @@ -129,7 +132,7 @@ namespace Comparisons.SQLiteVSDoublets.SQLite } ``` -## Дуплеты +### Дуплеты ``` C# using System.IO; using Platform.IO; @@ -187,18 +190,18 @@ namespace Comparisons.SQLiteVSDoublets.Doublets } ``` -## [Результат](https://www.icloud.com/keynote/0cYVNWkWD5RLU0k-XIBs3qWkA#Sqlite_vs_Doublets) +### [Результат](https://www.icloud.com/keynote/0cYVNWkWD5RLU0k-XIBs3qWkA#Sqlite_vs_Doublets) -### Производительность +#### Производительность ![Изображение с результатом сравнения производительности SQLite и Дуплетов.](https://raw.githubusercontent.com/linksplatform/Documentation/master/doc/Examples/sqlite_vs_doublets_performance.png "Результат сравнения производительности SQLite и Дуплетов") -### Использование пространства на диске +#### Использование пространства на диске ![Изображение с результатом сравнения использования пространства на диске SQLite и Дуплетов.](https://raw.githubusercontent.com/linksplatform/Documentation/master/doc/Examples/sqlite_vs_doublets_disk_usage.png "Результат сравнения использования пространства на диске SQLite и Дуплетов") -### Использование оперативной памяти +#### Использование оперативной памяти ![Изображение с результатом сравнения использования оперативной памяти SQLite и Дуплетов.](https://raw.githubusercontent.com/linksplatform/Documentation/master/doc/Examples/sqlite_vs_doublets_ram_usage.png "Результат сравнения использования оперативной памяти SQLite и Дуплетов") -### Исходные данные +#### Исходные данные ``` ini BenchmarkDotNet=v0.12.0, OS=Windows 10.0.18362 @@ -221,6 +224,6 @@ WarmupCount=2 | Doublets | 100000 | 13,083.4 ms | NA | 3088000.0000 | 328000.0000 | - | 12356.33 MB | 64192256 | -## Заключение +### Заключение В этом конкретном сравнении Дуплеты работают быстрее и используют меньше памяти на диске, но это достигается за счёт дополнительного использования оперативной памяти (Sqlite использует её меньше). diff --git a/docs/benchmarks/bench_csharp.png b/docs/benchmarks/bench_csharp.png deleted file mode 100644 index 6e916d8c8b200229052dc330bbb693b70be7ab15..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 45558 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