diff --git a/.claude/hooks/ask-before-risky-commands.sh b/.claude/hooks/ask-before-risky-commands.sh new file mode 100755 index 0000000..4f8788c --- /dev/null +++ b/.claude/hooks/ask-before-risky-commands.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Claude Code PreToolUse(Bash) gate — pathscale backend service. +# +# Prompts before prod-affecting / destructive commands in ANY wrapper form +# (env VAR=val …, tool -C …, chained with ; && |, multi-line, quoted via +# bash -c '…'). For everything else it stays SILENT (exit 0, no decision), so the +# normal permission rules — permissions.allow / ask / deny in .claude/settings.json +# and the session's permission mode — decide as usual. +# +# This is one layer of defense, not a replacement for the permission system: a +# pattern match over a command string is best-effort (quoting and indirection can +# evade any blocklist). It backs up the declarative permissions.ask list in +# .claude/settings.json — KEEP THE TWO IN SYNC — and for fully autonomous runs, +# prefer OS-level sandboxing on top. +# +# Adapted from PakhomovAlexander/project-hub. Edit RISKY_WORDS for this repo; +# tooling; further branches below gate `git`/`docker push`, `git clean`, recursive +# `rm`, `find -delete`, package publishing, PR-merge/release via `gh`, and a deploy +# script run by path. Add your own command families (e.g. `ssh`, a bespoke deploy +# CLI) to RISKY_WORDS, or trim what you don't use — and mirror the change in +# permissions.ask in .claude/settings.json. +set -u + +# --- the watchlist: command words that should prompt before running ---------------- +RISKY_WORDS="aws|gcloud|az|kubectl|helm|terraform|terragrunt|flyctl|fly" +# ----------------------------------------------------------------------------------- + +# Pull the command out of the hook's stdin JSON (jq if available, else python3). +if command -v jq >/dev/null 2>&1; then + cmd="$(jq -r '.tool_input.command // ""' 2>/dev/null)" +else + cmd="$(python3 -c 'import sys, json; print(json.load(sys.stdin).get("tool_input", {}).get("command", ""))' 2>/dev/null)" +fi + +# Couldn't read the command → stay neutral, let normal permission rules decide. +[ -z "$cmd" ] && exit 0 + +# A command word counts as "at a command position" after start-of-line, whitespace, +# ; & | ( or a quote — and ends before whitespace, a quote, ) ; & | or end-of-line — +# so `bash -c 'git push'` is still seen. +b="(^|[[:space:];&|(\"'\`])" +e="([[:space:];&|)\"'\`]|$)" +# Global options that may sit between a tool and its subcommand (git -C dir push). +opts='([[:space:]]+(-[A-Za-z-]+|[-_A-Za-z0-9]+=[^[:space:]]+|-C[[:space:]]+[^[:space:]]+))*' + +re="${b}(${RISKY_WORDS})${e}" +# git push / git clean, docker push — allowing global options before the subcommand. +re="$re|${b}git${opts}[[:space:]]+(push|clean)${e}" +re="$re|${b}docker${opts}[[:space:]]+push${e}" +# recursive rm (-r / -R / -fr / --recursive), with flags/paths in any order. +re="$re|${b}rm([[:space:]]+[^[:space:]]+)*[[:space:]]+(-[A-Za-z]*[rR]|--recursive)" +# find … -delete — irreversible bulk delete. +re="$re|${b}find([[:space:]]+[^[:space:]]+)*[[:space:]]+-delete${e}" +# package publishing — ships artifacts to a registry. +re="$re|${b}(npm|pnpm|yarn|bun|cargo|gem)([[:space:]]+[^[:space:]]+)*[[:space:]]+publish${e}" +re="$re|${b}twine([[:space:]]+[^[:space:]]+)*[[:space:]]+upload${e}" +# gh mutations that merge, ship, or destroy. +re="$re|${b}gh[[:space:]]+(pr[[:space:]]+merge|repo[[:space:]]+delete|release[[:space:]]+(create|delete))${e}" +re="$re|${b}gh[[:space:]]+api([[:space:]]+[^[:space:]]+)*[[:space:]]+(-X|--method)[[:space:]]+(POST|PUT|PATCH|DELETE)${e}" +# a deploy script invoked by path, e.g. ./scripts/deploy.sh — a word-list can't see it. +re="$re|${b}([./A-Za-z0-9_-]*/)?deploy(\.[A-Za-z]+)?${e}" +# WorkTable data migrations and endpoint regeneration rewrite committed artifacts. +re="$re|${b}([./A-Za-z0-9_-]*/)?regenerate_endpoints(\.[A-Za-z]+)?${e}" + +if printf '%s\n' "$cmd" | grep -Eq "$re"; then + printf '%s' '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"ask","permissionDecisionReason":"Prod-affecting / destructive command — confirm before running."}}' +fi +# No match → no output: fall through to the normal permission flow. +exit 0 diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a7af469 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,44 @@ +{ + "permissions": { + "allow": [ + "Bash(cargo build:*)", + "Bash(cargo check:*)", + "Bash(cargo test:*)", + "Bash(cargo fmt:*)", + "Bash(cargo clippy:*)", + "Bash(cargo tree:*)", + "Bash(git status:*)", + "Bash(git diff:*)", + "Bash(git log:*)" + ], + "ask": [ + "Bash(git push:*)", + "Bash(cargo publish:*)", + "Bash(npm publish:*)", + "Bash(bun publish:*)", + "Bash(docker push:*)", + "Bash(gh pr merge:*)", + "Bash(gh release:*)", + "Bash(aws:*)", + "Bash(gcloud:*)", + "Bash(az:*)", + "Bash(kubectl:*)", + "Bash(helm:*)", + "Bash(terraform:*)", + "Bash(flyctl:*)" + ] + }, + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "bash \"$CLAUDE_PROJECT_DIR/.claude/hooks/ask-before-risky-commands.sh\"" + } + ] + } + ] + } +} diff --git a/.gitignore b/.gitignore index aa8da64..dddbbb5 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ tests/data/* !tests/data/expected/ !tests/data/persist_index_table_of_contents.wt.idx -.claude +/.claude/settings.local.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b10df2a --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,84 @@ +# Working agreement — WorkTable + +The operating contract for **any** coding agent working in this repository. This file is +the single source of truth for the rules: Codex, Cursor and Gemini CLI read `AGENTS.md` +natively, and Claude Code loads it through the `@AGENTS.md` import in +[`CLAUDE.md`](CLAUDE.md). **Never fork these rules into a per-vendor file.** + +**Rust crate** (`worktable`). + +## Invariants (don't break these) + +- **Keep `cargo fmt` and `cargo clippy --all-targets` clean.** Lint failures are part of the build here, not advisory. +- **Publishing to crates.io is irreversible.** A version number can never be reused, and yanking does not delete. Run `cargo publish --dry-run` first, publish from the merged default branch, and tag the release. +- **A pre-release version (`-alpha`, `-beta`) needs an exact dependency pin.** A plain `"2.0"` requirement will not match `2.0.0-alpha.1`, so consumers must be bumped deliberately. +- **Docs describe what is true now.** If you change behaviour, update the README and any affected doc in the same change. + +## Build & test + +```bash +cargo build +cargo test +cargo fmt && cargo clippy --all-targets # run after every change +``` + +## Verification + +Run what you build before reporting it done. Type-checks and tests verify code correctness, +not feature correctness — **if you can't run it, say so explicitly** rather than implying +success. + +- Compare against the base branch rather than asserting: a pre-existing failing test or lint + error is not something you introduced, and saying so requires checking. +- A build that finishes suspiciously fast was cached, not rebuilt. Force a real rebuild when + the rebuild is the thing you're verifying. + +## PR discipline + +**Always paste the full PR URL** (`https://github.com/pathscale/WorkTable/pull/`), not just the number, so it's +clickable. + + + +## Keeping docs honest + +Hit a factual error here — a stale path, a wrong command, a moved status? Fix it in the same +change. Don't open cosmetic rewording PRs. + +Learned something durable — a gotcha, a decision, a constraint? It belongs **in this repo's +docs**, not in your agent's private memory. Repo docs are versioned, reviewable, and visible +to every agent and human; private memory dies with your machine. + +## Git workflow + +- **Always specify the branch when pushing**: `git push origin branch-name` +- **Branch naming**: `fix/issue-description` or `feat/issue-description` +- **No force-pushing** during PR review + +## Guardrails + +[`.claude/settings.json`](.claude/settings.json) and [`.claude/hooks/`](.claude/hooks/) make +Claude Code prompt a human before prod-affecting or destructive commands — pushes, publishing +to a registry, `gh pr merge`, cloud CLIs, recursive deletes, deploy scripts. + +**Other agents don't get that net automatically.** Apply the same rule yourself: ask before +running any command family listed in +[`.claude/hooks/ask-before-risky-commands.sh`](.claude/hooks/ask-before-risky-commands.sh). +It is one layer of defence, not a guarantee — a pattern match over a command string is +best-effort. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7d37442 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,12 @@ +@AGENTS.md + +# Claude Code notes — WorkTable + +The import above is binding: [`AGENTS.md`](AGENTS.md) is the **working agreement** for this +repository, and every Claude Code session loads it automatically. Don't copy rules here — +one source of truth, no drift. Only genuinely Claude-specific wiring belongs below. + +- Guardrails live in [`.claude/settings.json`](.claude/settings.json): safe read-only + commands are pre-allowed; pushes, publishing, `gh pr merge`, cloud CLIs and deploys prompt + first (`permissions.ask` plus the `PreToolUse` hook in [`.claude/hooks/`](.claude/hooks/)). +- Keep the hook's `RISKY_WORDS` and `permissions.ask` **in sync** — they back each other up. diff --git a/paper-bench/Cargo.toml b/paper-bench/Cargo.toml new file mode 100644 index 0000000..1718650 --- /dev/null +++ b/paper-bench/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "wt-paper-bench" +version = "0.1.0" +edition = "2024" + +# Opt out of the parent WorkTable workspace +[workspace] + +[dependencies] +worktable = { path = ".." } +tokio = { version = "1", features = ["full"] } +parking_lot = "0.12" +dashmap = "6" + +[profile.release] +lto = "fat" +codegen-units = 1 diff --git a/paper-bench/README.md b/paper-bench/README.md new file mode 100644 index 0000000..3153d97 --- /dev/null +++ b/paper-bench/README.md @@ -0,0 +1,67 @@ +# wt-paper-bench — benchmark harness for the CIDR 2027 paper + +Fills the `\ph{...}` placeholders in the paper. Run **in your macOS terminal** +(not through Claude's sandbox — it has no crates.io access). For the paper's +*official* numbers, re-run on a quiet, dedicated Linux x86 box with cores +pinned; Mac runs are fine for development and go/no-go decisions. + +## Run + +```bash +cd ~/code/WorkTable/paper-bench +mkdir -p results + +# 1. Lock-granularity contention matrix (~7 min: 4 modes x 5 task counts x 2 runs x 5s) +cargo run --release --bin contention | tee results/contention.csv + +# 2. Specialization ablation (the make-or-break experiment) +ROWS=1000000 REPS=5 cargo run --release --bin ablation | tee results/ablation.csv + +# 3. Hand-rolled baselines (Vec / RwLock / DashMap) +ROWS=1000000 REPS=5 cargo run --release --bin baselines | tee results/baselines.csv + +# 4. Compile-time + binary-size cost of specialization +bash scripts/compile_cost.sh | tee results/compile_cost.csv +``` + +Knobs: `DURATION_SECS` (contention, default 5), `ROWS` (default 1M), +`REPS` (default 5, median reported). + +When done, just tell Claude the results are in `paper-bench/results/` — they'll +be staged back, analyzed, and the paper tables/figures filled. + +## What maps to what in the paper + +| Output | Paper location | +|---|---| +| `contention.csv` | §5 lock-granularity ablation + scaling figure (C2) | +| `ablation.csv` | Table 1 (specialization ablation — C1, the thesis test) | +| `baselines.csv` | Table 2 rows: Vec/HashMap/DashMap | +| `compile_cost.csv` | §5 "cost of specialization" sentence | + +Still TODO after this round: external baselines (sled, redb, LMDB/heed, +SQLite `:memory:`) as a follow-up crate, YCSB-shaped mixes, and multi-threaded +ablation runs. + +## Fairness caveats to revisit before trusting Table 1 + +The dynamic twin (`src/dynamic.rs`) models the dynamism tax as: tagged-value +rows + runtime catalog lookup + encode/decode per access + coarse per-row +mutex. It does NOT share WorkTable's page allocator or B-tree (it uses a slot +vector + BTreeMap, which is *favorable* to the twin for point ops — so a +specialized win is conservative). A v2 twin reusing WorkTable's pages/indexes +with only the row representation dynamized would isolate the typing cost more +precisely. Review `dynamic.rs` before the official campaign. + +## Notes + +- The bench table (`src/lib.rs`) has an indexed column `a`, non-indexed `b`/`e` + (the disjoint contention pair), an `f64`, and a fixed-length `String` (keeps + the table on the unsized code path; same-length updates stay in place). +- `contention` modes: `disjoint` (b vs e — field locks shouldn't collide), + `overlap` (both tasks write {b,e} — must serialize), `mutex` (external + single lock), `inplace` (closure RMW). The C2 claim is + disjoint >> overlap ≈ correct, with mutex as the floor. +- First build takes a few minutes (workspace + LTO). If `cargo` complains + about the parent workspace, the `[workspace]` opt-out table in Cargo.toml + should prevent it — report the error if not. diff --git a/paper-bench/scripts/compile_cost.sh b/paper-bench/scripts/compile_cost.sh new file mode 100644 index 0000000..09f96a1 --- /dev/null +++ b/paper-bench/scripts/compile_cost.sh @@ -0,0 +1,69 @@ +#!/usr/bin/env bash +# Compile-time and binary-size cost of specialization (paper §5). +# Builds tiny crates with 1 and 8 worktable! tables, times cold release builds, +# reports binary sizes. Run from paper-bench/ inside the WorkTable clone. +set -euo pipefail + +WT_PATH="$(cd "$(dirname "$0")/../.." && pwd)" # the WorkTable clone root +WORK=$(mktemp -d) +echo "bench,tables,cold_build_secs,binary_bytes" + +gen_crate() { + local n=$1 dir=$2 + mkdir -p "$dir/src" + cat > "$dir/Cargo.toml" < "$dir/src/main.rs" +} + +for n in 1 8; do + dir="$WORK/cost-$n" + gen_crate "$n" "$dir" + ( cd "$dir" && cargo fetch >/dev/null 2>&1 ) + start=$(date +%s.%N) + ( cd "$dir" && cargo build --release >/dev/null 2>&1 ) + end=$(date +%s.%N) + bin=$(find "$dir/target/release" -maxdepth 1 -type f -perm -111 -name "wt-cost-*" | head -1) + size=$(stat -f%z "$bin" 2>/dev/null || stat -c%s "$bin") + echo "compile_cost,$n,$(echo "$end $start" | awk '{printf "%.1f", $1-$2}'),$size" +done + +rm -rf "$WORK" diff --git a/paper-bench/src/bin/ablation.rs b/paper-bench/src/bin/ablation.rs new file mode 100644 index 0000000..f9f04b6 --- /dev/null +++ b/paper-bench/src/bin/ablation.rs @@ -0,0 +1,139 @@ +//! Specialization ablation (paper Table 1): WorkTable vs the dynamic twin. +//! +//! Single-threaded per-op cost; ROWS rows; REPS repetitions, median reported. +//! CSV to stdout: bench,engine,op,ops_per_sec + +use wt_paper_bench::dynamic::*; +use wt_paper_bench::util::*; +use wt_paper_bench::*; + +fn main() { + let rows = env_u64("ROWS", 1_000_000); + let reps = env_u64("REPS", 5) as usize; + println!("bench,engine,op,ops_per_sec"); + + // ---------------- specialized ---------------- + let rt = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .unwrap(); + + // insert + let table = BenchWorkTable::default(); + let ins = median_ops_per_sec(reps, || { + // fresh table per rep to avoid unbounded growth distorting later reps + let t = BenchWorkTable::default(); + for v in 0..rows { + t.insert(mk_row(&t, v)).unwrap(); + } + rows + }); + println!("ablation,specialized,insert,{ins:.0}"); + + // populate once for read/update reps + for v in 0..rows { + table.insert(mk_row(&table, v)).unwrap(); + } + + // random point read + let rd = median_ops_per_sec(reps, || { + let mut rng = Rng::new(42); + let n = rows; + for _ in 0..n { + let pk = rng.below(rows); + std::hint::black_box(table.select(pk)); + } + n + }); + println!("ablation,specialized,point_read,{rd:.0}"); + + // non-indexed field update (UpdB) + let upd = { + let table = &table; + median_ops_per_sec(reps, || { + let mut rng = Rng::new(7); + let n = rows / 10; + rt.block_on(async { + for _ in 0..n { + let pk = rng.below(rows); + table.update_upd_b(UpdBQuery { b: pk }, pk).await.unwrap(); + } + }); + n + }) + }; + println!("ablation,specialized,update_field,{upd:.0}"); + + // indexed field update (UpdA) — includes index maintenance + let updi = { + let table = &table; + median_ops_per_sec(reps, || { + let mut rng = Rng::new(9); + let n = rows / 10; + rt.block_on(async { + for _ in 0..n { + let pk = rng.below(rows); + table.update_upd_a(UpdAQuery { a: pk }, pk).await.unwrap(); + } + }); + n + }) + }; + println!("ablation,specialized,update_indexed,{updi:.0}"); + + // in-place RMW + let inp = { + let table = &table; + median_ops_per_sec(reps, || { + let mut rng = Rng::new(11); + let n = rows / 10; + rt.block_on(async { + for _ in 0..n { + let pk = rng.below(rows); + table.update_inc_b_in_place(|b| *b += 1, pk).await.unwrap(); + } + }); + n + }) + }; + println!("ablation,specialized,inplace_rmw,{inp:.0}"); + + // ---------------- dynamic twin ---------------- + let ins = median_ops_per_sec(reps, || { + let t = DynTable::new(); + for _ in 0..rows { + let pk = t.get_next_pk(); + t.insert(mk_dyn_row(pk, pk)); + } + rows + }); + println!("ablation,dynamic,insert,{ins:.0}"); + + let dt = DynTable::new(); + for _ in 0..rows { + let pk = dt.get_next_pk(); + dt.insert(mk_dyn_row(pk, pk)); + } + + let rd = median_ops_per_sec(reps, || { + let mut rng = Rng::new(42); + for _ in 0..rows { + let pk = rng.below(rows); + std::hint::black_box(dt.select(pk)); + } + rows + }); + println!("ablation,dynamic,point_read,{rd:.0}"); + + let upd = median_ops_per_sec(reps, || { + let mut rng = Rng::new(7); + let n = rows / 10; + for _ in 0..n { + let pk = rng.below(rows); + dt.update_field(pk, "b", Value::U64(pk)).unwrap(); + } + n + }); + println!("ablation,dynamic,update_field,{upd:.0}"); +} diff --git a/paper-bench/src/bin/baselines.rs b/paper-bench/src/bin/baselines.rs new file mode 100644 index 0000000..6044d41 --- /dev/null +++ b/paper-bench/src/bin/baselines.rs @@ -0,0 +1,119 @@ +//! Hand-rolled Rust baselines (paper Table 2 rows 1-2): the structures a Rust +//! engineer reaches for before adopting an engine. Bounds the price of the +//! engine itself. +//! +//! CSV to stdout: bench,engine,op,ops_per_sec +//! External DB baselines (sled/redb/LMDB/SQLite) are a separate follow-up +//! crate so their heavy deps don't gate this one. + +use dashmap::DashMap; +use parking_lot::RwLock; +use std::collections::HashMap; +use wt_paper_bench::util::*; + +#[derive(Clone, Debug)] +struct Row { + #[allow(dead_code)] + id: u64, + a: u64, + b: u64, + e: u64, + c: f64, + d: String, +} + +fn mk(id: u64) -> Row { + Row { id, a: id, b: id, e: id, c: id as f64, d: "payloadpayload".into() } +} + +fn main() { + let rows = env_u64("ROWS", 1_000_000); + let reps = env_u64("REPS", 5) as usize; + println!("bench,engine,op,ops_per_sec"); + + // Vec — the absolute floor (pk == index) + let ins = median_ops_per_sec(reps, || { + let mut v: Vec = Vec::new(); + for i in 0..rows { + v.push(mk(i)); + } + std::hint::black_box(&v); + rows + }); + println!("baseline,vec,insert,{ins:.0}"); + + let v: Vec = (0..rows).map(mk).collect(); + let rd = median_ops_per_sec(reps, || { + let mut rng = Rng::new(42); + for _ in 0..rows { + let i = rng.below(rows) as usize; + std::hint::black_box(&v[i]); + } + rows + }); + println!("baseline,vec,point_read,{rd:.0}"); + + // RwLock — simplest shared map + let m: RwLock> = RwLock::new(HashMap::new()); + let ins = median_ops_per_sec(reps, || { + let mut g = m.write(); + g.clear(); + for i in 0..rows { + g.insert(i, mk(i)); + } + rows + }); + println!("baseline,rwlock_hashmap,insert,{ins:.0}"); + let rd = median_ops_per_sec(reps, || { + let mut rng = Rng::new(42); + let g = m.read(); + for _ in 0..rows { + std::hint::black_box(g.get(&rng.below(rows))); + } + rows + }); + println!("baseline,rwlock_hashmap,point_read,{rd:.0}"); + let upd = median_ops_per_sec(reps, || { + let mut rng = Rng::new(7); + let n = rows / 10; + for _ in 0..n { + let pk = rng.below(rows); + if let Some(r) = m.write().get_mut(&pk) { + r.b = pk; + } + } + n + }); + println!("baseline,rwlock_hashmap,update_field,{upd:.0}"); + + // DashMap — sharded concurrent map + let dm: DashMap = DashMap::new(); + let ins = median_ops_per_sec(reps, || { + dm.clear(); + for i in 0..rows { + dm.insert(i, mk(i)); + } + rows + }); + println!("baseline,dashmap,insert,{ins:.0}"); + let rd = median_ops_per_sec(reps, || { + let mut rng = Rng::new(42); + for _ in 0..rows { + std::hint::black_box(dm.get(&rng.below(rows))); + } + rows + }); + println!("baseline,dashmap,point_read,{rd:.0}"); + let upd = median_ops_per_sec(reps, || { + let mut rng = Rng::new(7); + let n = rows / 10; + for _ in 0..n { + let pk = rng.below(rows); + if let Some(mut r) = dm.get_mut(&pk) { + r.b = pk; + } + } + n + }); + println!("baseline,dashmap,update_field,{upd:.0}"); +} diff --git a/paper-bench/src/bin/contention.rs b/paper-bench/src/bin/contention.rs new file mode 100644 index 0000000..dd083c6 --- /dev/null +++ b/paper-bench/src/bin/contention.rs @@ -0,0 +1,90 @@ +//! Lock-granularity contention matrix (paper §5, C2 claim). +//! +//! One hot row; N tasks update it continuously for DURATION_SECS. +//! Modes: +//! disjoint — half the tasks update column `b`, half update column `e` +//! (disjoint write sets => field-granular locks don't collide) +//! overlap — every task updates {b,e} (identical write sets => serialize) +//! mutex — like disjoint, but behind one external tokio::Mutex +//! (emulates a single-lock table) +//! inplace — every task runs the in_place closure increment on `b` +//! +//! CSV to stdout: bench,mode,tasks,ops_per_sec + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Instant; +use wt_paper_bench::util::*; +use wt_paper_bench::*; + +async fn run(mode: &'static str, tasks: usize) -> f64 { + let table = Arc::new(BenchWorkTable::default()); + let pk = table.insert(mk_row(&table, 1)).unwrap(); + let pk_val: u64 = pk.into(); + + let stop = Arc::new(AtomicBool::new(false)); + let total = Arc::new(AtomicU64::new(0)); + let big_lock = Arc::new(tokio::sync::Mutex::new(())); + + let mut handles = Vec::new(); + for i in 0..tasks { + let table = table.clone(); + let stop = stop.clone(); + let total = total.clone(); + let big_lock = big_lock.clone(); + handles.push(tokio::spawn(async move { + let mut n = 0u64; + while !stop.load(Ordering::Relaxed) { + match mode { + "disjoint" => { + if i % 2 == 0 { + table.update_upd_b(UpdBQuery { b: n }, pk_val).await.unwrap(); + } else { + table.update_upd_e(UpdEQuery { e: n }, pk_val).await.unwrap(); + } + } + "overlap" => { + table.update_upd_be(UpdBEQuery { b: n, e: n }, pk_val).await.unwrap(); + } + "mutex" => { + let _g = big_lock.lock().await; + if i % 2 == 0 { + table.update_upd_b(UpdBQuery { b: n }, pk_val).await.unwrap(); + } else { + table.update_upd_e(UpdEQuery { e: n }, pk_val).await.unwrap(); + } + } + "inplace" => { + table.update_inc_b_in_place(|b| *b += 1, pk_val).await.unwrap(); + } + _ => unreachable!(), + } + n += 1; + } + total.fetch_add(n, Ordering::Relaxed); + })); + } + + let dur = env_secs("DURATION_SECS", 5); + let t0 = Instant::now(); + tokio::time::sleep(dur).await; + stop.store(true, Ordering::Relaxed); + for h in handles { + h.await.unwrap(); + } + total.load(Ordering::Relaxed) as f64 / t0.elapsed().as_secs_f64() +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + println!("bench,mode,tasks,ops_per_sec"); + let task_counts = [1usize, 2, 4, 8, 16]; + for mode in ["disjoint", "overlap", "mutex", "inplace"] { + for &t in &task_counts { + // warmup + let _ = run(mode, t).await; + let ops = run(mode, t).await; + println!("contention,{mode},{t},{ops:.0}"); + } + } +} diff --git a/paper-bench/src/dynamic.rs b/paper-bench/src/dynamic.rs new file mode 100644 index 0000000..f6e61a1 --- /dev/null +++ b/paper-bench/src/dynamic.rs @@ -0,0 +1,159 @@ +//! The "dynamic twin" for the specialization ablation (paper Table 1). +//! +//! Models what a dynamic-schema engine is forced to do on every operation: +//! * rows are vectors of tagged values (boxed representation) +//! * a runtime catalog maps column name -> position (hash lookup per access) +//! * rows are encoded/decoded through a tag-dispatched serializer per access +//! * row locking is a coarse per-row mutex from a dynamic lock table +//! +//! FAIRNESS NOTE (review before trusting numbers): this v1 twin does NOT share +//! WorkTable's page allocator or B-tree; it uses a slot vector + BTreeMap. +//! That is *favorable* to the twin for point ops (no page indirection), so a +//! win for the specialized engine here is conservative. A v2 twin sharing the +//! page/index machinery with only the row representation dynamized would +//! isolate the typing cost even more precisely. + +use parking_lot::{Mutex, RwLock}; +use std::collections::{BTreeMap, HashMap}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +#[derive(Clone, Debug, PartialEq)] +pub enum Value { + U64(u64), + F64(f64), + Str(String), +} + +pub struct DynSchema { + pub cols: Vec<&'static str>, + pub catalog: HashMap<&'static str, usize>, +} + +impl DynSchema { + pub fn bench() -> Self { + let cols = vec!["id", "a", "b", "e", "c", "d"]; + let catalog = cols.iter().enumerate().map(|(i, c)| (*c, i)).collect(); + DynSchema { cols, catalog } + } +} + +fn encode(row: &[Value], out: &mut Vec) { + out.clear(); + for v in row { + match v { + Value::U64(x) => { + out.push(0); + out.extend_from_slice(&x.to_le_bytes()); + } + Value::F64(x) => { + out.push(1); + out.extend_from_slice(&x.to_le_bytes()); + } + Value::Str(s) => { + out.push(2); + out.extend_from_slice(&(s.len() as u32).to_le_bytes()); + out.extend_from_slice(s.as_bytes()); + } + } + } +} + +fn decode(mut b: &[u8]) -> Vec { + let mut row = Vec::with_capacity(6); + while !b.is_empty() { + match b[0] { + 0 => { + row.push(Value::U64(u64::from_le_bytes(b[1..9].try_into().unwrap()))); + b = &b[9..]; + } + 1 => { + row.push(Value::F64(f64::from_le_bytes(b[1..9].try_into().unwrap()))); + b = &b[9..]; + } + 2 => { + let n = u32::from_le_bytes(b[1..5].try_into().unwrap()) as usize; + row.push(Value::Str(String::from_utf8(b[5..5 + n].to_vec()).unwrap())); + b = &b[5 + n..]; + } + _ => unreachable!(), + } + } + row +} + +pub struct DynTable { + pub schema: DynSchema, + slots: RwLock>>, + pk_index: RwLock>, + locks: RwLock>>>, + next_pk: AtomicU64, +} + +impl DynTable { + pub fn new() -> Self { + DynTable { + schema: DynSchema::bench(), + slots: RwLock::new(Vec::new()), + pk_index: RwLock::new(BTreeMap::new()), + locks: RwLock::new(HashMap::new()), + next_pk: AtomicU64::new(0), + } + } + + pub fn get_next_pk(&self) -> u64 { + self.next_pk.fetch_add(1, Ordering::AcqRel) + } + + pub fn insert(&self, row: Vec) -> u64 { + let pk = match &row[0] { + Value::U64(x) => *x, + _ => panic!("pk must be u64"), + }; + let mut buf = Vec::with_capacity(64); + encode(&row, &mut buf); + let mut slots = self.slots.write(); + let idx = slots.len(); + slots.push(buf); + drop(slots); + self.pk_index.write().insert(pk, idx); + pk + } + + pub fn select(&self, pk: u64) -> Option> { + let idx = *self.pk_index.read().get(&pk)?; + let slots = self.slots.read(); + Some(decode(&slots[idx])) + } + + /// Field update through the catalog — the dynamic path a specialized + /// `update_upd_b` avoids: lock table, hash lookup, decode, dispatch, + /// re-encode, write back. + pub fn update_field(&self, pk: u64, col: &str, v: Value) -> Option<()> { + let lock = { + let mut locks = self.locks.write(); + locks.entry(pk).or_insert_with(|| Arc::new(Mutex::new(()))).clone() + }; + let _g = lock.lock(); + let idx = *self.pk_index.read().get(&pk)?; + let pos = *self.schema.catalog.get(col)?; + let mut slots = self.slots.write(); + let mut row = decode(&slots[idx]); + row[pos] = v; + let mut buf = Vec::with_capacity(64); + encode(&row, &mut buf); + slots[idx] = buf; + Some(()) + } +} + +pub fn mk_dyn_row(pk: u64, v: u64) -> Vec { + vec![ + Value::U64(pk), + Value::U64(v), + Value::U64(v), + Value::U64(v), + Value::F64(v as f64), + Value::Str("payloadpayload".to_string()), + ] +} diff --git a/paper-bench/src/lib.rs b/paper-bench/src/lib.rs new file mode 100644 index 0000000..5b19bd8 --- /dev/null +++ b/paper-bench/src/lib.rs @@ -0,0 +1,46 @@ +pub mod dynamic; +pub mod util; + +use worktable::prelude::*; +use worktable::worktable; + +// The paper's benchmark table. `a` is indexed (non-unique) so we can measure +// indexed vs non-indexed update cost separately. `b`/`e` are the disjoint +// contention pair (both non-indexed). `d` keeps the table "unsized" (String), +// matching realistic schemas. +worktable!( + name: Bench, + columns: { + id: u64 primary_key autoincrement, + a: u64, + b: u64, + e: u64, + c: f64, + d: String, + }, + indexes: { + a_idx: a, + }, + queries: { + update: { + UpdA(a) by id, + UpdB(b) by id, + UpdE(e) by id, + UpdBE(b, e) by id, + }, + in_place: { + IncB(b) by id, + } + } +); + +pub fn mk_row(table: &BenchWorkTable, v: u64) -> BenchRow { + BenchRow { + id: table.get_next_pk().into(), + a: v, + b: v, + e: v, + c: v as f64, + d: "payloadpayload".to_string(), // fixed-length: updates stay in place + } +} diff --git a/paper-bench/src/util.rs b/paper-bench/src/util.rs new file mode 100644 index 0000000..5e46f28 --- /dev/null +++ b/paper-bench/src/util.rs @@ -0,0 +1,43 @@ +use std::time::{Duration, Instant}; + +/// Tiny xorshift RNG — no external dep, deterministic across runs. +pub struct Rng(u64); +impl Rng { + pub fn new(seed: u64) -> Self { + Rng(seed | 1) + } + #[inline] + pub fn next(&mut self) -> u64 { + let mut x = self.0; + x ^= x << 13; + x ^= x >> 7; + x ^= x << 17; + self.0 = x; + x + } + #[inline] + pub fn below(&mut self, n: u64) -> u64 { + self.next() % n + } +} + +pub fn env_u64(key: &str, default: u64) -> u64 { + std::env::var(key).ok().and_then(|v| v.parse().ok()).unwrap_or(default) +} + +pub fn env_secs(key: &str, default: u64) -> Duration { + Duration::from_secs(env_u64(key, default)) +} + +/// Run `f` repeatedly for `reps`, return median ops/sec given per-rep op count. +pub fn median_ops_per_sec u64>(reps: usize, mut f: F) -> f64 { + let mut rates: Vec = (0..reps) + .map(|_| { + let t = Instant::now(); + let ops = f(); + ops as f64 / t.elapsed().as_secs_f64() + }) + .collect(); + rates.sort_by(|a, b| a.partial_cmp(b).unwrap()); + rates[rates.len() / 2] +}