Repository navigation
perf(icons): load SVG payloads on demand, and add a benchmark to prove it - #42
Merged
Merged
Conversation
Stands up an isolated WordPress instance (port 8899, so it runs alongside the normal dev env) holding 500 SVG icons: 200 in a theme folder and 300 contributed through the media library, sized from ~11 KB to ~1.95 MB with 25 above 1 MB. The rig exists to produce comparable before/after numbers rather than assertions. It reproduces the three things that make WordPress VIP hurt: - a persistent object cache that refuses items over 1 MB the way memcached does, silently returning false, which is exactly what no caller checks; - media-library reads routed through a bpifs:// stream wrapper, so they are counted exactly and can be charged VIP Files-like latency; - collections registered on every request, front end included. Metrics land as one JSON record per request and are reduced to medians. `resident` -- the SVG bytes actually held in memory -- is read by reflecting on CollectionItem's private properties rather than through content(), so the measurement stays honest once loading becomes lazy. The object cache is file-backed rather than memcached: it reproduces the semantics exactly but not the latency, so operation counts are the transferable metric and wall time only indicative. That and the other limits are written up in tests/perf/README.md. phpcs now excludes ./tests/: grumphp passes changed files to phpcs explicitly, which overrides the ruleset's file list, and the harness has to break WordPress conventions to do its job -- a cache drop-in must assign $GLOBALS['wp_object_cache'] and declare both a class and the wp_cache_* functions in one file. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
With 500 icons totalling ~102 MB, every request held all of it in memory -- including a front-end page containing no icon block at all. At PHP's common 128 MB limit the plugin did not merely run slowly, it fatalled while serializing a collection for the cache. Three changes, measured with tests/perf against 500 icons on a cache that refuses items over 1 MB: 1. Registering a collection now builds a lightweight index. CollectionItem holds a small serializable descriptor saying where its bytes live, and ContentLoader reads them the first time content() is called -- one icon to render a block, one page's worth to list a collection in the editor. __serialize() keeps resolved payloads out of the cached index so it cannot grow with use. 2. Payloads are cached individually, and Cache::set_cache() declines anything over 900 KB. Memcached (VIP and most managed hosts) refuses items above 1 MB and signals it only through a return value nothing checks, so an oversized entry was rebuilt, refused and rebuilt again forever. Sixteen entries were in that state in the baseline, including the whole 200-icon folder collection as a single entry, which meant re-reading all 200 files on every request. The ceiling is adjustable with the blockparty_icons_cache_max_item_bytes filter. 3. New `attachments` collection type for SVGs contributed through the media library: one query and one cached index, salted on the posts cache's last_changed so a newly uploaded icon appears at once. It replaces the pattern the README used to recommend -- a WP_Query plus a from_file() call per attachment -- which cost a query and a cache round trip per icon per request. Cached index keys carry a format revision. A salt cannot do that job: wp_cache_get_salted() unserializes the stored value before it compares salts, so an entry written by an earlier release would be unserialized into the new class shape before anything could reject it. Typed properties also gained defaults so such an entry cannot leave one uninitialised. Measured on 500 icons (median of 4 runs), front-end page with no icon block: resident SVG 101.7 MB -> 0.015 MB, object-cache gets 301 -> 2, refused writes 16 -> 0, blockparty_icons_init 602 ms -> 2.3 ms. Charging 2 ms per media read to emulate VIP Files, the same page on a cold cache goes 4806 ms -> 422 ms. Not addressed here: the editor still bootstraps ~22 MB because block_editor_rest_api_preload_paths inlines the first 50 icons of each collection with their content. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The previous comment read as though it were fixing something the code does today. It is not: the factories cache their index before anything calls content(), so no resolved payload can currently reach the cache. It is a structural guard, and the comment now says so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
They guarded against a resolved payload reaching a cached index, but no call path does that: every factory caches its index before anything calls content(). Default PHP serialization is enough, and two fewer magic methods is two fewer things to reason about. Cached indexes stay comfortably cacheable without them -- 108 KB for the 200-icon folder collection, 138 KB for 300 media attachments, against a 900 KB ceiling. Measured metrics are unchanged: resident SVG, object-cache operations, refused writes and response bytes are all identical, and the timing difference is container jitter (repeat runs of this same code give 1.8-2.3 ms for the init hook, matching the previous commit). CACHE_FORMAT goes to v3 because the on-disk shape changed: an entry written by __serialize() would otherwise be unserialized into public dynamic properties, leaving the real typed ones uninitialised. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The exclusion was added for tests/perf, whose object-cache drop-in has to assign $GLOBALS['wp_object_cache'] and declare a class alongside the wp_cache_* functions. Written as ./tests/ it now also covers the PHPUnit suite that landed on develop, which is phpcs-clean and should stay linted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
herewithme
force-pushed
the
perf/lazy-icon-loading
branch
from
September 10, 2026 20:31
d9d47d7 to
f638db8
Compare
Both columns re-taken in one session on the same machine and the same 500 icons, the "before" side by checking the original classes back into the working tree rather than reusing an older run. Every operation count is unchanged — resident SVG 101.7 MB to 0.015 MB, 301 cache gets to 2, 16 refused writes to none. Wall times shifted by a few tens of percent, which is what wall times do; the table now reports what was actually measured. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
A client site on WordPress VIP with 300+ icons, several SVGs above 1 MB, was unusable. The plugin loaded every icon's content on every request — front-end pages with no icon block included — and the object cache could not store the result, so nothing ever warmed up.
Two VIP specifics turn "wasteful" into "fatal" there: media-library files live on VIP Files (every read is a network fetch), and the object cache is memcached (silently refuses items over 1 MB).
What changed
A performance protocol first (
tests/perf/)Before touching the plugin, a rig that reproduces the problem so the fix can be measured rather than asserted. It builds an isolated instance on port 8899 with 500 SVG icons — 200 in a theme folder, 300 contributed through the media library, sized ~11 KB → ~1.95 MB with 25 above 1 MB — behind an object cache that refuses items over 1 MB, and routes media reads through a
bpifs://stream wrapper that counts them and can charge VIP-like latency.Then the three fixes
CollectionItemholds a small serializable descriptor (['type' => 'file', 'path' => …]) instead of the SVG, andContentLoaderreads the bytes the first timecontent()is called — one icon to render a block, one page's worth to list a collection in the editor. The indexes stay small: 108 KB for the 200-icon folder collection, 138 KB for 300 media attachments, against a 900 KB ceiling.blockparty_icons_cache_max_item_bytes.attachmentscollection type for media-library SVGs: one query, one cached index salted on the posts cache'slast_changed. Replaces the loop the README used to recommend, which cost a query plus a cache round trip per icon per request.Results
Baseline first, because it sets the scale: at PHP's common 128 MB limit the plugin does not run at all against this dataset — it fatals while
serialize()-ing a collection for the cache. Everything below runs at 512 MB, matching VIP.Front-end page containing no icon block, median of 4 runs:
blockparty_icons_initCharging 2 ms per media read to emulate VIP Files:
Response bodies are unchanged where the payload is genuinely wanted — the same icons are still delivered byte for byte.
For the reviewer
wp_cache_get_salted()unserializes the stored value before it compares salts — the payload is already rebuilt into the new class shape by the time anything could reject it. Cached index keys therefore carry a format revision (CACHE_FORMAT), and typed properties gained defaults. Verified by warming the cache with 1.1.1 (908 entries written), then loading the new code over it.CollectionItemhas no__serialize()/__unserialize(). An earlier revision of this branch added them to keep resolved payloads out of a cached index. They were dropped after checking that no call path needs them: every factory caches its index before anything callscontent(). Default PHP serialization is enough, and the measured metrics are identical without them. Worth knowing if you wonder why nothing stops a resolved item from being serialized — the answer is call order, not a guard.residentis measured by reflection, not throughcontent()— calling the getter would force every icon to resolve and destroy the thing being measured.tests/perf/README.mdalong with the other limits.phpcs.xml.distnow excludes./tests/: grumphp passes changed files to phpcs explicitly, overriding the ruleset's file list, and a cache drop-in must assign$GLOBALS['wp_object_cache']and declare both a class and thewp_cache_*functions in one file. The harness is already kept out of releases by.distignore.Not addressed
The editor still bootstraps ~22 MB.
block_editor_rest_api_preload_pathsinlines the first 50 icons of every collection with their content; a single REST page is 12 MB. Fixing that means changing what the editor asks for — droppingcontentfrom theviewcontext, or having the picker fetch payloads only for icons it draws. Deliberately left out of this PR.Verification
Mixed*noise at errorLevel 1 as the existing codebase; not gated anywhereOne caution for anyone re-running the benchmark: at these magnitudes wall time is dominated by container jitter. A run right after the
__serialize()removal showed the init hook at 14.97 ms against 2.33 ms before, which looks like a regression and is not — two further runs of the same code gave 1.83 ms and 2.05 ms. Read the operation counts (resident,cache get,refused), which did not move at all.🤖 Generated with Claude Code
Note
Medium Risk
Core icon loading, caching, and registration paths change for all collection types; behavior should be equivalent when icons are rendered but cache/index shape and memory profile differ, so sites with large media-library sets need validation.
Overview
Icon collections no longer load every SVG at registration.
CollectionItemkeeps a serializable source descriptor and resolves markup on firstcontent()via newContentLoader, with per-payload object-cache entries. Folder/file factory paths build lightweight indexes (empty files skipped viafilesizeonly).Object-cache behavior is hardened for memcached-style 1 MB limits:
Cache::set_cacherefuses payloads over a default 900 KB ceiling (blockparty_icons_cache_max_item_bytes), and collection indexes use format-scoped keys (CACHE_FORMATv3) plus plugin version in salts so old serialized shapes are not reused.Media-library SVGs get a first-class
attachmentstype (Collection::from_attachments/CollectionItemsFactory::from_attachments): oneWP_Query, one cached index salted withwp_cache_get_last_changed('posts'), optionalqueryargs. Registration API and demo theme drop the per-attachmentfrom_fileloop; README documents the pattern.A reproducible perf harness lives under
tests/perf/(fixtures, wp-env on 8899, instrumented object cache, bench/report npm scripts), with gitignored fixtures/results and phpcs exclusion for harness code.Reviewed by Cursor Bugbot for commit d423c1f. Bugbot is set up for automated code reviews on this repo. Configure here.