Files
Oxicloud/examples/bench_dto_map.rs
T
Claude cd4c62042a perf: keyset/LATERAL SQL shapes, auth+blob-cache single-flight, spool buffers, DTO interning
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
2026-07-17 11:10:27 +00:00

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
);
}
}