Skip to content

Commit c731650

Browse files
committed
docs(bench): refresh the results with the json source and the extension
The read path changed the most: a JSON stream is now read through a generated `json` -> class mapper instead of being decoded to an intermediate shape, so deserializing a single object goes from ~101 to ~13.5 µs (~11x faster than the Symfony streamer) and reading a 20k collection from ~3 s to ~0.15-0.25 s (~16-27x faster), while streaming keeps a bounded peak. On the write side the generated `json` mappers brought the per element cost from ~4.0 to ~2.2 µs, and streaming a nested collection now peaks exactly like the Symfony writer. Also document that `json_stream_decode()` is ~6.7x faster with the `json_stream` extension than with the polyfill, since every read number assumes the extension.
1 parent d7d697a commit c731650

2 files changed

Lines changed: 141 additions & 158 deletions

File tree

bench/README.md

Lines changed: 136 additions & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -118,29 +118,31 @@ where it is measured (see `src/Factory/MapperFactory.php`):
118118
- **eval**`EvalLoader`, no on-disk cache (mappers eval'd into the process).
119119
- **no constructor**`ConstructorStrategy::NEVER` (writes properties directly).
120120
- **no attribute checking**`attributeChecking: false`, `mapPrivateProperties: false`.
121+
- **no checking** — the above plus `groupChecking: false`.
121122

122123
## Results
123124

124-
Indicative numbers on PHP 8.5 (your absolute numbers will differ; the *ratios* are
125-
the point). Lower is better.
125+
Numbers below were measured on PHP 8.5 with the `json_stream` extension loaded (see
126+
[the note on the extension](#the-json_stream-extension-matters-on-the-read-path)).
127+
Your absolute numbers will differ; the *ratios* are the point. Lower is better.
126128

127129
### Single object — denormalize (array → object, no JSON)
128130

129131
| Approach | Time | vs AutoMapper |
130132
|----------|-----:|--------------:|
131-
| AutoMapper, no attribute checking | ~3.0 µs | fastest mapper |
132-
| AutoMapper (default) | ~7.4 µs | |
133-
| AutoMapper, eval loader | ~7.3 µs ||
134-
| AutoMapper, no constructor | ~7.4 µs | |
135-
| Symfony Serializer (`denormalize`) | ~167 µs | **~23× slower** |
133+
| AutoMapper, no attribute checking | ~2.9 µs | fastest mapper |
134+
| AutoMapper, eval loader | ~7.1 µs | |
135+
| AutoMapper, no constructor | ~7.1 µs ||
136+
| AutoMapper (default) | ~7.2 µs | |
137+
| Symfony Serializer (`denormalize`) | ~165 µs | **~23× slower** |
136138

137139
### Single object — normalize (object → array, no JSON)
138140

139141
| Approach | Time |
140142
|----------|-----:|
141-
| AutoMapper, no attribute checking | ~2.9 µs |
142-
| AutoMapper (default) | ~8.2 µs |
143-
| Symfony Serializer (`normalize`) | ~66 µs |
143+
| AutoMapper, no attribute checking | ~2.5 µs |
144+
| AutoMapper (default) | ~8.0 µs |
145+
| Symfony Serializer (`normalize`) | ~63 µs |
144146

145147
### Single object — deserialize (JSON → object)
146148

@@ -149,185 +151,165 @@ fields) so the work is comparable — see the fairness note below.
149151

150152
| Approach | Time |
151153
|----------|-----:|
152-
| `json_decode` (array only, no hydration — *pure floor*) | ~2.4 µs |
153154
| **Manual** (`json_decode` + hand-written hydration — *fair floor*) | ~3.3 µs |
154-
| AutoMapper, no attribute checking (`json_decode` + map) | ~6 µs |
155-
| AutoMapper (`json_decode` + map) | ~11 µs |
156-
| AutoMapper JSON streamer, no attribute checking | ~91 µs |
157-
| AutoMapper JSON streamer | ~101 µs |
158-
| Symfony JSON streamer | ~155 µs |
159-
| Symfony Serializer | ~167 µs |
160-
161-
> **Fairness note.** Symfony's `JsonStreamReader::read()` on a `Type::object`
162-
> returns a *lazy ghost* whose hydration is deferred until a property is read, so a
163-
> subject that never touched the result would clock ~13 µs while actually doing
164-
> almost nothing. Reading the whole graph forces that deferred work, and the fully
165-
> hydrated cost is ~155 µs. The AutoMapper JSON streamer, which hands back a fully
166-
> mapped object up front, is actually *faster* than the stock streamer once both
167-
> produce a usable object.
168-
169-
Note the two JSON streamers are an order of magnitude slower than plain `map()` for a
170-
single object: they exist to stream **large collections** at flat memory (see below),
171-
and pay a heavy per-call overhead that only amortizes over big inputs. For single
172-
objects, `map()` + native `json_decode` is the right tool.
155+
| AutoMapper, no checking (`json_decode` + map) | ~5.8 µs |
156+
| AutoMapper JSON streamer, no attribute checking | ~8.2 µs |
157+
| AutoMapper (`json_decode` + map) | ~10.6 µs |
158+
| AutoMapper JSON streamer | ~13.5 µs |
159+
| Symfony JSON streamer | ~156 µs |
160+
| Symfony Serializer | ~169 µs |
161+
162+
The AutoMapper JSON streamer is now **~11× faster than Symfony's** on this shape, and
163+
within ~1.3× of a plain `json_decode` + `map()`. It reads straight from the decoded
164+
document through a generated `json` → class mapper, so nothing is materialized twice.
165+
166+
> **Fairness note.** Symfony's `JsonStreamReader::read()` on a `Type::object` returns a
167+
> *lazy ghost* whose hydration is deferred until a property is read, so a subject that
168+
> never touched the result would clock ~13 µs while actually doing almost nothing.
169+
> Reading the whole graph forces that deferred work.
173170
174171
### Single object — serialize (object → JSON)
175172

176173
| Approach | Time |
177174
|----------|-----:|
178-
| `json_encode` (array only, no traversal — *pure floor*) | ~0.8 µs |
179-
| **Manual** (hand-written normalization + `json_encode`*fair floor*) | ~1.2 µs |
180-
| AutoMapper, no attribute checking (map + `json_encode`) | ~3.9 µs |
181-
| AutoMapper JSON streamer, no attribute checking | ~8 µs |
182-
| Symfony JSON streamer | ~9 µs |
183-
| AutoMapper (map + `json_encode`) | ~10 µs |
184-
| AutoMapper JSON streamer | ~14 µs |
185-
| Symfony Serializer | ~66 µs |
186-
187-
The AutoMapper JSON streamer writer was ~25 µs here until its `__toString()` was
188-
changed to encode the lazy structure in one native `json_encode` call (both
189-
`LazyMap` and `LazyCollection` are `JsonSerializable`) instead of concatenating
190-
hand-built chunks — a ~1.85× speed-up (2.5× with attribute checking off), with
191-
byte-identical output. Chunk streaming is still used when the result is *iterated*
192-
(`getIterator`), for callers writing to an output stream.
193-
194-
The **manual** row is the fair baseline: it produces/consumes the same `Person`
195-
graph the libraries do, just by hand (see `src/ManualMapper.php`). The pure
196-
`json_*` rows never touch a `Person`, so they only show the JSON cost in isolation.
175+
| **Manual** (hand-written normalization + `json_encode`*fair floor*) | ~1.3 µs |
176+
| AutoMapper, no attribute checking (map + `json_encode`) | ~3.6 µs |
177+
| AutoMapper, no checking (map + `json_encode`) | ~3.8 µs |
178+
| AutoMapper JSON streamer, no attribute checking | ~7.4 µs |
179+
| Symfony JSON streamer | ~9.2 µs |
180+
| AutoMapper (map + `json_encode`) | ~9.5 µs |
181+
| AutoMapper JSON streamer | ~14.2 µs |
182+
| Symfony Serializer | ~69 µs |
183+
184+
The **manual** row is the fair baseline: it produces/consumes the same `Person` graph
185+
the libraries do, just by hand (see `src/ManualMapper.php`).
197186

198187
### Object to object
199188

200189
| Approach | Time |
201190
|----------|-----:|
202-
| Manual (hand-written) | ~0.2 µs |
191+
| Manual (hand-written) | ~0.23 µs |
203192
| AutoMapper, no attribute checking | ~1.6 µs |
204-
| AutoMapper | ~4.9 µs |
205-
| AutoMapper ObjectMapper bridge | ~5.4 µs |
206-
| Symfony ObjectMapper | ~48 µs (**~10× slower**) |
207-
208-
### Collection — peak memory (the headline)
209-
210-
Reading a JSON array of `Person` objects, **every object fully hydrated** (the loop
211-
reads a nested field so each library does the same work — see the note below).
212-
Peak memory (`mem_peak`) and time for 20 000 items:
213-
214-
| Approach | 1 000 items | 20 000 items | time (20k) |
215-
|----------|------------:|-------------:|-----------:|
216-
| `json_decode` (array only, no objects) | 11 MB | 90 MB | 0.10 s |
217-
| AutoMapper `mapCollection` (eager) | 14 MB | 107 MB | 0.29 s |
218-
| Symfony Serializer | 14 MB | 101 MB | 3.44 s |
219-
| Symfony JSON streamer, **iterable** (lazy) | 9.8 MB | **9.8 MB** | 4.11 s |
220-
| Symfony JSON streamer, `list` (materialized) | 43 MB | 681 MB | 4.50 s |
221-
| AutoMapper JSON streamer, buffered | 12 MB | 51 MB | 3.16 s |
222-
| AutoMapper JSON streamer, buffered, no attr checking | 12 MB | 51 MB | 2.96 s |
223-
| **AutoMapper JSON streamer, `STREAM` mode** | **9.8 MB** | **9.8 MB** | 3.06 s |
224-
| **AutoMapper JSON streamer, `STREAM`, no attr checking** | **9.8 MB** | **9.8 MB** | 3.11 s |
225-
226-
Disabling attribute checking barely moves the streamer here (buffered 3.16 → 2.96 s,
227-
`STREAM` ~unchanged): unlike the plain `map()` path — where it's a ~2–3× win — the
228-
streamer's time is dominated by Symfony's userland JSON tokenizer decoding each
229-
element, so the AutoMapper mapping step it speeds up is only a small slice. Memory is
230-
identical, since attribute checking is a code-generation concern, not a runtime one.
231-
232-
Both truly-streaming readers — Symfony's `iterable` shape and AutoMapper's `STREAM`
233-
mode — keep a **flat ~10 MB** peak regardless of collection size, because they yield
234-
one object at a time and never hold the whole collection in memory. Streaming trades
235-
throughput for memory: it is slower per element than the eager mappers, so use it
236-
when the input is large enough that memory — not wall time — is the constraint.
237-
AutoMapper's streaming reader is a little faster here and, unlike Symfony's raw
238-
reader, runs each element through the full AutoMapper pipeline (renames, transformers,
239-
`#[MapTo]`, private properties, discriminators, …).
193+
| AutoMapper | ~5.0 µs |
194+
| AutoMapper ObjectMapper bridge | ~5.5 µs |
195+
| Symfony ObjectMapper | ~49 µs (**~10× slower**) |
196+
197+
### Collection — read a list of `Person` (`src/CollectionBench.php`)
198+
199+
Reading a JSON array of `Person`, **every object fully hydrated** (the loop reads a
200+
nested field so each library does the same work). Peak memory (`mem_peak`) and time:
201+
202+
| Approach | 1 000 | 20 000 | time (20k) |
203+
|----------|------:|-------:|-----------:|
204+
| `json_decode` (array only, no objects) | 14 MB | 93 MB | 0.09 s |
205+
| AutoMapper `mapCollection`, no checking (eager) | 16 MB | 108 MB | 0.21 s |
206+
| AutoMapper `mapCollection` (eager) | 15 MB | 107 MB | 0.29 s |
207+
| **AutoMapper JSON streamer, `STREAM`, no checking** | 11 MB | **21 MB** | **0.15 s** |
208+
| **AutoMapper JSON streamer, `STREAM`** | 11 MB | **21 MB** | 0.25 s |
209+
| AutoMapper JSON streamer, buffered, no attr checking | 18 MB | 183 MB | 0.25 s |
210+
| AutoMapper JSON streamer, buffered | 18 MB | 183 MB | 0.37 s |
211+
| Symfony Serializer | 14 MB | 101 MB | 3.45 s |
212+
| Symfony JSON streamer, **iterable** (lazy) | 10 MB | **10 MB** | 4.14 s |
213+
| Symfony JSON streamer, `list` (materialized) | 43 MB | 682 MB | 4.82 s |
214+
215+
This is where the `json` source pays off the most: streaming through the AutoMapper is
216+
**~16–27× faster than Symfony's streamer** (0.15–0.25 s vs 4.14 s) while staying within
217+
~2× of its memory, and it is *also faster than eagerly decoding the whole array* with
218+
`mapCollection`. Unlike on the read path before, disabling the checks now matters again
219+
(0.25 s → 0.15 s), because the decoding is no longer the bottleneck.
220+
221+
Use `STREAM` for large inputs: buffered mode memoizes every mapped object (183 MB at
222+
20k) to stay countable and re-iterable, while `STREAM` keeps a bounded peak at the cost
223+
of being single-pass.
240224

241225
#### `iterable` vs `list`: the type drives whether Symfony actually streams
242226

243-
The same JSON array read by the same reader costs **~10 MB or ~680 MB** depending
244-
only on the requested type:
227+
The same JSON array read by the same Symfony reader costs **~10 MB or ~682 MB**
228+
depending only on the requested type:
245229

246230
- **`Type::iterable(Type::object(Person::class), Type::int())`**`read()` returns a
247231
`Generator` that decodes and yields one hydrated `Person` at a time. Flat, ~10 MB.
248232
- **`Type::list(Type::object(Person::class))`** → the generated reader ends with
249233
`iterator_to_array(...)`, materializing the whole collection. Each element is a
250234
`ReflectionClass::newLazyGhost()` whose initializer closure captures its own
251235
*suspended* boundary generator (lexer + stream state). At 20 000 elements that is
252-
~20 000 ghosts + ~20 000 live generators retained at once~30 KB each, hence the
253-
~679 MB peak. It holds even if the ghosts are never hydrated. **Prefer `iterable`.**
236+
~20 000 ghosts + ~20 000 live generators retained at once. It holds even if the
237+
ghosts are never hydrated. **Prefer `iterable`.**
254238

255239
The `int` key type is what tells Symfony to read a JSON array (`[…]`) rather than an
256240
object (`{…}`) — omit it and the reader expects `{…}` and throws on a list.
257241

258-
> **Fairness note.** The `list` reader returns lazy ghosts, so a benchmark loop that
259-
> never reads a property would measure it deferring all the hydration work the
260-
> other mappers do up front. Every subject in `CollectionBench` therefore reads
261-
> `$person->address->city` to force full realization, so timings are comparable.
242+
### Collection — write a list of `Person` (`src/WriteCollectionBench.php`)
262243

263-
> The `STREAM` option (`AutoMapper\MapperContext::STREAM => true`) also makes the
264-
> collection single-pass (it cannot be re-iterated). Without it, the reader buffers
265-
> mapped instances so the collection is countable and re-iterable, at the cost of
266-
> holding them all in memory.
267-
268-
### Collection — write, a list of `Person` (`src/WriteCollectionBench.php`)
269-
270-
The write-side counterpart of the read collection: serialize a list of `Person` to
271-
JSON, comparing the two JSON stream **writer** implementations. AutoMapper's writer
272-
now handles a top-level list of objects (it maps each element through the AutoMapper
273-
pipeline and streams the JSON array; with `STREAM=true` it never buffers the mapped
274-
elements). Peak memory (`mem_peak`) and time:
244+
Serializing a list of `Person` (fed from a generator) to JSON, comparing the two JSON
245+
stream **writer** implementations:
275246

276247
| Writer / consumption | 1 000 | 20 000 | time (20k) |
277248
|----------------------|------:|-------:|-----------:|
278-
| AutoMapper JSON streamer — `__toString()` (buffered) | 15 MB | 180 MB | 0.63 s |
279-
| **AutoMapper JSON streamer — `STREAM=true` (streamed)** | 8.3 MB | 37 MB | 0.49 s |
280-
| Symfony JSON streamer — `__toString()` | 8.6 MB | 45 MB | 0.10 s |
281-
| **Symfony JSON streamer — `getIterator()` (streamed)** | **8.3 MB** | **36 MB** | **0.07 s** |
249+
| **Symfony JSON streamer — `getIterator()` (streamed)** | **7.2 MB** | **7.2 MB** | **0.10 s** |
250+
| Symfony JSON streamer — `__toString()` | 7.5 MB | 16 MB | 0.12 s |
251+
| AutoMapper `mapCollection` + `json_encode` (eager) | 11 MB | 77 MB | 0.14 s |
252+
| **AutoMapper JSON streamer — `STREAM=true` (streamed)** | 7.8 MB | **7.8 MB** | 0.15 s |
253+
| AutoMapper JSON streamer — `__toString()` | 8.2 MB | 17 MB | 0.17 s |
282254

283-
Streamed, AutoMapper reaches the **same flat memory as Symfony** (~37 MB at 20k —
284-
essentially just the source list) but is **~6× slower** (0.49 s vs 0.07 s), the same
285-
per-element gap as the wide-object case. Buffered `__toString()` is worse still —
286-
~180 MB, because it resolves the whole list into an array-of-arrays before encoding,
287-
whereas Symfony encodes straight from the objects (~45 MB). So AutoMapper's list
288-
writer is worth it only when you need AutoMapper's mapping on the way out *and* want
289-
bounded memory (use `STREAM`); for a plain list, Symfony's writer is far faster.
255+
Both streamed writers keep a **flat peak** whatever the collection size, and the
256+
AutoMapper one is now within ~1.5× of Symfony's while running every element through the
257+
full mapping pipeline. The eager `mapCollection` + `json_encode` path is comparable in
258+
time but allocates the whole array-of-arrays (77 MB at 20k).
290259

291-
### Collection — write, one object with a big nested collection (`src/WriteWideObjectBench.php`)
260+
### Collection — write one object with a big nested collection (`src/WriteWideObjectBench.php`)
292261

293-
This is the only shape that exercises the **AutoMapper** JSON stream writer's own
294-
streaming (a single `Person` whose `addresses` is huge). The `STREAM` option applies
295-
on the way out. Peak memory (`mem_peak`):
262+
A single `Person` whose `addresses` is huge, so the nested collection is what streams:
296263

297264
| Writer / consumption | 5 000 | 50 000 | time (50k) |
298265
|----------------------|------:|-------:|-----------:|
299-
| AutoMapper — `__toString()` (buffered) | 18 MB | 113 MB | 0.17 s |
300-
| AutoMapper — `__toString()`, no attr checking | 18 MB | 113 MB | 0.11 s |
301-
| AutoMapper — `getIterator()` `STREAM=true` | 8.9 MB | 23 MB | 0.21 s |
302-
| AutoMapper — `getIterator()` `STREAM=true`, no attr | 8.9 MB | 23 MB | 0.16 s |
303-
| **Symfony JSON streamer — `__toString()`** | 9 MB | 27 MB | **0.047 s** |
304-
| **Symfony JSON streamer — `getIterator()`** | **8.9 MB** | **23 MB** | **0.036 s** |
305-
306-
`STREAM=true` yields the JSON chunk by chunk and never buffers the mapped
307-
collection, so peak stays low (the residual ~23 MB at 50k is the source object's own
308-
materialized `addresses` array). It is single-pass. Disabling attribute checking
309-
gives a **real ~30 % write-side speed-up** here (unlike the read side), because
310-
encoding is pure AutoMapper — map to a lazy array, then `json_encode`/chunk — with no
311-
Symfony JSON tokenizer in the path to dominate the time.
312-
313-
#### Why Symfony's writer is so much faster at collection scale
314-
315-
For a *single* small object the two writers look close (single-object serialize:
316-
AutoMapper JSON writer ~14 µs vs Symfony ~9 µs), so the collection gap seems
317-
surprising. It comes down to **per-element cost**, which a collection multiplies by
266+
| **Symfony JSON streamer — `getIterator()`** | **9.5 MB** | **23 MB** | **0.038 s** |
267+
| Symfony JSON streamer — `__toString()` | 9.8 MB | 27 MB | 0.047 s |
268+
| AutoMapper — `getIterator()` `STREAM=true`, no attr | 9.5 MB | 23 MB | 0.052 s |
269+
| AutoMapper — `__toString()`, no attr checking | 9.8 MB | 27 MB | 0.062 s |
270+
| AutoMapper — `getIterator()` `STREAM=true` | 9.5 MB | 23 MB | 0.10 s |
271+
| AutoMapper — `__toString()` (buffered) | 9.8 MB | 27 MB | 0.11 s |
272+
273+
Memory is now **identical to Symfony** in both consumption modes: the nested collection
274+
is streamed through the sub-mappers instead of being encoded whole. Disabling attribute
275+
checking gives a **~2× write-side speed-up** here, because encoding is pure AutoMapper
276+
with no decoding in the path.
277+
278+
#### Why Symfony's writer is still faster at collection scale
279+
280+
For a *single* small object the two writers are close (serialize: ~14 µs vs ~9 µs), so
281+
the collection gap comes down to **per-element cost**, which a collection multiplies by
318282
`N`. Measured marginal cost per extra nested element:
319283

320-
| | fixed (0 elements) | per element |
321-
|---|------:|------:|
322-
| AutoMapper writer (`STREAM`) | ~15 µs | **~4.0 µs** |
323-
| Symfony writer (`getIterator`) | ~7 µs | **~0.8 µs** |
324-
325-
So AutoMapper pays ~5× more *per element*. At single-object scale that gap is a fixed
326-
~8 µs that's easy to miss; at 50 000 elements it becomes 50 000 × ~3.2 µs ≈ 160 ms —
327-
the whole difference. The reason: for each element AutoMapper builds a `LazyMap`,
328-
runs the per-item mapping closure, `iterator_to_array()`s it, then `json_encode`s
329-
each scalar field and yields it through nested generators. Symfony's compiled,
330-
type-specialized writer inlines the field encoding straight from the object with none
331-
of that per-element machinery. Reach for the AutoMapper JSON writer when you need its
332-
mapping features (renames, transformers, `#[MapTo]`, …) on the way out; for a plain
333-
object graph, Symfony's writer is the faster tool.
284+
| | per element |
285+
|---|------:|
286+
| AutoMapper writer (`STREAM`) | **~2.2 µs** |
287+
| Symfony writer (`getIterator`) | **~0.9 µs** |
288+
289+
AutoMapper pays ~2.4× more per element (it was ~5× before the generated `json` mappers
290+
replaced the lazy-structure walk). Symfony emits from a compiled, type-specialized
291+
writer that reads fields straight off the object; the AutoMapper one additionally runs
292+
each element through the mapping pipeline. Reach for it when you need that pipeline
293+
(renames, transformers, `#[MapTo]`, private properties, discriminators, …); for a plain
294+
object graph with no mapping, Symfony's writer is still the faster tool.
295+
296+
### The `json_stream` extension matters on the read path
297+
298+
`json_stream_decode()` comes either from the
299+
[`json_stream` PHP extension](https://github.com/joelwurtz/php-json-stream) or from the
300+
pure-PHP [polyfill](https://github.com/joelwurtz/php-json-stream-polyfill) that ships
301+
with the AutoMapper. Same single-object read, same code:
302+
303+
| Decoder | Time |
304+
|---------|-----:|
305+
| `json_stream` extension | ~14 µs |
306+
| polyfill (pure PHP) | ~93 µs |
307+
308+
That is a **~6.7× difference on the read path**, so all the read numbers above assume
309+
the extension. Install it with [pie](https://github.com/php/pie):
310+
311+
```shell
312+
pie install joelwurtz/json-stream
313+
```
314+
315+
The write path does not decode anything and is unaffected.

0 commit comments

Comments
 (0)