From 9f5e41bddf6049cbff04111d51aab36e386498ae Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 23:09:42 +0000 Subject: [PATCH 01/12] Add Reader streaming benchmarks (10k and 100k rows) https://claude.ai/code/session_015wqo8AZuJGkKfrojR9xgBN --- benches/parse.rs | 36 +++++++++++++++++++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/benches/parse.rs b/benches/parse.rs index e7b7b14..2e08c21 100644 --- a/benches/parse.rs +++ b/benches/parse.rs @@ -1,5 +1,5 @@ use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId}; -use nsv::{encode, decode, decode_bytes, decode_bytes_projected}; +use nsv::{encode, decode, decode_bytes, decode_bytes_projected, Reader}; fn generate_test_data(rows: usize, cells_per_row: usize) -> Vec> { (0..rows) @@ -151,6 +151,38 @@ fn bench_projection_wide(c: &mut Criterion) { group.finish(); } +// ── Reader (streaming) benchmarks ──────────────────────────────────── + +fn bench_reader_10k(c: &mut Criterion) { + let data = generate_test_data(10_000, 10); + let nsv = encode(&data); + let nsv_bytes = nsv.as_bytes(); + + c.bench_function("reader_10k_rows", |b| { + b.iter(|| { + let mut reader = Reader::new(std::io::Cursor::new(black_box(nsv_bytes))); + while let Some(row) = reader.next_row().unwrap() { + black_box(row); + } + }) + }); +} + +fn bench_reader_100k(c: &mut Criterion) { + let data = generate_test_data(100_000, 10); + let nsv = encode(&data); + let nsv_bytes = nsv.as_bytes(); + + c.bench_function("reader_100k_rows", |b| { + b.iter(|| { + let mut reader = Reader::new(std::io::Cursor::new(black_box(nsv_bytes))); + while let Some(row) = reader.next_row().unwrap() { + black_box(row); + } + }) + }); +} + criterion_group!( benches, bench_loads_small, @@ -162,5 +194,7 @@ criterion_group!( bench_projection_10k, bench_projection_100k, bench_projection_wide, + bench_reader_10k, + bench_reader_100k, ); criterion_main!(benches); From da8857aeb5c6254f7cbf8a999b179f563366bc5f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 23:43:09 +0000 Subject: [PATCH 02/12] Add threshold benchmark probing parallel crossover point MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sweeps 8KB–1MB with mixed realistic data (escapes, empty cells, newlines, backslashes) to find where rayon parallelism breaks even. Current finding: 64KB threshold is too aggressive — rayon is a net loss until ~256KB on 2-core hardware. https://claude.ai/code/session_015wqo8AZuJGkKfrojR9xgBN --- Cargo.toml | 4 +++ benches/threshold.rs | 85 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 benches/threshold.rs diff --git a/Cargo.toml b/Cargo.toml index f63a016..613cfcc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,7 @@ harness = false [[bench]] name = "comparison" harness = false + +[[bench]] +name = "threshold" +harness = false diff --git a/benches/threshold.rs b/benches/threshold.rs new file mode 100644 index 0000000..8883d1e --- /dev/null +++ b/benches/threshold.rs @@ -0,0 +1,85 @@ +use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId}; +use nsv::{encode, decode_bytes}; + +fn generate_mixed_data(rows: usize, cols: usize) -> Vec> { + let patterns: &[&str] = &[ + "John Smith", + "john.smith@example.com", + "2024-03-15", + "1234.56", + r"C:\Users\Documents\file.txt", + "Line one\nLine two\nLine three", + "", + "Normal cell with no special chars at all", + r#"{"key": "value", "n": 42}"#, + "Springfield, IL 62704", + ]; + (0..rows) + .map(|i| { + (0..cols) + .map(|j| { + let idx = (i * cols + j) % patterns.len(); + if patterns[idx].is_empty() { + String::new() + } else { + format!("{} [{}]", patterns[idx], i) + } + }) + .collect() + }) + .collect() +} + +fn make_nsv_near_size(target_bytes: usize) -> (Vec, usize, usize) { + let cols = 8; + let mut rows = 1; + loop { + let data = generate_mixed_data(rows, cols); + let nsv = encode(&data); + let size = nsv.len(); + if size >= target_bytes { + return (nsv.into_bytes(), rows, size); + } + rows = (rows as f64 * (target_bytes as f64 / size as f64).max(1.1)) as usize; + } +} + +fn bench_threshold(c: &mut Criterion) { + let mut group = c.benchmark_group("threshold"); + group.sample_size(50); + + let targets = [ + 8 * 1024, + 16 * 1024, + 32 * 1024, + 48 * 1024, + 64 * 1024, + 96 * 1024, + 128 * 1024, + 256 * 1024, + 512 * 1024, + 1024 * 1024, + ]; + + println!(); + for &target in &targets { + let (nsv_bytes, rows, actual_size) = make_nsv_near_size(target); + let label = if actual_size >= 1024 * 1024 { + format!("{:.0}MB_{}r", actual_size as f64 / (1024.0 * 1024.0), rows) + } else { + format!("{}KB_{}r", actual_size / 1024, rows) + }; + println!(" {} → {} bytes, {} rows x 8 cols", label, actual_size, rows); + + group.bench_with_input( + BenchmarkId::new("decode_bytes", &label), + &nsv_bytes, + |b, data| b.iter(|| decode_bytes(black_box(data))), + ); + } + + group.finish(); +} + +criterion_group!(benches, bench_threshold); +criterion_main!(benches); From 37342ca9c6e1b0e65fc134b93b67eb4d0bb7a9af Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 23:50:02 +0000 Subject: [PATCH 03/12] Add parallel breakdown diagnostic example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instruments the parallel decode path to measure split-point scanning, rayon task scheduling, per-chunk parsing, and result concatenation independently. Findings: rayon scheduling is ~110µs fixed cost, concatenation scales linearly with output size. https://claude.ai/code/session_015wqo8AZuJGkKfrojR9xgBN --- examples/bench_breakdown.rs | 196 ++++++++++++++++++++++++++++++++++++ 1 file changed, 196 insertions(+) create mode 100644 examples/bench_breakdown.rs diff --git a/examples/bench_breakdown.rs b/examples/bench_breakdown.rs new file mode 100644 index 0000000..b6117f4 --- /dev/null +++ b/examples/bench_breakdown.rs @@ -0,0 +1,196 @@ +use nsv::decode_bytes; +use std::time::Instant; +use std::borrow::Cow; +use memchr::memmem; + +fn generate_mixed_data(rows: usize, cols: usize) -> Vec> { + let patterns: &[&str] = &[ + "John Smith", + "john.smith@example.com", + "2024-03-15", + "1234.56", + r"C:\Users\Documents\file.txt", + "Line one\nLine two\nLine three", + "", + "Normal cell with no special chars at all", + r#"{"key": "value", "n": 42}"#, + "Springfield, IL 62704", + ]; + (0..rows) + .map(|i| { + (0..cols) + .map(|j| { + let idx = (i * cols + j) % patterns.len(); + if patterns[idx].is_empty() { + String::new() + } else { + format!("{} [{}]", patterns[idx], i) + } + }) + .collect() + }) + .collect() +} + +fn make_nsv_near_size(target_bytes: usize) -> Vec { + let cols = 8; + let mut rows = 1; + loop { + let data = generate_mixed_data(rows, cols); + let nsv = nsv::encode(&data); + if nsv.len() >= target_bytes { + return nsv.into_bytes(); + } + rows = (rows as f64 * (target_bytes as f64 / nsv.len() as f64).max(1.1)) as usize; + } +} + +fn unescape_bytes(s: &[u8]) -> Cow<'_, [u8]> { + if s == b"\\" { + return Cow::Owned(Vec::new()); + } + if !s.contains(&b'\\') { + return Cow::Borrowed(s); + } + let mut out = Vec::with_capacity(s.len()); + let mut escaped = false; + for &b in s { + if escaped { + match b { + b'n' => out.push(b'\n'), + b'\\' => out.push(b'\\'), + _ => { out.push(b'\\'); out.push(b); } + } + escaped = false; + } else if b == b'\\' { + escaped = true; + } else { + out.push(b); + } + } + Cow::Owned(out) +} + +fn decode_seq<'a>(input: &'a [u8]) -> Vec>> { + let mut data = Vec::new(); + let mut row: Vec> = Vec::new(); + let mut start = 0; + for (pos, &b) in input.iter().enumerate() { + if b == b'\n' { + if pos > start { + row.push(unescape_bytes(&input[start..pos])); + } else { + data.push(row); + row = Vec::new(); + } + start = pos + 1; + } + } + if start < input.len() { + row.push(unescape_bytes(&input[start..])); + } + if !row.is_empty() { + data.push(row); + } + data +} + +fn median(v: &mut Vec) -> f64 { + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + v[v.len() / 2] +} + +fn bench_n(n: usize, mut f: impl FnMut()) -> f64 { + // warmup + for _ in 0..n/4 { f(); } + let mut times = Vec::with_capacity(n); + for _ in 0..n { + let t = Instant::now(); + f(); + times.push(t.elapsed().as_nanos() as f64 / 1000.0); // µs + } + median(&mut times) +} + +fn main() { + let num_threads = rayon::current_num_threads(); + println!("rayon threads: {}", num_threads); + println!(); + + for &target in &[64*1024, 128*1024, 256*1024, 512*1024] { + let input = make_nsv_near_size(target); + let size = input.len(); + println!("=== {}KB ({} bytes) ===", size / 1024, size); + + // 1. Full sequential decode + let t_seq = bench_n(200, || { let _ = std::hint::black_box(decode_seq(&input)); }); + println!(" sequential total: {:.1}µs", t_seq); + + // 2. Split point finding only + let chunk_size = size / num_threads; + let t_split = bench_n(2000, || { + let finder = memmem::Finder::new(b"\n\n"); + let mut splits = Vec::with_capacity(num_threads + 1); + splits.push(0usize); + for i in 1..num_threads { + let nominal = i * chunk_size; + if let Some(offset) = finder.find(&input[nominal..]) { + let split = nominal + offset + 2; + if split < size { splits.push(split); } + } + } + splits.push(size); + splits.dedup(); + std::hint::black_box(splits); + }); + println!(" split point scan: {:.1}µs", t_split); + + // 3. Sequential parse of each chunk (serially, to measure pure parse cost without rayon) + let finder = memmem::Finder::new(b"\n\n"); + let mut splits = Vec::with_capacity(num_threads + 1); + splits.push(0usize); + for i in 1..num_threads { + let nominal = i * chunk_size; + if let Some(offset) = finder.find(&input[nominal..]) { + let split = nominal + offset + 2; + if split < size { splits.push(split); } + } + } + splits.push(size); + splits.dedup(); + let chunks: Vec<&[u8]> = splits.windows(2).map(|w| &input[w[0]..w[1]]).collect(); + + let t_chunks_serial = bench_n(200, || { + let results: Vec<_> = chunks.iter().map(|c| decode_seq(c)).collect(); + std::hint::black_box(results); + }); + println!(" chunks parsed serially: {:.1}µs ({} chunks)", t_chunks_serial, chunks.len()); + + // 4. Chunks parsed with rayon par_iter + let t_chunks_par = bench_n(200, || { + use rayon::prelude::*; + let results: Vec<_> = chunks.par_iter().map(|c| decode_seq(c)).collect(); + std::hint::black_box(results); + }); + println!(" chunks parsed rayon: {:.1}µs", t_chunks_par); + + // 5. Concatenation cost only (parse once, measure merge) + let pre_parsed: Vec>>> = chunks.iter().map(|c| decode_seq(c)).collect(); + let t_concat = bench_n(2000, || { + let total_rows: usize = pre_parsed.iter().map(|r| r.len()).sum(); + let mut result = Vec::with_capacity(total_rows); + for chunk_rows in &pre_parsed { + result.extend(chunk_rows.iter().map(|row| row.clone())); + } + std::hint::black_box(result); + }); + println!(" concatenation: {:.1}µs", t_concat); + + // 6. Full parallel path via library + let t_par = bench_n(200, || { let _ = std::hint::black_box(decode_bytes(&input)); }); + println!(" library decode_bytes: {:.1}µs", t_par); + + println!(" par/seq ratio: {:.2}x", t_seq / t_par); + println!(); + } +} From 856bf3ca8adc5bb6ebe67e6e3265c4c1e3efa8cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 19 Jun 2026 23:57:27 +0000 Subject: [PATCH 04/12] Rewrite breakdown to use library's own decode_bytes Previous version reimplemented internal functions, producing measurements of different code. Now calls only the public API compiled with/without the parallel feature to get honest numbers. https://claude.ai/code/session_015wqo8AZuJGkKfrojR9xgBN --- examples/bench_breakdown.rs | 164 ++++++++---------------------------- 1 file changed, 33 insertions(+), 131 deletions(-) diff --git a/examples/bench_breakdown.rs b/examples/bench_breakdown.rs index b6117f4..1a41540 100644 --- a/examples/bench_breakdown.rs +++ b/examples/bench_breakdown.rs @@ -1,7 +1,4 @@ -use nsv::decode_bytes; use std::time::Instant; -use std::borrow::Cow; -use memchr::memmem; fn generate_mixed_data(rows: usize, cols: usize) -> Vec> { let patterns: &[&str] = &[ @@ -45,152 +42,57 @@ fn make_nsv_near_size(target_bytes: usize) -> Vec { } } -fn unescape_bytes(s: &[u8]) -> Cow<'_, [u8]> { - if s == b"\\" { - return Cow::Owned(Vec::new()); - } - if !s.contains(&b'\\') { - return Cow::Borrowed(s); - } - let mut out = Vec::with_capacity(s.len()); - let mut escaped = false; - for &b in s { - if escaped { - match b { - b'n' => out.push(b'\n'), - b'\\' => out.push(b'\\'), - _ => { out.push(b'\\'); out.push(b); } - } - escaped = false; - } else if b == b'\\' { - escaped = true; - } else { - out.push(b); - } - } - Cow::Owned(out) -} - -fn decode_seq<'a>(input: &'a [u8]) -> Vec>> { - let mut data = Vec::new(); - let mut row: Vec> = Vec::new(); - let mut start = 0; - for (pos, &b) in input.iter().enumerate() { - if b == b'\n' { - if pos > start { - row.push(unescape_bytes(&input[start..pos])); - } else { - data.push(row); - row = Vec::new(); - } - start = pos + 1; - } - } - if start < input.len() { - row.push(unescape_bytes(&input[start..])); - } - if !row.is_empty() { - data.push(row); - } - data -} - fn median(v: &mut Vec) -> f64 { v.sort_by(|a, b| a.partial_cmp(b).unwrap()); v[v.len() / 2] } fn bench_n(n: usize, mut f: impl FnMut()) -> f64 { - // warmup - for _ in 0..n/4 { f(); } + for _ in 0..n / 4 { + f(); + } let mut times = Vec::with_capacity(n); for _ in 0..n { let t = Instant::now(); f(); - times.push(t.elapsed().as_nanos() as f64 / 1000.0); // µs + times.push(t.elapsed().as_nanos() as f64 / 1000.0); } median(&mut times) } fn main() { - let num_threads = rayon::current_num_threads(); - println!("rayon threads: {}", num_threads); - println!(); + #[cfg(feature = "parallel")] + println!("parallel feature: ON (rayon threads: {})", rayon::current_num_threads()); + #[cfg(not(feature = "parallel"))] + println!("parallel feature: OFF (sequential only)"); - for &target in &[64*1024, 128*1024, 256*1024, 512*1024] { + println!(); + println!("{:>8} {:>10} {:>10}", "size", "decode_bytes", "µs/KB"); + println!("{:>8} {:>10} {:>10}", "----", "----------", "-----"); + + for &target in &[ + 8 * 1024, + 16 * 1024, + 32 * 1024, + 48 * 1024, + 64 * 1024, + 96 * 1024, + 128 * 1024, + 192 * 1024, + 256 * 1024, + 384 * 1024, + 512 * 1024, + 768 * 1024, + 1024 * 1024, + 2 * 1024 * 1024, + 4 * 1024 * 1024, + ] { let input = make_nsv_near_size(target); let size = input.len(); - println!("=== {}KB ({} bytes) ===", size / 1024, size); - - // 1. Full sequential decode - let t_seq = bench_n(200, || { let _ = std::hint::black_box(decode_seq(&input)); }); - println!(" sequential total: {:.1}µs", t_seq); - - // 2. Split point finding only - let chunk_size = size / num_threads; - let t_split = bench_n(2000, || { - let finder = memmem::Finder::new(b"\n\n"); - let mut splits = Vec::with_capacity(num_threads + 1); - splits.push(0usize); - for i in 1..num_threads { - let nominal = i * chunk_size; - if let Some(offset) = finder.find(&input[nominal..]) { - let split = nominal + offset + 2; - if split < size { splits.push(split); } - } - } - splits.push(size); - splits.dedup(); - std::hint::black_box(splits); - }); - println!(" split point scan: {:.1}µs", t_split); - - // 3. Sequential parse of each chunk (serially, to measure pure parse cost without rayon) - let finder = memmem::Finder::new(b"\n\n"); - let mut splits = Vec::with_capacity(num_threads + 1); - splits.push(0usize); - for i in 1..num_threads { - let nominal = i * chunk_size; - if let Some(offset) = finder.find(&input[nominal..]) { - let split = nominal + offset + 2; - if split < size { splits.push(split); } - } - } - splits.push(size); - splits.dedup(); - let chunks: Vec<&[u8]> = splits.windows(2).map(|w| &input[w[0]..w[1]]).collect(); - - let t_chunks_serial = bench_n(200, || { - let results: Vec<_> = chunks.iter().map(|c| decode_seq(c)).collect(); - std::hint::black_box(results); - }); - println!(" chunks parsed serially: {:.1}µs ({} chunks)", t_chunks_serial, chunks.len()); - - // 4. Chunks parsed with rayon par_iter - let t_chunks_par = bench_n(200, || { - use rayon::prelude::*; - let results: Vec<_> = chunks.par_iter().map(|c| decode_seq(c)).collect(); - std::hint::black_box(results); + let t = bench_n(200, || { + let _ = std::hint::black_box(nsv::decode_bytes(std::hint::black_box(&input))); }); - println!(" chunks parsed rayon: {:.1}µs", t_chunks_par); - - // 5. Concatenation cost only (parse once, measure merge) - let pre_parsed: Vec>>> = chunks.iter().map(|c| decode_seq(c)).collect(); - let t_concat = bench_n(2000, || { - let total_rows: usize = pre_parsed.iter().map(|r| r.len()).sum(); - let mut result = Vec::with_capacity(total_rows); - for chunk_rows in &pre_parsed { - result.extend(chunk_rows.iter().map(|row| row.clone())); - } - std::hint::black_box(result); - }); - println!(" concatenation: {:.1}µs", t_concat); - - // 6. Full parallel path via library - let t_par = bench_n(200, || { let _ = std::hint::black_box(decode_bytes(&input)); }); - println!(" library decode_bytes: {:.1}µs", t_par); - - println!(" par/seq ratio: {:.2}x", t_seq / t_par); - println!(); + let kb = size as f64 / 1024.0; + println!("{:>7.0}KB {:>9.1}µs {:>9.2}", kb, t, t / kb); } } From d7710bb0ddebb781e4e84b967ddf0141832cc39c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 08:01:53 +0000 Subject: [PATCH 05/12] Extend breakdown to larger files (up to 128MB) and all thread counts https://claude.ai/code/session_015wqo8AZuJGkKfrojR9xgBN --- examples/bench_breakdown.rs | 35 ++++++++++++++++++++--------------- 1 file changed, 20 insertions(+), 15 deletions(-) diff --git a/examples/bench_breakdown.rs b/examples/bench_breakdown.rs index 1a41540..076eb18 100644 --- a/examples/bench_breakdown.rs +++ b/examples/bench_breakdown.rs @@ -70,29 +70,34 @@ fn main() { println!("{:>8} {:>10} {:>10}", "size", "decode_bytes", "µs/KB"); println!("{:>8} {:>10} {:>10}", "----", "----------", "-----"); - for &target in &[ - 8 * 1024, - 16 * 1024, - 32 * 1024, - 48 * 1024, + let targets: Vec = vec![ 64 * 1024, - 96 * 1024, 128 * 1024, - 192 * 1024, 256 * 1024, - 384 * 1024, 512 * 1024, - 768 * 1024, 1024 * 1024, 2 * 1024 * 1024, 4 * 1024 * 1024, - ] { - let input = make_nsv_near_size(target); + 8 * 1024 * 1024, + 16 * 1024 * 1024, + 32 * 1024 * 1024, + 64 * 1024 * 1024, + 128 * 1024 * 1024, + ]; + + // Pre-generate all inputs so allocation isn't measured + let inputs: Vec> = targets.iter().map(|&t| make_nsv_near_size(t)).collect(); + + for input in &inputs { let size = input.len(); - let t = bench_n(200, || { - let _ = std::hint::black_box(nsv::decode_bytes(std::hint::black_box(&input))); + let iters = if size > 32 * 1024 * 1024 { 10 } else if size > 4 * 1024 * 1024 { 30 } else { 100 }; + let t = bench_n(iters, || { + let _ = std::hint::black_box(nsv::decode_bytes(std::hint::black_box(input))); }); - let kb = size as f64 / 1024.0; - println!("{:>7.0}KB {:>9.1}µs {:>9.2}", kb, t, t / kb); + if size >= 1024 * 1024 { + println!("{:>6.0}MB {:>11.1}µs {:>9.2}", size as f64 / (1024.0 * 1024.0), t, t / (size as f64 / 1024.0)); + } else { + println!("{:>5.0}KB {:>11.1}µs {:>9.2}", size as f64 / 1024.0, t, t / (size as f64 / 1024.0)); + } } } From b0bc8a8f9ff6df93bf3e50c138974c7983680f8d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 08:47:53 +0000 Subject: [PATCH 06/12] Raise parallel threshold to 256KB; use memchr in sequential decode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Threshold: benchmarks show 64KB was too aggressive — rayon is a net loss until ~200KB. 256KB is safe for 2-4 thread counts. memchr: replace byte-by-byte newline scan with SIMD-accelerated memchr::memchr in both decode_bytes_sequential and decode_projected_sequential. ~15-19% improvement on sequential path. Also scale up parallel test data to exceed the new threshold. https://claude.ai/code/session_015wqo8AZuJGkKfrojR9xgBN --- src/lib.rs | 63 ++++++++++++++++++++++++++---------------------------- 1 file changed, 30 insertions(+), 33 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index e09ad25..f24e3b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,7 +28,7 @@ use std::io::{self, Read, Write}; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); /// Threshold for using parallel parsing (64KB) -const PARALLEL_THRESHOLD: usize = 64 * 1024; +const PARALLEL_THRESHOLD: usize = 256 * 1024; /// Decode an NSV string into a seqseq. pub fn decode(s: &str) -> Vec> { @@ -65,22 +65,22 @@ pub fn decode_bytes<'a>(input: &'a [u8]) -> Vec>> { decode_bytes_sequential(input) } -/// Sequential implementation for small inputs (byte-level). +/// Sequential implementation (byte-level). Uses memchr for SIMD-accelerated +/// newline scanning instead of a byte-by-byte loop. fn decode_bytes_sequential<'a>(input: &'a [u8]) -> Vec>> { let mut data = Vec::new(); let mut row: Vec> = Vec::new(); let mut start = 0; - for (pos, &b) in input.iter().enumerate() { - if b == b'\n' { - if pos > start { - row.push(unescape_bytes(&input[start..pos])); - } else { - data.push(row); - row = Vec::new(); - } - start = pos + 1; + while let Some(offset) = memchr::memchr(b'\n', &input[start..]) { + let pos = start + offset; + if pos > start { + row.push(unescape_bytes(&input[start..pos])); + } else { + data.push(row); + row = Vec::new(); } + start = pos + 1; } if start < input.len() { @@ -292,28 +292,27 @@ fn decode_projected_sequential<'a>(input: &'a [u8], columns: &[usize]) -> Vec start { - if col_idx <= max_col { - if let Some(&proj_idx) = col_map.get(col_idx) { - if proj_idx != usize::MAX { - row[proj_idx] = unescape_bytes(&input[start..pos]); - } + while let Some(offset) = memchr::memchr(b'\n', &input[start..]) { + let pos = start + offset; + if pos > start { + if col_idx <= max_col { + if let Some(&proj_idx) = col_map.get(col_idx) { + if proj_idx != usize::MAX { + row[proj_idx] = unescape_bytes(&input[start..pos]); } } - col_idx += 1; - row_has_cells = true; - } else { - if row_has_cells || !data.is_empty() || col_idx == 0 { - data.push(row); - row = vec![Cow::Borrowed(b""); stride]; - } - col_idx = 0; - row_has_cells = false; } - start = pos + 1; + col_idx += 1; + row_has_cells = true; + } else { + if row_has_cells || !data.is_empty() || col_idx == 0 { + data.push(row); + row = vec![Cow::Borrowed(b""); stride]; + } + col_idx = 0; + row_has_cells = false; } + start = pos + 1; } if start < input.len() { @@ -759,11 +758,9 @@ mod tests { // Test parallel path with empty rows mixed in let mut data = Vec::new(); - // Create enough data to exceed 64KB threshold - for i in 0..10_000 { + for i in 0..40_000 { data.push(vec![format!("value{}", i)]); - // Add empty row every 100 rows if i % 100 == 0 { data.push(vec![]); } @@ -781,7 +778,7 @@ mod tests { // Test parallel path with cells containing escape sequences let mut data = Vec::new(); - for i in 0..10_000 { + for i in 0..20_000 { data.push(vec![ format!("Line 1\nLine 2 {}", i), format!("Backslash: \\ {}", i), From c1ed048665876b2360907bb442767e5093a5abbf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 08:59:38 +0000 Subject: [PATCH 07/12] Add arena and two-pass benchmarks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Arena (native type): 25-30% faster than current decode, eliminating per-cell malloc via bump allocation. But converting back to Vec> erases the win — arenas only pay off with a different return type. Two-pass: no improvement on full decode. Pass 1 (boundary scan) is fast at ~0.4 µs/KB but the index overhead negates savings. https://claude.ai/code/session_015wqo8AZuJGkKfrojR9xgBN --- examples/bench_arena_twopass.rs | 278 ++++++++++++++++++++++++++++++++ 1 file changed, 278 insertions(+) create mode 100644 examples/bench_arena_twopass.rs diff --git a/examples/bench_arena_twopass.rs b/examples/bench_arena_twopass.rs new file mode 100644 index 0000000..d83f6af --- /dev/null +++ b/examples/bench_arena_twopass.rs @@ -0,0 +1,278 @@ +use std::borrow::Cow; +use std::time::Instant; + +fn generate_mixed_data(rows: usize, cols: usize) -> Vec> { + let patterns: &[&str] = &[ + "John Smith", + "john.smith@example.com", + "2024-03-15", + "1234.56", + r"C:\Users\Documents\file.txt", + "Line one\nLine two\nLine three", + "", + "Normal cell with no special chars at all", + r#"{"key": "value", "n": 42}"#, + "Springfield, IL 62704", + ]; + (0..rows) + .map(|i| { + (0..cols) + .map(|j| { + let idx = (i * cols + j) % patterns.len(); + if patterns[idx].is_empty() { + String::new() + } else { + format!("{} [{}]", patterns[idx], i) + } + }) + .collect() + }) + .collect() +} + +fn make_nsv_near_size(target_bytes: usize) -> Vec { + let cols = 8; + let mut rows = 1; + loop { + let data = generate_mixed_data(rows, cols); + let nsv = nsv::encode(&data); + if nsv.len() >= target_bytes { + return nsv.into_bytes(); + } + rows = (rows as f64 * (target_bytes as f64 / nsv.len() as f64).max(1.1)) as usize; + } +} + +fn median(v: &mut Vec) -> f64 { + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + v[v.len() / 2] +} + +fn bench_n(n: usize, mut f: impl FnMut()) -> f64 { + for _ in 0..n / 4 { f(); } + let mut times = Vec::with_capacity(n); + for _ in 0..n { + let t = Instant::now(); + f(); + times.push(t.elapsed().as_nanos() as f64 / 1000.0); + } + median(&mut times) +} + +// ── Arena decode ───────────────────────────────────────────────────── +// +// Cells are either slices of the input (no unescaping) or slices of a +// bump-allocated arena (unescaped). One big allocation instead of +// per-cell mallocs. + +#[derive(Clone, Copy)] +enum CellRef { + Input(u32, u32), + Arena(u32, u32), +} + +struct ArenaRows { + arena: Vec, + row_offsets: Vec, + cells: Vec, +} + +fn unescape_into(s: &[u8], arena: &mut Vec) { + if s == b"\\" { + return; + } + let mut escaped = false; + for &b in s { + if escaped { + match b { + b'n' => arena.push(b'\n'), + b'\\' => arena.push(b'\\'), + _ => { arena.push(b'\\'); arena.push(b); } + } + escaped = false; + } else if b == b'\\' { + escaped = true; + } else { + arena.push(b); + } + } +} + +fn decode_arena(input: &[u8]) -> ArenaRows { + let mut arena = Vec::with_capacity(input.len() / 4); + let mut row_offsets: Vec = Vec::new(); + let mut cells: Vec = Vec::new(); + let mut start = 0; + let mut row_start: u32 = 0; + + while let Some(offset) = memchr::memchr(b'\n', &input[start..]) { + let pos = start + offset; + if pos > start { + let cell = &input[start..pos]; + if !cell.contains(&b'\\') { + cells.push(CellRef::Input(start as u32, pos as u32)); + } else { + let arena_start = arena.len() as u32; + unescape_into(cell, &mut arena); + cells.push(CellRef::Arena(arena_start, arena.len() as u32)); + } + } else { + row_offsets.push(row_start); + row_start = cells.len() as u32; + } + start = pos + 1; + } + + if start < input.len() { + let cell = &input[start..]; + if !cell.contains(&b'\\') { + cells.push(CellRef::Input(start as u32, input.len() as u32)); + } else { + let arena_start = arena.len() as u32; + unescape_into(cell, &mut arena); + cells.push(CellRef::Arena(arena_start, arena.len() as u32)); + } + } + + if cells.len() as u32 > row_start { + row_offsets.push(row_start); + } + + ArenaRows { arena, row_offsets, cells } +} + +fn arena_to_cow<'a>(ar: &ArenaRows, input: &'a [u8]) -> Vec>> { + let num_rows = ar.row_offsets.len(); + let mut result = Vec::with_capacity(num_rows); + + for (i, &offset) in ar.row_offsets.iter().enumerate() { + let start = offset as usize; + let end = if i + 1 < num_rows { ar.row_offsets[i + 1] as usize } else { ar.cells.len() }; + let mut row = Vec::with_capacity(end - start); + for &cell in &ar.cells[start..end] { + match cell { + CellRef::Input(s, e) => row.push(Cow::Borrowed(&input[s as usize..e as usize])), + CellRef::Arena(s, e) => row.push(Cow::Owned(ar.arena[s as usize..e as usize].to_vec())), + } + } + result.push(row); + } + result +} + +// ── Two-pass decode ────────────────────────────────────────────────── +// +// Pass 1: scan for cell/row boundaries (offsets only, no data work). +// Pass 2: unescape each cell. + +struct CellBoundary { + start: u32, + end: u32, +} + +struct RowIndex { + boundaries: Vec, + row_offsets: Vec, +} + +fn pass1_scan(input: &[u8]) -> RowIndex { + let mut boundaries = Vec::new(); + let mut row_offsets: Vec = Vec::new(); + let mut start = 0; + let mut row_start: u32 = 0; + + while let Some(offset) = memchr::memchr(b'\n', &input[start..]) { + let pos = start + offset; + if pos > start { + boundaries.push(CellBoundary { start: start as u32, end: pos as u32 }); + } else { + row_offsets.push(row_start); + row_start = boundaries.len() as u32; + } + start = pos + 1; + } + + if start < input.len() { + boundaries.push(CellBoundary { start: start as u32, end: input.len() as u32 }); + } + + if boundaries.len() as u32 > row_start { + row_offsets.push(row_start); + } + + RowIndex { boundaries, row_offsets } +} + +fn pass2_unescape<'a>(input: &'a [u8], idx: &RowIndex) -> Vec>> { + let num_rows = idx.row_offsets.len(); + let mut result = Vec::with_capacity(num_rows); + + for (i, &offset) in idx.row_offsets.iter().enumerate() { + let start = offset as usize; + let end = if i + 1 < num_rows { idx.row_offsets[i + 1] as usize } else { idx.boundaries.len() }; + let mut row = Vec::with_capacity(end - start); + for b in &idx.boundaries[start..end] { + row.push(nsv::unescape_bytes(&input[b.start as usize..b.end as usize])); + } + result.push(row); + } + result +} + +fn decode_two_pass<'a>(input: &'a [u8]) -> Vec>> { + let idx = pass1_scan(input); + pass2_unescape(input, &idx) +} + +// ── Main ───────────────────────────────────────────────────────────── + +fn main() { + println!("{:>8} {:>12} {:>12} {:>12} {:>12} {:>12}", + "size", "current", "arena", "arena+conv", "two-pass", "2p-pass1"); + println!("{:>8} {:>12} {:>12} {:>12} {:>12} {:>12}", + "----", "µs/KB", "µs/KB", "µs/KB", "µs/KB", "µs/KB"); + + for &target in &[ + 64 * 1024, + 256 * 1024, + 1024 * 1024, + 4 * 1024 * 1024, + 16 * 1024 * 1024, + 64 * 1024 * 1024, + ] { + let input = make_nsv_near_size(target); + let size = input.len(); + let kb = size as f64 / 1024.0; + let iters = if size > 16 * 1024 * 1024 { 20 } else if size > 4 * 1024 * 1024 { 40 } else { 100 }; + + let t_current = bench_n(iters, || { + let _ = std::hint::black_box(nsv::decode_bytes(std::hint::black_box(&input))); + }); + + let t_arena = bench_n(iters, || { + let _ = std::hint::black_box(decode_arena(std::hint::black_box(&input))); + }); + + let t_arena_conv = bench_n(iters, || { + let ar = decode_arena(std::hint::black_box(&input)); + let _ = std::hint::black_box(arena_to_cow(&ar, &input)); + }); + + let t_two_pass = bench_n(iters, || { + let _ = std::hint::black_box(decode_two_pass(std::hint::black_box(&input))); + }); + + let t_pass1 = bench_n(iters, || { + let _ = std::hint::black_box(pass1_scan(std::hint::black_box(&input))); + }); + + if size >= 1024 * 1024 { + println!("{:>6.0}MB {:>12.2} {:>12.2} {:>12.2} {:>12.2} {:>12.2}", + size as f64 / (1024.0 * 1024.0), + t_current / kb, t_arena / kb, t_arena_conv / kb, t_two_pass / kb, t_pass1 / kb); + } else { + println!("{:>5.0}KB {:>12.2} {:>12.2} {:>12.2} {:>12.2} {:>12.2}", + kb, t_current / kb, t_arena / kb, t_arena_conv / kb, t_two_pass / kb, t_pass1 / kb); + } + } +} From 7a46d8b705815bf53cc4b6967803ec0c76bc63ee Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 10:31:10 +0000 Subject: [PATCH 08/12] Add scan A/B benchmark across cell widths Isolates the newline-scan decision (byte-loop vs memchr), identical except the scan primitive. Shows memchr crossover at ~8-byte cells: a 10-18% regression on tiny cells, growing win above (3.7x at 128B). Confirms keeping memchr is right for realistic cell widths but it is a tradeoff, not a free win. https://claude.ai/code/session_015wqo8AZuJGkKfrojR9xgBN --- examples/bench_scan_ab.rs | 129 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 129 insertions(+) create mode 100644 examples/bench_scan_ab.rs diff --git a/examples/bench_scan_ab.rs b/examples/bench_scan_ab.rs new file mode 100644 index 0000000..641b2f0 --- /dev/null +++ b/examples/bench_scan_ab.rs @@ -0,0 +1,129 @@ +// Controlled A/B of the two newline-scanning strategies, identical except +// for the scan primitive. Both reproduce the library's decode logic exactly; +// the ONLY difference is byte-by-byte iteration vs memchr. This isolates the +// scanning decision across a range of cell widths (the variable memchr is +// sensitive to: short cells = short haystacks = more per-call overhead). + +use std::borrow::Cow; +use std::time::Instant; + +fn unescape_bytes(s: &[u8]) -> Cow<'_, [u8]> { + if s == b"\\" { + return Cow::Owned(Vec::new()); + } + if !s.contains(&b'\\') { + return Cow::Borrowed(s); + } + let mut out = Vec::with_capacity(s.len()); + let mut escaped = false; + for &b in s { + if escaped { + match b { + b'n' => out.push(b'\n'), + b'\\' => out.push(b'\\'), + _ => { out.push(b'\\'); out.push(b); } + } + escaped = false; + } else if b == b'\\' { + escaped = true; + } else { + out.push(b); + } + } + Cow::Owned(out) +} + +fn decode_byteloop<'a>(input: &'a [u8]) -> Vec>> { + let mut data = Vec::new(); + let mut row: Vec> = Vec::new(); + let mut start = 0; + for (pos, &b) in input.iter().enumerate() { + if b == b'\n' { + if pos > start { + row.push(unescape_bytes(&input[start..pos])); + } else { + data.push(row); + row = Vec::new(); + } + start = pos + 1; + } + } + if start < input.len() { + row.push(unescape_bytes(&input[start..])); + } + if !row.is_empty() { + data.push(row); + } + data +} + +fn decode_memchr<'a>(input: &'a [u8]) -> Vec>> { + let mut data = Vec::new(); + let mut row: Vec> = Vec::new(); + let mut start = 0; + while let Some(offset) = memchr::memchr(b'\n', &input[start..]) { + let pos = start + offset; + if pos > start { + row.push(unescape_bytes(&input[start..pos])); + } else { + data.push(row); + row = Vec::new(); + } + start = pos + 1; + } + if start < input.len() { + row.push(unescape_bytes(&input[start..])); + } + if !row.is_empty() { + data.push(row); + } + data +} + +// Generate NSV with a fixed cell width, no escapes (pure scan cost). +fn make_nsv_cellwidth(cell_width: usize, target_bytes: usize) -> Vec { + let cols = 8; + let cell: String = "x".repeat(cell_width); + let mut out = Vec::with_capacity(target_bytes + 1024); + while out.len() < target_bytes { + for _ in 0..cols { + out.extend_from_slice(cell.as_bytes()); + out.push(b'\n'); + } + out.push(b'\n'); + } + out +} + +fn median(v: &mut Vec) -> f64 { + v.sort_by(|a, b| a.partial_cmp(b).unwrap()); + v[v.len() / 2] +} + +fn bench_n(n: usize, mut f: impl FnMut()) -> f64 { + for _ in 0..n / 4 { f(); } + let mut times = Vec::with_capacity(n); + for _ in 0..n { + let t = Instant::now(); + f(); + times.push(t.elapsed().as_nanos() as f64 / 1000.0); + } + median(&mut times) +} + +fn main() { + let size = 1024 * 1024; // 1MB, sequential range + println!("1MB input, no escapes, varying cell width (µs/KB)\n"); + println!("{:>10} {:>10} {:>10} {:>8}", "cell_width", "byteloop", "memchr", "speedup"); + println!("{:>10} {:>10} {:>10} {:>8}", "----------", "--------", "------", "-------"); + + for &w in &[1usize, 2, 4, 8, 16, 32, 64, 128] { + let input = make_nsv_cellwidth(w, size); + let kb = input.len() as f64 / 1024.0; + + let t_loop = bench_n(200, || { let _ = std::hint::black_box(decode_byteloop(std::hint::black_box(&input))); }); + let t_mc = bench_n(200, || { let _ = std::hint::black_box(decode_memchr(std::hint::black_box(&input))); }); + + println!("{:>10} {:>10.3} {:>10.3} {:>7.2}x", w, t_loop / kb, t_mc / kb, t_loop / t_mc); + } +} From a32f2256f9dfc2f0379e0d6cf2c6d31e211516a6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 10:37:13 +0000 Subject: [PATCH 09/12] Allow scan A/B to benchmark a real fixture file MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass a path to compare byteloop vs memchr on real data. Champernowne fixtures (avg ~4.5 bytes/cell, heavy escaping) confirm the short-cell regression: memchr is 0.79x on the raw fixture, 0.96x on the fixed one — matching the synthetic sweep's sub-8-byte crossover. https://claude.ai/code/session_015wqo8AZuJGkKfrojR9xgBN --- examples/bench_scan_ab.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/examples/bench_scan_ab.rs b/examples/bench_scan_ab.rs index 641b2f0..cfffa4e 100644 --- a/examples/bench_scan_ab.rs +++ b/examples/bench_scan_ab.rs @@ -112,6 +112,32 @@ fn bench_n(n: usize, mut f: impl FnMut()) -> f64 { } fn main() { + // If a file path is given, benchmark both strategies on that real fixture. + let args: Vec = std::env::args().collect(); + if args.len() > 1 { + let path = &args[1]; + let input = std::fs::read(path).expect("read fixture"); + let kb = input.len() as f64 / 1024.0; + let mb = input.len() as f64 / (1024.0 * 1024.0); + println!("fixture: {} ({:.1}MB)\n", path, mb); + + // sanity: both must agree + let a = decode_byteloop(&input); + let b = decode_memchr(&input); + assert_eq!(a.len(), b.len(), "row count mismatch"); + println!("rows: {}", a.len()); + let total_cells: usize = a.iter().map(|r| r.len()).sum(); + println!("cells: {} (avg {:.2} bytes/cell across input)\n", + total_cells, input.len() as f64 / total_cells.max(1) as f64); + + let iters = 15; + let t_loop = bench_n(iters, || { let _ = std::hint::black_box(decode_byteloop(std::hint::black_box(&input))); }); + let t_mc = bench_n(iters, || { let _ = std::hint::black_box(decode_memchr(std::hint::black_box(&input))); }); + println!("{:>10} {:>10} {:>8}", "byteloop", "memchr", "speedup"); + println!("{:>9.3}µs/KB {:>8.3}µs/KB {:>7.2}x", t_loop / kb, t_mc / kb, t_loop / t_mc); + return; + } + let size = 1024 * 1024; // 1MB, sequential range println!("1MB input, no escapes, varying cell width (µs/KB)\n"); println!("{:>10} {:>10} {:>10} {:>8}", "cell_width", "byteloop", "memchr", "speedup"); From e0dd066e638717f17829394fc6cac68f4bfeea0a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 11:27:19 +0000 Subject: [PATCH 10/12] Revert memchr for newline scanning, keep byte-by-byte loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit memchr wins 15-19% on wide cells (>16 bytes) but loses up to 21% on short cells (<8 bytes). Numeric columns (1-5 bytes), boolean flags, and enum codes are common enough that the regression isn't acceptable. The byte loop doesn't degrade catastrophically anywhere. Threshold bump to 256KB is kept — that's a clean win independent of scan strategy. https://claude.ai/code/session_015wqo8AZuJGkKfrojR9xgBN --- src/lib.rs | 55 +++++++++++++++++++++++++++--------------------------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index f24e3b8..c8f7c8c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -65,22 +65,22 @@ pub fn decode_bytes<'a>(input: &'a [u8]) -> Vec>> { decode_bytes_sequential(input) } -/// Sequential implementation (byte-level). Uses memchr for SIMD-accelerated -/// newline scanning instead of a byte-by-byte loop. +/// Sequential implementation (byte-level). fn decode_bytes_sequential<'a>(input: &'a [u8]) -> Vec>> { let mut data = Vec::new(); let mut row: Vec> = Vec::new(); let mut start = 0; - while let Some(offset) = memchr::memchr(b'\n', &input[start..]) { - let pos = start + offset; - if pos > start { - row.push(unescape_bytes(&input[start..pos])); - } else { - data.push(row); - row = Vec::new(); + for (pos, &b) in input.iter().enumerate() { + if b == b'\n' { + if pos > start { + row.push(unescape_bytes(&input[start..pos])); + } else { + data.push(row); + row = Vec::new(); + } + start = pos + 1; } - start = pos + 1; } if start < input.len() { @@ -292,27 +292,28 @@ fn decode_projected_sequential<'a>(input: &'a [u8], columns: &[usize]) -> Vec start { - if col_idx <= max_col { - if let Some(&proj_idx) = col_map.get(col_idx) { - if proj_idx != usize::MAX { - row[proj_idx] = unescape_bytes(&input[start..pos]); + for (pos, &b) in input.iter().enumerate() { + if b == b'\n' { + if pos > start { + if col_idx <= max_col { + if let Some(&proj_idx) = col_map.get(col_idx) { + if proj_idx != usize::MAX { + row[proj_idx] = unescape_bytes(&input[start..pos]); + } } } + col_idx += 1; + row_has_cells = true; + } else { + if row_has_cells || !data.is_empty() || col_idx == 0 { + data.push(row); + row = vec![Cow::Borrowed(b""); stride]; + } + col_idx = 0; + row_has_cells = false; } - col_idx += 1; - row_has_cells = true; - } else { - if row_has_cells || !data.is_empty() || col_idx == 0 { - data.push(row); - row = vec![Cow::Borrowed(b""); stride]; - } - col_idx = 0; - row_has_cells = false; + start = pos + 1; } - start = pos + 1; } if start < input.len() { From 08d19956edff40555fdc4ae26556edfd30390ad4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 11:30:30 +0000 Subject: [PATCH 11/12] Remove scratch diagnostic examples and threshold bench These tested hypotheses (arena, two-pass, memchr crossover, parallel threshold) that are now settled. Findings live in commit history. Keep only the library benchmarks (parse.rs, comparison.rs). https://claude.ai/code/session_015wqo8AZuJGkKfrojR9xgBN --- Cargo.toml | 4 - benches/threshold.rs | 85 ---------- examples/bench_arena_twopass.rs | 278 -------------------------------- examples/bench_breakdown.rs | 103 ------------ examples/bench_scan_ab.rs | 155 ------------------ 5 files changed, 625 deletions(-) delete mode 100644 benches/threshold.rs delete mode 100644 examples/bench_arena_twopass.rs delete mode 100644 examples/bench_breakdown.rs delete mode 100644 examples/bench_scan_ab.rs diff --git a/Cargo.toml b/Cargo.toml index 613cfcc..f63a016 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,7 +29,3 @@ harness = false [[bench]] name = "comparison" harness = false - -[[bench]] -name = "threshold" -harness = false diff --git a/benches/threshold.rs b/benches/threshold.rs deleted file mode 100644 index 8883d1e..0000000 --- a/benches/threshold.rs +++ /dev/null @@ -1,85 +0,0 @@ -use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId}; -use nsv::{encode, decode_bytes}; - -fn generate_mixed_data(rows: usize, cols: usize) -> Vec> { - let patterns: &[&str] = &[ - "John Smith", - "john.smith@example.com", - "2024-03-15", - "1234.56", - r"C:\Users\Documents\file.txt", - "Line one\nLine two\nLine three", - "", - "Normal cell with no special chars at all", - r#"{"key": "value", "n": 42}"#, - "Springfield, IL 62704", - ]; - (0..rows) - .map(|i| { - (0..cols) - .map(|j| { - let idx = (i * cols + j) % patterns.len(); - if patterns[idx].is_empty() { - String::new() - } else { - format!("{} [{}]", patterns[idx], i) - } - }) - .collect() - }) - .collect() -} - -fn make_nsv_near_size(target_bytes: usize) -> (Vec, usize, usize) { - let cols = 8; - let mut rows = 1; - loop { - let data = generate_mixed_data(rows, cols); - let nsv = encode(&data); - let size = nsv.len(); - if size >= target_bytes { - return (nsv.into_bytes(), rows, size); - } - rows = (rows as f64 * (target_bytes as f64 / size as f64).max(1.1)) as usize; - } -} - -fn bench_threshold(c: &mut Criterion) { - let mut group = c.benchmark_group("threshold"); - group.sample_size(50); - - let targets = [ - 8 * 1024, - 16 * 1024, - 32 * 1024, - 48 * 1024, - 64 * 1024, - 96 * 1024, - 128 * 1024, - 256 * 1024, - 512 * 1024, - 1024 * 1024, - ]; - - println!(); - for &target in &targets { - let (nsv_bytes, rows, actual_size) = make_nsv_near_size(target); - let label = if actual_size >= 1024 * 1024 { - format!("{:.0}MB_{}r", actual_size as f64 / (1024.0 * 1024.0), rows) - } else { - format!("{}KB_{}r", actual_size / 1024, rows) - }; - println!(" {} → {} bytes, {} rows x 8 cols", label, actual_size, rows); - - group.bench_with_input( - BenchmarkId::new("decode_bytes", &label), - &nsv_bytes, - |b, data| b.iter(|| decode_bytes(black_box(data))), - ); - } - - group.finish(); -} - -criterion_group!(benches, bench_threshold); -criterion_main!(benches); diff --git a/examples/bench_arena_twopass.rs b/examples/bench_arena_twopass.rs deleted file mode 100644 index d83f6af..0000000 --- a/examples/bench_arena_twopass.rs +++ /dev/null @@ -1,278 +0,0 @@ -use std::borrow::Cow; -use std::time::Instant; - -fn generate_mixed_data(rows: usize, cols: usize) -> Vec> { - let patterns: &[&str] = &[ - "John Smith", - "john.smith@example.com", - "2024-03-15", - "1234.56", - r"C:\Users\Documents\file.txt", - "Line one\nLine two\nLine three", - "", - "Normal cell with no special chars at all", - r#"{"key": "value", "n": 42}"#, - "Springfield, IL 62704", - ]; - (0..rows) - .map(|i| { - (0..cols) - .map(|j| { - let idx = (i * cols + j) % patterns.len(); - if patterns[idx].is_empty() { - String::new() - } else { - format!("{} [{}]", patterns[idx], i) - } - }) - .collect() - }) - .collect() -} - -fn make_nsv_near_size(target_bytes: usize) -> Vec { - let cols = 8; - let mut rows = 1; - loop { - let data = generate_mixed_data(rows, cols); - let nsv = nsv::encode(&data); - if nsv.len() >= target_bytes { - return nsv.into_bytes(); - } - rows = (rows as f64 * (target_bytes as f64 / nsv.len() as f64).max(1.1)) as usize; - } -} - -fn median(v: &mut Vec) -> f64 { - v.sort_by(|a, b| a.partial_cmp(b).unwrap()); - v[v.len() / 2] -} - -fn bench_n(n: usize, mut f: impl FnMut()) -> f64 { - for _ in 0..n / 4 { f(); } - let mut times = Vec::with_capacity(n); - for _ in 0..n { - let t = Instant::now(); - f(); - times.push(t.elapsed().as_nanos() as f64 / 1000.0); - } - median(&mut times) -} - -// ── Arena decode ───────────────────────────────────────────────────── -// -// Cells are either slices of the input (no unescaping) or slices of a -// bump-allocated arena (unescaped). One big allocation instead of -// per-cell mallocs. - -#[derive(Clone, Copy)] -enum CellRef { - Input(u32, u32), - Arena(u32, u32), -} - -struct ArenaRows { - arena: Vec, - row_offsets: Vec, - cells: Vec, -} - -fn unescape_into(s: &[u8], arena: &mut Vec) { - if s == b"\\" { - return; - } - let mut escaped = false; - for &b in s { - if escaped { - match b { - b'n' => arena.push(b'\n'), - b'\\' => arena.push(b'\\'), - _ => { arena.push(b'\\'); arena.push(b); } - } - escaped = false; - } else if b == b'\\' { - escaped = true; - } else { - arena.push(b); - } - } -} - -fn decode_arena(input: &[u8]) -> ArenaRows { - let mut arena = Vec::with_capacity(input.len() / 4); - let mut row_offsets: Vec = Vec::new(); - let mut cells: Vec = Vec::new(); - let mut start = 0; - let mut row_start: u32 = 0; - - while let Some(offset) = memchr::memchr(b'\n', &input[start..]) { - let pos = start + offset; - if pos > start { - let cell = &input[start..pos]; - if !cell.contains(&b'\\') { - cells.push(CellRef::Input(start as u32, pos as u32)); - } else { - let arena_start = arena.len() as u32; - unescape_into(cell, &mut arena); - cells.push(CellRef::Arena(arena_start, arena.len() as u32)); - } - } else { - row_offsets.push(row_start); - row_start = cells.len() as u32; - } - start = pos + 1; - } - - if start < input.len() { - let cell = &input[start..]; - if !cell.contains(&b'\\') { - cells.push(CellRef::Input(start as u32, input.len() as u32)); - } else { - let arena_start = arena.len() as u32; - unescape_into(cell, &mut arena); - cells.push(CellRef::Arena(arena_start, arena.len() as u32)); - } - } - - if cells.len() as u32 > row_start { - row_offsets.push(row_start); - } - - ArenaRows { arena, row_offsets, cells } -} - -fn arena_to_cow<'a>(ar: &ArenaRows, input: &'a [u8]) -> Vec>> { - let num_rows = ar.row_offsets.len(); - let mut result = Vec::with_capacity(num_rows); - - for (i, &offset) in ar.row_offsets.iter().enumerate() { - let start = offset as usize; - let end = if i + 1 < num_rows { ar.row_offsets[i + 1] as usize } else { ar.cells.len() }; - let mut row = Vec::with_capacity(end - start); - for &cell in &ar.cells[start..end] { - match cell { - CellRef::Input(s, e) => row.push(Cow::Borrowed(&input[s as usize..e as usize])), - CellRef::Arena(s, e) => row.push(Cow::Owned(ar.arena[s as usize..e as usize].to_vec())), - } - } - result.push(row); - } - result -} - -// ── Two-pass decode ────────────────────────────────────────────────── -// -// Pass 1: scan for cell/row boundaries (offsets only, no data work). -// Pass 2: unescape each cell. - -struct CellBoundary { - start: u32, - end: u32, -} - -struct RowIndex { - boundaries: Vec, - row_offsets: Vec, -} - -fn pass1_scan(input: &[u8]) -> RowIndex { - let mut boundaries = Vec::new(); - let mut row_offsets: Vec = Vec::new(); - let mut start = 0; - let mut row_start: u32 = 0; - - while let Some(offset) = memchr::memchr(b'\n', &input[start..]) { - let pos = start + offset; - if pos > start { - boundaries.push(CellBoundary { start: start as u32, end: pos as u32 }); - } else { - row_offsets.push(row_start); - row_start = boundaries.len() as u32; - } - start = pos + 1; - } - - if start < input.len() { - boundaries.push(CellBoundary { start: start as u32, end: input.len() as u32 }); - } - - if boundaries.len() as u32 > row_start { - row_offsets.push(row_start); - } - - RowIndex { boundaries, row_offsets } -} - -fn pass2_unescape<'a>(input: &'a [u8], idx: &RowIndex) -> Vec>> { - let num_rows = idx.row_offsets.len(); - let mut result = Vec::with_capacity(num_rows); - - for (i, &offset) in idx.row_offsets.iter().enumerate() { - let start = offset as usize; - let end = if i + 1 < num_rows { idx.row_offsets[i + 1] as usize } else { idx.boundaries.len() }; - let mut row = Vec::with_capacity(end - start); - for b in &idx.boundaries[start..end] { - row.push(nsv::unescape_bytes(&input[b.start as usize..b.end as usize])); - } - result.push(row); - } - result -} - -fn decode_two_pass<'a>(input: &'a [u8]) -> Vec>> { - let idx = pass1_scan(input); - pass2_unescape(input, &idx) -} - -// ── Main ───────────────────────────────────────────────────────────── - -fn main() { - println!("{:>8} {:>12} {:>12} {:>12} {:>12} {:>12}", - "size", "current", "arena", "arena+conv", "two-pass", "2p-pass1"); - println!("{:>8} {:>12} {:>12} {:>12} {:>12} {:>12}", - "----", "µs/KB", "µs/KB", "µs/KB", "µs/KB", "µs/KB"); - - for &target in &[ - 64 * 1024, - 256 * 1024, - 1024 * 1024, - 4 * 1024 * 1024, - 16 * 1024 * 1024, - 64 * 1024 * 1024, - ] { - let input = make_nsv_near_size(target); - let size = input.len(); - let kb = size as f64 / 1024.0; - let iters = if size > 16 * 1024 * 1024 { 20 } else if size > 4 * 1024 * 1024 { 40 } else { 100 }; - - let t_current = bench_n(iters, || { - let _ = std::hint::black_box(nsv::decode_bytes(std::hint::black_box(&input))); - }); - - let t_arena = bench_n(iters, || { - let _ = std::hint::black_box(decode_arena(std::hint::black_box(&input))); - }); - - let t_arena_conv = bench_n(iters, || { - let ar = decode_arena(std::hint::black_box(&input)); - let _ = std::hint::black_box(arena_to_cow(&ar, &input)); - }); - - let t_two_pass = bench_n(iters, || { - let _ = std::hint::black_box(decode_two_pass(std::hint::black_box(&input))); - }); - - let t_pass1 = bench_n(iters, || { - let _ = std::hint::black_box(pass1_scan(std::hint::black_box(&input))); - }); - - if size >= 1024 * 1024 { - println!("{:>6.0}MB {:>12.2} {:>12.2} {:>12.2} {:>12.2} {:>12.2}", - size as f64 / (1024.0 * 1024.0), - t_current / kb, t_arena / kb, t_arena_conv / kb, t_two_pass / kb, t_pass1 / kb); - } else { - println!("{:>5.0}KB {:>12.2} {:>12.2} {:>12.2} {:>12.2} {:>12.2}", - kb, t_current / kb, t_arena / kb, t_arena_conv / kb, t_two_pass / kb, t_pass1 / kb); - } - } -} diff --git a/examples/bench_breakdown.rs b/examples/bench_breakdown.rs deleted file mode 100644 index 076eb18..0000000 --- a/examples/bench_breakdown.rs +++ /dev/null @@ -1,103 +0,0 @@ -use std::time::Instant; - -fn generate_mixed_data(rows: usize, cols: usize) -> Vec> { - let patterns: &[&str] = &[ - "John Smith", - "john.smith@example.com", - "2024-03-15", - "1234.56", - r"C:\Users\Documents\file.txt", - "Line one\nLine two\nLine three", - "", - "Normal cell with no special chars at all", - r#"{"key": "value", "n": 42}"#, - "Springfield, IL 62704", - ]; - (0..rows) - .map(|i| { - (0..cols) - .map(|j| { - let idx = (i * cols + j) % patterns.len(); - if patterns[idx].is_empty() { - String::new() - } else { - format!("{} [{}]", patterns[idx], i) - } - }) - .collect() - }) - .collect() -} - -fn make_nsv_near_size(target_bytes: usize) -> Vec { - let cols = 8; - let mut rows = 1; - loop { - let data = generate_mixed_data(rows, cols); - let nsv = nsv::encode(&data); - if nsv.len() >= target_bytes { - return nsv.into_bytes(); - } - rows = (rows as f64 * (target_bytes as f64 / nsv.len() as f64).max(1.1)) as usize; - } -} - -fn median(v: &mut Vec) -> f64 { - v.sort_by(|a, b| a.partial_cmp(b).unwrap()); - v[v.len() / 2] -} - -fn bench_n(n: usize, mut f: impl FnMut()) -> f64 { - for _ in 0..n / 4 { - f(); - } - let mut times = Vec::with_capacity(n); - for _ in 0..n { - let t = Instant::now(); - f(); - times.push(t.elapsed().as_nanos() as f64 / 1000.0); - } - median(&mut times) -} - -fn main() { - #[cfg(feature = "parallel")] - println!("parallel feature: ON (rayon threads: {})", rayon::current_num_threads()); - #[cfg(not(feature = "parallel"))] - println!("parallel feature: OFF (sequential only)"); - - println!(); - println!("{:>8} {:>10} {:>10}", "size", "decode_bytes", "µs/KB"); - println!("{:>8} {:>10} {:>10}", "----", "----------", "-----"); - - let targets: Vec = vec![ - 64 * 1024, - 128 * 1024, - 256 * 1024, - 512 * 1024, - 1024 * 1024, - 2 * 1024 * 1024, - 4 * 1024 * 1024, - 8 * 1024 * 1024, - 16 * 1024 * 1024, - 32 * 1024 * 1024, - 64 * 1024 * 1024, - 128 * 1024 * 1024, - ]; - - // Pre-generate all inputs so allocation isn't measured - let inputs: Vec> = targets.iter().map(|&t| make_nsv_near_size(t)).collect(); - - for input in &inputs { - let size = input.len(); - let iters = if size > 32 * 1024 * 1024 { 10 } else if size > 4 * 1024 * 1024 { 30 } else { 100 }; - let t = bench_n(iters, || { - let _ = std::hint::black_box(nsv::decode_bytes(std::hint::black_box(input))); - }); - if size >= 1024 * 1024 { - println!("{:>6.0}MB {:>11.1}µs {:>9.2}", size as f64 / (1024.0 * 1024.0), t, t / (size as f64 / 1024.0)); - } else { - println!("{:>5.0}KB {:>11.1}µs {:>9.2}", size as f64 / 1024.0, t, t / (size as f64 / 1024.0)); - } - } -} diff --git a/examples/bench_scan_ab.rs b/examples/bench_scan_ab.rs deleted file mode 100644 index cfffa4e..0000000 --- a/examples/bench_scan_ab.rs +++ /dev/null @@ -1,155 +0,0 @@ -// Controlled A/B of the two newline-scanning strategies, identical except -// for the scan primitive. Both reproduce the library's decode logic exactly; -// the ONLY difference is byte-by-byte iteration vs memchr. This isolates the -// scanning decision across a range of cell widths (the variable memchr is -// sensitive to: short cells = short haystacks = more per-call overhead). - -use std::borrow::Cow; -use std::time::Instant; - -fn unescape_bytes(s: &[u8]) -> Cow<'_, [u8]> { - if s == b"\\" { - return Cow::Owned(Vec::new()); - } - if !s.contains(&b'\\') { - return Cow::Borrowed(s); - } - let mut out = Vec::with_capacity(s.len()); - let mut escaped = false; - for &b in s { - if escaped { - match b { - b'n' => out.push(b'\n'), - b'\\' => out.push(b'\\'), - _ => { out.push(b'\\'); out.push(b); } - } - escaped = false; - } else if b == b'\\' { - escaped = true; - } else { - out.push(b); - } - } - Cow::Owned(out) -} - -fn decode_byteloop<'a>(input: &'a [u8]) -> Vec>> { - let mut data = Vec::new(); - let mut row: Vec> = Vec::new(); - let mut start = 0; - for (pos, &b) in input.iter().enumerate() { - if b == b'\n' { - if pos > start { - row.push(unescape_bytes(&input[start..pos])); - } else { - data.push(row); - row = Vec::new(); - } - start = pos + 1; - } - } - if start < input.len() { - row.push(unescape_bytes(&input[start..])); - } - if !row.is_empty() { - data.push(row); - } - data -} - -fn decode_memchr<'a>(input: &'a [u8]) -> Vec>> { - let mut data = Vec::new(); - let mut row: Vec> = Vec::new(); - let mut start = 0; - while let Some(offset) = memchr::memchr(b'\n', &input[start..]) { - let pos = start + offset; - if pos > start { - row.push(unescape_bytes(&input[start..pos])); - } else { - data.push(row); - row = Vec::new(); - } - start = pos + 1; - } - if start < input.len() { - row.push(unescape_bytes(&input[start..])); - } - if !row.is_empty() { - data.push(row); - } - data -} - -// Generate NSV with a fixed cell width, no escapes (pure scan cost). -fn make_nsv_cellwidth(cell_width: usize, target_bytes: usize) -> Vec { - let cols = 8; - let cell: String = "x".repeat(cell_width); - let mut out = Vec::with_capacity(target_bytes + 1024); - while out.len() < target_bytes { - for _ in 0..cols { - out.extend_from_slice(cell.as_bytes()); - out.push(b'\n'); - } - out.push(b'\n'); - } - out -} - -fn median(v: &mut Vec) -> f64 { - v.sort_by(|a, b| a.partial_cmp(b).unwrap()); - v[v.len() / 2] -} - -fn bench_n(n: usize, mut f: impl FnMut()) -> f64 { - for _ in 0..n / 4 { f(); } - let mut times = Vec::with_capacity(n); - for _ in 0..n { - let t = Instant::now(); - f(); - times.push(t.elapsed().as_nanos() as f64 / 1000.0); - } - median(&mut times) -} - -fn main() { - // If a file path is given, benchmark both strategies on that real fixture. - let args: Vec = std::env::args().collect(); - if args.len() > 1 { - let path = &args[1]; - let input = std::fs::read(path).expect("read fixture"); - let kb = input.len() as f64 / 1024.0; - let mb = input.len() as f64 / (1024.0 * 1024.0); - println!("fixture: {} ({:.1}MB)\n", path, mb); - - // sanity: both must agree - let a = decode_byteloop(&input); - let b = decode_memchr(&input); - assert_eq!(a.len(), b.len(), "row count mismatch"); - println!("rows: {}", a.len()); - let total_cells: usize = a.iter().map(|r| r.len()).sum(); - println!("cells: {} (avg {:.2} bytes/cell across input)\n", - total_cells, input.len() as f64 / total_cells.max(1) as f64); - - let iters = 15; - let t_loop = bench_n(iters, || { let _ = std::hint::black_box(decode_byteloop(std::hint::black_box(&input))); }); - let t_mc = bench_n(iters, || { let _ = std::hint::black_box(decode_memchr(std::hint::black_box(&input))); }); - println!("{:>10} {:>10} {:>8}", "byteloop", "memchr", "speedup"); - println!("{:>9.3}µs/KB {:>8.3}µs/KB {:>7.2}x", t_loop / kb, t_mc / kb, t_loop / t_mc); - return; - } - - let size = 1024 * 1024; // 1MB, sequential range - println!("1MB input, no escapes, varying cell width (µs/KB)\n"); - println!("{:>10} {:>10} {:>10} {:>8}", "cell_width", "byteloop", "memchr", "speedup"); - println!("{:>10} {:>10} {:>10} {:>8}", "----------", "--------", "------", "-------"); - - for &w in &[1usize, 2, 4, 8, 16, 32, 64, 128] { - let input = make_nsv_cellwidth(w, size); - let kb = input.len() as f64 / 1024.0; - - let t_loop = bench_n(200, || { let _ = std::hint::black_box(decode_byteloop(std::hint::black_box(&input))); }); - let t_mc = bench_n(200, || { let _ = std::hint::black_box(decode_memchr(std::hint::black_box(&input))); }); - - println!("{:>10} {:>10.3} {:>10.3} {:>7.2}x", w, t_loop / kb, t_mc / kb, t_loop / t_mc); - } -} From 4c6164f67c9b01e79da6b0f916e9aa310ce3049d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 20 Jun 2026 11:35:11 +0000 Subject: [PATCH 12/12] Make PARALLEL_THRESHOLD comment unit-only, drop stale number The "(64KB)" parenthetical had drifted from the actual 256KB value. Replace with "(bytes)" so the number lives only in the constant. https://claude.ai/code/session_015wqo8AZuJGkKfrojR9xgBN --- src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib.rs b/src/lib.rs index c8f7c8c..4cfeb1e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,7 +27,7 @@ use std::io::{self, Read, Write}; pub const VERSION: &str = env!("CARGO_PKG_VERSION"); -/// Threshold for using parallel parsing (64KB) +/// Threshold for using parallel parsing (bytes) const PARALLEL_THRESHOLD: usize = 256 * 1024; /// Decode an NSV string into a seqseq.