Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -170,3 +170,12 @@ debug = "line-tables-only"
name = "hot_path"
harness = false
required-features = ["encryption"]

# Instruction-count gate (`make perf-ir`). It needs valgrind, so `bench = false`
# keeps it out of a plain `cargo bench`; `--bench perf_ir` still selects it, and
# `clippy --all-targets` still compiles it.
[[bench]]
name = "perf_ir"
harness = false
bench = false
required-features = ["encryption"]
13 changes: 12 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# cachekit-core - Development Makefile

.PHONY: help check test lint clippy audit deny vet fmt fmt-check bench bench-quick fuzz-quick fuzz-deep sbom clean
.PHONY: help check test lint clippy audit deny vet fmt fmt-check bench bench-quick perf-ir perf-ir-update fuzz-quick fuzz-deep sbom clean
.DEFAULT_GOAL := help

# Colors for output
Expand Down Expand Up @@ -84,6 +84,17 @@ bench-quick: ## Quick bench run for CI (1s warmup, 2s measurement, 10 samples)
$(call require_binary,cargo,Install Rust: https://rustup.rs)
@cargo bench -p cachekit-core --features encryption --bench hot_path -- --warm-up-time 1 --measurement-time 2 --sample-size 10

# Per-op instruction counts (benches/perf_ir.rs) against the budgets in
# benches/perf_ir_baselines.json. Needs valgrind. Pass gate options through
# ARGS, e.g. make perf-ir ARGS="--case store/1024". See README, "Instruction counts".
perf-ir: ## Instruction-count gate: fail on >=1% Ir/op regression (needs valgrind)
$(call require_binary,cargo,Install Rust: https://rustup.rs)
@cargo bench -p cachekit-core --features encryption --bench perf_ir -- $(ARGS)

perf-ir-update: ## Ratchet Ir/op budgets down to the measured figures (never up)
$(call require_binary,cargo,Install Rust: https://rustup.rs)
@cargo bench -p cachekit-core --features encryption --bench perf_ir -- --update $(ARGS)

fuzz-quick: ## Quick corpus-only fuzz run (2 min per target)
@echo "$(BLUE)Running quick fuzzing (2 min per target)...$(RESET)"
$(call require_binary,cargo-fuzz,Install: cargo install cargo-fuzz)
Expand Down
25 changes: 25 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,10 @@ cachekit-core/
├── include/
│ └── cachekit.h # Generated C header
│
├── benches/
│ ├── hot_path.rs # Criterion wall-clock suite (make bench)
│ └── perf_ir.rs # Per-op instruction counts and their gate (make perf-ir)
│
├── fuzz/ # Fuzzing targets (16 targets)
│ └── fuzz_targets/
│
Expand Down Expand Up @@ -329,6 +333,27 @@ Benchmarks on Apple M2 Max (64KB payload, compressible data):

The figures above come from `examples/bench_throughput.rs` on highly compressible data. The Criterion suite in `benches/hot_path.rs` needs the `encryption` feature (`make bench`, or `cargo bench --features encryption`; a plain `cargo bench` skips it). It runs the ByteStorage roundtrip on three corpora side by side: `byte_storage/roundtrip` (synthetic ramp, kept for history), `byte_storage/roundtrip_msgpack` (realistic msgpack records, about 0.38 LZ4 ratio at 64 KB) and `byte_storage/roundtrip_incompressible`. The bench profile keeps symbols, so callgrind and perf attribute cost to functions.

### Instruction counts

`make perf-ir` counts the instructions each hot path executes per call and fails when a case costs **1% or more** above its committed budget. It warns from 0.2%. Wall clock on a shared machine moves by far more than 1% between identical runs, and Criterion picks its iteration count from wall time; instruction counts at a fixed iteration count are exact. It needs valgrind (`apt install valgrind`).

| Case | What one call does |
|:-----|:-------------------|
| `store/<size>`, `retrieve/<size>` | `ByteStorage::store` / `retrieve` on the realistic msgpack payload `hot_path` uses |
| `prescan/<size>` | `check_msgpack_structure` on the envelope, the pre-scan `retrieve` runs first |
| `encrypt/<size>`, `decrypt/<size>` | `ZeroKnowledgeEncryptor` AES-256-GCM |
| `keyring_decrypt/<size>` | `Keyring::decrypt_indexed`, which re-derives the tenant key (HKDF) on every call |
| `tenant_keyring_decrypt/<size>` | `TenantKeyring::decrypt_indexed`, key derived once at `for_tenant` |
| `hkdf` | `derive_domain_key` (takes no payload, so it has no size) |

Sizes are 64 B, 1 KiB and 64 KiB. One case at a time: `make perf-ir ARGS="--case store/1024"`.

**Method** (`benches/perf_ir.rs`): each case runs in its own process under `valgrind --tool=cachegrind --cache-sim=no`, once with 1,000 calls and once with none, after the same setup and one warm-up call. Ir/op is `(Ir[1000] - Ir[0]) / 1000`, so process start, setup and exit cancel. The measured process gets an empty environment, so neither the shell nor a `VALGRIND_OPTS` changes what is counted.

**Reproducibility:** two runs of the same build agree exactly on every case. Between builds, the heap moves with the size of the binary, and alignment-dependent code in AES-GCM and memcpy follows it: `encrypt/1024` moves by up to 0.19%, every other case by at most 0.02%. The counts also depend on the compiler, the locked dependencies, the CPU features the binary is compiled for and the ones valgrind passes through. Budgets are keyed by architecture, OS and any compiled-in CPU features (a `-C target-cpu=x86-64-v3` build gets its own set), and each set records the rustc and valgrind versions it was taken with; the gate prints a note when yours differ. Other `RUSTFLAGS` and profile overrides are not detected, so measure the default build.

**Budgets** live in `benches/perf_ir_baselines.json`. After a change that makes a path cheaper, `make perf-ir-update` ratchets its budget down. It never raises one: an increase you mean takes `make perf-ir-update ARGS=--allow-increase`. Ratcheting works only on the rustc and valgrind the budgets were recorded with; on a new toolchain, re-record every case with `make perf-ir-update ARGS=--allow-increase` (no `--case`). Instruction counts ignore cache misses and branch mispredictions, so a claimed wall-clock win still needs a wall-clock A/B.

---

## Testing
Expand Down
60 changes: 60 additions & 0 deletions benches/common/mod.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
//! Payload generators shared by the bench targets. `hot_path` (Criterion wall
//! clock) and `perf_ir` (instruction counts) must measure the same bytes, or
//! their figures cannot be read against each other.

use serde::Serialize;

pub fn xorshift(mut state: u64) -> u64 {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
}

/// A typical cached row: what the SDKs hand ByteStorage after msgpack-encoding
/// a dict/object with string keys.
#[derive(Serialize)]
struct Record {
id: u64,
user: String,
email: String,
score: f64,
active: bool,
tags: Vec<&'static str>,
created_at: String,
}

/// Realistic payload: a stream of msgpack-named records with deterministic,
/// varied values, truncated to `size`. ByteStorage treats the payload as
/// opaque bytes, so the cut tail does not affect what is measured.
pub fn msgpack_payload(size: usize) -> Vec<u8> {
const TAGS: &[&str] = &["free", "pro", "trial", "eu", "us", "beta", "admin"];
let mut state: u64 = 0x2545f4914f6cdd1d;
let mut out = Vec::with_capacity(size + 256);
let mut id = 0u64;
while out.len() < size {
state = xorshift(state);
id += 1;
let record = Record {
id: 1_000_000 + id,
user: format!("user_{:x}", state % 0xff_ffff),
email: format!("u{}@example{}.com", state % 100_000, state % 7),
score: (state % 10_000) as f64 / 100.0,
active: state % 3 != 0,
tags: (0..(state % 4) as usize)
.map(|i| TAGS[(state as usize >> (8 * i)) % TAGS.len()])
.collect(),
created_at: format!(
"2026-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1 + state % 12,
1 + (state >> 8) % 28,
(state >> 16) % 24,
(state >> 24) % 60,
(state >> 32) % 60
),
};
out.extend_from_slice(&rmp_serde::to_vec_named(&record).unwrap());
}
out.truncate(size);
out
}
59 changes: 3 additions & 56 deletions benches/hot_path.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@

use std::cell::OnceCell;

mod common;

use cachekit_core::{ByteStorage, StorageEnvelope, ZeroKnowledgeEncryptor};
use common::{msgpack_payload, xorshift};
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion, Throughput};
use serde::Serialize;

const SIZES: &[usize] = &[64, 256, 1024, 4 * 1024, 16 * 1024, 64 * 1024];

Expand All @@ -41,61 +43,6 @@ fn incompressible(len: usize) -> Vec<u8> {
out
}

fn xorshift(mut state: u64) -> u64 {
state ^= state << 13;
state ^= state >> 7;
state ^= state << 17;
state
}

/// A typical cached row: what the SDKs hand ByteStorage after msgpack-encoding
/// a dict/object with string keys.
#[derive(Serialize)]
struct Record {
id: u64,
user: String,
email: String,
score: f64,
active: bool,
tags: Vec<&'static str>,
created_at: String,
}

/// Realistic payload: a stream of msgpack-named records with deterministic,
/// varied values, truncated to `size`. ByteStorage treats the payload as
/// opaque bytes, so the cut tail does not affect what is measured.
fn msgpack_payload(size: usize) -> Vec<u8> {
const TAGS: &[&str] = &["free", "pro", "trial", "eu", "us", "beta", "admin"];
let mut state: u64 = 0x2545f4914f6cdd1d;
let mut out = Vec::with_capacity(size + 256);
let mut id = 0u64;
while out.len() < size {
state = xorshift(state);
id += 1;
let record = Record {
id: 1_000_000 + id,
user: format!("user_{:x}", state % 0xff_ffff),
email: format!("u{}@example{}.com", state % 100_000, state % 7),
score: (state % 10_000) as f64 / 100.0,
active: state % 3 != 0,
tags: (0..(state % 4) as usize)
.map(|i| TAGS[(state as usize >> (8 * i)) % TAGS.len()])
.collect(),
created_at: format!(
"2026-{:02}-{:02}T{:02}:{:02}:{:02}Z",
1 + state % 12,
1 + (state >> 8) % 28,
(state >> 16) % 24,
(state >> 24) % 60,
(state >> 32) % 60
),
};
out.extend_from_slice(&rmp_serde::to_vec_named(&record).unwrap());
}
out.truncate(size);
out
}

/// Build a corpus payload and report its LZ4 compressed/raw ratio once, so
/// the synthetic and realistic corpora can be compared side by side.
fn corpus_fixture(storage: &ByteStorage, id: &str, payload: Vec<u8>) -> Vec<u8> {
Expand Down
Loading
Loading