diff --git a/.gitignore b/.gitignore index 6bd3701..5309cd3 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,7 @@ phpcs.xml /wp-tests-config.php /phpunit.xml /.phpunit.result.cache + +# Perf protocol: generated fixtures (~100 MB) and captured results +tests/perf/fixtures/ +tests/perf/results/ diff --git a/.wp-env/themes/icon-block-theme/functions.php b/.wp-env/themes/icon-block-theme/functions.php index 6a40cad..6e86efb 100644 --- a/.wp-env/themes/icon-block-theme/functions.php +++ b/.wp-env/themes/icon-block-theme/functions.php @@ -1,8 +1,6 @@ 'attachment', - 'post_status' => 'inherit', - 'post_mime_type' => 'image/svg+xml', - 'posts_per_page' => 500, //phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page - 'no_found_rows' => true, + 'label' => __( 'Media library', 'beapi-frontend-framework' ), + 'type' => 'attachments', ] ); - - if ( $query->have_posts() ) { - $media_collection = new Collection( 'mediatheque', __( 'Media library', 'beapi-frontend-framework' ) ); - foreach ( $query->posts as $svg ) { - $path = get_attached_file( $svg->ID ); - - if ( empty( $path ) ) { - continue; - } - - try { - $items = CollectionItemsFactory::from_file( - $path, - [ - 'name' => $svg->post_name, - 'label' => get_the_title( $svg ), - ] - ); - array_map( [ $media_collection, 'add' ], $items ); - } catch ( \Exception $e ) { // phpcs:ignore - } - } - register_icon_collection( $media_collection ); - } } ); diff --git a/CHANGELOG.md b/CHANGELOG.md index bd1c62e..39e4829 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,11 @@ This plugin **doesn't run any sanitization on SVGs** before using them. Only use ### Unreleased +- Load an icon's SVG only when it is used. Registering a collection now builds a lightweight index, so a front-end page no longer holds every icon of every collection in memory +- Cache icon payloads individually, and skip the object cache for payloads over 900 KB. Memcached refuses items above 1 MB and reports it only through an unchecked return value, which made oversized collections rebuild on every request forever. Adjust with the `blockparty_icons_cache_max_item_bytes` filter +- Add an `attachments` collection type for SVGs contributed through the media library: one query and one cached index, instead of a query plus a cache round trip per icon on every request +- Version cached index keys, so entries written by an earlier release are never read back after an update +- Add a reproducible performance protocol under `tests/perf` - Add a PHPUnit integration test suite covering every SVG source (folder, sprite, single file), the collection container, the registration API, front-end block rendering, both REST controllers, the object-cache wrapper and the KSES allowances — 176 tests. See `tests/README.md` - Run the suite in CI on PHP 8.1 through 8.4 diff --git a/README.md b/README.md index 68af1d5..6024810 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,57 @@ add_action( 'blockparty_icons_init', 'register_collection' ); This is an example for adding a SVG sprite as a source. If you want to add icons from a folder, change the `type` value to `folder` and the path of your `source` to `get_stylesheet_directory() . '/dist/icons/'` for example. +## Icons contributed in the back office + +To offer the SVG files uploaded to the media library as a collection, use the `attachments` type: + +```php +\Blockparty\Icons\register_icon_collection( + 'mediatheque', + [ + 'label' => 'Media library', + 'type' => 'attachments', + ] +); +``` + +This runs a single query and caches one compact index, invalidated as soon as any +attachment changes. Pass extra `WP_Query` arguments through `query` to narrow the +selection: + +```php +\Blockparty\Icons\register_icon_collection( + 'mediatheque', + [ + 'label' => 'Media library', + 'type' => 'attachments', + 'query' => [ 'posts_per_page' => 1000 ], + ] +); +``` + +Do **not** build such a collection by looping over attachments and calling +`CollectionItemsFactory::from_file()` for each one. That costs a database query and +one object-cache round trip per icon on every request, front end included. + +## Performance notes + +An icon's SVG payload is read only when it is actually needed — one icon when a block +renders, one page's worth when the editor lists a collection. Registering a collection +builds a lightweight index and reads no SVG at all. + +Payloads are cached individually rather than inside the collection, and payloads larger +than 900 KB are not sent to the object cache: memcached (WordPress VIP and most managed +hosts) refuses items over 1 MB and signals it only through a return value that nothing +checks, which would otherwise mean rebuilding the same entry on every request forever. +Raise or disable that ceiling on Redis or APCu: + +```php +add_filter( 'blockparty_icons_cache_max_item_bytes', fn() => 5 * MB_IN_BYTES ); +``` + +A reproducible benchmark for all of this lives in [`tests/perf`](tests/perf/README.md). + ## Params | param | description | @@ -41,7 +92,8 @@ This is an example for adding a SVG sprite as a source. If you want to add icons |-----------|---------------------------| | `label` | Label of the collection. | | `source` | Path to the SVG sprite file or folder containing SVG files. | -| `type` | | +| `type` | | +| `query` | Optional. For `attachments`, extra `WP_Query` arguments. | | `version` | Optional. Version string used for cache busting (e.g. theme version). When set, the sprite URL is appended with a `?v=...` query parameter. | ## How to develop diff --git a/blockparty-icons.php b/blockparty-icons.php index c294308..804737d 100644 --- a/blockparty-icons.php +++ b/blockparty-icons.php @@ -92,9 +92,10 @@ function preload_rest_endpoints( $paths ) { * Optional. An array of additional arguments. Default empty array. * * @type string $label Optional. A human friendly name for the collection. - * @type string $type Optional. The type of icons. Supported values are 'folder', 'sprite' or 'raw'. - * @type string $source Optional. The path to load the collection's icons. Depending on the 'type' can be a path to a folder or a SVG file. + * @type string $type Optional. The type of icons. Supported values are 'folder', 'sprite', 'attachments' or 'raw'. + * @type string $source Optional. The path to load the collection's icons. Depending on the 'type' can be a path to a folder or a SVG file. Unused for 'attachments'. * @type array $icon_map Optional. Use an array to override default labels for the icons. + * @type array $query Optional. For 'attachments', extra WP_Query arguments. * @type string|null $version Optional. The collection version, use for cache busting in the sprite URL. * } * @@ -125,6 +126,7 @@ function register_icon_collection( $name, array $args = [] ) { 'type' => '', 'source' => '', 'icon_map' => [], + 'query' => [], 'version' => null, ] ); @@ -144,6 +146,9 @@ function register_icon_collection( $name, array $args = [] ) { $collection = false; } break; + case 'attachments': + $collection = Collection::from_attachments( $name, $args ); + break; /*case 'file': try { $collection = new Collection( $name, $args['label'] ?? null ); @@ -165,7 +170,7 @@ function register_icon_collection( $name, array $args = [] ) { * Add icons to an existing collection. * * @param string $collection_name The collection to add the icons to. - * @param string $type The type of icons. Supported values are 'folder', 'sprite' or 'raw'. + * @param string $type The type of icons. Supported values are 'folder', 'sprite', 'file' or 'attachments'. * @param string $source The path to load the collection's icons. Depending on the 'type' can be a path to a folder or a SVG file. * @param array $args { * Optional. An array of additional arguments. Default empty array. @@ -187,6 +192,7 @@ function add_icons( string $collection_name, string $type, string $source, array [ 'label' => '', 'icon_map' => [], + 'query' => [], 'version' => null, ] ); @@ -214,6 +220,9 @@ function add_icons( string $collection_name, string $type, string $source, array return false; } break; + case 'attachments': + $items = CollectionItemsFactory::from_attachments( $args ); + break; default: return false; } diff --git a/includes/Helpers/Cache.php b/includes/Helpers/Cache.php index ecd4e5f..8c9ee27 100644 --- a/includes/Helpers/Cache.php +++ b/includes/Helpers/Cache.php @@ -4,6 +4,21 @@ class Cache { + /** + * Largest payload worth handing to the object cache, in bytes. + * + * Memcached — what WordPress VIP and most managed hosts run — refuses any item + * over 1 MB and reports it only through a `false` return that nothing checks. + * The effect is a cache that never warms: the entry is rebuilt, refused, and + * rebuilt again on the next request, forever. + * + * We stay under the limit so the refusal never happens, leaving headroom for + * the key and the backend's own framing. Redis and APCu tolerate far more; + * raise it with the `blockparty_icons_cache_max_item_bytes` filter, or set 0 + * to disable the guard entirely. + */ + private const DEFAULT_MAX_ITEM_BYTES = 900000; + /** * Retrieves cached data if valid and unchanged. * @@ -57,6 +72,11 @@ public static function set_cache( $cache_key, $data, $group, $salt, $expire = 0 return true; } + // Do not pay for a write the backend is going to refuse. + if ( self::exceeds_max_item_bytes( $data ) ) { + return false; + } + if ( function_exists( 'wp_cache_set_salted' ) ) { return wp_cache_set_salted( $cache_key, $data, $group, $salt, $expire ); } @@ -73,6 +93,50 @@ public static function set_cache( $cache_key, $data, $group, $salt, $expire = 0 ); } + /** + * The size ceiling in force for this site. + * + * @return int Bytes, or 0 when the guard is disabled. + */ + public static function max_item_bytes(): int { + /** + * Filters the largest payload the plugin will hand to the object cache. + * + * Set to 0 to store items of any size — appropriate on Redis or APCu, where + * there is no 1 MB per-item limit. + * + * @param int $bytes Default 900000. + */ + return max( 0, (int) apply_filters( 'blockparty_icons_cache_max_item_bytes', self::DEFAULT_MAX_ITEM_BYTES ) ); + } + + /** + * Whether a payload is too large to be worth caching. + * + * @param mixed $data + * + * @return bool + */ + private static function exceeds_max_item_bytes( $data ): bool { + $max = self::max_item_bytes(); + if ( 0 === $max ) { + return false; + } + + // Icon payloads are strings, and measuring one is free. + if ( is_string( $data ) ) { + return strlen( $data ) > $max; + } + + if ( is_scalar( $data ) || null === $data ) { + return false; + } + + // Anything else has to be measured. The extra serialize() costs far less + // than rebuilding an uncacheable entry on every request. + return strlen( serialize( $data ) ) > $max; // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize + } + private static function is_cache_disabled() { if ( function_exists( 'wp_is_development_mode' ) ) { return wp_is_development_mode( 'all' ); diff --git a/includes/Icon/Collection.php b/includes/Icon/Collection.php index cb2f243..3a2e140 100644 --- a/includes/Icon/Collection.php +++ b/includes/Icon/Collection.php @@ -65,6 +65,23 @@ public static function from_sprite( string $name, string $path, array $args = [] return $collection; } + /** + * Create a new collection from the SVG attachments in the media library. + * + * @param string $name collection's name. + * @param array $args collection's args. See CollectionItemsFactory::from_attachments(). + * + * @return static + */ + public static function from_attachments( string $name, array $args = [] ): self { + $collection = new self( $name, $args['label'] ?? null ); + + $items = CollectionItemsFactory::from_attachments( $args ); + array_map( [ $collection, 'add' ], $items ); + + return $collection; + } + public function __construct( string $name, $label = null ) { $this->name = $name; $this->label = $label ?? $name; diff --git a/includes/Icon/CollectionItem.php b/includes/Icon/CollectionItem.php index f2c716f..49453ac 100644 --- a/includes/Icon/CollectionItem.php +++ b/includes/Icon/CollectionItem.php @@ -8,14 +8,60 @@ class CollectionItem { private ?string $label; private string $type; private ?string $version; - private string $content; + /** + * Resolved SVG payload, or null while the item is still unresolved. + * + * @var string|null + */ + private ?string $content = null; + + /** + * Serializable descriptor saying where the payload can be read from. + * + * Null for items created with literal content. A plain array rather than a + * closure so the item survives a round trip through the object cache. + * + * @var array|null + */ + private ?array $source = null; + + /** + * Create an item that already holds its content. + * + * @param string $name + * @param string $type + * @param string $content + * @param string|null $label + * @param string|null $version + */ public function __construct( string $name, string $type, string $content, ?string $label = null, ?string $version = null ) { $this->name = $name; $this->type = $type; $this->content = $content; $this->label = $label ?? $name; $this->version = $version; + $this->source = null; + } + + /** + * Create an item whose content is read only when it is first needed. + * + * @param string $name + * @param string $type + * @param array $source Descriptor understood by ContentLoader. + * @param string|null $label + * @param string|null $version + * + * @return self + */ + public static function from_source( string $name, string $type, array $source, ?string $label = null, ?string $version = null ): self { + $item = new self( $name, $type, '', $label, $version ); + + $item->content = null; + $item->source = $source; + + return $item; } /** @@ -48,12 +94,32 @@ public function type(): string { /** * Get icon's content. * + * For a deferred item this is where the SVG is actually read. The result is + * memoized, so repeated calls within a request cost nothing. + * * @return string */ public function content(): string { + if ( null === $this->content ) { + $this->content = null === $this->source + ? '' + : ContentLoader::load( $this->source ); + } + return $this->content; } + /** + * Whether the content is already in memory. + * + * Lets callers avoid triggering a read they do not need. + * + * @return bool + */ + public function is_resolved(): bool { + return null !== $this->content; + } + /** * Get icon's version. * diff --git a/includes/Icon/CollectionItemsFactory.php b/includes/Icon/CollectionItemsFactory.php index dd62873..3c4e25f 100644 --- a/includes/Icon/CollectionItemsFactory.php +++ b/includes/Icon/CollectionItemsFactory.php @@ -8,6 +8,37 @@ class CollectionItemsFactory { private const CACHE_GROUP = 'blockparty-icons'; + /** + * Format revision of the cached index. + * + * Cached indexes hold serialized CollectionItem objects. Bump this whenever + * their shape changes so that entries written by an earlier release are never + * read back — the salt cannot do this job, because `wp_cache_get_salted()` + * unserializes the stored value *before* it compares salts. Only a different + * key keeps the two formats apart. + */ + private const CACHE_FORMAT = 'v3'; + + /** + * Build a format-scoped cache key. + * + * @param string $key + * + * @return string + */ + private static function cache_key( string $key ): string { + return self::CACHE_FORMAT . ':' . $key; + } + + /** + * Salt component that invalidates cached indexes when the plugin is updated. + * + * @return string + */ + private static function cache_version(): string { + return defined( 'BLOCKPARTY_ICONS_VERSION' ) ? BLOCKPARTY_ICONS_VERSION : '0'; + } + /** * Instantiate array of CollectionItem from a folder. * @@ -18,8 +49,8 @@ class CollectionItemsFactory { * @throws CollectionCreationException */ public static function from_folder( string $folder, array $icon_map = [] ): array { - $cache_key = 'from_folder:' . $folder; - $salt = md5( wp_json_encode( $icon_map ) ); + $cache_key = self::cache_key( 'from_folder:' . $folder ); + $salt = [ md5( wp_json_encode( $icon_map ) ), self::cache_version() ]; $items = Cache::get_cache( $cache_key, self::CACHE_GROUP, $salt ); if ( is_array( $items ) ) { @@ -34,14 +65,23 @@ public static function from_folder( string $folder, array $icon_map = [] ): arra $items = []; $folder = trailingslashit( $folder ); foreach ( glob( $folder . '*.svg' ) as $svg ) { - $item_content = file_get_contents( $svg ); //phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- read local file - if ( ! $item_content ) { + // Skip empty files as before, but without reading their contents: + // the payload is loaded only if the icon is actually used. + if ( ! filesize( $svg ) ) { continue; } $name = pathinfo( $svg, PATHINFO_FILENAME ); $label = $icon_map[ $name ] ?? $name; - $items[] = new CollectionItem( $name, 'raw', $item_content, $label ); + $items[] = CollectionItem::from_source( + $name, + 'raw', + [ + 'type' => 'file', + 'path' => $svg, + ], + $label + ); } Cache::set_cache( $cache_key, $items, self::CACHE_GROUP, $salt, DAY_IN_SECONDS ); @@ -60,8 +100,8 @@ public static function from_folder( string $folder, array $icon_map = [] ): arra * @throws CollectionCreationException */ public static function from_sprite( string $path, array $icon_map = [], ?string $version = null ): array { - $cache_key = 'from_sprite:' . $path; - $salts = [ md5( wp_json_encode( $icon_map ) ) ]; + $cache_key = self::cache_key( 'from_sprite:' . $path ); + $salts = [ md5( wp_json_encode( $icon_map ) ), self::cache_version() ]; if ( $version ) { $salts[] = $version; } @@ -113,8 +153,8 @@ public static function from_sprite( string $path, array $icon_map = [], ?string * @throws CollectionCreationException */ public static function from_file( string $path, array $args = [] ): array { - $cache_key = 'from_file:' . $path; - $salt = md5( wp_json_encode( $args ) ); + $cache_key = self::cache_key( 'from_file:' . $path ); + $salt = [ md5( wp_json_encode( $args ) ), self::cache_version() ]; $items = Cache::get_cache( $cache_key, self::CACHE_GROUP, $salt ); if ( is_array( $items ) ) { @@ -134,9 +174,8 @@ public static function from_file( string $path, array $args = [] ): array { ] ); - $items = []; - $item_content = file_get_contents( $path ); //phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- read local file - if ( ! $item_content ) { + $items = []; + if ( ! filesize( $path ) ) { Cache::set_cache( $cache_key, $items, self::CACHE_GROUP, $salt, DAY_IN_SECONDS ); return $items; @@ -144,13 +183,101 @@ public static function from_file( string $path, array $args = [] ): array { $name = $args['name'] ?? pathinfo( $path, PATHINFO_FILENAME ); $label = $args['label'] ?? $name; - $items[] = new CollectionItem( $name, 'raw', $item_content, $label ); + $items[] = CollectionItem::from_source( + $name, + 'raw', + [ + 'type' => 'file', + 'path' => $path, + ], + $label + ); Cache::set_cache( $cache_key, $items, self::CACHE_GROUP, $salt, DAY_IN_SECONDS ); return $items; } + /** + * Instantiate array of CollectionItem from SVG attachments in the media library. + * + * Registering media-library icons one file at a time — a WP_Query followed by a + * `from_file()` call per attachment — costs a database query and one cache + * round trip per icon on every request. This builds the whole collection from a + * single query and stores it as one small index, keyed on the posts cache's + * `last_changed` value so contributing a new SVG in the back office invalidates + * it immediately. + * + * @param array $args { + * Optional. An array of additional arguments. Default empty array. + * + * @type array $icon_map Optional. Override labels, keyed by attachment slug. + * @type array $query Optional. Extra WP_Query arguments, merged over the defaults. + * } + * + * @return CollectionItem[] + */ + public static function from_attachments( array $args = [] ): array { + $args = (array) wp_parse_args( + $args, + [ + 'icon_map' => [], + 'query' => [], + ] + ); + + $query_args = (array) wp_parse_args( + (array) $args['query'], + [ + 'post_type' => 'attachment', + 'post_status' => 'inherit', + 'post_mime_type' => 'image/svg+xml', + 'posts_per_page' => 500, //phpcs:ignore WordPress.WP.PostsPerPage.posts_per_page_posts_per_page + 'orderby' => 'title', + 'order' => 'ASC', + 'no_found_rows' => true, + ] + ); + + $cache_key = self::cache_key( 'from_attachments:' . md5( wp_json_encode( $query_args ) ) ); + $salts = [ + md5( wp_json_encode( $args['icon_map'] ) ), + self::cache_version(), + // Any change to a post or attachment bumps this, so the index cannot go stale. + (string) wp_cache_get_last_changed( 'posts' ), + ]; + + $items = Cache::get_cache( $cache_key, self::CACHE_GROUP, $salts ); + if ( is_array( $items ) ) { + return $items; + } + + // Only the fields needed to build the index. + $query_args['fields'] = 'all'; + + $query = new \WP_Query( $query_args ); + + $items = []; + foreach ( $query->posts as $attachment ) { + $name = $attachment->post_name; + $label = $args['icon_map'][ $name ] ?? get_the_title( $attachment ); + + $items[] = CollectionItem::from_source( + $name, + 'raw', + [ + 'type' => 'attachment', + 'id' => (int) $attachment->ID, + ], + $label + ); + } + + Cache::set_cache( $cache_key, $items, self::CACHE_GROUP, $salts, DAY_IN_SECONDS ); + + return $items; + } + /** * Format SVG name from its id attribute. * diff --git a/includes/Icon/ContentLoader.php b/includes/Icon/ContentLoader.php new file mode 100644 index 0000000..493ebb7 --- /dev/null +++ b/includes/Icon/ContentLoader.php @@ -0,0 +1,92 @@ +./includes/ ./build/ + + ./tests/perf/ ./node_modules/ ./src/ ./tools/ diff --git a/tests/perf/README.md b/tests/perf/README.md new file mode 100644 index 0000000..d02853f --- /dev/null +++ b/tests/perf/README.md @@ -0,0 +1,268 @@ +# Blockparty Icons — performance protocol + +A reproducible local rig for the WordPress VIP performance problem: a site with +500+ SVG icons, some larger than 1 MB, where the plugin loads every icon's content +into memory on every request — front end included, whether or not any icon is used. + +The rig exists to produce **comparable before/after numbers**, so that a fix can be +shown to work rather than asserted to. + +--- + +## What it builds + +An isolated WordPress instance on (the normal dev env on +8888 is untouched and can run at the same time): + +| | | +|---|---| +| `perf-theme` | 200 SVG icons in a theme folder, registered with `type => folder` | +| `perf-media` | 300 SVG icons contributed through the back office (media library) | +| icon sizes | ~11 KB → ~1.95 MB, following a fixed ladder; **25 icons exceed 1 MB** | +| total payload | ~102 MB of SVG | +| object cache | persistent, and refuses any item over 1 MB — like VIP's memcached | +| pages | `/perf-empty/` (no icon), `/perf-single/` (1 icon), `/perf-many/` (20 icons) | + +The two collections deliberately mirror the VIP topology: theme files are local, +media-library files are remote. `get_attached_file()` is routed through a `bpifs://` +stream wrapper that counts every open and can charge artificial latency. + +--- + +## Prerequisites + +- Docker running +- Node 22 (`volta` pins it) +- `composer` on the PATH — **the plugin resolves its classes through Composer's + PSR-4 autoloader**, so without `vendor/` nothing loads + +## Usage + +```bash +# One-off: generate fixtures, boot the env, import 300 icons into the media library. +# Takes several minutes, mostly the media import. +npm run perf:setup + +# Capture a run. --label names the result set. +npm run perf:bench -- --label=baseline + +# After changing the plugin, capture again and diff against the baseline. +npm run perf:bench -- --label=after +node tests/perf/bin/report.mjs --label=after --compare=baseline + +# Tear down (removes containers and the database, keeps the fixtures). +npm run perf:teardown +``` + +Useful flags: + +| flag | effect | +|---|---| +| `--scale=small` (setup) | 50 icons instead of 500, for quick iteration on the rig itself | +| `--skip-fixtures` (setup) | reuse the SVGs already generated | +| `--runs=N` (bench) | samples per scenario, default 5; the first is dropped as warm-up | +| `--latency-us=2000` (bench) | charge 2 ms per media-library file open, emulating VIP Files | + +--- + +## Metrics + +The probe (`mu-plugins/bpi-perf-probe.php`) writes one JSON record per request. +`report.mjs` reduces each scenario to a **median** so one container hiccup cannot +move a number. + +| column | meaning | +|---|---| +| `icons` | icons held in registered collections | +| `response KB` | bytes on the wire | +| `resident` | **MB of SVG content held in memory.** The direct measure of the problem | +| `peak mem` | `memory_get_peak_usage(true)` | +| `media reads` | media-library file opens, exact, via the `bpifs://` wrapper | +| `cache get` / `cache miss` | object-cache operations in the `blockparty-icons` group | +| `set >1MB refused` | cache writes memcached would reject. **These never warm up** | +| `init hook` | wall time inside `blockparty_icons_init` | +| `wall` | total request time | + +`resident` is measured by reflecting on `CollectionItem`'s private properties, not +by calling `content()`. Calling the getter would force lazily-loaded icons to +resolve and destroy the very thing being measured — the number stays honest across +the refactor. + +--- + +## Faithfulness, and where it stops + +Deliberate choices worth knowing before trusting a number: + +- **The object cache is file-backed, not memcached.** It reproduces the *semantics* + exactly — persistence across requests, a 1 MB item ceiling, a silent `false` from + `wp_cache_set()` — but not the latency. Local file I/O is far cheaper than a + network round trip. **Treat operation counts as the transferable metric and wall + time as indicative only.** To model VIP, multiply `cache get` by a memcached RTT. +- **Theme-folder file reads are not counted directly.** `glob()` bypasses PHP stream + wrappers, so only media-library reads get exact counts. Folder reads are inferred + from cache misses: a miss on `from_folder:*` means all 200 files were re-read. + `resident` covers what actually ended up in memory either way. +- **Latency emulation is off by default** (`--latency-us=0`) so the baseline states + only what was really measured. Turn it on for a VIP-shaped picture. +- **PHP memory limit is raised to 512 MB**, matching VIP's web limit. At PHP's + common 128 MB default this dataset does not merely run slowly — see below. + +--- + +## Baseline (2026-09-10, 500 icons, latency 0, 4 runs) + +### At a 128 MB PHP memory limit, the plugin does not run at all + +Activating the plugin against this dataset fatals outright: + +``` +PHP Fatal error: Allowed memory size of 134217728 bytes exhausted +(tried to allocate 31887360 bytes) in .../object-cache.php on line 216 +``` + +The allocation that fails is `serialize()` on the 200-icon folder collection. The +collection is built in full (~40 MB), then serialized for the cache (~32 MB more). +Everything below therefore runs at 512 MB. + +### Measured + +| scenario | icons | response KB | resident MB | peak MB | media reads | cache get | cache miss | >1MB refused | init ms | wall ms | +|---|---|---|---|---|---|---|---|---|---|---| +| front-empty-warm | 500 | 20 | 101.7 | 154 | 15 | 301 | 16 | 16 | 602 | 640 | +| front-empty-cold | 500 | 20 | 101.7 | 154 | 300 | 301 | 301 | 16 | 2557 | 2658 | +| front-single-warm | 500 | 39 | 101.7 | 154 | 15 | 301 | 16 | 16 | 679 | 768 | +| front-single-cold | 500 | 39 | 101.7 | 154 | 300 | 301 | 301 | 16 | 860 | 934 | +| front-many-warm | 500 | 3513 | 101.7 | 154 | 15 | 301 | 16 | 16 | 305 | 363 | +| rest-collections | 500 | <1 | 101.7 | 173 | 15 | 301 | 16 | 16 | 539 | 601 | +| rest-theme-p1 | 500 | 12065 | 101.7 | 154 | 15 | 301 | 16 | 16 | 568 | 729 | +| rest-media-p1 | 500 | 11805 | 101.7 | 171 | 15 | 301 | 16 | 16 | 230 | 284 | +| rest-theme-search | 500 | 8818 | 101.7 | 154 | 15 | 301 | 16 | 16 | 459 | 558 | +| editor-new-page | 500 | **24530** | 101.7 | 239 | 15 | 301 | 16 | 16 | 506 | 1201 | + +### What the numbers say + +1. **`resident` is 101.7 MB on every single row**, including `front-empty-*`, a page + with no icon block at all. The entire 102 MB icon corpus is loaded to render a + paragraph. It never varies, because nothing about the request influences it. + +2. **16 cache entries are permanently refused** for exceeding 1 MB: + - `from_folder:.../theme-icons` — the whole 200-icon collection as one entry + - 15 × `from_file:...` — each individual media icon above 1 MB + + These are not slow-to-warm; they *never* warm. `wp_cache_set()` returns `false`, + no caller checks it, and the work is redone on every request forever. The + `from_folder` rejection alone means all 200 theme SVGs are re-read from disk on + every request. + +3. **301 object-cache gets per request**, 300 of them from the media-library + collection's per-file cache keys. On VIP each is a memcached round trip; at a + conservative 0.3 ms that is ~90 ms of pure latency before any rendering. + +4. **A cold cache costs 300 media reads and 61.8 MB** of remote reads. With + `--latency-us=2000` that alone adds ~600 ms. + +5. **The editor bootstraps 24.5 MB of HTML** because + `block_editor_rest_api_preload_paths` inlines the first 50 icons *with content* + for each collection. A single REST page is 12 MB. + +These reproduce the three failure modes in the audit: eager whole-corpus loading +(point 1), cache entries too large to store (point 3), and the media-library +registration pattern (point 5). + +--- + +## After the fix + +Points 1, 3 and 5 applied: lazy payload loading, per-icon caching with a size +guard, and the `attachments` collection type. Same dataset, same 4 runs. + +| scenario | resident MB | peak MB | media reads | cache get | >1MB refused | init ms | wall ms | +|---|---|---|---|---|---|---|---| +| front-empty-warm | 0.015 (−100%) | 12 (−92%) | 0 (−100%) | 2 (−99%) | 0 | 2.3 (−100%) | 58 (−91%) | +| front-empty-cold | 0.015 (−100%) | 16 (−90%) | 0 (−100%) | 2 (−99%) | 0 | 329 (−87%) | 388 (−85%) | +| front-single-warm | 0.032 (−100%) | 10 (−94%) | 0 (−100%) | 2 (−99%) | 0 | 1.3 (−100%) | 32 (−96%) | +| front-many-warm | 3.42 (−97%) | 31 (−80%) | 0 (−100%) | 2 (−99%) | 0 | 1.5 (−99%) | 95 (−74%) | +| rest-theme-p1 | 11.70 (−88%) | 33 (−79%) | 0 (−100%) | 2 (−99%) | 0 | 0.5 (−100%) | 55 (−92%) | +| rest-media-p1 | 9.11 (−91%) | 27 (−84%) | 3 (−80%) | 2 (−99%) | 0 | 0.8 (−100%) | 67 (−77%) | +| editor-new-page | 20.80 (−80%) | 147 (−38%) | 3 (−80%) | 2 (−99%) | 0 | 81 (−84%) | 432 (−64%) | + +Reading the rows: + +- **`resident` collapses from a flat 101.7 MB to what the request actually uses** — + 15 KB for a page with no icon, 32 KB for a page with one, 11.7 MB for an editor + page that genuinely lists 50 icons. +- **`cache get` drops from 301 to 2**: one index per collection, instead of one + lookup per media attachment. +- **No cache entry is refused any more.** Indexes are small enough to store, and + payloads are cached individually — a 1.8 MB icon no longer poisons a collection. +- `response KB` is unchanged where the payload is genuinely wanted: the same icons + are still delivered, byte for byte. + +### With VIP-shaped latency (`--latency-us=2000`) + +Charging 2 ms per media-library file open, which is what a VIP Files fetch costs: + +| scenario | before | after | +|---|---|---| +| front-empty-warm | 402 ms | 25 ms (−94%) | +| front-empty-cold | **4546 ms** | 435 ms (−90%) | +| front-single-warm | 414 ms | 41 ms (−90%) | +| front-single-cold | 4739 ms | 493 ms (−90%) | +| editor-new-page | 834 ms | 364 ms (−56%) | + +Both sides of this table were measured in the same session, on the same machine, +against the same 500 icons — the "before" column by checking the original classes +back out into the working tree, not by reusing an older run. Wall time moves by a +few tens of percent between sessions depending on what else the machine is doing; +the operation counts above do not move at all. + +### What is not addressed + +- **The editor still bootstraps ~22 MB.** `block_editor_rest_api_preload_paths` + inlines the first 50 icons of every collection, with content. Cutting that means + changing what the editor asks for — dropping `content` from the `view` context, or + having the picker fetch payloads only for icons it draws. That was point 4 of the + audit and is out of scope here. +- Rendering a page with 20 icons still resolves 20 payloads (3.4 MB). That is the + work the page actually requires. + +--- + +## Files + +``` +tests/perf/ + bin/ + _env.sh shared config; works around three wp-env quirks + generate-fixtures.mjs deterministic SVG generator (seeded, reproducible) + setup.sh boot the env, import media, create pages + bench.sh run the scenarios, capture metrics + report.mjs aggregate to medians, render the table, diff runs + teardown.sh destroy the env + drop-ins/object-cache.php instrumented cache with memcached's 1 MB ceiling + mu-plugins/ + bpi-perf-probe.php metrics + the bpifs:// remote-filesystem wrapper + bpi-perf-auth.php test-only auth shim for REST scenarios + theme/blockparty-perf/ fixture theme; registers both collections + env/.wp-env.json the isolated environment definition + fixtures/ generated SVGs (git-ignored, ~102 MB) + results/ captured runs (git-ignored) +``` + +### wp-env quirks worked around + +`@wordpress/env` 10.39: + +1. has **no `--config` flag** on any subcommand — it reads `.wp-env.json` from the + working directory, which is why the perf env lives in `tests/perf/env/`; +2. ignores the `port` key, templating `${WP_ENV_PORT:-8888}` into docker-compose + instead — so the ports are set through environment variables; +3. gives `wp-env run` no way to target a non-default env — so the harness talks to + the containers with `docker exec`, resolving them by published port. + +### Safety + +`bpi-perf-auth.php` authenticates any request presenting the `BPI_PERF_TOKEN` +constant's value. It is inert unless that constant is defined, and it is defined +only in `tests/perf/env/.wp-env.json`. It must never be copied into a real site. diff --git a/tests/perf/bin/_env.sh b/tests/perf/bin/_env.sh new file mode 100644 index 0000000..3776501 --- /dev/null +++ b/tests/perf/bin/_env.sh @@ -0,0 +1,66 @@ +#!/usr/bin/env bash +# +# Shared settings for the perf harness. Sourced by setup.sh and bench.sh. +# +# Three wp-env facts shape this file (@wordpress/env 10.39): +# +# 1. There is no --config flag. wp-env always reads .wp-env.json from the current +# working directory. The perf environment therefore lives in its own directory, +# tests/perf/env/, with its own .wp-env.json. That also gives it its own project +# hash, so its containers and database are fully isolated from the dev env. +# +# 2. Ports come from WP_ENV_PORT / WP_ENV_TESTS_PORT, not from the config file. +# We pin them so the perf instance can run alongside the dev env on 8888/8889. +# +# 3. `wp-env run` takes no config either, so we address the containers directly +# with docker exec, resolving them by the port they publish. + +export WP_ENV_PORT="${WP_ENV_PORT:-8899}" +export WP_ENV_TESTS_PORT="${WP_ENV_TESTS_PORT:-8898}" + +PERF_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/../../.." && pwd )" +PERF_ENV_DIR="${PERF_ROOT}/tests/perf/env" +PERF_BASE="http://localhost:${WP_ENV_PORT}" +PERF_TOKEN="local-perf-harness-only" + +export PERF_ROOT PERF_ENV_DIR PERF_BASE PERF_TOKEN + +say() { + printf '\n\033[1m==> %s\033[0m\n' "$1" +} + +# Run a wp-env subcommand against the perf environment. +perf_wp_env() { + ( cd "$PERF_ENV_DIR" && npx --prefix "$PERF_ROOT" wp-env "$@" ) +} + +# Resolve the perf environment's container names from the published port. +perf_resolve_containers() { + local wp + wp="$( docker ps \ + --filter "publish=${WP_ENV_PORT}" \ + --filter "name=-wordpress-1" \ + --format '{{.Names}}' | head -n1 )" + + if [ -z "$wp" ]; then + echo "No running wp-env WordPress container publishing port ${WP_ENV_PORT}." >&2 + echo "Start it with: tests/perf/bin/setup.sh" >&2 + return 1 + fi + + PERF_WP_CONTAINER="$wp" + PERF_PROJECT="${wp%-wordpress-1}" + PERF_CLI_CONTAINER="${PERF_PROJECT}-cli-1" + + export PERF_WP_CONTAINER PERF_PROJECT PERF_CLI_CONTAINER +} + +# Run a command inside the CLI container, as the web user, at the WordPress root. +incli() { + docker exec -u 33 -w /var/www/html "$PERF_CLI_CONTAINER" "$@" +} + +# Run WP-CLI inside the perf environment. +wpcli() { + incli wp "$@" +} diff --git a/tests/perf/bin/bench.sh b/tests/perf/bin/bench.sh new file mode 100755 index 0000000..e857a36 --- /dev/null +++ b/tests/perf/bin/bench.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash +# +# Run the Blockparty Icons perf scenarios and write a comparable result set. +# +# tests/perf/bin/bench.sh --label=baseline [--runs=5] [--latency-us=0] +# +# --label names the result file, so before/after runs can be diffed +# --runs samples per scenario (the first is discarded as a warm-up) +# --latency-us artificial per-file latency for media-library reads, emulating VIP Files +# +set -euo pipefail + +# shellcheck source=tests/perf/bin/_env.sh +source "$( dirname "${BASH_SOURCE[0]}" )/_env.sh" + +BASE="$PERF_BASE" +TOKEN="$PERF_TOKEN" + +LABEL="" +RUNS=5 +LATENCY=0 + +for arg in "$@"; do + case "$arg" in + --label=*) LABEL="${arg#*=}" ;; + --runs=*) RUNS="${arg#*=}" ;; + --latency-us=*) LATENCY="${arg#*=}" ;; + *) echo "unknown argument: $arg" >&2; exit 1 ;; + esac +done + +if [ -z "$LABEL" ]; then + echo "--label is required (e.g. --label=baseline)" >&2 + exit 1 +fi + +cd "$PERF_ROOT" +mkdir -p tests/perf/results + +perf_resolve_containers + +LOG_HOST="tests/perf/results/${LABEL}.log" +SIZES_FILE="tests/perf/results/${LABEL}.sizes" +: > "$SIZES_FILE" + +# wp-admin is gated by auth_redirect(), which validates the *auth*-scheme cookie, +# while REST and the rest of WordPress read the logged_in one. Mint both. +say "Minting an admin session for the editor scenario" +ADMIN_ID="$( wpcli user list --role=administrator --field=ID --number=1 | tr -d '\r' )" +LOGIN_COOKIE_NAME="$( wpcli eval 'echo LOGGED_IN_COOKIE;' | tr -d '\r' )" +AUTH_COOKIE_NAME="$( wpcli eval 'echo AUTH_COOKIE;' | tr -d '\r' )" +LOGIN_COOKIE_VALUE="$( wpcli eval "echo wp_generate_auth_cookie( ${ADMIN_ID}, time() + 7200, 'logged_in' );" | tr -d '\r' )" +AUTH_COOKIE_VALUE="$( wpcli eval "echo wp_generate_auth_cookie( ${ADMIN_ID}, time() + 7200, 'auth' );" | tr -d '\r' )" + +if [ -z "$LOGIN_COOKIE_VALUE" ] || [ -z "$AUTH_COOKIE_VALUE" ]; then + echo "Could not mint auth cookies; the editor scenario will redirect." >&2 +else + echo " admin user ${ADMIN_ID}, cookies minted" +fi + +say "Resetting perf log" +incli rm -f /var/www/html/wp-content/bpi-perf.log >/dev/null 2>&1 || true + +# hit URL, scenario-name, cold|warm, [auth] +hit() { + local url="$1" name="$2" cache="$3" auth="${4:-noauth}" + local sep="?" + case "$url" in *\?*) sep="&" ;; esac + + local full="${BASE}${url}${sep}bpi_perf_label=${name}&bpi_perf_latency_us=${LATENCY}" + local -a curl_args=( -s -o /dev/null -w '%{http_code} %{size_download}' --max-time 600 ) + + if [ "$auth" = "auth" ]; then + curl_args+=( -H "X-BPI-Perf-Token: ${TOKEN}" ) + if [ -n "$LOGIN_COOKIE_VALUE" ]; then + curl_args+=( -b "${LOGIN_COOKIE_NAME}=${LOGIN_COOKIE_VALUE}" ) + curl_args+=( -b "${AUTH_COOKIE_NAME}=${AUTH_COOKIE_VALUE}" ) + fi + fi + + local last_bytes=0 + for i in $( seq 1 "$RUNS" ); do + if [ "$cache" = "cold" ]; then + wpcli cache flush >/dev/null 2>&1 || true + fi + local out code bytes + out="$( curl "${curl_args[@]}" "$full" )" + code="${out%% *}" + bytes="${out##* }" + last_bytes="$bytes" + if [ "$code" != "200" ]; then + printf ' %s run %s -> HTTP %s\n' "$name" "$i" "$code" >&2 + fi + done + + printf '%s\t%s\n' "$name" "$last_bytes" >> "$SIZES_FILE" + printf ' %-28s %s cache, %s runs, %s KB response\n' \ + "$name" "$cache" "$RUNS" "$(( last_bytes / 1024 ))" +} + +say "Running scenarios (latency=${LATENCY}us per media read)" + +# --- front end ------------------------------------------------------------- +hit "/perf-empty/" "front-empty-warm" warm +hit "/perf-empty/" "front-empty-cold" cold +hit "/perf-single/" "front-single-warm" warm +hit "/perf-single/" "front-single-cold" cold +hit "/perf-many/" "front-many-warm" warm + +# --- REST (editor data path) ---------------------------------------------- +hit "/wp-json/icons/v1/collections?context=edit" "rest-collections" warm auth +hit "/wp-json/icons/v1/perf-theme?context=edit&per_page=50" "rest-theme-p1" warm auth +hit "/wp-json/icons/v1/perf-media?context=edit&per_page=50" "rest-media-p1" warm auth +hit "/wp-json/icons/v1/perf-media?context=edit&per_page=50&page=4" "rest-media-p4" warm auth +hit "/wp-json/icons/v1/perf-theme?context=edit&search=icon-1" "rest-theme-search" warm auth + +# --- editor ---------------------------------------------------------------- +hit "/wp-admin/post-new.php?post_type=page" "editor-new-page" warm auth + +say "Collecting results" +incli cat /var/www/html/wp-content/bpi-perf.log > "$LOG_HOST" 2>/dev/null || true + +if [ ! -s "$LOG_HOST" ]; then + echo "Perf log is empty — is the probe mu-plugin loaded?" >&2 + exit 1 +fi + +node tests/perf/bin/report.mjs --label="$LABEL" diff --git a/tests/perf/bin/generate-fixtures.mjs b/tests/perf/bin/generate-fixtures.mjs new file mode 100755 index 0000000..9f4e722 --- /dev/null +++ b/tests/perf/bin/generate-fixtures.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node +/** + * Generate deterministic SVG fixtures for the Blockparty Icons perf protocol. + * + * Two sets are produced: + * - theme-icons/ consumed by the fixture theme as a `folder` collection (local FS, like a VIP theme) + * - media-icons/ imported into the media library (remote FS on VIP, see the bpifs:// wrapper) + * + * Sizes follow a fixed ladder from ~10 KB to ~2 MB so that: + * - the aggregate of any collection blows past memcached's 1 MB per-item limit + * - a handful of *individual* icons also blow past it on their own + * + * Usage: + * node tests/perf/bin/generate-fixtures.mjs [--scale=full|small] [--out=DIR] + */ + +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const HERE = dirname( fileURLToPath( import.meta.url ) ); +const DEFAULT_OUT = resolve( HERE, '..', 'fixtures' ); + +const KB = 1024; +const MB = 1024 * 1024; + +/** + * Size ladder. Each bucket is [share, minBytes, maxBytes]. + * `share` values are relative weights within a set. + */ +const LADDER = [ + { key: 'a', share: 0.55, min: 10 * KB, max: 50 * KB }, + { key: 'b', share: 0.28, min: 50 * KB, max: 250 * KB }, + { key: 'c', share: 0.12, min: 250 * KB, max: 1 * MB }, + { key: 'd', share: 0.05, min: 1 * MB, max: 2 * MB }, +]; + +const SCALES = { + full: { theme: 200, media: 300 }, + small: { theme: 20, media: 30 }, +}; + +/** Deterministic PRNG (mulberry32) so runs are byte-for-byte reproducible. */ +function prng( seed ) { + let a = seed >>> 0; + return () => { + a = ( a + 0x6d2b79f5 ) >>> 0; + let t = a; + t = Math.imul( t ^ ( t >>> 15 ), t | 1 ); + t ^= t + Math.imul( t ^ ( t >>> 7 ), t | 61 ); + return ( ( t ^ ( t >>> 14 ) ) >>> 0 ) / 4294967296; + }; +} + +/** Assign a target byte size to every icon index, deterministically. */ +function planSizes( count, rand ) { + const plan = []; + let assigned = 0; + + LADDER.forEach( ( bucket, i ) => { + // Last bucket soaks up the rounding remainder. + const n = + i === LADDER.length - 1 + ? count - assigned + : Math.round( count * bucket.share ); + assigned += n; + for ( let k = 0; k < n; k++ ) { + plan.push( { + bucket: bucket.key, + bytes: Math.floor( bucket.min + rand() * ( bucket.max - bucket.min ) ), + } ); + } + } ); + + // Interleave buckets so a paginated REST response (50 per page) sees a mix + // of sizes rather than 50 tiny icons on page 1. + const shuffled = []; + const stride = Math.ceil( Math.sqrt( plan.length ) ) || 1; + for ( let offset = 0; offset < stride; offset++ ) { + for ( let i = offset; i < plan.length; i += stride ) { + shuffled.push( plan[ i ] ); + } + } + + return shuffled; +} + +/** + * Build a syntactically valid SVG padded with real path data up to `targetBytes`. + * + * Padding is genuine `` geometry rather than a comment blob, so that + * strip_tags(), preg_match_all() and WP_HTML_Tag_Processor do representative work. + */ +function buildSvg( name, targetBytes, rand ) { + const head = + `` + + `${ name }` + + ``; + const tail = ``; + + const parts = [ head ]; + let size = Buffer.byteLength( head ) + Buffer.byteLength( tail ); + + // Each filler path is ~600-700 bytes of plausible curve data. + while ( size < targetBytes ) { + const coords = []; + const segments = 24; + let x = ( rand() * 24 ).toFixed( 2 ); + let y = ( rand() * 24 ).toFixed( 2 ); + coords.push( `M${ x } ${ y }` ); + for ( let s = 0; s < segments; s++ ) { + const c1x = ( rand() * 24 ).toFixed( 2 ); + const c1y = ( rand() * 24 ).toFixed( 2 ); + const c2x = ( rand() * 24 ).toFixed( 2 ); + const c2y = ( rand() * 24 ).toFixed( 2 ); + x = ( rand() * 24 ).toFixed( 2 ); + y = ( rand() * 24 ).toFixed( 2 ); + coords.push( `C${ c1x } ${ c1y } ${ c2x } ${ c2y } ${ x } ${ y }` ); + } + coords.push( 'Z' ); + + const opacity = ( 0.1 + rand() * 0.9 ).toFixed( 3 ); + const piece = ``; + + parts.push( piece ); + size += Buffer.byteLength( piece ); + } + + parts.push( tail ); + return parts.join( '' ); +} + +function generateSet( outDir, prefix, count, seed ) { + rmSync( outDir, { recursive: true, force: true } ); + mkdirSync( outDir, { recursive: true } ); + + const rand = prng( seed ); + const plan = planSizes( count, rand ); + const entries = []; + + plan.forEach( ( item, i ) => { + const index = String( i + 1 ).padStart( 3, '0' ); + const name = `${ prefix }-${ index }`; + const svg = buildSvg( name, item.bytes, rand ); + const bytes = Buffer.byteLength( svg ); + + writeFileSync( resolve( outDir, `${ name }.svg` ), svg ); + entries.push( { name, file: `${ name }.svg`, bucket: item.bucket, bytes } ); + } ); + + return entries; +} + +function summarise( entries ) { + const total = entries.reduce( ( acc, e ) => acc + e.bytes, 0 ); + const overLimit = entries.filter( ( e ) => e.bytes > MB ); + const byBucket = {}; + for ( const e of entries ) { + byBucket[ e.bucket ] = byBucket[ e.bucket ] || { count: 0, bytes: 0 }; + byBucket[ e.bucket ].count++; + byBucket[ e.bucket ].bytes += e.bytes; + } + return { + count: entries.length, + totalBytes: total, + totalMB: +( total / MB ).toFixed( 2 ), + minBytes: Math.min( ...entries.map( ( e ) => e.bytes ) ), + maxBytes: Math.max( ...entries.map( ( e ) => e.bytes ) ), + overOneMB: overLimit.length, + byBucket, + }; +} + +function main() { + const args = Object.fromEntries( + process.argv.slice( 2 ).map( ( a ) => { + const [ k, v ] = a.replace( /^--/, '' ).split( '=' ); + return [ k, v ?? true ]; + } ) + ); + + const scaleKey = args.scale === 'small' ? 'small' : 'full'; + const scale = SCALES[ scaleKey ]; + const out = args.out ? resolve( String( args.out ) ) : DEFAULT_OUT; + + mkdirSync( out, { recursive: true } ); + + const theme = generateSet( resolve( out, 'theme-icons' ), 'theme-icon', scale.theme, 1337 ); + const media = generateSet( resolve( out, 'media-icons' ), 'media-icon', scale.media, 4242 ); + + const manifest = { + generatedAt: new Date().toISOString(), + scale: scaleKey, + ladder: LADDER, + sets: { + theme: { dir: 'theme-icons', ...summarise( theme ), entries: theme }, + media: { dir: 'media-icons', ...summarise( media ), entries: media }, + }, + }; + + writeFileSync( + resolve( out, 'manifest.json' ), + JSON.stringify( manifest, null, '\t' ) + ); + + const t = manifest.sets.theme; + const m = manifest.sets.media; + const fmt = ( s ) => + `${ s.count } icons, ${ s.totalMB } MB total, ${ ( s.minBytes / KB ).toFixed( 0 ) } KB → ` + + `${ ( s.maxBytes / MB ).toFixed( 2 ) } MB, ${ s.overOneMB } over 1 MB`; + + process.stdout.write( + `scale : ${ scaleKey }\n` + + `theme set : ${ fmt( t ) }\n` + + `media set : ${ fmt( m ) }\n` + + `total : ${ ( ( t.totalBytes + m.totalBytes ) / MB ).toFixed( 2 ) } MB in ${ out }\n` + ); +} + +main(); diff --git a/tests/perf/bin/report.mjs b/tests/perf/bin/report.mjs new file mode 100755 index 0000000..c99436c --- /dev/null +++ b/tests/perf/bin/report.mjs @@ -0,0 +1,224 @@ +#!/usr/bin/env node +/** + * Aggregate a perf log into a comparable report. + * + * node tests/perf/bin/report.mjs --label=baseline + * node tests/perf/bin/report.mjs --label=after --compare=baseline + * + * Reads tests/perf/results/