Gunnar

nordisk/znippy-zoomies

benches/json_scan.rs

//! NDJSON scanner throughput: sequential vs parallel vs serde_json baseline.
//!
//! Corpus: ~200 MB of deterministic synthetic NDJSON — varied record sizes,
//! escaped quotes/backslashes inside strings, nested objects/arrays, and an
//! empty line every 97 records. Same corpus for all three measurements so the
//! MB/s figures are directly comparable.
//!
//! - `scan_seq`:    `json::scan_records_slice` single-threaded
//! - `scan_par`:    `json::scan_records_parallel` at available cores
//! - `serde_value`: `serde_json::from_slice::<Value>` per line (the "just
//!                  parse everything" baseline the span scanner replaces)
//!
//! Run: `cargo bench -p znippy-zoomies --bench json_scan`

use criterion::{Criterion, Throughput, criterion_group, criterion_main};
use std::hint::black_box;
use std::io::Write as _;
use znippy_zoomies::json::{scan_records_parallel, scan_records_slice};

const TARGET_BYTES: usize = 200 * 1024 * 1024; // ~200 MB

/// xorshift64* — deterministic, no dependency.
#[inline]
fn xorshift(state: &mut u64) -> u64 {
    let mut x = *state;
    x ^= x << 13;
    x ^= x >> 7;
    x ^= x << 17;
    *state = x;
    x.wrapping_mul(0x2545_F491_4F6C_DD1D)
}

/// Build the synthetic corpus. Record shapes rotate through small / medium /
/// large with PRNG-driven padding; every name string carries escaped quotes
/// and backslashes; every 97th record is followed by an empty line.
fn build_corpus(target: usize) -> Vec<u8> {
    let mut rng = 0x9E37_79B9_7F4A_7C15u64;
    let mut buf = Vec::with_capacity(target + 4096);
    let mut i = 0u64;

    while buf.len() < target {
        let r = xorshift(&mut rng);
        // Varied record sizes: 0–63, 0–511, or 0–4095 bytes of padding.
        let pad_len = match i % 3 {
            0 => (r % 64) as usize,
            1 => (r % 512) as usize,
            _ => (r % 4096) as usize,
        };
        let pad: String = std::iter::repeat('x').take(pad_len).collect();

        write!(
            buf,
            "{{\"id\":{i},\"name\":\"user_{i}\\\"esc\\\\path\",\"score\":{},\
             \"meta\":{{\"depth\":2,\"ok\":{}}},\"tags\":[\"a\",\"b\"],\"pad\":\"{pad}\"}}\n",
            r % 100_000,
            i % 2 == 0,
        )
        .expect("write to Vec");

        if i % 97 == 96 {
            buf.push(b'\n'); // tolerated empty line
        }
        i += 1;
    }
    buf
}

fn bench(c: &mut Criterion) {
    let n_workers = std::thread::available_parallelism()
        .map(|n| n.get())
        .unwrap_or(8);
    let corpus = build_corpus(TARGET_BYTES);
    eprintln!("corpus: {} bytes, workers: {n_workers}", corpus.len(),);

    let mut group = c.benchmark_group("json_scan");
    group.throughput(Throughput::Bytes(corpus.len() as u64));
    group.sample_size(10);

    // (a) Sequential span scan.
    group.bench_function("scan_seq", |b| {
        b.iter(|| {
            let mut count = 0u64;
            scan_records_slice(black_box(&corpus), 0, &mut |_r| count += 1);
            black_box(count)
        })
    });

    // (b) Parallel span scan at available cores.
    group.bench_function(format!("scan_par_{n_workers}"), |b| {
        b.iter(|| {
            let mut count = 0u64;
            scan_records_parallel(black_box(&corpus), n_workers, |_r| count += 1);
            black_box(count)
        })
    });

    // (c) Baseline: full serde_json parse of every line.
    group.bench_function("serde_value_per_line", |b| {
        b.iter(|| {
            let mut count = 0u64;
            for line in black_box(&corpus).split(|&b| b == b'\n') {
                let line = match line.last() {
                    Some(&b'\r') => &line[..line.len() - 1],
                    _ => line,
                };
                if line.is_empty() {
                    continue;
                }
                let v: serde_json::Value = serde_json::from_slice(line).expect("valid NDJSON line");
                black_box(&v);
                count += 1;
            }
            black_box(count)
        })
    });

    group.finish();
}

criterion_group!(benches, bench);
criterion_main!(benches);