Skip to content

Eliminate deep copies: 20-6000x faster, linear memory - #1

Merged
megasteve19 merged 7 commits into
0.xfrom
perf/eliminate-deep-copies
Jul 24, 2026
Merged

Eliminate deep copies: 20-6000x faster, linear memory#1
megasteve19 merged 7 commits into
0.xfrom
perf/eliminate-deep-copies

Conversation

@megasteve19

@megasteve19 megasteve19 commented Jul 24, 2026

Copy link
Copy Markdown
Member

What

Benchmarking showed every hot path was dominated by redundant deepCopy calls: whole-document copies in JsonPointer::get/has, per-traversal-step copies in childValue, and per-comparison copies in the equality helpers — the latter made diff quadratic in nesting depth for both time and memory (diffing two identical 800-level documents exhausted the default 128M limit).

Four commits, no extensions, PHP engine features only:

  1. Copy-on-write rewrite — arrays are engine value types, so sharing them is copying; only stdClass (the sole reference-semantics JSON value) is cloned, and only in object-bearing subtrees. Read paths copy nothing. Index-based descent replaces array_shift; diff builds pointer strings incrementally.
  2. === fast path + single-pass diff — strict equality is sound as a positive-only check, and the engine compares identical COW hashtables by pointer, so diffing a document against a patched derivative of it is near O(changed paths). diffValue no longer deep-compares at every ancestor.
  3. Trusted decode pathapplyJson/diffJson/merge applyJson skip validation and isolation for freshly decoded input (structurally valid by construction, referenced by nobody).
  4. Token machineryctype_digit with a leading-zero guard replaces preg_match per array token; escape handling bails early when no escapable characters exist.

Numbers (PHP 8.4, medians)

Scenario Before After Factor
1000× JsonPointer::get, 1k-item stdClass doc 1334 ms 0.8 ms ~1600×
500 deep replaces, stdClass doc 666 ms 2.3 ms ~290×
diff, ~9.8k-node tree, 2 changes 402 ms / 51 MB 12.7 ms / ~0 MB 32×
diff, identical 800-deep chain 109 ms / 231 MB (OOM at 128M) 0.04 ms / ~0 MB ~2900×, quadratic → linear
pointer set at depth 400 37 ms / 34 MB 0.11 ms / 0.2 MB ~330×
diff, identical 5k-item docs 37 ms 2.9 ms 13×
applyJson, 1 op on 10k-item doc 14.8 ms 7.9 ms ~2× (rest is decode/encode)

Contract

  • All 189 tests pass, phpstan (strict rules) clean, cs-fixer clean. Emitted diff operations are byte-identical; RFC 6902/6901/7396 behavior unchanged.
  • New tests pin the isolation contract (mutating a result never writes through to inputs, copy op never aliases inside the result) and a memory bound on deep diffs that fails on master.
  • One deliberate refinement: JsonPointer::get/has validate only the subtree they traverse and return, not the whole document — a read no longer throws because an unrelated branch holds a non-JSON value. Write operations still validate the full document exactly as before.

Benchmarking showed every hot path was dominated by deepCopy calls:
whole-document copies in JsonPointer::get/has, per-traversal-step copies
in childValue, and per-comparison copies in the equality helpers (the
latter made diff quadratic in nesting depth for both time and memory —
diffing two identical 800-level documents exhausted 128M).

Arrays are PHP value types with engine copy-on-write, so sharing them is
already copying. The new import() validates and clones only stdClass
nodes (the sole reference-semantics JSON values), rebuilding just the
object-bearing subtrees. Read paths (traversal, has, get, equality) now
copy nothing. Token descent is index-based instead of array_shift, and
diff builds pointer strings incrementally.

Isolation contract is unchanged and now pinned by tests, including a
memory bound on deep diffs that failed before this change.
jsonValuesAreEqual now tries strict equality first: it is sound as a
positive-only check, and the engine compares identical copy-on-write
hashtables by pointer, so subtrees shared between a document and a
patched derivative of it compare in constant time.

diffValue previously ran a full deep-equality check at every node before
recursing, so each ancestor re-compared all of its descendants. It now
recurses directly into same-type containers and only falls back to the
equality helper for leaves and mixed representations (1 vs 1.0, assoc
array vs stdClass), which keeps emitted operations byte-identical while
dropping the O(size x depth) factor.
Values produced by json_decode cannot contain resources, closures, or
non-finite floats, and no other code holds references into them. The
JSON-string entry points (applyJson, diffJson, merge applyJson) now
bypass the validating isolation pass and operate on the decoded values
directly; results are encoded immediately, so nothing observable leaks.
Array-index tokens went through preg_match on every traversal step; a
ctype_digit check with an explicit leading-zero guard accepts and
rejects exactly the same token set. Escape handling now bails out
before scanning when a token contains no escapable characters, and
fromString unescapes in place instead of mapping through a closure.
This targets the per-call constant of pointer-heavy workloads.
@megasteve19
megasteve19 force-pushed the perf/eliminate-deep-copies branch from a129be6 to a924c35 Compare July 24, 2026 09:15
diffObject now walks members in two passes instead of three: the target
pass buffers add and member-diff operations separately and concatenates
in the documented remove/add/replace order, saving a full pass and one
membership lookup per common member. Array diff hoists count() out of
loop conditions, and the three stdClass equality branches collapse into
one objectsAreEqual entry that accepts both object representations.
Emitted operations are unchanged.
The copy-on-write rework moved validation onto new entry paths that the
suite no longer exercised: diff() input validation, has() target
validation for objects and invalid values, merge imports of objects
nested inside arrays, and diff's equal-leaf fallback for numerically
equal values. New tests pin each of those, restoring the 100% coverage
gate.

Array-index tokens now use strspn() instead of ctype_digit() so the
package keeps working when the ctype extension is disabled, without
declaring an ext-ctype requirement.
Level 10 treats implicit mixed strictly. The src narrowing style
already satisfies it; the gap was in tests, which accessed properties
and offsets on mixed API results. Those sites now extract values and
assert their structure first, which both narrows for analysis (through
PHPUnit's own type assertions) and makes the tests assert the shapes
they previously assumed.
@megasteve19
megasteve19 merged commit 7c2b686 into 0.x Jul 24, 2026
5 checks passed
@megasteve19
megasteve19 deleted the perf/eliminate-deep-copies branch July 24, 2026 09:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant