From 126c66fa37841e045b34ff7a26bfb0bd187bc7eb Mon Sep 17 00:00:00 2001 From: Amaury Balmer Date: Thu, 10 Sep 2026 15:21:29 +0200 Subject: [PATCH 1/6] test: add reproducible performance protocol for large icon sets 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 --- .gitignore | 4 + package.json | 7 +- phpcs.xml.dist | 7 + tests/perf/README.md | 261 ++++++++++ tests/perf/bin/_env.sh | 66 +++ tests/perf/bin/bench.sh | 128 +++++ tests/perf/bin/generate-fixtures.mjs | 221 ++++++++ tests/perf/bin/report.mjs | 224 ++++++++ tests/perf/bin/setup.sh | 171 +++++++ tests/perf/bin/teardown.sh | 20 + tests/perf/drop-ins/object-cache.php | 477 ++++++++++++++++++ tests/perf/env/.wp-env.json | 20 + tests/perf/mu-plugins/bpi-perf-auth.php | 70 +++ tests/perf/mu-plugins/bpi-perf-probe.php | 371 ++++++++++++++ .../perf/theme/blockparty-perf/functions.php | 133 +++++ tests/perf/theme/blockparty-perf/index.php | 24 + tests/perf/theme/blockparty-perf/style.css | 6 + 17 files changed, 2209 insertions(+), 1 deletion(-) create mode 100644 tests/perf/README.md create mode 100644 tests/perf/bin/_env.sh create mode 100755 tests/perf/bin/bench.sh create mode 100755 tests/perf/bin/generate-fixtures.mjs create mode 100755 tests/perf/bin/report.mjs create mode 100755 tests/perf/bin/setup.sh create mode 100755 tests/perf/bin/teardown.sh create mode 100644 tests/perf/drop-ins/object-cache.php create mode 100644 tests/perf/env/.wp-env.json create mode 100644 tests/perf/mu-plugins/bpi-perf-auth.php create mode 100644 tests/perf/mu-plugins/bpi-perf-probe.php create mode 100644 tests/perf/theme/blockparty-perf/functions.php create mode 100644 tests/perf/theme/blockparty-perf/index.php create mode 100644 tests/perf/theme/blockparty-perf/style.css 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/package.json b/package.json index bd0052c..4e64d31 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,12 @@ "make-json": "wp i18n make-json languages/blockparty-icons-fr_FR.po languages/ --no-purge", "env:start": "wp-env start --config=./.wp-env.json", "env:stop": "wp-env stop --config=./.wp-env.json", - "test:php": "bash tests/bin/phpunit.sh" + "test:php": "bash tests/bin/phpunit.sh", + "perf:setup": "bash tests/perf/bin/setup.sh", + "perf:bench": "bash tests/perf/bin/bench.sh", + "perf:report": "node tests/perf/bin/report.mjs", + "perf:fixtures": "node tests/perf/bin/generate-fixtures.mjs", + "perf:teardown": "bash tests/perf/bin/teardown.sh" }, "devDependencies": { "@wordpress/env": "^10.39.0", diff --git a/phpcs.xml.dist b/phpcs.xml.dist index 1c41647..607c551 100644 --- a/phpcs.xml.dist +++ b/phpcs.xml.dist @@ -10,6 +10,13 @@ ./includes/ ./build/ + + ./tests/ ./node_modules/ ./src/ ./tools/ diff --git a/tests/perf/README.md b/tests/perf/README.md new file mode 100644 index 0000000..e363a84 --- /dev/null +++ b/tests/perf/README.md @@ -0,0 +1,261 @@ +# 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 | 618 ms | 25 ms (−96%) | +| front-empty-cold | **4806 ms** | 422 ms (−91%) | +| front-single-warm | 547 ms | 30 ms (−94%) | +| editor-new-page | 1057 ms | 556 ms (−47%) | + +### 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/