Skip to content

Zero-copy fill_tape from a caller-padded buffer, with optional skip of UTF-8 validation #469

Description

@ranflarion

We consume simd-json from a columnar query engine (Spark from_json over Arrow string columns): millions of small documents per second, each parsed row-by-row into a reused Tape with reused Buffers. Two things fill_tape does are pure overhead in that setting. It copies every input into buffers.input_buffer to gain the SIMD over-read padding, an O(len) memcpy per document that callers who control the input layout can avoid by providing the padding themselves (we pack a whole batch of rows into one scratch Vec with SIMDINPUT_LENGTH trailing bytes, each row's slice extending to the end of the scratch). And stage 1 validates UTF-8, a second full pass over every byte, though Arrow string columns are valid UTF-8 by construction.

We implemented this against 0.17.3 and would like to upstream it:

/// Padding bytes callers must provide beyond the logical input.
pub const INPUT_PADDING: usize = SIMDINPUT_LENGTH;

/// Fills a tape from a caller-padded input, skipping the internal padded copy
/// of the input that `fill_tape` performs.
///
/// # Safety
///
/// The caller must guarantee `s.len() >= len + INPUT_PADDING`. With
/// `validate_utf8 == false` the caller must also guarantee `s[..len]` is valid
/// UTF-8; stage 1 then skips its fused UTF-8 validation entirely.
pub unsafe fn fill_tape_padded<'de>(
    s: &'de mut [u8],
    len: usize,
    buffers: &mut Buffers,
    tape: &mut Tape<'de>,
    validate_utf8: bool,
) -> Result<()>;

Naming and shape are all negotiable: validate_utf8 could instead be a const generic or a separate _unchecked entry point, and the two halves (padded input, validation skip) are independent if you would rather take one without the other.

The main design constraint is aliasing. With the internal copy gone, stage 2 reads the same buffer that string unescaping writes into, and carrying the read side as &[u8] becomes UB the moment an unescape write lands during a call holding it as an argument (argument protectors under both Stacked and Tree Borrows; rustc marks such arguments noalias readonly). Our implementation threads the read side as a raw pointer+len view through build_tape and the per-ISA parse_str impls, materializing only transient slices that are dead before any write touches their range. 0.17.3 already carries parse_str_(input: *mut u8, ...) for the write side, so this completes the same discipline for the read side. The result is Miri-clean under both Stacked Borrows and -Zmiri-tree-borrows, on aarch64 (neon) and x86_64 (native fallback), which hopefully slots into the Miri work in #446/#450. The PR would include the Miri/equivalence harness we use: node-level equality of fill_tape_padded against fill_tape over escape positions swept across the 16/32/64 byte lane boundaries, escapes landing against the padding, multi-row shared-padding layouts, error paths, and a deterministic randomized corpus.

On performance: the structural saving is one O(len) memcpy per document, plus one full validation pass when validation is skipped. Measured on dedicated EC2 instances (interleaved arms rotating each round to cancel drift, medians of 11 rounds, reproduced in a second run within 0.5%; parsing batches of documents into a reused Tape with reused Buffers):

c7i.2xlarge (Sapphire Rapids, avx2), parse throughput, higher is better:

shape fill_tape fill_tape_padded padded + skip validation
57B, 3 fields 10.50M rows/s 10.70M rows/s (+1.9%) 10.82M rows/s (+3.1%)
281B, escape-dense payload 2.73M rows/s 2.84M rows/s (+4.2%) 2.87M rows/s (+5.2%)
2KB, long plain payload 1.70M rows/s 1.82M rows/s (+6.8%) 1.91M rows/s (+12.0%)

c8g.2xlarge (Graviton4, neon): skipping the copy improves throughput by 0.5-1.7% (57B), 1.2% (281B), and 5.6% (2KB). Also skipping validation is another 8% faster on the 57B shape but neutral to ~1% slower than the validated padded arm on the larger shapes (reproducible; looks like a codegen/layout effect in the stage-1 monomorphization rather than a cost of the removed work). The copy saving grows with document size as expected; the validation skip matters most for pre-validated columnar inputs like ours. Happy to let pr-perf produce the authoritative numbers on your benchmarks.

If the direction sounds right we would split this into two PRs (padded entry point with the harness, then the validation skip) or one, whichever you prefer.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions