# `rs-json` JSON performance evidence log
[简体中文](benchmark_baseline.zh_CN.md)
> Number-contract note: strict paths accept only the union of the `i64` and
> `u64` integer ranges and finite `f64` values. `serde_json` arbitrary precision
> is not enabled. Numeric rechecks must separately cover the `i64`/`u64`
> boundaries, range rejection, and `NumberBytes` rejection; historical
> arbitrary-precision behavior is not a compatibility baseline.
## Recorded scope
The first record preserves key medians from a complete `decoder_bench` run on
2026-07-23 for downstream-shaped strict byte decoding and normalizing typed
decoding. It supports review of the response and SSE paths in `rs-http`; it is
not a cross-machine or cross-run performance threshold.
Benchmark command:
```bash
cargo +1.94.0 bench --bench decoder_bench
```
The baseline commit was `081fb98f73ba7cd5dc7a930f9d0c98cf70a5bd92`.
The complete run also covered public entry points, control-character
normalization, and representative failures. Criterion's raw statistics remain
under the untracked local `target/criterion/` directory.
## Environment
- CPU: Intel(R) Core(TM) i5-9600K CPU @ 3.70GHz, 6 cores, 1 thread per core.
- Operating system: Linux 6.17.0-35-generic x86_64.
- Toolchain: rustc 1.94.0 (4a4ef493e 2026-03-02), LLVM 21.1.8.
## Strict byte-decode medians
| 1 KiB | 231.56 ns | 239.74 ns | 241.28 ns |
| 64 KiB | 10.978 µs | 10.944 µs | 10.895 µs |
| 1 MiB | 184.94 µs | 185.18 µs | 186.52 µs |
The fresh case constructs `JsonDecoder` inside every Criterion iteration and
then calls `decode_utf8`, matching current `rs-http` response and SSE usage.
All three strict paths are close; comparisons require repeated same-machine
runs and Criterion confidence intervals rather than one measurement.
## Normalizing typed-decode medians
| 1 KiB | 311.00 ns | 355.11 ns | 2.4553 µs |
| 64 KiB | 13.337 µs | 13.656 µs | 241.17 µs |
| 1 MiB | 223.03 µs | 220.23 µs | 2.2784 ms |
These cases use `NormalizingJsonDecoder::with_limits` with the default policy
and unlimited explicit limits, followed by `decode_object_str`. They cover the
plain, fenced, and control-character normalization shapes used by lenient SSE
messages. Control-character density materially changes cost and must be
compared only with historical runs of the same shape.
## 2026-07-24 appended baseline
Commit `76e027c35639542c8df1bac846907cc85e37043d` was measured with a complete
rerun of `cargo +1.94.0 bench --bench decoder_bench`. The environment was
unchanged. Tables use Criterion `median.point_estimate`.
### Strict byte-decode medians
| 1 KiB | 244.69 ns | 242.00 ns | 243.75 ns |
| 64 KiB | 10.959 µs | 11.400 µs | 11.040 µs |
| 1 MiB | 182.17 µs | 188.68 µs | 186.63 µs |
### Normalizing typed-decode medians
| 1 KiB | 580.23 ns | 581.39 ns | 615.90 ns | 2.3020 µs | 2.4164 µs |
| 64 KiB | 30.450 µs | 30.397 µs | 29.871 µs | 131.09 µs | 141.79 µs |
| 1 MiB | 506.53 µs | 491.52 µs | 495.86 µs | 2.1129 ms | 2.4197 ms |
The Unicode case isolates normalization pre-scan behavior and is not directly
comparable to control-character input. CPU-frequency state can affect a single
run, so these values are same-machine, same-shape regression evidence only.
## 2026-07-24 control-character pre-scan A/B
A scalar byte-at-a-time pre-scan (A, commit `c422d0b`) was compared with a
pre-scan that safely handles eight bytes at a time (B, commit `82dcbe2`). The
same harness kept verification outside Criterion timing. Runs were pinned to
CPU 3 with Rust 1.94.0, 1 second warm-up, 2 seconds measurement, 50 samples,
and interleaved as `A1-B1-B2-A2-A3-B3`. The table reports the last pair's
medians and Criterion 95% confidence interval for change; the other pairs
confirmed direction. Trial runs with abnormal host load and simultaneous
strict-control drift were excluded.
### Primary 1 MiB paths
| Plain JSON | 490.39 µs | 232.54 µs | -53.11% (-53.72% to -52.50%) |
| Unicode without raw controls | 489.64 µs | 233.28 µs | -52.08% (-52.69% to -51.37%) |
| Markdown-fenced JSON | 489.20 µs | 231.39 µs | -51.94% (-52.49% to -51.37%) |
| Pretty JSON | 2.0519 ms | 1.8591 ms | -9.99% (-10.97% to -9.02%) |
| One raw control per 1024 bytes | 2.1805 ms | 2.0044 ms | -7.53% (-8.63% to -6.37%) |
All three no-raw-control 1 MiB paths exceeded the planned 20% improvement
threshold, and the upper confidence bounds still exceeded 10%. Corresponding
1 KiB and 64 KiB paths also improved. Interleaved runs found no reproducible
regression in pretty, sparse-control, dense-control, explicit-normalization-
limit, or strict decode controls. In the third pair, dense-control 1 MiB was
-0.77% (95% CI -1.80% to +0.29%) and reused strict decode was +0.66% (95% CI
-1.83% to +3.89%), neither statistically significant.
The eight-byte pre-scan was retained. Pretty JSON also benefited, but its
remaining 1 MiB cost was still about eight times compact JSON, indicating that
later whitespace compaction dominated and should be evaluated separately.
## 2026-07-24 state-aware ordinary-block skipping A/B
The existing scalar JSON state scan (A, commit
`c87bd2eba740444e6c2e7b605bb30eb1d9c669aa`) was compared with state-aware
eight-byte ordinary-block skipping (B). Both were pinned to CPU 3 and used Rust
1.94.0, 1 second warm-up, 2 seconds measurement, and 50 samples for the full
matrix. Final endpoints were rechecked with 3 seconds and 100 samples.
### Representative endpoints
| 1 MiB plain normalizing typed JSON | 231.06 µs | 227.53 µs | -1.53% (-2.48% to -0.19%) |
| 1 MiB pretty normalizing typed JSON | 1.8384 ms | 457.10 µs | -75.14% (-75.60% to -74.83%) |
| 1 MiB, one raw control per 1024 bytes | 1.9980 ms | 546.72 µs | -72.64% (-73.15% to -72.36%) |
| 1 MiB, one raw control per 2 bytes | 12.496 ms | 12.839 ms | +2.75% (+1.51% to +3.42%) |
| 1 KiB, fresh strict decoder | 240.89 ns | 241.12 ns | +0.10% (-0.52% to +0.77%) |
Ordinary-block skipping carries string and backslash state and falls back to
scalar handling for blocks containing quotes, in-string backslashes, or raw
in-string controls. A block with at least two C0 bytes also falls back, avoiding
repeated classification cost for dense malformed strings. The complete matrix
found no reproducible regression above 5%; the previously scalar-bound pretty
and sparse-control 1 MiB paths improved about 75% and 73%, so B was retained.
## 2026-08-14 multi-size `budgeted_serde_json` baseline
This is the comparison baseline before the resource-budget JSON codec rewrite.
Fixtures are compact `{"records":[...]}` arrays containing
`{"id":7,"text":"benchmark-record"}`. Generation verifies that actual size
does not exceed the target or differ by more than one record. Actual sizes are
993 B, 65,533 B, and 1,048,543 B, representing about 1 KiB, 64 KiB, and 1 MiB.
Throughput uses those actual byte counts.
```bash
cargo bench --bench budgeted_serde_json -- --noplot
```
The run was on 2026-08-14 in an uncommitted task tree based on
`97e49b5f575ac0fce0da9a15f8403c1d6fb5456b`. Criterion used its default 3
second warm-up, 5 second measurement, and 100 samples. Latency is
`median.point_estimate`; ratios use same-size `serde_json` as 1.00× and smaller
is faster. This is not a cross-machine threshold.
### Environment
- CPU: Intel(R) Core(TM) i5-9600K CPU @ 3.70GHz, 6 cores, 1 thread per core.
- OS: Linux 6.17.0-35-generic x86_64 GNU/Linux.
- Toolchain: rustc 1.94.0 (4a4ef493e 2026-03-02), LLVM 21.1.8.
### Strict decode
`owned-session` constructs `JsonDecodeSession::from_limits` each iteration;
`borrowed-session` constructs a session borrowing `JsonValueBudget`; and
`reused-session` reuses an unlimited session. The normalizing rows compare the
explicit normalization facade.
Encoding uses the same pre-parsed fixture. The three budgeted cases use a
fresh owned session, a fresh borrowed session, and a reused unlimited session.
includes transactional output accounting. Absolute time is therefore not
equivalent to direct `serde_json`. Later work must compare identical scenarios
and actual sizes, looking for changes beyond Criterion confidence intervals.
## 2026-08-14 post-admission-rewrite recheck
The same machine, toolchain, and 993 B, 65,533 B, and 1,048,543 B inputs were
used for all 30 scenarios after tasks 1-8 and final clippy/style fixes in a tree
based on `97e49b5f575ac0fce0da9a15f8403c1d6fb5456b`. Criterion settings were
unchanged. Ratios use the new same-size `serde_json`; parenthesized change is
relative to the preceding pre-rewrite data. Runs were not interleaved, so small
shifts can reflect CPU frequency or load.
### Strict decode recheck
current `decode_str()`. Current plain and session-backed `decode_str()` both
perform lexical admission before directly deserializing the target; see the
2026-08-18 quick recheck. Future comparison must resample the same current
implementation rather than interpret this obsolete difference.
Owned, borrowed, and reused general decode paths differed by only about
0.27-0.36 µs at 1 KiB; signs reverse at larger sizes and magnitudes are below
about 2%. Session construction/borrowing is visible only for small inputs and
is not the medium/large optimization priority.
### Strict encode recheck
35 ns above reused, with no consistent construction-cost direction at larger
sizes. Budgeted throughput is about 195-220 MiB/s, only 23%-26% of direct
`serde_json`, making it the clearest hotspot in this matrix.
### Priorities supported by this data only
1. Optimize structure, payload, and transactional output accounting in
budgeted encode, continuously rechecking with the same Criterion command.
2. Evaluate lexical-admission scan cost shared by strict and normalizing decode
while preserving complete value admission and direct Serde deserialization.
3. Optimize session construction, borrowing, or reuse only after a current
fixed benchmark shows a stable gain.
## 2026-08-18 current-implementation quick recheck
Quick screening used the following commands. `--quick` determines direction
only and does not replace a full fixed-environment benchmark.
```bash
cargo bench --bench budgeted_serde_json -- --quick
cargo bench --bench budgeted_serde_json -- decode --quick
```
For encode without an output-byte limit, caching whether accounting is needed
avoided borrowing accounting on every serializer `Write` callback. Owned 1 KiB
/ 64 KiB / 1 MiB medians changed from 4.866 / 290.31 / 4412 µs to 4.009 /
232.55 / 3721 µs. Incremental writing changed from 2.761 / 180.92 / 2976 µs
to 2.308 / 155.98 / 2456 µs. Output-limited paths retain every pre-write check
and accepted-byte charge.
On 993 B input, strict and normalizing session paths were about 6.10-6.17 µs,
with no statistically significant change. Both perform lexical admission then
direct target deserialization. Future baselines must resample on the same
hardware with full Criterion settings and report bounded and unbounded cases.
## 2026-08-25 encode capability-cache optimization
The encode benchmark was split into `strict-only`, `output-only`, `value-only`,
and `full`. Before the change, strict mode dynamically borrowed shared context
for every Serde event even without value limits. Integers computed budget-only
lexeme lengths, and sequences/maps maintained budget-only counts. At 1 MiB,
`strict-only` was about 3.40× direct `serde_json`; `full` was about 4.96×. The
primary cost was per-node borrowing, measurement, and container accounting,
not session construction.
The encode entry point now computes `has_value_limits` once and copies that
immutable capability into serializer, compound, nested-value, and key wrappers.
Without value limits they return before borrowing context and skip numeric
lexeme measurement, byte-array element measurement, and container counts.
The 64-bit number union, RawValue syntax scan, and output accounting always
remain. Same-process quick A/B direction was:
confirmed that unnecessary numeric lexeme measurement was the root cause;
`full` still requires that work and moved near the noise range.
The final baseline used Criterion's default 3 second warm-up, 5 second
measurement, and 100 samples in a working tree based on `40121aa`:
```bash
cargo bench --bench budgeted_serde_json -- encode --noplot
```
Environment: Intel(R) Core(TM) i5-9600K, Linux 6.17.0-35-generic x86_64,
rustc 1.94.0 (LLVM 21.1.8).
decoration, transaction lifetime, per-event output writing, and enabled value
accounting. Any further output-writer experiment must be isolated from this
capability-cache change.
## 2026-08-25 output-accounting separation experiment
`encode/incremental-output-only` was added so incremental writing used the same
output-byte limit as `encode/output-only`:
```bash
cargo bench --bench budgeted_serde_json -- encode/output-only --quick
cargo bench --bench budgeted_serde_json -- encode/incremental-writer --quick
cargo bench --bench budgeted_serde_json -- encode/incremental-output-only --quick
```
For 993 B / 64 KiB / 1 MiB, buffered `output-only` throughput was about 248 /
287 / 292 MiB/s; unlimited incremental writing was about 566 / 600 / 604 MiB/s;
and equally bounded incremental writing was about 381 / 393 / 377 MiB/s. This
shows measurable output-accounting cost plus independent allocation/copy cost
in the buffered `Vec` path. Evidence did not justify changing accounting
semantics or removing per-write boundary checks, so only the repeatable
comparison benchmark was retained.
## 2026-08-26 remaining encode-cost separation
Before-change quick measurements were pinned to CPU 3 at commit
`0d6db73a2c29838769df44f762f3b6ec913ec91f`. Three same-shape direct
`serde_json` controls were then added: `incremental-serde-json`,
`numeric-serde-json`, and `raw-value-serde-json`. They isolate ordinary
serializer wrapping, numeric-event wrapping, and complete RawValue lexical
validation. Toolchain and CPU remained rustc 1.94.0 / LLVM 21.1.8 and
Intel(R) Core(TM) i5-9600K.
Linux `perf` was unavailable because `kernel.perf_event_paranoid=4`; no usable
flamegraph or valgrind tool was installed. Permissions were not changed.
Shape controls within the same Criterion process were used instead. Final
sampling used 1 second warm-up, 2 seconds measurement, and 50 samples:
```bash
taskset -c 3 cargo bench --bench budgeted_serde_json -- \
```
Ratios use the direct same-row `serde_json` path as 1.00×. Direct RawValue
trusts the constructed value while `JsonEncoder` revalidates by contract, so
that ratio quantifies contract cost and is not evidence for removing validation.
| Ordinary buffered structure | 67.018 / 178.70 µs / 2.67× | 1.0743 / 2.8220 ms / 2.63× |
| Ordinary incremental structure | 22.768 / 103.30 µs / 4.54× | 398.64 µs / 1.6665 ms / 4.18× |
| Numeric array | 63.969 / 82.219 µs / 1.29× | 1.0306 / 1.3013 ms / 1.26× |
| `RawValue` | 1.2525 / 140.00 µs / 111.78× | 31.164 µs / 2.2557 ms / 72.38× |
Ordinary structure primarily pays for re-entering strict serializer/compound
wrappers for each nested value; numeric paths differ by only about 26%-29%;
RawValue is dominated by the required full lexical scan. Without duplicating a
Serde serializer/compound state machine or removing RawValue validation, no
small candidate was likely to meet the 10% primary-endpoint threshold. No
candidate code was retained; the three controls remain for future architecture
work.
This experiment also found a missing contract regression: non-finite `f32` and
`f64` values had been delegated to `serde_json` and emitted as `null`, contrary
to the number contract. The serializer now rejects them in every budget mode.
The regression test failed before the fix and the full text-encode tests passed
after it. Quick primary matrices showed no reproducible regression above 5%.
This is a correctness fix, not a performance claim.
## 2026-08-31 unlimited encode and tree fast paths
First-principles separation identified required work (strict Serde traversal,
number/key validation, byte generation) and removable work (output
buffer/accounting with no output limit; tree measurement/admission with no
value limits). Quick screening was pinned to CPU 3 and used only to select
candidates for full sampling.
### 1 MiB object-array encode quick medians
| `serde_json` | 1.0228 ms | same control | reference |
| `strict-only` | 2.3252 ms | 1.0620 ms | about -54%; retain E1 |
| `value-only` | 3.1161 ms | 1.8543 ms | about -40%; retain E1 |
| `output-only` | 2.9761 ms | 3.2209 ms | E1 not used; verify quick noise with full sampling |
| `full` | 3.7308 ms | 3.9392 ms | E1 not used; verify quick noise with full sampling |
E1 writes directly to the owned `Vec<u8>` when there is no output limit while
retaining value transactions and RawValue failure propagation. Afterwards
`strict-only` was only about 4% from direct `serde_json`, below the theoretical
5% benefit ceiling of duplicating a no-value-budget serializer, so E2 was
rejected. E3 lacked profiler evidence because `kernel.perf_event_paranoid=4`
and permissions were not changed. E4 would couple full RawValue scanning to
output state and cannot emit incremental bytes before validation completes, so
it was rejected. String-heavy, owned/reused-session, and RawValue controls
remain in the suite.
### Representative unlimited tree quick medians
| reader array / 1K | 16.2 µs | 8.22 µs | about -49% |
| reader array / 16K | 252 µs | 156.85 µs | about -38% |
| reader object / 256 | 15.5 µs | 10.09 µs | about -35% |
| reader object / 4096 | 269 µs | 164.87 µs | about -39% |
| reader deep tree | 8.66 µs | 5.87 µs | about -32% |
| mutator large array | 669 µs | 157.8 µs | about -76% |
| mutator large object | 1.97 ms | 1.49 ms | about -24% |
| mutator deep tree | 48.5 µs | 30.97 µs | about -36% |
The fast path met the retention threshold; bounded paths kept all original
checks and quick variation was reviewed through full Criterion sampling:
```bash
taskset -c 3 cargo bench --bench budgeted_serde_json -- --noplot
taskset -c 3 cargo bench --bench tree_bench -- --noplot
```
Both completed with Criterion's default 3 second warm-up, 5 second measurement,
and 100 samples. Final 1 MiB encode medians were `serde_json` 1.1117 ms,
`strict-only` 0.9944 ms, `value-only` 1.7384 ms, `output-only` 3.2153 ms, and
`full` 4.0015 ms. The `output-only` change interval crossed zero, so no
regression was detected. `value-only` changed -5.87%, consistent with E1.
Final reader unlimited medians for array 1K/16K, object 256/4096, and deep tree
were 9.93 µs, 157.05 µs, 9.87 µs, 161.64 µs, and 5.74 µs; corresponding
bounded medians were 17.96 µs, 281.65 µs, 16.51 µs, 270.58 µs, and 9.24 µs.
Mutator unlimited medians for large array, large object, and deep tree were
169.53 µs, 1.4316 ms, and 28.51 µs. All four limit combinations completed.
Protected paths varied in both directions from earlier quick samples, but large
differences were insignificant and no stable regression above 3% appeared.
Behavioral tests also confirmed identical reader callback sequences for
unlimited and bounded paths.
## 2026-08-31 current budgeted-encode hotspot recheck
The current tree ran
`cargo bench --bench budgeted_serde_json -- encode --quick`. Same-fixture
`strict-only`, `value-only`, `output-only`, `full`, and incremental controls
separated costs. One MiB medians were:
| `serde_json` | 1.1144 ms | 1.00× |
| `strict-only` | 996.43 µs | 0.89× |
| `value-only` | 1.7374 ms | 1.56× |
| `output-only` | 3.1972 ms | 2.87× |
| `full` | 4.2207 ms | 3.79× |
| `incremental-serde-json` | 423.93 µs | 0.38× |
| `incremental-writer` | 1.7001 ms | 1.53× |
| `incremental-output-only` | 2.3780 ms | 2.13× |
The matrix confirms that output accounting and materialized-value accounting
are the main current costs; session construction is not. Criterion changes
against saved baselines did not identify a stable 5% candidate, and output
accounting must preserve per-write boundary checks and partial-output behavior.
No new encode hot-path optimization was committed. Future work requires
fixed-CPU profiler evidence, an isolated A/B of the output writer or admission
plan, and simultaneous verification of budget, I/O, and incremental-output
semantics.