Benchmarks¶
This page measures three ways of producing the same Drupal form:
- A: bare render arrays (the status quo). This is the floor. Any object layer costs more than writing arrays by hand, so A is the reference point for the other two, not something they compete with.
- B: element-plugin object wrappers, the code proposed in #3525331, measured for real on a patched Drupal core.
- C: Form Factory Kits, this module.
The comparison that matters is B against C. Comment #95 on the core issue asks whether converting between render arrays and objects "could be a memory issue", so the page also measures the round trip that only B can do: opening an existing array as objects, editing it, and handing the array back.
How B was obtained¶
Fetched 28 October 2026: #3525331
is Needs work (field_issue_status 13), comment_count 95, latest comment
still #95.
#3539320 is also
Needs work, 33 comments. The wrapper code is not in any Drupal release.
- Source: GitLab !13985
HEAD
91930afa94d95026a353f41f49227d0e1ae19cae(opened, targetmain,detailed_merge_status: conflict).fromRenderable(),fromClass(),toRenderable(),createChild(),getChildren(), andDrupal\Core\Render\Element\ElementInterfaceare present on that SHA. - Applied:
git apply --rejectof that diff onto Drupal 11.4.8 (drupal/recommended-project). The wrapper API files applied cleanly. Rejects:WidgetBase.php,ModerationStateWidget.php,BreakLockLink.php,LayoutBuilder.php,HtmlTagTest.php. None of those is loaded by the harness. B apply: ok. - Booted: only the real
ElementInfoManagerplus a container holding it. Module and theme handlers are do-nothing objects. No database, no kernel.
Method¶
Script: docs/benchmarks/memory.php
(kept out of the built site). From the module root:
# A and C only (no patched core needed):
php docs/benchmarks/memory.php
# A, B and C:
FFK_WRAPPER_CORE=/path/to/patched/core FFK_DRUPAL_VENDOR=/path/to/drupal/vendor \
FFK_KITS_DIR=/path/to/kits FFK_FORMFACTORY_DIR=/path/to/formfactory \
php docs/benchmarks/memory.php [--fields=5000]
- Fixtures.
nested-textfields(default): 5 nesteddetailsand 500 or 5,000textfields.mixed-small: onedetailswith textfield, textarea, select, radios, checkboxes, number, and a submit (types C ships without a kernel).mixed-nested: that mixed form inside one extradetails. - Same data. All three variants set the same properties, including the
#parentsand#groupkeys kits add. The script normalizes each result (removing B's##object) and checks the hashes. - Scenario "build". Create the whole form from nothing.
- A implements the tree as a
FormInterface-style array. - B uses
fromRenderable()/createChild()/ magic properties. - C is a
FormObjectInterfaceimplementor whosebuildFormObject(FormFactoryInterface, FormFactoryKitsInterface)calls$kits->details(),$kits->textField(), and the other factories, thenFormFactoryInterface::getForm().
- A implements the tree as a
- JSON.
--out=writes the numbers used on this page underdocs/benchmarks/results/.--publishedrefuses to exit 0 if C used stand-ins or B was skipped. - Scenario "alter". Start from an existing form (built as A before the
measurement starts and counted in the baseline) and change the description
of every text field.
- A walks the array with a recursive function.
- B opens it with
fromRenderable(), walks it withgetChildren(), and edits through magic properties. This is the array → object → array round trip from comment #95. - C is not measured: kits cannot open an existing array.
- Memory. Each variant runs in its own PHP process. After a small warm-up
run (classes and plugin definitions loaded), the script records
memory_get_usage()as the baseline and resets the peak. Peak is the highest memory above the baseline during the run. Retained is what is still allocated afterwards, with the form held and everything else freed (gc_collect_cycles()is called first). - Objects. The number of
##objectentries left inside the final array. - Copy coupled. After "build", the script copies the form
(
$copy = $form), changes one field's title in the original, and checks whether$copychanged too. - Serialized.
strlen(serialize($form)), whetherunserialize()gives back the same element data, and how many##objectentries the original array has afterserialize()ran. - Time. Median, p95, minimum and maximum of 20 runs,
hrtime(). - Kits dependencies. Published rows use real drupal/kits and drupal/formfactory. Stand-ins remain for a local A/C smoke only.
Environment¶
- PHP 8.3.30 CLI, Linux (WSL2) x86_64, Intel Core i7-1360P, OPcache CLI off.
Extra row: PHP 8.5.12 in Docker image
ffk-php85. - B: Drupal 11.4.8 core with MR !13985 at
91930afa, applied as above. - C: drupal/kits 2.0.0-beta5, drupal/formfactory 2.1.0-beta3 (git tags),
this module HEAD, through
FormObjectInterface+FormFactoryKitsInterface. - Run on 28 October 2026. Machine JSON:
docs/benchmarks/results/2026-10-28-php83-nested-500-r1.json(and r2, r3),…-nested-5000.json,…-mixed-small.json,…-php85-nested-500.json.
Results¶
Memory figures were identical on every run (three isolated-process repeats of the 500-field form, and the PHP 8.5.12 extra row). Times varied with load. Over the three PHP 8.3 repeats, the B build median was 2.87–4.54 ms and the C build median 1.03–1.96 ms. Full output of repeat 1:
500 fields (28 October 2026)¶
$ php docs/benchmarks/memory.php --published --out=docs/benchmarks/results/2026-10-28-php83-nested-500-r1.json
C API: FormObjectInterface+FormFactoryKitsInterface
Published three-way: yes
Form: fixture=nested-textfields, 500 textfields in 5 nested details levels, 20 timed iterations
[build] build the form from nothing
Same element data in all variants (ignoring '##object'): yes
variant peak retained median ms p95 ms min ms max ms objects
A raw 464.2 KiB 455.8 KiB 0.34 0.47 0.33 0.50 0
B wrappers 884.4 KiB 883.7 KiB 3.35 8.44 2.05 16.74 506
C kits 1,040.2 KiB 549.7 KiB 1.03 2.58 0.72 3.67 0
variant copy coupled serialized unserialize ok objects after ser.
A raw no 147.4 KiB yes 0
B wrappers yes 414.8 KiB yes 0
C kits no 147.4 KiB yes 0
C kits / B wrappers: peak x1.18, retained x0.62, median time x0.31
[alter] alter descriptions of an existing form (kits cannot do this)
Same element data in all variants (ignoring '##object'): yes
variant peak retained median ms p95 ms min ms max ms objects
A raw 137.5 KiB 137.5 KiB 0.26 0.37 0.18 0.46 0
B wrappers 312.8 KiB 294.7 KiB 1.30 3.09 0.93 3.19 506
5,000 fields (28 October 2026)¶
$ php docs/benchmarks/memory.php --fields=5000 --published --out=docs/benchmarks/results/2026-10-28-php83-nested-5000.json
[build] Same element data: yes
variant peak retained median ms p95 ms objects
A raw 4,382.7 KiB 4,342.3 KiB 1.86 2.30 0
B wrappers 8,651.0 KiB 8,650.3 KiB 26.29 55.19 5006
C kits 10,111.9 KiB 5,336.0 KiB 28.67 33.27 0
C kits / B wrappers: peak x1.17, retained x0.62, median time x1.09
serialize: A and C 1,487.8 KiB; B 4,148.8 KiB; B copy coupled yes
[alter] A 1,371.4 / 1,367.9 KiB 5.07 ms; B 3,085.0 / 2,979.4 KiB 37.96 ms; 5006 objects
mixed-small (28 October 2026)¶
$ php docs/benchmarks/memory.php --fixture=mixed-small --published --out=docs/benchmarks/results/2026-10-28-php83-mixed-small.json
[build] Same element data: yes
variant peak retained median ms objects
A raw 3.0 KiB 3.0 KiB 0.00 0
B wrappers 8.4 KiB 8.4 KiB 0.04 9
C kits 21.8 KiB 7.0 KiB 0.02 0
C kits / B wrappers: peak x2.60, retained x0.83, median time x0.56
Times on this fixture are sub-millisecond and close to timer noise. Memory
still shows C's higher peak (kit objects + array) and lower retained (plain
array, 0 objects). mixed-nested hashes also matched; JSON is
2026-10-28-php83-mixed-nested.json.
PHP 8.5.12 extra row (500 fields)¶
Memory matched PHP 8.3.30 to the reported KiB. JSON:
docs/benchmarks/results/2026-10-28-php85-nested-500.json. Build medians:
A 0.22 ms, B 3.03 ms, C 2.38 ms.
FormBuilder pipeline¶
docs/benchmarks/pipeline.php looks for an installed Drupal site
(FFK_SITE, then /tmp/ffk-d12-site, then /tmp/ffk-bench/d11). On 28
October 2026 the throwaway 11.4.8 tree had Composer autoload and no
sites/default/settings.php. Command that failed:
php docs/benchmarks/pipeline.php
# exit 2 — no settings.php; Drupal recommended-project at /tmp/ffk-bench/d11 is not installed
JSON: docs/benchmarks/results/2026-10-28-pipeline-skip.json. No pipeline
numbers are published.
B against C¶
Building the form (the scenario both can do):
| B wrappers | C kits | C relative to B | |
|---|---|---|---|
| Peak memory, 500 fields | 884.4 KiB | 1,040.2 KiB | 18% higher |
| Retained memory, 500 fields | 883.7 KiB | 549.7 KiB | 38% lower |
| Median time, 500 fields (3 repeats) | 2.87–4.54 ms | 1.03–1.96 ms | about 0.3–0.4× |
| Peak memory, 5,000 fields | 8,651.0 KiB | 10,111.9 KiB | 17% higher |
| Retained memory, 5,000 fields | 8,650.3 KiB | 5,336.0 KiB | 38% lower |
| Median time, 5,000 fields | 26.29 ms | 28.67 ms | within noise this run |
| Objects left in the form | 506 / 5,006 | 0 | |
Copy of $form coupled to original |
yes | no | |
serialize($form) size, 500 fields |
414.8 KiB | 147.4 KiB | 64% smaller |
What this shows:
- Peak: C is a little higher than B. While
getForm()runs, the kit objects and the finished array exist at the same time. B builds into the array directly and has no such second copy. - Retained: B keeps one element object per element inside the form
(the
##objectentry), for as long as the form exists: through the rest of the build, alter hooks, caching and rendering. C's objects are gone oncegetForm()returns, so what stays is a plain array, about 21% larger than A's. (That 21% is consistent withgetArray()building each key as'#' . $parameter, a new string per key per element, where literal keys are shared; a micro-test showed about 256 bytes per seven-key element. Not confirmed with a profiler.) - Time: Wall time moves with machine load (the 4 October 2026 run was quieter). Memory ratios did not. On 500 fields C was still faster than B in every 28 October repeat. On 5,000 fields this run C and B were within noise. Both stay several times slower than A.
- Behavior: B's array is coupled to its copies and carries objects, which is the mechanism described in #3539320. C's array is a plain array. See the comparison for what this means.
The round trip and the large-array copy question¶
Comment #95 asks: "Without references, conversion between render arrays and objects could involve copying a lot of large arrays. Could this be a memory issue?" The "alter" scenario measures B's actual round trip, which does use references:
- Opening the existing 500-field form (455.8 KiB) with wrappers and editing
it added 312.8 KiB at peak, against 137.5 KiB for the same edit as array
code. The wrapper overhead is therefore about 175 KiB at peak, about 157 KiB
retained (5,000 fields: about 1.7 MiB and 1.6 MiB). That is less than the
size of the array, so the array was not copied:
initializeInternalStorage()takes it by reference ($this->storage = &$element). The cost is the 506 element objects, which stay in the array as##object. - The price of avoiding the copy is the reference itself: after wrapping, a plain copy of the form is still coupled to the original ("copy coupled: yes"). That is the coupling #3539320 describes.
- A version of B without references, as comment #95 suggests, would have to copy each element into its object and back. That variant does not exist in code, so it is not measured here.
- C has no round trip: kits only build, and the array they return is never read back into objects.
Two side effects of B observed while measuring, not costs as such:
serialize($form)works andunserialize()restores the same element data, but the serialized form is 2.8× the size of A's and C's, because each element object is serialized with its storage. Serializing also removes the##objectentries from the live array (RenderElementBase::__sleep()unsets them through the reference): 506 objects before, 0 after.var_export()on a wrapped form warns "var_export does not handle circular references", because each##objectholds a reference to the array that contains it.
Limits¶
- One machine (WSL2 i7-1360P). Primary PHP 8.3.30; one extra PHP 8.5.12 row.
OPcache CLI off. The PHP 8.5 JSON records
kits_tag/formfactory_tagas null (git describewas not available on the container bind mounts); memory matched the tagged PHP 8.3 primary. - Nested fixture still uses two element types; mixed-small adds five more kernel-free types. Entity and file kits are not in the mixed fixture.
- B runs on a partly booted core (real element plugins, no hooks, no
database). FormBuilder / form cache / AJAX / rendering were not
measured:
pipeline.phpexited 2 (no installed site). AJAX for B is the known #3539320 failure mode and was not re-probed. - B is MR !13985 at
91930afaapplied to 11.4.8, not a future redesign. - Sub-millisecond mixed-form times are timer noise. Use memory ratios there.
- C CLI uses
FormObjectInterfaceimplementors rather thanFormObjectBase, becauseFormObjectBaseextendsFormBaseand needs a kernel.
Previous run (4 October 2026)¶
PHP 8.3.30, kits 2.0.0-beta4, formfactory 2.1.0-beta1, C via
FormFactoryService::append + DetailsKit::create (not FormObjectInterface).
Memory matched the 28 October run to 0.2 KiB.
500 fields (4 October 2026)¶
[build] Same element data: yes
variant peak retained median ms min ms max ms objects
A raw 464.2 KiB 455.8 KiB 0.11 0.11 0.14 0
B wrappers 884.4 KiB 883.7 KiB 1.04 0.82 2.36 506
C kits 1,040.4 KiB 549.7 KiB 0.61 0.56 0.71 0
serialize: A and C 147.4 KiB; B 414.8 KiB; B copy coupled yes
[alter] A 137.5 KiB 0.11 ms; B 312.8 / 294.7 KiB 0.87 ms; 506 objects
5,000 fields (4 October 2026)¶
[build] Same element data: yes
variant peak retained median ms min ms max ms objects
A raw 4,382.7 KiB 4,342.3 KiB 1.17 1.13 1.49 0
B wrappers 8,651.0 KiB 8,650.3 KiB 12.56 9.02 21.83 5006
C kits 10,112.2 KiB 5,336.0 KiB 6.01 5.77 7.34 0
C kits / B wrappers: peak x1.17, retained x0.62, median time x0.48
[alter] A 1,371.4 / 1,367.9 KiB 1.03 ms; B 3,085.0 / 2,979.4 KiB 10.46 ms