sonic-rs (Rust)
A SIMD-accelerated JSON library from ByteDance/CloudWeGo whose distinguishing bet is on-demand parsing: instead of building a whole-input structural index like simdjson or an eager tape like simd-json, it points SIMD kernels only at the work you ask for — skipping over unread containers, whitespace, and strings — and hands back a LazyValue that is a borrowed slice of the still-unparsed JSON.
| Field | Value |
|---|---|
| Language | Rust (edition 2021; stable Rust — nightly no longer required) |
| License | Apache-2.0 (Cargo.toml); vendored notices for the sources it borrows from under licenses/ |
| Repository | cloudwego/sonic-rs |
| Documentation | docs.rs/sonic-rs · docs/performance.md · docs/serdejson_compatibility.md |
| Authors | Volo Team <volo@cloudwego.io> (ByteDance / CloudWeGo) |
| Category | SIMD / data-parallel (Rust) — targeted-SIMD, lazy on-demand reader + serde deserializer |
| Algorithm / grammar class | Scalar recursive-descent skeleton over RFC 8259 JSON, with SIMD kernels bolted onto the hot loops (strings, floats, skips) |
| Error recovery | None — fail-fast validating parser; serde_json-style errors with source position |
| On-demand / lazy model | get/get_many by JSON-pointer → LazyValue (borrowed raw-JSON slice); to_array_iter/to_object_iter lazy iterators |
| Latest release | v0.5.9 (Cargo.toml) |
NOTE
sonic-rs is not a parser generator or combinator library — it parses exactly one grammar (JSON, plus a streaming StreamDeserializer). Its interest to this survey is its placement in the SIMD design space: it is the data point for targeted, on-demand SIMD JSON in Rust, deliberately rejecting the whole-input two-stage pipeline that defines simdjson and its Rust port simd-json. Read it against those two, against the tape-building C library yyjson, and against the DOM-first rapidjson; the capstone comparison lines them up.
Overview
What it solves
sonic-rs targets the same bottleneck as every parser in this category — JSON ingestion at line rate — but for the Rust/serde ecosystem, and with a different center of gravity. Its pitch is a superset of serde_json's API plus "blazing performance" on two workloads serde_json handles poorly: deserializing straight into Rust structs, and plucking a few fields out of a large document without parsing the rest. The README frames the library as an all-in-one:
"A fast Rust JSON library based on SIMD. It has some references to other open-source libraries like sonic_cpp, serde_json, sonic, simdjson, [rust-std] … and more." —
README.md
That lineage is the key to reading the project: it is the Rust member of ByteDance's sonic family — sonic-cpp (C++) and sonic (Go) came first — reusing their SIMD kernels, borrowing serde_json's de/serialization code for compatibility, and pulling float parsing from rust-std. The Acknowledgement is explicit:
"We rewrote many SIMD algorithms from sonic-cpp/sonic/simdjson/yyjson for performance. We reused the de/ser codes and modified necessary parts from serde_json to make high compatibility with
serde. We reused part codes about floating parsing from rust-std to make it more accurate."
Design philosophy
Two commitments shape the codebase, and both are departures from simdjson:
Targeted SIMD, not whole-input structural indexing. sonic-rs applies vector instructions surgically to the four spots that dominate a JSON workload, and nowhere else. The
READMEbenchmark note states the design choice outright:"The main optimization in sonic-rs is the use of SIMD. However, we do not use the two-stage SIMD algorithms from
simd-json. We primarily use SIMD in the following scenarios: 1. parsing/serialize long JSON strings 2. parsing the fraction of float number 3. Getting a specific elem or field from JSON 4. Skipping white spaces when parsing JSON."There is no stage-1 pass that classifies every byte and emits a structural index; the parser is a conventional recursive-descent walk whose inner loops call into SIMD helpers. This is the structural antithesis of
simdjson's "index the whole document first" model.Directness — no intermediate representation on the struct path. Where
simd-jsonparses to atapeand then walks the tape into a Rust value, sonic-rs deserializes JSON text straight into the target struct. From theREADME:"Sonic-rs is faster than simd-json because simd-json (Rust) first parses the JSON into a
tape, then parses thetapeinto a Rust struct. Sonic-rs directly parses the JSON into a Rust struct, and there are no temporary data structures."
On-demand access is the philosophy taken to its limit: for get, the "value" you receive is a LazyValue — a borrowed slice of the original JSON text that has been located but not decoded — so the cost of a field lookup is bounded by skipping to it, not by parsing the document.
How it works
sonic-rs has three front-ends over a shared scalar/SIMD Parser (src/parser.rs): the serde deserializer (from_str/from_slice into any Deserialize type), the Value DOM (an arena-backed mutable tree), and the on-demand get/iterator layer (src/lazyvalue/). All three share the same SIMD primitives.
The four SIMD kernels
| Workload | Kernel | Mechanism |
|---|---|---|
| Skip whitespace | get_nonspace_bits (util/arch/x86_64.rs) | _mm256_shuffle_epi8 (pshufb) classifies 64 bytes as space/non-space → bitmask of non-space |
| In-string / escape masking | prefix_xor, get_escaped_branchless_u64 (arch/x86_64.rs, parser.rs) | carry-less multiply (_mm_clmulepi64_si128) for the in-string mask; branchless escape run detection |
| Skip a container | skip_container_loop (docs/performance.md, parser.rs) | SIMD bracket bitmaps &! instring, count {/} (or [/]) to find the matching close |
| Parse float fraction | simd_str2int (sonic-number, from sonic-cpp) | vectorized ASCII-digit accumulation for ≤16-digit runs; Lemire's algorithm for the f64 |
The whitespace and string kernels are literally the simdjson tricks re-expressed in Rust. prefix_xor is the same four-instruction carry-less multiply that simdjson uses to turn a quote bitmask into an in-string mask (arch/x86_64.rs):
pub unsafe fn prefix_xor(bitmask: u64) -> u64 {
let all_ones = _mm_set1_epi8(-1i8);
let result = _mm_clmulepi64_si128(_mm_set_epi64x(0, bitmask as i64), all_ones, 0);
_mm_cvtsi128_si64(result) as u64
}and get_escaped_branchless_u64 is the odd-/even-backslash-run computation from simdjson, marked as such in docs/performance.md: "This SIMD branchless algorithm is borrowed from simdjson, implemented in get_escaped_branchless_u64." The difference from simdjson is where they run: not over the whole input, but inside skip_space, skip_string, and skip_container — only when the parser actually needs to move past something.
On-demand get by JSON-pointer
The headline feature. get(json, path) walks a JSON-pointer path and returns the located sub-value as a LazyValue without decoding it (README):
let path = pointer!["a", "b", "c", 1];
let json = r#"{"u": 123, "a": {"b" : {"c": [null, "found"]}}}"#;
let target = unsafe { get_unchecked(json, &path).unwrap() };
assert_eq!(target.as_raw_str(), r#""found""#); // still raw JSON text
assert_eq!(target.as_str().unwrap(), "found"); // decoded only on demandA LazyValue "wrappers an unparsed raw JSON text … borrowed from the origin JSON text" (lazyvalue/value.rs). The traversal (get_from_object / get_from_array, src/parser.rs) is where the SIMD skips earn their keep. In the unchecked fast path, once a key mismatches, the parser skips its value wholesale — a container via the SIMD skip_container, a string via skip_string_unchecked, then jumps to the next " or } with get_next_token:
// skip object,array,string at first (unchecked fast path)
match self.skip_space() {
Some(b'{') => self.skip_container(b'{', b'}')?,
Some(b'[') => self.skip_container(b'[', b']')?,
Some(b'"') => unsafe { let _ = self.skip_string_unchecked()?; },
None => return perr!(self, EofWhileParsing),
_ => {}
};
// optimize: direct find the next quote of key or object ending
match self.get_next_token([b'"', b'}'], 1) { /* … */ }skip_container is the SIMD core: skip_container_loop loads 64-byte blocks, computes the in-string bitmap, masks the bracket bitmaps with &! instring, and counts brackets to find the balance point — so { "key": "value {}" } skips correctly despite the braces inside the string. docs/performance.md credits the JSONSki paper for the bit-parallel container skip. get_many(json, &PointerTree) extends this to fetch several pointers in one traversal (get_many_rec, src/parser.rs).
The checked path (get, no unsafe) does the same skips but fully validates each skipped value with skip_one(true) — the safety/speed knob discussed under Error handling.
The serde deserializer path
from_str/from_slice route through from_trait (src/serde/de.rs), which drives the recursive-descent Parser straight into the caller's Deserialize impl — no tape, no DOM. UTF-8 is validated by the simdutf8 crate: from_slice validates (checked), while from_str/from_slice_unchecked trust their input; a final check_utf8_final() runs after parse. There is a hard 4 GB ceiling ("parsing JSON larger than 4 GB is not supported", from_trait) because Value node offsets are 32-bit.
The Value DOM — arena, not node-graph
sonic_rs::Value is a mutable untyped document backed by a bumpalo bump arena, borrowed from sonic-cpp/rapidjson's pool idea (docs/performance.md):
"we also use the
bumpcrate in sonic-rs to preallocate memory for the entire document. Arena allocation can reduce memory allocation overhead and make the cache more friendly since the memory locations of nodes in the document are adjacent."
Two more DOM tricks: the node vector is pre-sized to json.len() / 2 + 2 (the maximum node count of valid JSON) so it never reallocates mid-parse, and a JSON object is stored as an array, not a hashmap — "Sonic-rs does not build a hashmap" (README) — trading O(1) key lookup for cache-friendly construction and mutation.
Number handling
Number is the untyped numeric type; RawNumber preserves numbers losslessly as their original text, "like encoding/json.Number in Golang" (README), and can even be parsed from a JSON string. Float parsing defaults to Rust-std precision (correctly rounded) via Lemire's algorithm in the sonic-number sub-crate — no float_roundtrip-style opt-in is needed, unlike serde_json.
serde_json drop-in compatibility
sonic-rs re-exports Deserialize/Serialize from serde and mirrors serde_json's free functions (from_str, from_slice, to_string, to_vec, …). The migration guide (docs/serdejson_compatibility.md) is a four-line type map:
serde_json | sonic_rs |
|---|---|
&serde_json::RawValue | sonic_rs::LazyValue<'a> |
Box<serde_json::RawValue> | sonic_rs::OwnedLazyValue |
serde_json::Value | sonic_rs::Value |
serde_json::RawNumber | sonic_rs::RawNumber |
with one documented semantic difference — sonic_rs::Value differs from serde_json::Value "when JSON has duplicate keys" (object-as-array keeps duplicates rather than collapsing them).
Algorithm & grammar class
sonic-rs parses exactly one grammar — RFC 8259 JSON (plus newline-independent streaming via StreamDeserializer); there is no grammar input. On the algorithm axis it sits between the pure-scalar recursive-descent parsers and the whole-input SIMD parsers:
- The control structure is ordinary recursive descent —
parse_object,parse_array,parse_number,parse_string,parse_literalinsrc/parser.rs, a pushdown walk with single-byte lookahead. JSON isLL(1)-style: each value's type is fixed by its first byte, so no backtracking or ambiguity arises. This is the same automaton class assimdjson's stage 2, but reached one byte at a time rather than by iterating a precomputed structural index. - SIMD appears only inside the leaf loops — skipping runs of whitespace, scanning to a string's closing quote, balancing a container's brackets, and accumulating a float's digits. These are the finite-state sub-problems reformulated as branchless bit/vector arithmetic (carry-less multiply,
pshufbclassification), exactly thesimdjsontechniques — but localized, so there is no O(n) structural-index array over the whole document.
The consequence is a different cost profile from the two-stage parsers: sonic-rs pays nothing to index bytes it will skip, which is why its on-demand get and struct-deserialize numbers lead the field, while its whole-document untyped numbers rely instead on the arena and object-as-array tricks rather than a vectorized front-end.
Interface & composition model
There is no grammar DSL, no combinator, no generator — the surface is JSON-in, values-out, with four host-facing shapes:
| API | Shape | When |
|---|---|---|
serde | from_str/from_slice → any Deserialize; to_string ← any Serialize | Drop-in serde_json replacement; fastest struct path |
| On-demand get | get/get_unchecked/get_many by pointer! → LazyValue/OwnedLazyValue | Pluck a few fields from a large document without full parse |
| Lazy iterators | to_array_iter/to_object_iter[_unchecked] → iterator of LazyValue | Stream over an array/object, decoding elements on demand |
Value DOM | from_str → mutable arena-backed Value; json! macro; pointer/index access | Random access, mutation, building JSON programmatically |
Composition is with the host program (borrowed &str/FastStr/Bytes in, borrowed LazyValue out), not with other parsers — the LazyValue returned by get borrows from the input buffer, so the buffer must outlive it, and it re-enters the same engine when you later decode or iterate it.
Performance
The published docs/ numbers (Intel Xeon Platinum 8260, twitter/citm_catalog/canada corpora) all compare against simd-json and serde_json:
- Deserialize into struct.
twitter: sonic-rsfrom_slice~828 µs vssimd_json~1.09 ms vsserde_json::from_slice~2.29 ms; the_uncheckedvariant ~708 µs. sonic-rs leads on all three files, attributed to skipping thesimd-jsontape (README). - Deserialize untyped (
Value).twitter:sonic_rs_dom::from_slice~556 µs vssimd_json::slice_to_borrowed_value~1.20 ms vsserde_json::from_slice~3.80 ms — credited to the arena, fewer allocations, and object-as-array (README). - Get one field.
twitter/get_unchecked_from_str~77 µs vsget_from_str(validated) ~435 µs vsgjson~363 µs — the on-demand advantage, and the sharp cost of turning validation on. - Serialize. Wins on
twitter(many long strings, favoring the SIMDcopy-and-findstring serializer) andcitm_catalog; roughly par withserde_jsononcanada(mostly floats). - Backtracking / memoization. None — recursive descent with single-byte lookahead; skips are forward-only.
- Zero-copy.
LazyValue/as_raw_strborrow from the input;to_array_iterstreams without materializing the whole array.
WARNING
The numbers assume -C target-cpu=native. sonic-rs selects its SIMD backend at compile time (cfg_if! on target_feature, src/util/arch/mod.rs) — x86-64 needs pclmulqdq+avx2+sse2, aarch64 needs neon, else a scalar fallback — so a generic build silently drops to the slow path. Runtime CPU detection is still an open ROADMAP item, a real difference from simdjson's runtime dispatch.
Error handling & recovery
sonic-rs is a strict, fail-fast validating parser — no recovery, no partial parse. Errors follow serde_json: an Error/ErrorCode with a source position and a rendered pointer at the failure site (e.g. "Expected this character to be either a ',' or a ']' while parsing at line 1 column 17", from the iterator example in the README). The get API surfaces typed failures — is_not_found(), is_unmatched_type() — for path lookups.
The distinctive knob is the checked/unchecked split, present on every entry point (from_slice vs from_slice_unchecked, get vs get_unchecked, to_object_iter vs to_object_iter_unchecked):
- Checked validates UTF-8 (via
simdutf8) and fully validates every value it skips (skip_one(true)), so malformed JSON anywhere is caught. - Unchecked (
unsafe) trusts the input is valid UTF-8 and well-formed, and uses the SIMD fast-skip that does not re-validate skipped regions — much faster (77 µs vs 435 µs forget) but "may return unexpected result" on invalid JSON (get.rsdocs). This is a caller-facing correctness/speed trade-off, not automatic.
There is no error recovery, no resynchronization, no incremental reparse — sonic-rs is built for batch ingestion and field extraction, not for editors or language servers. For the tolerant, incremental end of the design space see tree-sitter; the contrast with the strict SIMD parsers is drawn in the comparison.
Ecosystem & maturity
sonic-rs is a production library from ByteDance's CloudWeGo org (alongside the Volo RPC framework and the Go sonic), published on crates.io with docs.rs documentation, CI, codecov, and a fuzz/ harness. It ships a for_Golang_user.md guide (for teams migrating from the Go sonic) and Golang/serde compatibility docs. Optional cargo features tune behavior: arbitrary_precision, sort_keys, utf8_lossy, sanitize (LLVM-sanitizer false-positive avoidance, ~30% serialize cost), non_trailing_zero, and avx512 (Rust 1.89+). The sonic-simd sub-crate is a portable SIMD layer (x86 AVX2/AVX-512, ARM NEON, wasm128, scalar fallback; RIS-V is a TODO).
Its lineage gives it unusual depth of prior art: it inherits algorithms and test corpora from sonic-cpp, sonic (Go), simdjson, yyjson, and serde_json at once. ROADMAP.md lists runtime CPU detection, JSONPath, and JSON Merge Patch (RFC 7396) as future work.
Strengths
- On-demand
get/get_manyreturns a borrowedLazyValuewithout decoding — the fastest way in the Rust ecosystem to pluck fields from a large document (~77 µs vsgjson~363 µs ontwitter). - Direct struct deserialization with no intermediate tape or DOM — beats
simd-json(which walks a tape) andserde_jsonon every struct benchmark. serde_jsondrop-in: same free functions, re-exportedserdetraits, a four-line type-map migration — adoption cost is near zero.- Arena-backed mutable
Valuewith pre-sized node storage and object-as-array — fast untyped parse and cheap in-place mutation. - Rust-std float precision by default (no
float_roundtripopt-in), plus losslessRawNumberwhen you need the original text. - Reuses battle-tested SIMD kernels from the
sonic-cpp/simdjson/yyjsonlineage rather than inventing them.
Weaknesses
- No whole-input validation shortcut: because SIMD is localized, there is no single fast structural pass — the validated (
checked) paths are markedly slower than theuncheckedones, and the fast paths areunsafeand trust their input. - Compile-time-only SIMD dispatch: needs
-C target-cpu=native; a portable binary silently uses the scalar fallback. No runtime CPU detection yet (unlikesimdjson). - 4 GB document ceiling —
Valueuses 32-bit node offsets, so larger inputs are rejected outright. - One grammar only. Not a parsing toolkit; you cannot express another language. (By design.)
- No error recovery / incremental reparse / IDE diagnostics — wrong tool for editors or language servers.
x86_64/aarch64only for the fast paths; other architectures fall back to scalar and "maybe very slower" (README).unsafe-heavy fast paths and object-duplicate-key semantics that differ subtly fromserde_json::Value.
Key design decisions and trade-offs
| Decision | Rationale | Trade-off |
|---|---|---|
| Targeted SIMD in four hot loops, not a two-stage whole-input structural index | Pays no cost to index bytes it will skip; wins on struct-deser and field-get | No single fast validation pass; validated paths are slower; loses simdjson's uniform cost model |
On-demand get → borrowed LazyValue | Field extraction bounded by skipping, not parsing; zero-copy raw slice | LazyValue borrows the input (lifetime constraint); re-parses when later decoded |
| Deserialize straight into the struct (no tape/DOM) | Eliminates simd-json's tape→struct second walk | No reusable intermediate; each API re-traverses the text |
checked vs unchecked on every entry point | Lets callers buy speed with an invariant (valid UTF-8 + well-formed JSON) | unchecked is unsafe and returns garbage on invalid input; correctness moved to the caller |
bumpalo arena + pre-sized node vec + object-as-array for Value | Fewer allocations, cache-adjacent nodes, no hashmap build; fast untyped parse & mutation | O(n) key lookup in objects; duplicate-key semantics differ from serde_json::Value |
Compile-time SIMD selection via cfg_if! on target_feature | Zero dispatch overhead in the hot loop; simplest to implement | Needs -C target-cpu=native; portable builds fall back to scalar; runtime detection still TODO |
Reuse simdjson/sonic-cpp/serde_json code rather than re-derive | Proven kernels and high serde compatibility, faster to a correct release | Inherits their constraints (e.g. simdjson's escape/quote tricks, 32-bit offsets) |
| Rust-std float precision by default | Correct rounding without a float_roundtrip-style opt-in; RawNumber for lossless text | Slightly more work than a fast-but-lossy float path |
Sources
cloudwego/sonic-rs— GitHub repository · docs.rs/sonic-rsREADME.md— SIMD scenarios, benchmarks,get/LazyValue/Numberusage, acknowledgementsdocs/performance.md— on-demand container skip,skip_space, SIMD float parse, arena allocatordocs/serdejson_compatibility.md— theserde_json→sonic_rstype mapsrc/parser.rs— recursive-descent core,get_from_object/get_from_array,skip_container, escape maskingsrc/util/arch/x86_64.rs—prefix_xor(clmul),get_nonspace_bits(pshufb)src/lazyvalue/value.rs—LazyValue(borrowed raw-JSON wrapper)- Geoff Langdale, Daniel Lemire, Parsing Gigabytes of JSON per Second, VLDB Journal 28(6), 2019 — the SIMD tricks sonic-rs borrows
- Lin Jiang, Junqiao Qiu, Zhijia Zhao, JSONSki: streaming semi-structured data with bit-parallel fast-forwarding, ASPLOS 2022 — the bit-parallel container skip
bytedance/sonic-cpp— the C++ sibling whose SIMD kernels sonic-rs rewrites- Related: umbrella · concepts glossary · comparison ·
simdjson·simd-json·yyjson·rapidjson·hyperscan· formal languages