6bc09cb3b3
Two items from the ROUND17 deferred list, each benchmark-gated with a
BEFORE/AFTER equivalence gate and a rollback-on-regression check (ROUND2–17
discipline). See benches/ROUND18.md.
[C1] backend — CalendarEvent::update_ical_property / remove_ical_property
rewrote the ENTIRE ical_data body with format!("{}{}{}") on every call and
allocated two search needles per call. calendar_storage_adapter::update_event
fans a multi-field edit out into one call per changed field, so a full REST
edit paid one full-body (up to ~11 KB) allocation per property. The body is
now mutated in place (replace_range for an existing property, four insert/
insert_str for a new one, byte-identical spans) and the single "\nNAME:"
needle is built on the stack (the "\r\nNAME:" needle was redundant — the LF
form is its suffix). bench_round18_micro [C1]: 70 -> 2 allocs/op (68 fewer),
2.46x wall, emitted body byte-identical.
[F1] frontend — ResourceList itemIndexById rebuilt a fresh Map over the whole
accumulated list every infinite-scroll page (O(N)/page, O(N^2) drain) and,
being a new instance each page, re-fired the reap-stale effect (another O(N)
id Set/page). New ItemIndexBuilder extends a persistent Map with the fresh
page only and reuses the reference across appends; the reap-stale effect now
tests membership against it. round18.bench.test.ts [F1]: 40x50 drain 74.1 ->
6.4 ms (11.5x), deep-equal to the reference at every page, reference-contract
gate (same-ref append / new-ref rebuild).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FoxFtikahM1N4PE5s3ZVH3
332 lines
12 KiB
Rust
332 lines
12 KiB
Rust
//! Round-18 calendar-event edit CPU/alloc micro-pack (no Postgres).
|
||
//!
|
||
//! Same rule as ROUND2–17: each section is BEFORE (verbatim replica of the
|
||
//! shipped-before shape) vs AFTER (verbatim replica of the shipped-after
|
||
//! shape), with a byte-for-byte equivalence gate and a `GATE FAIL … rollback`
|
||
//! check that exits non-zero if the AFTER arm fails to reduce allocations — the
|
||
//! round's roll-back rule encoded into the benchmark.
|
||
//!
|
||
//! [C1] `CalendarEvent::update_ical_property` / `remove_ical_property`
|
||
//! rewrote the ENTIRE `ical_data` body with `format!("{}{}{}")` on every
|
||
//! call, and allocated TWO search needles per call (`\nNAME:` and the
|
||
//! redundant `\r\nNAME:` — the CRLF form can never match where the LF
|
||
//! form doesn't, since `\nNAME:` is its suffix). Because
|
||
//! `calendar_storage_adapter::update_event` applies each changed field
|
||
//! independently, a multi-field REST edit paid one full-body (up to
|
||
//! ~11 KB) String allocation PER changed property, plus two needles.
|
||
//! The shipped-after form mutates the body in place (`replace_range` for
|
||
//! an existing property, four `insert`/`insert_str` for a new one) and
|
||
//! builds the single `\nNAME:` needle on the stack — zero heap needle,
|
||
//! no fresh-body allocation. The edited spans are byte-for-byte the same
|
||
//! the `format!` reconstruction produced (`replace_range(a..b, v)` ≡
|
||
//! `before + v + after`), so the emitted body is identical — including
|
||
//! the pre-existing quirk that editing a CRLF line drops its `\r`.
|
||
//!
|
||
//! Run:
|
||
//! cargo run --release --features bench --example bench_round18_micro
|
||
//! Tunables (env): BENCH_ITERS (200000)
|
||
|
||
use std::alloc::{GlobalAlloc, Layout, System};
|
||
use std::env;
|
||
use std::hint::black_box;
|
||
use std::sync::atomic::{AtomicU64, Ordering};
|
||
use std::time::Instant;
|
||
|
||
static ALLOC_CALLS: AtomicU64 = AtomicU64::new(0);
|
||
|
||
struct CountingAlloc;
|
||
|
||
unsafe impl GlobalAlloc for CountingAlloc {
|
||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
||
unsafe { System.alloc(layout) }
|
||
}
|
||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||
unsafe { System.dealloc(ptr, layout) }
|
||
}
|
||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
||
unsafe { System.realloc(ptr, layout, new_size) }
|
||
}
|
||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||
ALLOC_CALLS.fetch_add(1, Ordering::Relaxed);
|
||
unsafe { System.alloc_zeroed(layout) }
|
||
}
|
||
}
|
||
|
||
#[global_allocator]
|
||
static GLOBAL: CountingAlloc = CountingAlloc;
|
||
|
||
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
|
||
env::var(key)
|
||
.ok()
|
||
.and_then(|v| v.parse().ok())
|
||
.unwrap_or(default)
|
||
}
|
||
|
||
struct Measured {
|
||
wall_ns_per_op: f64,
|
||
allocs_per_op: f64,
|
||
}
|
||
|
||
fn measure<F: FnMut()>(iters: usize, mut f: F) -> Measured {
|
||
let a0 = ALLOC_CALLS.load(Ordering::Relaxed);
|
||
let t = Instant::now();
|
||
for _ in 0..iters {
|
||
f();
|
||
}
|
||
let wall = t.elapsed().as_nanos() as f64 / iters as f64;
|
||
let allocs = (ALLOC_CALLS.load(Ordering::Relaxed) - a0) as f64 / iters as f64;
|
||
Measured {
|
||
wall_ns_per_op: wall,
|
||
allocs_per_op: allocs,
|
||
}
|
||
}
|
||
|
||
fn print_row(label: &str, m: &Measured) {
|
||
println!(
|
||
"| {:<44} | {:>12.1} | {:>10.2} |",
|
||
label, m.wall_ns_per_op, m.allocs_per_op
|
||
);
|
||
}
|
||
|
||
fn header_footer(name: &str, before: &Measured, after: &Measured) {
|
||
println!("| arm | ns/op | allocs/op |");
|
||
print_row(&format!("BEFORE {name}"), before);
|
||
print_row(&format!("AFTER {name}"), after);
|
||
println!(
|
||
"# {:.2}x wall, {:.2} fewer allocs/op",
|
||
before.wall_ns_per_op / after.wall_ns_per_op,
|
||
before.allocs_per_op - after.allocs_per_op
|
||
);
|
||
}
|
||
|
||
fn gate_allocs(tag: &str, before: &Measured, after: &Measured) {
|
||
if after.allocs_per_op >= before.allocs_per_op {
|
||
eprintln!("GATE FAIL [{tag}]: AFTER did not reduce allocations — rollback");
|
||
std::process::exit(1);
|
||
}
|
||
}
|
||
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
// [C1] calendar-event edit — per-property full-body format! vs in-place edit
|
||
// ────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// An edit step, mirroring the `event.update_*(…)` calls that
|
||
/// `calendar_storage_adapter::update_event` fans out into `update_ical_property`
|
||
/// (present → replace) and `remove_ical_property` (a cleared `Option`).
|
||
enum Op<'a> {
|
||
Update(&'a str, &'a str),
|
||
Remove(&'a str),
|
||
}
|
||
|
||
// ── BEFORE: verbatim replica of the shipped-before methods ──────────────────
|
||
|
||
fn before_update(ical: &mut String, property_name: &str, value: &str) {
|
||
let search_str = format!("\n{}:", property_name);
|
||
let search_str_alt = format!("\r\n{}:", property_name);
|
||
|
||
let pos = ical
|
||
.find(&search_str)
|
||
.or_else(|| ical.find(&search_str_alt));
|
||
|
||
if let Some(pos) = pos {
|
||
let value_start = pos + search_str.len();
|
||
let value_end = ical[value_start..]
|
||
.find('\n')
|
||
.map(|p| value_start + p)
|
||
.unwrap_or_else(|| ical.len());
|
||
let before = &ical[..value_start];
|
||
let after = &ical[value_end..];
|
||
*ical = format!("{}{}{}", before, value, after);
|
||
} else {
|
||
let end_pos = ical.find("END:VEVENT").unwrap_or(ical.len());
|
||
let before = &ical[..end_pos];
|
||
let after = &ical[end_pos..];
|
||
*ical = format!("{}{}:{}\n{}", before, property_name, value, after);
|
||
}
|
||
}
|
||
|
||
fn before_remove(ical: &mut String, property_name: &str) {
|
||
let search_str = format!("\n{}:", property_name);
|
||
let search_str_alt = format!("\r\n{}:", property_name);
|
||
|
||
let pos = ical
|
||
.find(&search_str)
|
||
.or_else(|| ical.find(&search_str_alt));
|
||
|
||
if let Some(pos) = pos {
|
||
let value_end = ical[pos + 1..]
|
||
.find('\n')
|
||
.map(|p| pos + 1 + p)
|
||
.unwrap_or_else(|| ical.len());
|
||
let before = &ical[..pos];
|
||
let after = &ical[value_end..];
|
||
*ical = format!("{}{}", before, after);
|
||
}
|
||
}
|
||
|
||
// ── AFTER: verbatim replica of the shipped-after methods ────────────────────
|
||
|
||
fn line_needle<'a>(buf: &'a mut [u8; 64], name: &str) -> Option<&'a str> {
|
||
let n = name.len();
|
||
if n + 2 > buf.len() {
|
||
return None;
|
||
}
|
||
buf[0] = b'\n';
|
||
buf[1..1 + n].copy_from_slice(name.as_bytes());
|
||
buf[1 + n] = b':';
|
||
std::str::from_utf8(&buf[..n + 2]).ok()
|
||
}
|
||
|
||
fn after_update(ical: &mut String, property_name: &str, value: &str) {
|
||
let mut buf = [0u8; 64];
|
||
let needle_owned;
|
||
let needle: &str = match line_needle(&mut buf, property_name) {
|
||
Some(n) => n,
|
||
None => {
|
||
needle_owned = format!("\n{property_name}:");
|
||
&needle_owned
|
||
}
|
||
};
|
||
|
||
if let Some(pos) = ical.find(needle) {
|
||
let value_start = pos + needle.len();
|
||
let value_end = ical[value_start..]
|
||
.find('\n')
|
||
.map_or(ical.len(), |p| value_start + p);
|
||
ical.replace_range(value_start..value_end, value);
|
||
} else {
|
||
let end_pos = ical.find("END:VEVENT").unwrap_or(ical.len());
|
||
ical.reserve(property_name.len() + value.len() + 2);
|
||
ical.insert(end_pos, '\n');
|
||
ical.insert_str(end_pos, value);
|
||
ical.insert(end_pos, ':');
|
||
ical.insert_str(end_pos, property_name);
|
||
}
|
||
}
|
||
|
||
fn after_remove(ical: &mut String, property_name: &str) {
|
||
let mut buf = [0u8; 64];
|
||
let needle_owned;
|
||
let needle: &str = match line_needle(&mut buf, property_name) {
|
||
Some(n) => n,
|
||
None => {
|
||
needle_owned = format!("\n{property_name}:");
|
||
&needle_owned
|
||
}
|
||
};
|
||
|
||
if let Some(pos) = ical.find(needle) {
|
||
let value_end = ical[pos + 1..]
|
||
.find('\n')
|
||
.map_or(ical.len(), |p| pos + 1 + p);
|
||
ical.replace_range(pos..value_end, "");
|
||
}
|
||
}
|
||
|
||
fn apply_before(base: &str, ops: &[Op]) -> String {
|
||
let mut ical = base.to_string();
|
||
for op in ops {
|
||
match op {
|
||
Op::Update(n, v) => before_update(&mut ical, n, v),
|
||
Op::Remove(n) => before_remove(&mut ical, n),
|
||
}
|
||
}
|
||
ical
|
||
}
|
||
|
||
fn apply_after(base: &str, ops: &[Op]) -> String {
|
||
let mut ical = base.to_string();
|
||
for op in ops {
|
||
match op {
|
||
Op::Update(n, v) => after_update(&mut ical, n, v),
|
||
Op::Remove(n) => after_remove(&mut ical, n),
|
||
}
|
||
}
|
||
ical
|
||
}
|
||
|
||
/// A realistic stored VEVENT body (CRLF-terminated, ~1.5 KB with attendees and
|
||
/// a VALARM) — the shape `calendar_storage_adapter` hydrates before applying an
|
||
/// `UpdateEventDto`.
|
||
fn base_body() -> String {
|
||
let mut b = String::from(
|
||
"BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//OxiCloud//NONSGML Calendar//EN\r\n\
|
||
BEGIN:VEVENT\r\nUID:evt-round18@oxicloud.test\r\nDTSTAMP:20260101T100000Z\r\n\
|
||
DTSTART:20260101T120000Z\r\nDTEND:20260101T130000Z\r\n\
|
||
SUMMARY:Original quarterly planning sync\r\n\
|
||
DESCRIPTION:The original description body for the event, moderately long.\r\n\
|
||
LOCATION:Room A, Ground Floor\r\nCATEGORIES:work,planning,quarterly\r\n\
|
||
ORGANIZER;CN=Alice Example:mailto:alice@oxicloud.test\r\n",
|
||
);
|
||
// A handful of attendees + a VALARM to bring the body to a realistic size,
|
||
// so BEFORE's per-property full-body `format!` copies real bytes.
|
||
for i in 0..8 {
|
||
b.push_str(&format!(
|
||
"ATTENDEE;CN=Guest {i};PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:guest{i}@oxicloud.test\r\n"
|
||
));
|
||
}
|
||
b.push_str(
|
||
"BEGIN:VALARM\r\nACTION:DISPLAY\r\nDESCRIPTION:Reminder\r\nTRIGGER:-PT15M\r\nEND:VALARM\r\n\
|
||
END:VEVENT\r\nEND:VCALENDAR\r\n",
|
||
);
|
||
b
|
||
}
|
||
|
||
fn section_calendar_edit() {
|
||
let iters: usize = env_or("BENCH_ITERS", 200_000);
|
||
let base = base_body();
|
||
|
||
// A full multi-field REST edit: five existing properties replaced (SUMMARY,
|
||
// DESCRIPTION, LOCATION, DTSTART, DTEND — the last two rewritten twice, as
|
||
// the time-range + all-day updates both do), one new property inserted
|
||
// (RRULE, absent from the body), one cleared (CATEGORIES). Exactly the
|
||
// `update_ical_property` / `remove_ical_property` fan-out of `update_event`.
|
||
let ops = [
|
||
Op::Update("SUMMARY", "Updated quarterly planning sync"),
|
||
Op::Update(
|
||
"DESCRIPTION",
|
||
"A revised, noticeably longer description so the replacement value differs in length from the original and exercises the grow path.",
|
||
),
|
||
Op::Update("LOCATION", "Conference Room 42, Building B"),
|
||
Op::Update("DTSTART", "20260202T090000Z"),
|
||
Op::Update("DTEND", "20260202T100000Z"),
|
||
Op::Update("DTSTART", "20260202T000000Z"),
|
||
Op::Update("DTEND", "20260202T010000Z"),
|
||
Op::Update("RRULE", "FREQ=WEEKLY;COUNT=10"),
|
||
Op::Remove("CATEGORIES"),
|
||
];
|
||
|
||
// Equivalence gate: the emitted body is byte-for-byte identical.
|
||
let b = apply_before(&base, &ops);
|
||
let a = apply_after(&base, &ops);
|
||
assert_eq!(b, a, "C1 emitted body differs between BEFORE and AFTER");
|
||
|
||
let before = measure(iters, || {
|
||
black_box(apply_before(black_box(&base), black_box(&ops)));
|
||
});
|
||
let after = measure(iters, || {
|
||
black_box(apply_after(black_box(&base), black_box(&ops)));
|
||
});
|
||
|
||
println!(
|
||
"\n## [C1] calendar-event multi-field edit ({} ops, {}-byte body)",
|
||
ops.len(),
|
||
base.len()
|
||
);
|
||
println!("# both arms pay one identical `base.to_string()` reset per op (constant, shared)");
|
||
header_footer("update_event in-place property rewrite", &before, &after);
|
||
gate_allocs("C1", &before, &after);
|
||
}
|
||
|
||
fn main() {
|
||
println!("#################################################################");
|
||
println!("# Round-18 calendar-event edit CPU/alloc micro-pack");
|
||
println!("#################################################################");
|
||
|
||
section_calendar_edit();
|
||
|
||
println!("\nGATE PASS (all sections)");
|
||
}
|