@@ -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 | 1× |
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 | 1× |
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
255239The ` int ` key type is what tells Symfony to read a JSON array (` […] ` ) rather than an
256240object (` {…} ` ) — 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