cd4c62042a
Round 3 of benchmark-gated optimizations (benches/ROUND3.md; every change gated by a before/after benchmark — an AFTER that did not beat its BEFORE was to be rolled back; none needed it. Equivalence gates assert identical row sequences / byte-identical output on every behavior-preserving rewrite): DB hot paths (local PG16, EXPLAIN-verified): - Web-UI listing (list_resources_paged): cursor pushed INSIDE the folders/files UNION-ALL branches as sargable row-value comparisons with per-branch ORDER/LIMIT + two partial expression indexes (folder_id, LOWER(name), id). 20k-entry folder: 26.6 -> 1.3 ms/page (19.5x); other sort modes at parity or better. New migration 20260918000000. [benches/LISTING-KEYSET.md section in ROUND3] - Photos timeline (list_media_files): per-drive CROSS JOIN LATERAL top-N on the timeline index, joins moved above the top-N. 50k-photo library: 97.4 -> 1.6 ms/page (55.7x). The old "LIMIT stops the scan early" comment was refuted by EXPLAIN. - PROPFIND sub-folders (both DAV surfaces): keyset list_folders_batch off idx_folders_unique_name replaces COUNT(*) OVER() + LIMIT/OFFSET (5k dirs: 79.7 -> 17.9 ms full walk, 4.5x). Concurrency: - Basic-auth cache single-flight (moka try_get_with): 8 concurrent DAV connections at TTL expiry paid 8 Argon2id runs (2.6 s CPU + 8x64 MiB); now 1 (300 ms). Failed verifications remain uncached. - CachedBlobBackend per-hash single-flight + unique tmp names: 16 concurrent cold readers = 16 full remote downloads racing truncating writes on ONE deterministic .tmp (corruptible cache); now 1 download (16x less egress, 2.8x wall on a shared link) and torn files can never be renamed into the cache. I/O and allocations: - Chunk-assembly reads 64K -> 512K buffers (2.3x, 8x fewer syscalls); chunk-spool writes via BufWriter 512K (5.6x, 32x fewer syscalls). - S3/Azure put_blob_from_bytes_unsynced overrides: dedup settle no longer pays a HEAD probe per new chunk (2 RTT -> 1, 1.8x); Azure stops copying every chunk (Bytes -> Body, -0.44 ms - 4 MiB alloc per 4 MiB chunk). - Entity->DTO mapping: Arc<str> interning of closed-set display fields + common MIMEs, 1-alloc etag/size formatting, FolderDto moves instead of clones. File row: 11 -> 4 allocs; folder row: 11.8 -> 1 (2.1x faster). - CardDAV REPORT: deleted dead per-contact vCard pre-generation and the O(N^2) uid scan whose result was discarded (5k contacts: 55.7 -> 5.7 ms, 9.8x); byte-identical XML asserted. - Search-results cache: byte weigher + 32 MiB budget (OXICLOUD_SEARCH_CACHE_MAX_BYTES) replaces the 1000-ENTRY cap that let ~300 MiB of enriched rows sit in RSS; read latency parity. - Dropped aws-config + aws-smithy-types (zero references; -82 dep-graph nodes, three SDK stacks gone from every build). tokio "process" is now an explicit feature (was enabled transitively by aws-config). Frontend: - Cached Intl.DateTimeFormat keyed by (locale, options) in formatDate and 4 sibling callsites: 20k dates 2612 -> 51 ms (51.6x); vitest gate asserts output identity across locales and a 3x floor. Validation: cargo fmt + clippy --all-features --all-targets -D warnings clean; 518 unit + 548 integration-cfg tests green; new-shape endpoints smoke-tested end-to-end over HTTP (all 5 listing sort modes with cursor walks, WebDAV PROPFIND Depth-1, photos timeline, Basic-auth DAV login); frontend npm run check clean, new vitest gates green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EBsU2qEzny3A8WQUEuMNCr
590 lines
22 KiB
Rust
590 lines
22 KiB
Rust
//! File/Folder entity → DTO mapping benchmark — per-row allocation churn.
|
|
//!
|
|
//! Isolates the variables the DTO-mapping change touches:
|
|
//!
|
|
//! • `Arc::<str>::from(&'static str)` for the closed-set display fields
|
|
//! (icon class, icon special class, category) — always alloc + copy —
|
|
//! vs interned `Arc<str>` lookups (`intern_display` / `intern_mime`).
|
|
//! • `File::compute_etag` / `Folder::compute_etag` — `chars().take(16)
|
|
//! .collect::<String>()` + `format!` (2 allocs) vs one sized buffer.
|
|
//! • `format_file_size` — two `format!` calls per row vs one buffer.
|
|
//! • `Folder → FolderDto` — per-getter `.to_string()` clones + a
|
|
//! double-allocated etag vs `into_parts()` moves.
|
|
//!
|
|
//! The OLD mapping logic is copied verbatim into `mod before` so one binary
|
|
//! reports BEFORE vs AFTER side by side, and an equivalence gate asserts the
|
|
//! two produce byte-identical DTOs for every row (exit 1 on any diff).
|
|
//!
|
|
//! Sections:
|
|
//! 1. File → FileDto wall time (p50 ns/row over BENCH_PASSES passes)
|
|
//! 2. Folder → FolderDto wall time (same)
|
|
//! 3. Alloc calls/row (counting global allocator wrapping System — the
|
|
//! lib crate sets no global allocator; mimalloc lives in main.rs only,
|
|
//! which examples do not link)
|
|
//! 4. Equivalence gate: BEFORE output == AFTER output, field by field
|
|
//!
|
|
//! Run (no Postgres needed):
|
|
//! cargo run --release --features bench --example bench_dto_map
|
|
//! Tunables (env):
|
|
//! BENCH_ROWS (10000) BENCH_PASSES (100)
|
|
|
|
use std::alloc::{GlobalAlloc, Layout, System};
|
|
use std::env;
|
|
use std::hint::black_box;
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
use std::time::Instant;
|
|
|
|
use oxicloud::application::dtos::file_dto::FileDto;
|
|
use oxicloud::application::dtos::folder_dto::FolderDto;
|
|
use oxicloud::domain::entities::file::File;
|
|
use oxicloud::domain::entities::folder::Folder;
|
|
use oxicloud::domain::services::path_service::StoragePath;
|
|
use uuid::Uuid;
|
|
|
|
// ─── Counting allocator (Section 3) ─────────────────────────────────────────
|
|
|
|
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;
|
|
|
|
// ─── BEFORE: verbatim copy of the pre-optimization mapping logic ────────────
|
|
|
|
/// Pre-optimization reference implementation. Copied verbatim from the old
|
|
/// `From<File> for FileDto` / `From<Folder> for FolderDto` bodies, the old
|
|
/// `File::compute_etag` / `Folder::compute_etag` formulas and the old
|
|
/// `format_file_size` — kept byte-for-byte in behaviour so the equivalence
|
|
/// gate proves the optimized paths change nothing observable.
|
|
#[allow(clippy::all)]
|
|
mod before {
|
|
use std::sync::Arc;
|
|
|
|
use oxicloud::application::dtos::display_helpers::{
|
|
category_for, icon_class_for, icon_special_class_for,
|
|
};
|
|
use oxicloud::application::dtos::file_dto::FileDto;
|
|
use oxicloud::application::dtos::folder_dto::FolderDto;
|
|
use oxicloud::domain::entities::file::File;
|
|
use oxicloud::domain::entities::folder::Folder;
|
|
|
|
/// Old `File::compute_etag`: intermediate `collect::<String>()` +
|
|
/// `format!` — 2 allocations for one ~21-char string.
|
|
fn file_compute_etag(blob_hash: &str, modified_at: u64) -> String {
|
|
let prefix: String = blob_hash.chars().take(16).collect();
|
|
format!("{}-{}", prefix, modified_at)
|
|
}
|
|
|
|
/// Old `Folder::compute_etag` (same shape as the file formula).
|
|
fn folder_compute_etag(id: &str, tree_modified_at: u64) -> String {
|
|
let prefix: String = id.chars().take(16).collect();
|
|
format!("{}-{}", prefix, tree_modified_at)
|
|
}
|
|
|
|
/// Old `format_file_size`: two `format!` calls per row.
|
|
fn format_file_size(bytes: u64) -> String {
|
|
if bytes == 0 {
|
|
return "0 Bytes".to_string();
|
|
}
|
|
|
|
const K: f64 = 1024.0;
|
|
const SIZES: [&str; 5] = ["Bytes", "KB", "MB", "GB", "TB"];
|
|
|
|
let i = ((bytes as f64).ln() / K.ln()).floor() as usize;
|
|
let i = i.min(SIZES.len() - 1);
|
|
|
|
let value = bytes as f64 / K.powi(i as i32);
|
|
|
|
let formatted = format!("{:.2}", value);
|
|
let formatted = formatted.trim_end_matches('0').trim_end_matches('.');
|
|
|
|
format!("{} {}", formatted, SIZES[i])
|
|
}
|
|
|
|
/// Old `From<File> for FileDto` body: `Arc::from(&str)` for the three
|
|
/// display fields and the mime type (alloc + copy each), 2-alloc etag,
|
|
/// 2-format size string.
|
|
pub fn file_to_dto(file: File) -> FileDto {
|
|
let etag = file_compute_etag(file.content_hash(), file.modified_at());
|
|
let content_hash = file.content_hash().to_string();
|
|
|
|
let parts = file.into_parts();
|
|
|
|
let icon_class: Arc<str> = Arc::from(icon_class_for(&parts.name, &parts.mime_type));
|
|
let icon_special_class: Arc<str> =
|
|
Arc::from(icon_special_class_for(&parts.name, &parts.mime_type));
|
|
let category: Arc<str> = Arc::from(category_for(&parts.name, &parts.mime_type));
|
|
let size_formatted = format_file_size(parts.size);
|
|
let mime_type: Arc<str> = Arc::from(parts.mime_type.as_str());
|
|
|
|
FileDto {
|
|
id: parts.id,
|
|
name: parts.name,
|
|
path: parts.path_string,
|
|
size: parts.size,
|
|
mime_type,
|
|
folder_id: parts.folder_id,
|
|
created_at: parts.created_at,
|
|
modified_at: parts.modified_at,
|
|
icon_class,
|
|
icon_special_class,
|
|
category,
|
|
size_formatted,
|
|
sort_date: None,
|
|
content_hash,
|
|
etag,
|
|
created_by: parts.created_by,
|
|
updated_by: parts.updated_by,
|
|
}
|
|
}
|
|
|
|
/// Old `From<Folder> for FolderDto` body: per-getter `.to_string()`
|
|
/// clones, `folder.etag().to_string()` (etag built then cloned — the
|
|
/// verbatim double alloc) and 3 fresh `Arc::from` constants per row.
|
|
pub fn folder_to_dto(folder: Folder) -> FolderDto {
|
|
let is_root = folder.parent_id().is_none();
|
|
let etag = folder_compute_etag(folder.id(), folder.tree_modified_at()).to_string();
|
|
|
|
FolderDto {
|
|
id: folder.id().to_string(),
|
|
name: folder.name().to_string(),
|
|
path: folder.path_string().to_string(),
|
|
parent_id: folder.parent_id().map(String::from),
|
|
drive_id: folder.drive_id(),
|
|
created_at: folder.created_at(),
|
|
modified_at: folder.modified_at(),
|
|
is_root,
|
|
icon_class: Arc::from("fas fa-folder"),
|
|
icon_special_class: Arc::from("folder-icon"),
|
|
category: Arc::from("Folder"),
|
|
etag,
|
|
created_by: folder.created_by(),
|
|
updated_by: folder.updated_by(),
|
|
}
|
|
}
|
|
}
|
|
|
|
// ─── Synthetic corpus ────────────────────────────────────────────────────────
|
|
|
|
/// (extension, mime) matrix: interned common types, generic MIMEs that
|
|
/// exercise the extension fallback, and exotic MIMEs that miss the intern
|
|
/// table so the fallback `Arc::from` path is measured too.
|
|
const KINDS: &[(&str, &str)] = &[
|
|
("jpg", "image/jpeg"),
|
|
("png", "image/png"),
|
|
("heic", "image/heic"),
|
|
("mp4", "video/mp4"),
|
|
("mov", "video/quicktime"),
|
|
("mp3", "audio/mpeg"),
|
|
("flac", "audio/flac"),
|
|
("pdf", "application/pdf"),
|
|
(
|
|
"docx",
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
|
),
|
|
(
|
|
"xlsx",
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
),
|
|
("txt", "text/plain"),
|
|
("md", "text/markdown"),
|
|
("csv", "text/csv"),
|
|
("json", "application/json"),
|
|
("zip", "application/zip"),
|
|
("gz", "application/gzip"),
|
|
// Extension fallback: generic MIME, type resolved from the name.
|
|
("rs", "application/octet-stream"),
|
|
("py", "application/octet-stream"),
|
|
("svelte", "application/octet-stream"),
|
|
("dmg", "application/octet-stream"),
|
|
("bin", "application/octet-stream"),
|
|
// No extension + empty MIME: full-default path.
|
|
("", ""),
|
|
// Exotic MIMEs: miss the intern table, fall back to Arc::from.
|
|
("pdb", "chemical/x-pdb"),
|
|
("xyz", "application/x-very-exotic-subtype+custom"),
|
|
];
|
|
|
|
const SIZES: &[u64] = &[
|
|
0,
|
|
137,
|
|
500,
|
|
1_024,
|
|
1_536,
|
|
65_536,
|
|
1_048_576,
|
|
3_423_744,
|
|
987_654_321,
|
|
1_073_741_824,
|
|
5_497_558_138_880, // ~5 TB
|
|
];
|
|
|
|
/// Deterministic xorshift64* — fake-but-plausible 64-char lowercase hex
|
|
/// BLAKE3 hashes.
|
|
fn next_seed(seed: &mut u64) -> u64 {
|
|
*seed ^= *seed << 13;
|
|
*seed ^= *seed >> 7;
|
|
*seed ^= *seed << 17;
|
|
seed.wrapping_mul(0x2545F4914F6CDD1D)
|
|
}
|
|
|
|
fn fake_blake3(seed: &mut u64) -> String {
|
|
format!(
|
|
"{:016x}{:016x}{:016x}{:016x}",
|
|
next_seed(seed),
|
|
next_seed(seed),
|
|
next_seed(seed),
|
|
next_seed(seed)
|
|
)
|
|
}
|
|
|
|
fn build_files(rows: usize) -> Vec<File> {
|
|
let mut seed = 0x9E3779B97F4A7C15u64;
|
|
(0..rows)
|
|
.map(|i| {
|
|
let (ext, mime) = KINDS[i % KINDS.len()];
|
|
let name = if ext.is_empty() {
|
|
format!("file_{i:05}")
|
|
} else {
|
|
format!("file_{i:05}.{ext}")
|
|
};
|
|
let path = StoragePath::from_string(&format!("/bench/dir_{}/{}", i % 37, name));
|
|
let folder_id = if i % 3 == 0 {
|
|
None
|
|
} else {
|
|
Some(Uuid::from_u128(1000 + (i % 37) as u128).to_string())
|
|
};
|
|
let created_by = (i % 2 == 0).then(|| Uuid::from_u128(7 + (i % 5) as u128));
|
|
let updated_by = (i % 4 == 0).then(|| Uuid::from_u128(11 + (i % 3) as u128));
|
|
File::with_timestamps_blob_hash_and_provenance(
|
|
Uuid::from_u128(i as u128).to_string(),
|
|
name,
|
|
path,
|
|
SIZES[i % SIZES.len()],
|
|
mime.to_string(),
|
|
folder_id,
|
|
1_600_000_000 + i as u64,
|
|
1_700_000_000 + (i as u64 * 7) % 100_000,
|
|
fake_blake3(&mut seed),
|
|
created_by,
|
|
updated_by,
|
|
)
|
|
.expect("valid synthetic file")
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
fn build_folders(rows: usize) -> Vec<Folder> {
|
|
(0..rows)
|
|
.map(|i| {
|
|
let name = format!("folder_{i:05}");
|
|
let path = StoragePath::from_string(&format!("/bench/parent_{}/{}", i % 37, name));
|
|
let parent_id = if i % 5 == 0 {
|
|
None
|
|
} else {
|
|
Some(Uuid::from_u128(2000 + (i % 37) as u128).to_string())
|
|
};
|
|
let created_by = (i % 2 == 0).then(|| Uuid::from_u128(7 + (i % 5) as u128));
|
|
let updated_by = (i % 4 == 0).then(|| Uuid::from_u128(11 + (i % 3) as u128));
|
|
Folder::with_timestamps_tree_and_provenance(
|
|
Uuid::from_u128(500_000 + i as u128).to_string(),
|
|
name,
|
|
path,
|
|
parent_id,
|
|
Uuid::from_u128(42 + (i % 4) as u128),
|
|
1_600_000_000 + i as u64,
|
|
1_700_000_000 + (i as u64 * 7) % 100_000,
|
|
1_700_000_000 + (i as u64 * 11) % 100_000,
|
|
created_by,
|
|
updated_by,
|
|
)
|
|
.expect("valid synthetic folder")
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
// ─── Measurement helpers ─────────────────────────────────────────────────────
|
|
|
|
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)
|
|
}
|
|
|
|
fn median(mut xs: Vec<f64>) -> f64 {
|
|
xs.sort_by(|a, b| a.partial_cmp(b).unwrap());
|
|
xs[xs.len() / 2]
|
|
}
|
|
|
|
/// p50 wall seconds per pass of `f` over `passes` passes.
|
|
fn p50_pass_secs(passes: usize, mut f: impl FnMut()) -> f64 {
|
|
f(); // warmup (also initializes LazyLock intern tables)
|
|
let mut xs = Vec::with_capacity(passes);
|
|
for _ in 0..passes {
|
|
let t0 = Instant::now();
|
|
f();
|
|
xs.push(t0.elapsed().as_secs_f64());
|
|
}
|
|
median(xs)
|
|
}
|
|
|
|
/// Allocation calls performed by one run of `f` (deterministic — the
|
|
/// mappings do no I/O and touch no shared caches beyond the intern tables,
|
|
/// which the warmup run already initialized).
|
|
fn allocs_of(mut f: impl FnMut()) -> u64 {
|
|
f(); // warmup so one-time lazy init isn't attributed to the variant
|
|
let start = ALLOC_CALLS.load(Ordering::Relaxed);
|
|
f();
|
|
ALLOC_CALLS.load(Ordering::Relaxed) - start
|
|
}
|
|
|
|
struct Row {
|
|
variant: &'static str,
|
|
ns_per_row: f64,
|
|
allocs_per_row: f64,
|
|
}
|
|
|
|
// ─── Equivalence gate (Section 4) ────────────────────────────────────────────
|
|
|
|
macro_rules! cmp_field {
|
|
($diffs:expr, $i:expr, $kind:expr, $b:expr, $a:expr, $field:ident) => {
|
|
if $b.$field != $a.$field {
|
|
$diffs += 1;
|
|
if $diffs <= 20 {
|
|
println!(
|
|
" DIFF {} row {}: {} BEFORE={:?} AFTER={:?}",
|
|
$kind,
|
|
$i,
|
|
stringify!($field),
|
|
$b.$field,
|
|
$a.$field
|
|
);
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
fn diff_file(i: usize, b: &FileDto, a: &FileDto, diffs: &mut u64) {
|
|
cmp_field!(*diffs, i, "file", b, a, id);
|
|
cmp_field!(*diffs, i, "file", b, a, name);
|
|
cmp_field!(*diffs, i, "file", b, a, path);
|
|
cmp_field!(*diffs, i, "file", b, a, size);
|
|
cmp_field!(*diffs, i, "file", b, a, mime_type);
|
|
cmp_field!(*diffs, i, "file", b, a, folder_id);
|
|
cmp_field!(*diffs, i, "file", b, a, created_at);
|
|
cmp_field!(*diffs, i, "file", b, a, modified_at);
|
|
cmp_field!(*diffs, i, "file", b, a, icon_class);
|
|
cmp_field!(*diffs, i, "file", b, a, icon_special_class);
|
|
cmp_field!(*diffs, i, "file", b, a, category);
|
|
cmp_field!(*diffs, i, "file", b, a, size_formatted);
|
|
cmp_field!(*diffs, i, "file", b, a, sort_date);
|
|
cmp_field!(*diffs, i, "file", b, a, content_hash);
|
|
cmp_field!(*diffs, i, "file", b, a, etag);
|
|
cmp_field!(*diffs, i, "file", b, a, created_by);
|
|
cmp_field!(*diffs, i, "file", b, a, updated_by);
|
|
}
|
|
|
|
fn diff_folder(i: usize, b: &FolderDto, a: &FolderDto, diffs: &mut u64) {
|
|
cmp_field!(*diffs, i, "folder", b, a, id);
|
|
cmp_field!(*diffs, i, "folder", b, a, name);
|
|
cmp_field!(*diffs, i, "folder", b, a, path);
|
|
cmp_field!(*diffs, i, "folder", b, a, parent_id);
|
|
cmp_field!(*diffs, i, "folder", b, a, drive_id);
|
|
cmp_field!(*diffs, i, "folder", b, a, created_at);
|
|
cmp_field!(*diffs, i, "folder", b, a, modified_at);
|
|
cmp_field!(*diffs, i, "folder", b, a, is_root);
|
|
cmp_field!(*diffs, i, "folder", b, a, icon_class);
|
|
cmp_field!(*diffs, i, "folder", b, a, icon_special_class);
|
|
cmp_field!(*diffs, i, "folder", b, a, category);
|
|
cmp_field!(*diffs, i, "folder", b, a, etag);
|
|
cmp_field!(*diffs, i, "folder", b, a, created_by);
|
|
cmp_field!(*diffs, i, "folder", b, a, updated_by);
|
|
}
|
|
|
|
// ─── Main ────────────────────────────────────────────────────────────────────
|
|
|
|
fn main() {
|
|
let rows: usize = env_or("BENCH_ROWS", 10_000).max(1);
|
|
let passes: usize = env_or("BENCH_PASSES", 100).max(1);
|
|
|
|
let files = build_files(rows);
|
|
let folders = build_folders(rows);
|
|
println!(
|
|
"corpus: {rows} files ({} kinds x {} sizes) + {rows} folders, {passes} timed passes",
|
|
KINDS.len(),
|
|
SIZES.len()
|
|
);
|
|
println!(
|
|
"note: each measured pass pays one entity clone per row (mapping consumes the\n\
|
|
entity); the clone-only baseline is measured separately and subtracted.\n"
|
|
);
|
|
|
|
// ── Section 1: File → FileDto wall time ─────────────────────────────
|
|
println!("── Section 1: File → FileDto (p50 wall, net of clone) ──");
|
|
let file_base_s = p50_pass_secs(passes, || {
|
|
for f in &files {
|
|
black_box(f.clone());
|
|
}
|
|
});
|
|
let file_before_s = p50_pass_secs(passes, || {
|
|
for f in &files {
|
|
black_box(before::file_to_dto(f.clone()));
|
|
}
|
|
});
|
|
let file_after_s = p50_pass_secs(passes, || {
|
|
for f in &files {
|
|
black_box(FileDto::from(f.clone()));
|
|
}
|
|
});
|
|
let file_base_ns = file_base_s * 1e9 / rows as f64;
|
|
let file_before_ns = (file_before_s - file_base_s) * 1e9 / rows as f64;
|
|
let file_after_ns = (file_after_s - file_base_s) * 1e9 / rows as f64;
|
|
println!(" clone-only baseline: {file_base_ns:8.1} ns/row");
|
|
println!(" BEFORE mapping: {file_before_ns:8.1} ns/row");
|
|
println!(" AFTER mapping: {file_after_ns:8.1} ns/row\n");
|
|
|
|
// ── Section 2: Folder → FolderDto wall time ─────────────────────────
|
|
println!("── Section 2: Folder → FolderDto (p50 wall, net of clone) ──");
|
|
let folder_base_s = p50_pass_secs(passes, || {
|
|
for f in &folders {
|
|
black_box(f.clone());
|
|
}
|
|
});
|
|
let folder_before_s = p50_pass_secs(passes, || {
|
|
for f in &folders {
|
|
black_box(before::folder_to_dto(f.clone()));
|
|
}
|
|
});
|
|
let folder_after_s = p50_pass_secs(passes, || {
|
|
for f in &folders {
|
|
black_box(FolderDto::from(f.clone()));
|
|
}
|
|
});
|
|
let folder_base_ns = folder_base_s * 1e9 / rows as f64;
|
|
let folder_before_ns = (folder_before_s - folder_base_s) * 1e9 / rows as f64;
|
|
let folder_after_ns = (folder_after_s - folder_base_s) * 1e9 / rows as f64;
|
|
println!(" clone-only baseline: {folder_base_ns:8.1} ns/row");
|
|
println!(" BEFORE mapping: {folder_before_ns:8.1} ns/row");
|
|
println!(" AFTER mapping: {folder_after_ns:8.1} ns/row\n");
|
|
|
|
// ── Section 3: allocation calls per row ─────────────────────────────
|
|
println!("── Section 3: allocator calls per row (net of clone) ──");
|
|
let file_base_a = allocs_of(|| {
|
|
for f in &files {
|
|
black_box(f.clone());
|
|
}
|
|
}) as f64
|
|
/ rows as f64;
|
|
let file_before_a = allocs_of(|| {
|
|
for f in &files {
|
|
black_box(before::file_to_dto(f.clone()));
|
|
}
|
|
}) as f64
|
|
/ rows as f64
|
|
- file_base_a;
|
|
let file_after_a = allocs_of(|| {
|
|
for f in &files {
|
|
black_box(FileDto::from(f.clone()));
|
|
}
|
|
}) as f64
|
|
/ rows as f64
|
|
- file_base_a;
|
|
let folder_base_a = allocs_of(|| {
|
|
for f in &folders {
|
|
black_box(f.clone());
|
|
}
|
|
}) as f64
|
|
/ rows as f64;
|
|
let folder_before_a = allocs_of(|| {
|
|
for f in &folders {
|
|
black_box(before::folder_to_dto(f.clone()));
|
|
}
|
|
}) as f64
|
|
/ rows as f64
|
|
- folder_base_a;
|
|
let folder_after_a = allocs_of(|| {
|
|
for f in &folders {
|
|
black_box(FolderDto::from(f.clone()));
|
|
}
|
|
}) as f64
|
|
/ rows as f64
|
|
- folder_base_a;
|
|
println!(" file clone baseline: {file_base_a:6.2} allocs/row");
|
|
println!(" file BEFORE mapping: {file_before_a:6.2} allocs/row");
|
|
println!(" file AFTER mapping: {file_after_a:6.2} allocs/row");
|
|
println!(" folder clone baseline: {folder_base_a:6.2} allocs/row");
|
|
println!(" folder BEFORE mapping: {folder_before_a:6.2} allocs/row");
|
|
println!(" folder AFTER mapping: {folder_after_a:6.2} allocs/row\n");
|
|
|
|
// ── Section 4: equivalence gate ─────────────────────────────────────
|
|
println!("── Section 4: equivalence gate (BEFORE == AFTER, field by field) ──");
|
|
let mut diffs: u64 = 0;
|
|
for (i, f) in files.iter().enumerate() {
|
|
let b = before::file_to_dto(f.clone());
|
|
let a = FileDto::from(f.clone());
|
|
diff_file(i, &b, &a, &mut diffs);
|
|
}
|
|
for (i, f) in folders.iter().enumerate() {
|
|
let b = before::folder_to_dto(f.clone());
|
|
let a = FolderDto::from(f.clone());
|
|
diff_folder(i, &b, &a, &mut diffs);
|
|
}
|
|
if diffs > 0 {
|
|
println!(" FAILED: {diffs} field diffs between BEFORE and AFTER mappings");
|
|
std::process::exit(1);
|
|
}
|
|
println!(" PASSED: {rows} files + {rows} folders map byte-identically\n");
|
|
|
|
// ── Markdown summary ─────────────────────────────────────────────────
|
|
let table = [
|
|
Row {
|
|
variant: "File→FileDto BEFORE",
|
|
ns_per_row: file_before_ns,
|
|
allocs_per_row: file_before_a,
|
|
},
|
|
Row {
|
|
variant: "File→FileDto AFTER",
|
|
ns_per_row: file_after_ns,
|
|
allocs_per_row: file_after_a,
|
|
},
|
|
Row {
|
|
variant: "Folder→FolderDto BEFORE",
|
|
ns_per_row: folder_before_ns,
|
|
allocs_per_row: folder_before_a,
|
|
},
|
|
Row {
|
|
variant: "Folder→FolderDto AFTER",
|
|
ns_per_row: folder_after_ns,
|
|
allocs_per_row: folder_after_a,
|
|
},
|
|
];
|
|
println!("| variant | ns/row | allocs/row |");
|
|
println!("|---|---:|---:|");
|
|
for r in &table {
|
|
println!(
|
|
"| {} | {:.1} | {:.2} |",
|
|
r.variant, r.ns_per_row, r.allocs_per_row
|
|
);
|
|
}
|
|
}
|