fastjson 0.3.0: drop-in faster ext/json for PHP, backed by yyjson (6× encode, 2.7× decode, 5× validate)
I maintain fastjson, a native PHP extension that drops in next to ext/json with a namespaced fastjson_* API and the same flag/error model. 0.3.0 just landed.
The problem. ext/json leaves a lot of performance on the table on both encode and decode paths. High-throughput PHP APIs spend a non-trivial fraction of CPU budget in JSON serialization. The two existing escape hatches are uncomfortable: simdjson_php is decode-only and not API-compatible, and rolling a custom validator is fragile.
The shape. fastjson exposes fastjson_encode/decode/validate behind a namespaced API that mirrors ext/json's. Backed by yyjson 0.12.0 (MIT). PHP 8.3 minimum; coexists with ext/json so adoption is opt-in per call site, not a repo-wide flag day.
Numbers. Full simdjson_php canonical 14.8MB corpus, 15 files, i9-13950HX, release builds of both PHP (8.6.0-dev) and fastjson:
- Decode (stdClass): 602 MB/s vs ext/json 227 MB/s = 2.66×
- Decode (assoc array): 628 MB/s vs ext/json 237 MB/s = 2.65×
- Encode: 1,092 MB/s vs ext/json 180 MB/s = 6.06×
- Validate: 1,352 MB/s vs ext/json 265 MB/s = 5.10×
Visual side-by-side (also includes ext/json (https://github.com/php/php-src/pull/17734) SIMD encode and simdjson_php on the same PHP build): https://iliaal.github.io/fastjson/baseline.html
Drop-in mechanics. fastjson_* signatures track ext/json. JSON_* flags and JSON_ERROR_* constants match byte-for-byte; fastjson_last_error mirrors json_last_error. Migration is search-and-replace.
Honest tradeoff. Decode and validate hold the yyjson doc in memory alongside results. Decode peak heap is ~1.7× ext/json's. Validate peak is ~101× ext/json's streaming validator (constant ~80 bytes), already 2.7× better than yyjson's stock read path thanks to a vendored patch. Encode is one-stage (direct write into smart_str), peak ~1.06× ext/json. If you are validate-heavy on huge inputs under tight memory_limit, the memory profile is a real consideration. For most callers the speedup wins.
What 0.3.0 does:
- ~36% speedup on object-heavy decode under JSON_INVALID_UTF8_IGNORE/SUBSTITUTE. A no-alloc UTF-8 validator scans each string and object key first; the sanitizer only runs on byte sequences that actually need replacement. Valid UTF-8 inputs no longer pay a per-string sanitize allocation and copy.
- HEX flag rewrites (JSON_HEX_TAG/AMP/APOS/QUOT) now scan first and skip the rewrite entirely when no candidates exist. ~4× faster on the no-hit case (532 µs to 125 µs on 1k strings), ~13% regression on the all-hit case from the extra scan pass.
- dw_emit_double checks the cheap range bound before calling floor(). Non-integer or out-of-range doubles in number-heavy arrays no longer pay libm per element.
- Two correctness fixes: a use-after-free in fastjson_encode for PHP 8.4 objects whose property has a SET hook but no GET hook (engine's trivial-read fast path returns a borrowed pointer; the previous stash logic freed it), and a 32-bit zend_long overflow on the integer-valued-double shortcut that could emit INT32-saturated garbage for things like fastjson_encode(1e10).
- ext/json parity for integer-valued doubles between 1e15 and 1e17. fastjson_encode(1e16) now emits "10000000000000000" instead of yyjson's "10000000000000000.0", matching json_encode.
Install via PIE:
pie install iliaal/fastjson
Repo: https://github.com/iliaal/fastjson
Open to feedback on flag coverage and edge cases, especially anyone running JSON_INVALID_UTF8_IGNORE or the HEX flags on hot paths.