std.json: parsing and writing JSON with nothing the caller did not hand it
Status: built.
std/json.cho; teststests/lex/json_*_test.cho(29, run bycancho test),conformance/json.rs; drivers undertests/programs/. Strict RFC 8259; no tree, no allocation of its own. §5 has the numbers, and §6 has the two compiler bugs it found.
1. Why
The question that started this was whether a FastAPI-shaped package is
buildable here, and the first thing it needs is the thing every request
carries. There was no JSON in the language at all: the VCS op log is JSON
on the Rust side, --output json is JSON out of the CLI, and
examples/serve writes {"ok":true} as a string literal.
2. The design: a tape, not a tree
A value tree is a heap, a free list and a drop order for every document,
and a request handler wants none of them. std.json parses into a tape
the caller provides: one flat [int], three ints a node, that points back
into the source bytes instead of copying them.
let tape = alloc_slice[a](json.tape_len(body), 0); // or a heap slice
let nodes = json.parse(body, tape);
let age = json.to_int(body, tape, json.get(body, tape, 0, "age"));
- A node is named by its index;
-1is “no node”, and every accessor answers its zero value for it, soget(get(get(..)))needs no check between steps. - A string is not decoded until asked for: the node holds the byte range
inside the quotes and a flag saying whether it has an escape. A string
with none (
string_plain) is its bytes in the source, borrowed with no copy (string_view). A number is not converted until asked for: an object is walked for the key and the one value, and the other forty are never turned into anything. - An object’s children are its pairs in order; a key at node
khas its value atk + 1and the next key atskip(k + 1).atandgetare linear in the position, which is what a flat list costs and what a request body of a few dozen fields makes irrelevant.
This is simdjson’s shape, and it is the shape here for a different reason:
it is the one that needs no ownership. Everything lives in a slice the
caller already knows how to size (tape_len is always enough: a node is at
least a byte) and free.
Writing goes the other way, through a Writer that owns a std.buffer,
puts the commas, colons and quotes in itself, and is moved through every
call like a Buffer is, because growing it replaces the allocation under
it.
3. What it decides
| Strict | RFC 8259 and nothing else: no comments, trailing commas, leading zeros, +1, .5, 1., NaN, 'single'; no unescaped control character in a string; no lone surrogate (\ud800) |
| UTF-8 is validated | Every byte ≥ 0x80 in a string is checked with std.utf8 (overlongs, surrogates, > U+10FFFF refused). A parser that accepts what another refuses is where request-smuggling bugs live |
| A parse error is a value | parse answers the node count, or 0 - (position * 16 + code); error_code, error_position, error_message take it apart. Nine codes |
parse_with is parse with the scratch state in the caller’s hands | parse_with(src, tape, st) answers exactly what parse(src, tape) answers and writes the same tape, for every input (§3.1), but takes the two ints of state as a slice instead of making a region to hold them |
| Nesting is limited to 128 | Not recursed into until the stack runs out: the stack is the one resource a parser cannot be handed |
| A full tape is an error | Code 8, not an overrun |
| Integers saturate | to_int clamps at the ends of int (a 20-digit number is not an overflow trap in a server); fits_int says whether it was exact. A float node truncates toward zero, saturating |
| Floats are correctly rounded | to_float is the nearest float to the decimal text, a tie to the even one (§4). A number past the range is ±infinity and one below it ±0.0: JSON has no such values but the number it spells does |
| Duplicate keys: the first wins | The document’s order, not an arbitrary one |
| Keys compare as decoded text | "café" is the key "café" |
| The writer cannot write bad JSON | A bare value in an object, a key outside one, two keys in a row, a close that does not match, nesting past 60, finishing with something open: a trap. They are the caller’s bugs, and the alternative is a service that sends {"a":} |
| Invalid UTF-8 going out becomes U+FFFD | The output is always a valid document, whatever was handed in |
A float that is not finite is null | What most encoders do, and what keeps the document valid |
| Floats keep their kind | 3.0 is written 3.0, so it reads back as a float; positional from 1e-6 to 1e21, scientific outside, shortest digits that round-trip |
3.1 parse_with: no region per call
parse begins region a { let st = alloc_slice[a](2, 0); ... }: two ints of state in an arena
made and freed on every call. A caller that parses one large document never notices. A
caller that parses a million small ones (JSON lines, one request body after another) pays for
the arena a million times, and on macOS that is nearly all of what a call costs.
cancho-table’s reader design (docs/readers.md §6) measured it and asked for this.
let st = alloc_slice[a](2, 0); // or a heap slice; once
let tape = alloc_slice[a](12288, 0); // once
let nodes = json.parse_with(line, tape, st); // per document, no region
st is at least two ints. Their contents on entry do not matter (parse_with sets them),
so one st serves any number of documents and nothing of one document is visible to the next.
A shorter st is the caller’s bug and traps at the first store, as any index out of range does.
parse is unchanged, byte for byte, so no identity that mentions it moved (§3.3).
The name is parse_with (open to a better one; readers.md §6 used it for its spike, and it
sorts beside parse).
Measured (benches/json_lines.py: 1,000,000 JSON lines of 72 bytes, {"id":..,"status":.., "bytes":..,"path":..,"note":..}, the task per line parse, get of status and bytes, two
to_int, a count and a sum; cost per line = (best of 5 runs of 11 rounds − best of 5 runs of 1
round) / 10 rounds / lines, so reading the file drops out; every mode’s answer is compared with
the rows’ before it is timed). parse is one tape reused for every line; region is a tape
allocated in a region per line, which is what a first version writes.
| ns per line, wall clock | parse | region | parse_with | parse / parse_with |
|---|---|---|---|---|
| macOS arm64, 1 thread (re-measured after the rebase onto edition 9; load 10 rising to 20; best of 5) | 567 | 1,035 | 94 | 6.1x |
| macOS, 16 threads (same run) | 1,623 | 3,219 | 9.7 | 167x |
macOS, 2 / 4 / 8 threads, parse (from the earlier run, load 15 to 22) | 1,241 / 1,403 / 1,492 | |||
macOS, 2 / 4 / 8 threads, parse_with (from the earlier run) | 55 / 30 / 13.8 | |||
macOS before this change (origin/main’s compiler, same program without parse_with; same session, load 20) | 571 (1 thread), 1,568 (16) | 1,139 (1), 3,222 (16) | not there | |
Linux x86-64 (gram, cores 0 to 5, load 5; re-measured; best of 5), 1 thread | 237 | 280 | 233 | 1.02x |
| Linux, 6 threads (same run) | 156 | 193 | 80.5 | 1.94x |
Linux, 2 / 4 threads (from the earlier run: parse 180 / 165, region 315 / 192, parse_with 164 / 142) |
Per line is wall time over all lines, so a perfectly parallel mode divides by the thread count.
parse on the Mac does the opposite: 1.6 µs per line at 16 threads against 0.57 µs at 1, 2.6x
slower with 16 threads than with one (2.9x in the re-measured run): a region is a malloc and a free, and this
allocator does not scale with threads here (not diagnosed further); parse_with goes from 94 to 9.7 ns, 9.6x. On Linux the arena is cheap (about 30 ns),
so the gain at one thread is small (1.02x in the re-measured run, 1.18x in the earlier one), which is what readers.md predicted (283 against 255). The number worth
having is the Mac’s: it is where a user’s laptop is, and where “a library function that gets
slower when you add threads” costs a day to find.
3.2 The differential
parse_with is the same grammar as parse and a second copy of its body (a call from parse
would have been shorter and would have moved parse’s identity, §3.3), so the claim that
they agree is tested rather than argued. tests/programs/json_with_diff.cho runs both on every
document, into a tape of the right size and into one too short (where the full code and the
partial tape come from), with the state slice still holding the previous document’s garbage, and
compares the answers and every int of both tapes. conformance/json.rs feeds it 58,733
documents (11,444 accepted, 47,289 refused): the seeds, every prefix of every seed, every short seed with each of 39 pool bytes in each
position, 40,000 randomly damaged seeds and the edge documents of the JSON test suites written
out (depth 127, 128, 129, 1000 and 100,000, lone surrogates, overlong and truncated UTF-8, a BOM,
01, 1., NaN, comments, trailing bytes). Zero differences. A second test classifies the edge
documents with serde_json so that “they agree” cannot mean “both wrong the same way”.
scripts/json_with_suite.py runs nst/JSONTestSuite (318 files; not vendored): 95 y_ all
accepted, 188 n_ all refused, 10 i_ accepted and 25 refused, parse and parse_with
agreeing on all 318, answers and tapes. Breaking parse_with three ways (not resetting the
node count, not checking trailing bytes, an off-by-one error position) fails the differential each
time (differ 3, differ 37, differ 0 is the document index that caught it).
3.3 Identity
Adding parse_with adds a body and a sig to std.json and changes none: cancho ids --std
over a program that imports every std module lists 1,805 identities on origin/main (6a8bc1ed; 1,677 when this was first measured, before more std modules arrived) and the same
1,805 plus those two on this branch (comm finds nothing only on origin/main). Over the programs in tests/ and examples/ that cancho ids can read, 95 list identical
identities with and without this change; with --std all 211 differ by exactly those two lines and nothing else. (The earlier run reported 165.)
4. Why it can be trusted
-
Numbers: 13,000 of them, bit for bit.
conformance/json.rsreads a corpus with Rust’sstr::parse::<f64>(correctly rounded) as the reference: random doubles over every exponent in their shortest form, 17-digit decimals, the exact decimal midpoint between two adjacent floats and one digit either side of it (the case a floating-point shortcut gets wrong, built fromm * 5^s / 10^sin 128-bit integers), integers on both sides of 2^53 and ofint’s ends, subnormals, the overflow edge, and exponents of a million digits. Zero mismatches.Corrected: that corpus built its halfway cells only among the normal floats (
mof 54 bits, shifts under 26), so “and subnormals” meant random subnormals in their shortest form – which no tie can be – and the claim above said more than it tested. A downstream table reader found thatto_floatread the exact halfway point between two of the larger subnormals (between about 1e-308 and 2^-1022: the 768 significant digits of(2k+1) * 2^-1075) to the wrong neighbour about half the time: the digit cap was 767, so the final5was dropped as a “sticky” digit, the tie became a value above the middle, and it went up whatever the parity. Measured: 33,541 of the 1,000,000 cells offloat_text.rs(3.4%; 440 of 1,700 in the first reproduction, and 113 of 400 exact midpoints of 52-bit subnormals) read wrong, none in any other shape. The cap is 768 now.conformance/float_text.rsreads 1,000,000 cells against Rust’s parse – the halfway point, a hair above and a hair below it for every subnormal below 64 units, for the largest subnormals and smallest normals, for 30,000 anywhere, and up toDBL_MAXplus half an ulp (a tie that rounds to infinity) – andscripts/float_differential.pyruns the same driver against Python’sfloat()and against exact rational arithmetic (fractions), 1,000,000 cells, 0 differences. The same run checksto_intagainst ani128parse clamped toi64. -
Accept and refuse what
serde_jsondoes, over damaged input. Ten valid documents and 1,500 mutations of them (one to two bytes replaced, deleted, inserted, swapped or truncated from a pool of structural and non-ASCII bytes): 291 accepted by both, 1,218 refused by both, no disagreement. Every accepted one is written back out bystd.json’s own writer and read byserde_jsonto the same value. One policy difference, found by it and declared in §3:1.5e22100is a numberserde_jsonrefuses as out of range and this accepts as an infinity. -
29 library tests, in the language, on both backends (
tests/lex/): every error code with its position, UTF-8 corner cases built byte by byte in a buffer, escape decoding including a surrogate pair, saturation at both ends ofint, writer output exact to the byte, and 1,500 floats written and read back to the same bits. -
Misuse traps are checked by building eight misusing programs and confirming each dies by signal.
4.1 The part that was hard: reading a float
A decimal with 15 or fewer digits and a power of ten within 10^±22 is two
exact floats and one correctly rounded operation (Clinger’s fast path).
Everything else – and a random double printed in 17 digits is the common
case – needs exact arithmetic, because m * 10^e in floating point is
off by an ulp often enough to break a round trip. slow_decimal does it
with std.bignum: the digits (up to 768, the most a halfway point can
have, plus a sticky digit for the rest) and the power of ten become
bignums, scaled so the quotient has 56–57 bits, divided bit by bit, and
rounded on those bits with the remainder as the sticky bit. Limbs are
sized to the number (a 17-digit one needs a dozen where a worst-case one
needs hundreds), which is what took it from 18 µs to under 1 µs.
5. Measured
tests/programs/json_bench.cho and json_write_bench.cho; timing from
outside as the difference between one round and twenty-one, so the
byte-at-a-time stdin read is left out. A 4-core sandbox; Python 3.11’s C
json, which builds every Python object where the tape builds none –
the comparison is real for a handler that touches a few fields and unfair
to the tape for one that needs every value, so both columns are given.
Reading
| document | size | tape only | tape + every float converted | Python json.loads |
|---|---|---|---|---|
| 30k API objects (strings, ints, bools, 2-decimal floats, nested, 17-digit lat/lon) | 7.2 MB | 20 ms, 355 MB/s | 93 ms, 77 MB/s | 119 ms, 60 MB/s |
| 300k floats, 17 digits | 5.9 MB | 13 ms, 461 MB/s | 277 ms, 21 MB/s | 85 ms, 70 MB/s |
| 300k floats, 3 decimals | 2.8 MB | 10 ms, 287 MB/s | 26 ms, 107 MB/s | 37 ms, 75 MB/s |
| 500k integers | 5.7 MB | 26 ms, 217 MB/s | 25 ms, 229 MB/s | 40 ms, 142 MB/s |
| 100k strings with escapes | 5.3 MB | 9 ms, 601 MB/s | 6 ms, 820 MB/s | 25 ms, 209 MB/s |
Writing 200,000 small objects (id, name, a score of the form
n/8, a bool, a two-element array), 15.7 MB, byte-for-byte the same
bytes as json.dumps(separators=(",", ":")):
| time | throughput | |
|---|---|---|
std.json Writer | 106 ms | 148 MB/s |
Python json.dumps | 266 ms | 59 MB/s |
The honest reading. Scanning is fast and conversion is not uniformly:
17-digit floats are the weak case (about 0.9 µs each, 3.3× slower than
Python’s float()), and writing a 17-digit float is slower still (§7). A
handler reading a few fields and writing a short reply is at the top of
both tables; one that converts 300,000 random doubles is not.
6. Found along the way
Writing a module that is the third to declare a pub fn drop found two
compiler bugs, both with their own regression test
(conformance/modules.rs, both backends), and modules.md §3.1.1 records
them:
- Functions: every function was emitted as
lexs_<name>. The type checker accepted two modules each declaringdrop; the assembler refused withinvalid redefinition of function 'lexs_drop'. Symbols aremodule.namenow. - Statics (silent, and worse): a
staticwas looked up among every module’s and the first taken, so two modules each withstatic tableread one table.a.first() * 10 + b.first()answered 55 where the tables held 5 and 7. Looked up in its own module now.
And one change outside this module’s files that it needed: std.fmt’s
float_into prints a number with a short decimal form (3.5, 19.99,
0.000125) without the exact algorithm (a candidate m / 10^k is checked
by one exact division; 10^-k wider than a float’s spacing makes the
candidate unique, so it is the shortest and the nearest). That took float
writing from 6 µs to under 0.5 µs where it applies, and the existing
oracle test (against Rust’s {:e}) now includes 2,000 short decimals and
their one-ulp neighbours, which must fall back correctly.
6.1 Splicing a document in: put_fragment
The writer had no way to take an already-written value – a schema another
library generated, a stored row, a cached answer – so a service assembling an
OpenAPI document or a list response from stored JSON built it by hand with a
Buffer and commas, correct only by construction. put_fragment(heap, w, fragment) writes the fragment as the next value, with the writer’s own commas
and colons. The fragment is checked: parsed with this module’s strict parser,
it must be exactly one value (whitespace around it is fine); a second value, a
trailing comma, or nothing traps, for the reason finish traps on an open
container – splicing a non-value makes the whole document invalid from a
distance. The cost is one pass over the fragment and a tape freed before it
returns. (put_raw is the writer’s private helper for null, true and the
rest, and is not that: it trusts its text.)
7. What it does not do
| Eisel–Lemire / Ryu | The fast float paths cover short decimals; a 17-digit double is ~0.9 µs to read and ~6 µs to write. Both want 128-bit multiplies and a 650-entry table of powers of five – generated, as std.math’s 2/π table was, not copied |
| A mutable document | The tape is read-only. Build a different document with the Writer |
| Streaming | A whole document in memory. A chunked reader would be a second parser |
| Integers past 64 bits | They saturate in to_int and are to_float-able; there is no big integer |
| Pretty printing | {"a":1}, never indented |
| Comments, JSON5, trailing commas | Refused, by design (§3) |
| Schema validation, typed decoding | No reflection, no macros: a model is a hand-written function over the tape. Generating those from a declaration is a tool, not a library |
| Newline-delimited JSON | Split on \n with std.bytes and parse each with parse_with (§3.1), one tape and one state for the whole file |
8. Open
| Question | Why it waits |
|---|---|
A hash map, for objects too wide for a linear get | std.map exists now (map.md) with byte-string keys; indexing a parsed object into one is a few lines a caller can write, and nothing here does it for them |
| Float reading and writing at Python speed | §7’s first row |
Built: http.md (parser, router) and server.md (examples/api, which reads and writes JSON bodies with this module) |