Performance & Benchmarks

JSON Tools RS achieves ~2,000+ ops/ms through multiple optimization layers.

Optimization Techniques

TechniqueImpact
SIMD JSON Parsingsonic-rs (64-bit) / simd-json (32-bit) -- used for the root-primitive fallback path; the main flatten/unflatten/normal paths use a custom tape scanner instead (see Architecture)
SIMD Byte Searchmemchr/memmem for fast string operations
FxHashMapFast non-cryptographic hashing via a custom in-tree FxHasher (src/fxhash.rs), not the rustc-hash crate
Multi-Tier Regex CacheCompile-time common-pattern table -> thread-local "sticky" cache -> larger thread-local FxHashMap -> global RwLock<FxHashMap>, all LRU-evicted when full
SmallVecStack allocation for depth stacks, number buffers, and 0-2 replacement patterns
CompactString + Arena KeysKeys inline up to 24 bytes (CompactString); flatten's slow path (key lowercasing/replacement/collision-handling) additionally uses a bumpalo arena to avoid one heap allocation per dotted key path
First-Byte DiscriminatorsRapid rejection of non-convertible strings
Rayon ParallelismPersistent work-stealing thread pool for batch and nested parallelism (no per-call spawn cost)
Zero-Copy (Cow)Avoid allocations when strings don't need modification
Stack-Allocated Integer FormattingCustom IntBuf formatter for array-index keys (replaced the itoa crate)
mimallocOptional high-performance allocator (features = ["mimalloc"], ~14-28% measured on allocation-heavy paths)
orjson (Python)Bundled dependency -- replaces the stdlib json module for dict/DataFrame-row (de)serialization, with a per-call stdlib fallback for inputs it can't handle (e.g. integers beyond 64-bit range)
Zero-Copy Arrow (Python)pyo3-arrow reads an embedded JSON-string column directly from a Polars DataFrame/PyArrow Table's Arrow buffer instead of round-tripping through the DataFrame's native JSON writer -- ~41-48% faster execute() end-to-end for that case; plain pandas/PySpark unaffected
Arrow-Native normalise() (Python)Reconstruction builds one real Arrow RecordBatch directly in Rust (no per-value PyObject boxing) and derives every target from it -- measured (interleaved A/B, 100-4,000 columns): a modest, honest ~1-4% end-to-end win, since core flattening/input serialization -- unchanged by this -- dominates total wall time for typical column-heavy data, not reconstruction. The real deliverable here is correctness, not raw speed: genuinely typed List<T> and Date32/Timestamp columns (no longer stringified) and consistent typing across all four targets, where only PySpark got real type-checking before. .convert_dates(True)'s own detection cost is small but real and separately measured: ~0.1-4.5% end-to-end, including the worst case where nothing actually is a date -- not charged at all when date conversion is off
Flat-DataFrame Fast Path (Python).flatten().execute(df) on a DataFrame with no nested columns skips the JSON-text round trip entirely (see DataFrame & Series Support) -- measured (interleaved A/B, 2 rounds, 20K rows x 20 cols): Polars ~2.7-3.7x faster, PyArrow ~4.2-5.6x faster, pandas ~1.5-2.2x faster for scenarios with real per-cell work (auto_convert_types, always_array_keys), up to ~345x faster for pandas' pure column-rename case (no value transform, reuses the source Series object directly). Automatic, no flag to set; falls back to the existing pipeline for any nested/struct column, embedded-JSON-string column, remove_nulls/value_exclusions, or a rename-induced column collision
Flat-DataFrame Fast Path: GIL Release (Python)A concurrency fix, not a speed one -- same wall-clock time per call, but the fast path above now releases the GIL during its computation like every other execute() path does, so it no longer stalls other Python threads in the process for the duration of a large call. Measured via a background pure-Python counting thread's throughput while execute(df) runs concurrently (ratio to uncontended solo throughput, 20K x 20 DataFrame): Polars 0.21 -> 1.00, PyArrow 0.14 -> 0.97, pandas 0.73 -> 0.76 (smaller -- pandas still builds one PyObject per cell with the GIL held)

Benchmark Results

Measured on Apple Silicon (M4) via cargo bench --bench stress_benchmarks -- --quick against the current source tree -- a quick/low-sample Criterion run, so treat these as indicative rather than lab-precise; re-run the suite yourself (see Running Benchmarks below) for reproducible numbers on your own hardware.

Stress Benchmarks

BenchmarkResultDescription
Deep nesting (100 levels)~2.1 usstress_01_deep_nesting/flatten/100 -- deeply nested object, 100 levels deep
Wide objects (1,000 keys)~24 usstress_02_wide_objects/flatten/1000 -- single object with 1,000 top-level keys
Large arrays (5,000 items)~420 usstress_03_large_arrays/flatten/5000 -- array containing 5,000 elements
Many small nested objects (10,000, nested-parallel)~610 usstress_05_many_small_objects/flatten_parallel/10000 -- single document containing 10,000 small nested objects, flattened with intra-document (Rayon) parallelism enabled

Throughput Targets (v0.9.0)

OperationTarget
Basic flatten>2,000 ops/ms
With transformations>1,300 ops/ms
Regex replacements>1,800 ops/ms
Batch (10 items)>2,500 ops/ms
Batch (100 items)>3,000 ops/ms
Roundtrip>1,000 cycles/ms

Performance Tuning

Three threshold parameters control when parallelism activates. Tuning them for your workload can significantly affect throughput.

parallel_threshold (default: 100)

Controls when batch processing (multiple JSON documents) switches from sequential to parallel execution.

When to lower (e.g., 20-50):

  • Each document is large or complex (deep nesting, many keys)
  • CPU cores are available and not contended
  • You are processing 50-100 items and want parallel speedup

When to raise (e.g., 200-500):

  • Each document is small (a few keys, shallow nesting)
  • Thread-spawning overhead dominates processing time
  • Running inside a container with limited CPU
# For large documents, parallel even at small batch sizes
tools = jt.JSONTools().flatten().parallel_threshold(20)

# For tiny documents, avoid parallelism overhead
tools = jt.JSONTools().flatten().parallel_threshold(500)
#![allow(unused)]
fn main() {
let tools = JSONTools::new()
    .flatten()
    .parallel_threshold(50);
}

nested_parallel_threshold (default: 100)

Controls when a single JSON document's top-level keys/array items are processed in parallel (intra-document parallelism). This is independent of batch parallelism.

When to lower (e.g., 50):

  • Individual documents have very wide objects (500+ keys) with deep sub-trees
  • Processing includes expensive transformations (regex replacements, type conversion)

When to raise (e.g., 500-1000) or effectively disable:

  • Documents are moderately sized (under 100 keys)
  • Sub-trees are shallow (1-2 levels), so per-key work is minimal
  • You want deterministic (sequential) output ordering
# Large documents with heavy per-key work
tools = jt.JSONTools().flatten().nested_parallel_threshold(50)

# Disable nested parallelism entirely
tools = jt.JSONTools().flatten().nested_parallel_threshold(999_999)

num_threads (default: CPU count)

Controls the number of worker threads for parallel processing.

When to set explicitly:

  • Running alongside other CPU-intensive workloads -- limit threads to avoid contention
  • In a container or VM with a CPU quota -- match thread count to available cores
  • Benchmarking -- fix thread count for reproducible results
tools = jt.JSONTools().flatten().num_threads(4)
#![allow(unused)]
fn main() {
let tools = JSONTools::new()
    .flatten()
    .num_threads(Some(4));
}

Environment Variable Overrides

All threshold defaults can be overridden without code changes via environment variables. These are read once at process startup (via LazyLock).

VariableDefaultDescription
JSON_TOOLS_PARALLEL_THRESHOLD100Minimum batch size for parallel processing
JSON_TOOLS_NESTED_PARALLEL_THRESHOLD100Minimum keys/items for nested parallelism
JSON_TOOLS_NUM_THREADS(CPU count)Thread count for parallel processing
JSON_TOOLS_MAX_ARRAY_INDEX100000Maximum array index during unflattening
# Example: tune for a workload of many small documents
export JSON_TOOLS_PARALLEL_THRESHOLD=200
export JSON_TOOLS_NUM_THREADS=8

python my_pipeline.py

Environment variable values are parsed as usize. Invalid values (non-numeric, negative) silently fall back to the default.

Running Benchmarks

# All benchmarks
cargo bench

# Specific suite
cargo bench --bench isolation_benchmarks
cargo bench --bench comprehensive_benchmark
cargo bench --bench stress_benchmarks
cargo bench --bench realworld_benchmarks
cargo bench --bench combination_benchmarks

Benchmark Suites

SuiteFocus
isolation_benchmarksIndividual features in isolation (10 groups)
combination_benchmarks2-way and 3-way feature interactions
realworld_benchmarksAWS CloudTrail, GitHub API, K8s, Elasticsearch, Stripe, Twitter/X
stress_benchmarksEdge cases: deep nesting, wide objects, large arrays
comprehensive_benchmarkFull feature coverage (15 groups)

Profiling

On macOS, use samply for profiling:

# Build with profiling symbols
cargo bench --profile profiling --bench stress_benchmarks --no-run

# Profile with samply
samply record --save-only -o /tmp/profile.json -- \
    ./target/profiling/deps/stress_benchmarks-* --bench

# View results
samply load /tmp/profile.json

Architecture

The codebase is organized into focused, single-responsibility modules (see Architecture for the full breakdown):

src/
├── lib.rs            Facade: mod declarations + pub use re-exports
├── json_parser.rs    Conditional SIMD parser (sonic-rs / simd-json) -- used for the
│                     root-primitive fallback, not the main tape-based paths
├── types.rs          Core types: JsonInput, JsonOutput
├── error.rs          Error types with codes E001-E008
├── config.rs         Configuration structs and operation modes
├── cache.rs          Multi-tier regex pattern cache (common-pattern table, sticky,
│                     thread-local, global RwLock)
├── fxhash.rs         Custom FxHash-style Hasher for FxHashMap/FxIndexMap
├── convert.rs        Type conversion: numbers, dates, booleans, nulls
├── transform.rs      Filtering, key/value replacements, collision handling
├── flatten.rs        Tape-based flattening engine (scan -> walk -> output)
├── unflatten.rs      Tape-based unflattening with SIMD separator detection
├── builder.rs        Public JSONTools builder API and execute()
├── python.rs         Python bindings via PyO3
├── tests.rs          Unit tests
└── main.rs           CLI examples

The processing pipeline:

  1. Parse -- single-pass tape scan (scan_and_fixup(), shared by flatten/unflatten/transform); json_parser's SIMD parser only handles the root-primitive edge case
  2. Flatten/Unflatten -- tape walk with CompactString-inlined keys (and an arena allocator for the slow path involving key transforms) (flatten/unflatten)
  3. Transform -- Lowercase, replacements (cached regex), collision handling (transform)
  4. Filter -- Remove empty strings, nulls, empty objects/arrays (transform)
  5. Convert -- Type conversion with first-byte discriminators (convert)
  6. Serialize -- Output to JSON string or native Python types