Skip to content

Instantly share code, notes, and snippets.

@soderlind
Created April 29, 2026 11:03
Show Gist options
  • Select an option

  • Save soderlind/4c621915e170762ae863135dfed11c47 to your computer and use it in GitHub Desktop.

Select an option

Save soderlind/4c621915e170762ae863135dfed11c47 to your computer and use it in GitHub Desktop.
WordPress #64863 benchmark: Eliminate switch_to_blog() from multisite option/post functions

Benchmark report: WordPress ticket #64863

Branch: benchmark/64863
PR: #11257Eliminate switch_to_blog() from multisite option/post functions
Environment: PHP 8.3.30 · WordPress 7.0-beta5-61991-src · MySQL 8.4 · Docker (linux/amd64 via Rosetta on arm64)
Method: 200 iterations × 3 runs per function, median taken. Cold = cache flushed before each run. Warm = cache intact.


Cold cache

Function Baseline ms/call Patched ms/call Δ Baseline switches Patched switches
get_blog_option 0.604 0.146 −75.8% 400 0
update_blog_option 1.531 0.989 −35.4% 400 0
add_blog_option + delete_blog_option 2.768 1.730 −37.5% 800 0
get_blog_post 0.503 0.214 −57.4% 400 0
WP_Site::blogname 0.043 0.030 −28.7% 10 0

Warm cache

Function Baseline ms/call Patched ms/call Δ Baseline switches Patched switches
get_blog_option 0.594 0.118 −80.2% 400 0
update_blog_option 1.485 0.983 −33.8% 400 0
add_blog_option + delete_blog_option 2.658 1.608 −39.5% 800 0
get_blog_post 0.483 0.187 −61.2% 400 0
WP_Site::blogname 0.015 0.015 −0.1% 0 0

What the patch does

Replaces switch_to_blog() / restore_current_blog() pairs inside five functions with direct $wpdb->get_blog_prefix( $blog_id ) queries and targeted cache operations:

File Change
src/wp-includes/ms-blogs.php New _get_option_from_blog() helper; rewrites get_blog_option, add_blog_option, update_blog_option, delete_blog_option
src/wp-includes/class-wp-site.php WP_Site::get_details() uses _get_option_from_blog() instead of switch_to_blog
src/wp-includes/ms-functions.php get_blog_post() queries {prefix}posts directly

Why the gains are this large

switch_to_blog() mutates six globals and — on the fallback (non-persistent) object cache — wipes the entire in-memory cache. The patched version issues one targeted SELECT and invalidates only blog-alloptions / blog-notoptions for the affected site. The cold-cache numbers show the biggest difference because every iteration in the baseline starts from a clean cache after the wipe.

WP_Site::blogname warm shows ~0% change because WordPress already caches the site object after the first get_site() call, so switch_to_blog was never reached on subsequent warm iterations.

Verification

  • get_blog_option: 400 baseline switches → 0 patched
  • update_blog_option: 400 → 0
  • add_blog_option + delete_blog_option: 800 → 0
  • get_blog_post: 400 → 0
  • WP_Site::blogname: 10 → 0

All switch_to_blog() calls eliminated.

Reproducibility

Prerequisites

  • Git, Node.js ≥ 18, npm, Docker Desktop

1. Clone and install dependencies

git clone https://github.com/WordPress/wordpress-develop.git
cd wordpress-develop
npm install

2. Create the benchmark branch

git checkout -b benchmark/64863

3. Configure a multisite environment

cp .env.example .env
sed -i '' 's/LOCAL_MULTISITE=false/LOCAL_MULTISITE=true/' .env

4. Start Docker and install WordPress as multisite

npm run env:start
npm run env:install

5. Copy benchmark scripts into the project

Copy the three benchmark files from this gist into:

  • tests/performance/benchmarks/multisite-options.php
  • tests/performance/benchmarks/run.js
  • tests/performance/benchmarks/compare.js

Add the following scripts to package.json (after "test:performance"):

"benchmark:multisite:baseline": "node tests/performance/benchmarks/run.js baseline",
"benchmark:multisite:patched":  "node tests/performance/benchmarks/run.js patched",
"benchmark:multisite:compare":  "node tests/performance/benchmarks/compare.js"

6. Run the baseline benchmark (trunk, no patch)

npm run benchmark:multisite:baseline
# Saves → artifacts/baseline-results.json

7. Download and apply the patch

curl -sL https://github.com/WordPress/wordpress-develop/pull/11257.diff -o 64863.diff
git apply 64863.diff

8. Run the patched benchmark

npm run benchmark:multisite:patched
# Saves → artifacts/patched-results.json

9. Compare

npm run benchmark:multisite:compare

To re-run from a clean state

git checkout -- src/ tests/phpunit/   # revert patch
npm run env:install                   # fresh DB install
npm run benchmark:multisite:baseline
git apply 64863.diff
npm run benchmark:multisite:patched
npm run benchmark:multisite:compare
#!/usr/bin/env node
/**
* Compare baseline vs. patched benchmark results.
*
* Reads:
* artifacts/baseline-results.json
* artifacts/patched-results.json
*
* Usage:
* node tests/performance/benchmarks/compare.js
* WP_ARTIFACTS_PATH=./artifacts node tests/performance/benchmarks/compare.js
*
* Outputs a Markdown table to STDOUT.
*/
const { readFileSync, existsSync } = require( 'node:fs' );
const { join } = require( 'node:path' );
const artifactsPath = process.env.WP_ARTIFACTS_PATH ?? join( process.cwd(), 'artifacts' );
const baselineFile = join( artifactsPath, 'baseline-results.json' );
const patchedFile = join( artifactsPath, 'patched-results.json' );
for ( const [ label, file ] of [ [ 'Baseline', baselineFile ], [ 'Patched', patchedFile ] ] ) {
if ( ! existsSync( file ) ) {
console.error( `${ label } results not found: ${ file }` );
process.exit( 1 );
}
}
const baseline = JSON.parse( readFileSync( baselineFile, 'utf8' ) );
const patched = JSON.parse( readFileSync( patchedFile, 'utf8' ) );
/** Format a number with a fixed number of decimal places. */
function fmt( value, decimals = 3 ) {
return Number( value ).toFixed( decimals );
}
/** Return a coloured delta string: green for improvement, red for regression. */
function delta( before, after ) {
if ( before === 0 ) {
return 'N/A';
}
const pct = ( ( after - before ) / before ) * 100;
const sign = pct > 0 ? '+' : '';
return `${ sign }${ fmt( pct, 1 ) }%`;
}
// ---------------------------------------------------------------------------
// Print metadata
// ---------------------------------------------------------------------------
console.log( '## WordPress multisite option/post benchmark results\n' );
console.log( `**Ticket:** [#64863](https://core.trac.wordpress.org/ticket/64863)` );
console.log( `**PR:** [#11257](https://github.com/WordPress/wordpress-develop/pull/11257)\n` );
for ( const [ label, data ] of [ [ 'Baseline (trunk)', baseline ], [ 'Patched', patched ] ] ) {
const m = data.meta;
console.log( `**${ label }** — PHP ${ m.php_version }, WordPress ${ m.wp_version }, ${ m.iterations } iterations × ${ m.runs } runs (${ m.timestamp })` );
}
console.log();
// ---------------------------------------------------------------------------
// Build comparison table per scenario
// ---------------------------------------------------------------------------
for ( const scenario of [ 'cold', 'warm' ] ) {
console.log( `### Cache scenario: ${ scenario }\n` );
console.log( '_Cold: cache flushed before every measurement run. Warm: cache intact._\n' );
console.log(
'| Function | Baseline ms/call | Patched ms/call | Δ% | Baseline switches | Patched switches |'
);
console.log(
'|---|---|---|---|---|---|'
);
const functions = Object.keys( baseline.results );
for ( const fn of functions ) {
const b = baseline.results[ fn ]?.[ scenario ];
const p = patched.results[ fn ]?.[ scenario ];
if ( ! b || ! p ) {
console.log( `| ${ fn } | N/A | N/A | N/A | N/A | N/A |` );
continue;
}
const deltaStr = delta( b.median_ms_per_call, p.median_ms_per_call );
const switchWarning = p.switch_count > 0 ? ` ⚠️` : '';
console.log(
`| \`${ fn }\` | ${ fmt( b.median_ms_per_call ) } | ${ fmt( p.median_ms_per_call ) } | ${ deltaStr } | ${ b.switch_count } | ${ p.switch_count }${ switchWarning } |`
);
}
console.log();
}
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
console.log( '### Verification\n' );
const functions = Object.keys( baseline.results );
let allSwitchesEliminated = true;
for ( const fn of functions ) {
const b = baseline.results[ fn ]?.cold;
const p = patched.results[ fn ]?.cold;
const ok = b && p && b.switch_count > 0 && p.switch_count === 0;
const status = ok ? '✅' : ( p && p.switch_count === 0 ? '✅ (was already 0)' : '❌ still switches' );
if ( p && p.switch_count > 0 ) {
allSwitchesEliminated = false;
}
console.log( `- \`${ fn }\`: baseline ${ b?.switch_count ?? '?' } switches → patched ${ p?.switch_count ?? '?' } switches ${ status }` );
}
console.log();
if ( allSwitchesEliminated ) {
console.log( '✅ All `switch_to_blog()` calls eliminated by the patch.' );
} else {
console.log( '⚠️ Some functions still call `switch_to_blog()` — verify the patch was applied correctly.' );
}
<?php
/**
* Benchmark: multisite option/post functions with vs. without switch_to_blog().
*
* Run via:
* npm run env:cli -- eval-file tests/performance/benchmarks/multisite-options.php \
* --path=/var/www/src --skip-wordpress=false
*
* Optional environment variables (pass via WP-CLI --exec-file or shell export):
* BENCH_ITERATIONS Number of iterations per function (default 200).
* BENCH_RUNS Warm-up+measure rounds per scenario (default 3).
* BENCH_OUTPUT_FILE Absolute path to write JSON results (default STDOUT only).
*
* Output: JSON to STDOUT.
*/
if ( ! defined( 'ABSPATH' ) ) {
die( 'Must be run inside WordPress context via wp eval-file.' );
}
if ( ! is_multisite() ) {
fwrite( STDERR, "ERROR: This benchmark requires a multisite installation.\n" );
fwrite( STDERR, "Set LOCAL_MULTISITE=true in .env and run npm run env:install.\n" );
exit( 1 );
}
// ---------------------------------------------------------------------------
// Config
// ---------------------------------------------------------------------------
$iterations = isset( $_ENV['BENCH_ITERATIONS'] ) ? (int) $_ENV['BENCH_ITERATIONS'] : 200;
$runs = isset( $_ENV['BENCH_RUNS'] ) ? (int) $_ENV['BENCH_RUNS'] : 3;
$output_file = isset( $_ENV['BENCH_OUTPUT_FILE'] ) ? $_ENV['BENCH_OUTPUT_FILE'] : null;
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Returns elapsed wall-clock seconds using the highest-res timer available.
*/
function bench_time(): float {
return microtime( true );
}
/**
* Flushes as many caches as possible.
*/
function bench_flush_cache(): void {
wp_cache_flush();
if ( function_exists( 'opcache_reset' ) ) {
opcache_reset();
}
}
/**
* Compute median of a float array.
*/
function bench_median( array $values ): float {
if ( empty( $values ) ) {
return 0.0;
}
sort( $values );
$n = count( $values );
$mid = intdiv( $n, 2 );
return ( $n % 2 === 0 ) ? ( $values[ $mid - 1 ] + $values[ $mid ] ) / 2 : $values[ $mid ];
}
/**
* Counts switch_to_blog() calls made during a callback execution.
*
* @param callable $callback
* @return array{calls: int, elapsed: float}
*/
function bench_run_and_count( callable $callback ): array {
$switch_count = 0;
$counter = static function () use ( &$switch_count ) {
$switch_count++;
};
add_action( 'switch_blog', $counter );
$start = bench_time();
$callback();
$elapsed = bench_time() - $start;
remove_action( 'switch_blog', $counter );
return [ 'calls' => $switch_count, 'elapsed' => $elapsed ];
}
// ---------------------------------------------------------------------------
// Setup: create test sites
// ---------------------------------------------------------------------------
/** @var int[] $test_blog_ids */
$test_blog_ids = [];
$num_sites = 5;
$run_suffix = time(); // unique per run so repeated executions never collide
fwrite( STDERR, "Setting up $num_sites test sites...\n" );
for ( $i = 1; $i <= $num_sites; $i++ ) {
$blog_id = wpmu_create_blog(
get_current_site()->domain,
"/benchmark-$run_suffix-$i/",
"Benchmark Site $i ($run_suffix)",
get_current_user_id()
);
if ( is_wp_error( $blog_id ) ) {
fwrite( STDERR, "Failed to create test site $i: " . $blog_id->get_error_message() . "\n" );
exit( 1 );
}
$test_blog_ids[] = $blog_id;
}
// Seed each site with a test option and a test post.
$option_name = 'bench_test_option';
$option_value = 'benchmark_value_' . time();
foreach ( $test_blog_ids as $blog_id ) {
update_blog_option( $blog_id, $option_name, $option_value );
switch_to_blog( $blog_id );
$post_id = wp_insert_post( [
'post_title' => "Benchmark post for site $blog_id",
'post_status' => 'publish',
'post_type' => 'post',
'post_content' => 'benchmark',
] );
restore_current_blog();
// Store post ID for later retrieval.
update_blog_option( $blog_id, 'bench_test_post_id', $post_id );
}
fwrite( STDERR, "Test sites created. Starting benchmarks ($runs runs × $iterations iterations)...\n" );
// ---------------------------------------------------------------------------
// Benchmark scenarios
// ---------------------------------------------------------------------------
/** @var array<string, array{cold: array<string,mixed>, warm: array<string,mixed>}> $results */
$results = [];
/**
* Run a single benchmark function across all test sites.
*
* @param string $label Human-readable label.
* @param callable $fn Function under test. Receives ($blog_id, $iteration_index).
* @param int[] $blog_ids Array of blog IDs to cycle through.
* @param int $iter Iterations per run.
* @param int $runs_count Number of measurement runs.
* @param bool $cold Whether to flush caches before each run.
*/
function bench_measure( string $label, callable $fn, array $blog_ids, int $iter, int $runs_count, bool $cold ): array {
$run_times = [];
$run_switches = [];
for ( $r = 0; $r < $runs_count; $r++ ) {
if ( $cold ) {
bench_flush_cache();
}
$result = bench_run_and_count( static function () use ( $fn, $iter, $blog_ids ) {
$n = count( $blog_ids );
for ( $i = 0; $i < $iter; $i++ ) {
$blog_id = $blog_ids[ $i % $n ];
$fn( $blog_id, $i );
}
} );
$run_times[] = $result['elapsed'];
$run_switches[] = $result['calls'];
}
$total_calls = $iter * $runs_count; // Not used directly but useful for context.
$median_elapsed = bench_median( $run_times );
$median_ms_per = ( $median_elapsed / $iter ) * 1000;
$total_switches = (int) bench_median( $run_switches );
return [
'label' => $label,
'iterations' => $iter,
'runs' => $runs_count,
'median_elapsed_sec' => round( $median_elapsed, 6 ),
'median_ms_per_call' => round( $median_ms_per, 6 ),
'switch_count' => $total_switches,
'raw_times_sec' => array_map( fn( $t ) => round( $t, 6 ), $run_times ),
'raw_switches' => $run_switches,
];
}
// ---------------------------------------------------------------------------
// 1. get_blog_option()
// ---------------------------------------------------------------------------
foreach ( [ 'cold' => true, 'warm' => false ] as $scenario => $cold ) {
$results['get_blog_option'][ $scenario ] = bench_measure(
"get_blog_option ($scenario)",
static function ( int $blog_id ) use ( $option_name ) {
get_blog_option( $blog_id, $option_name );
},
$test_blog_ids,
$iterations,
$runs,
$cold
);
fwrite( STDERR, " get_blog_option [$scenario]: done\n" );
}
// ---------------------------------------------------------------------------
// 2. update_blog_option()
// ---------------------------------------------------------------------------
foreach ( [ 'cold' => true, 'warm' => false ] as $scenario => $cold ) {
$results['update_blog_option'][ $scenario ] = bench_measure(
"update_blog_option ($scenario)",
static function ( int $blog_id, int $i ) use ( $option_name ) {
update_blog_option( $blog_id, $option_name, 'value_' . $i );
},
$test_blog_ids,
$iterations,
$runs,
$cold
);
fwrite( STDERR, " update_blog_option [$scenario]: done\n" );
}
// ---------------------------------------------------------------------------
// 3. add_blog_option() + delete_blog_option() (alternating pairs)
// ---------------------------------------------------------------------------
foreach ( [ 'cold' => true, 'warm' => false ] as $scenario => $cold ) {
$temp_option = 'bench_temp_' . uniqid();
$results['add_delete_blog_option'][ $scenario ] = bench_measure(
"add+delete_blog_option ($scenario)",
static function ( int $blog_id, int $i ) use ( $temp_option ) {
$key = $temp_option . "_$i";
add_blog_option( $blog_id, $key, 'val' );
delete_blog_option( $blog_id, $key );
},
$test_blog_ids,
$iterations,
$runs,
$cold
);
fwrite( STDERR, " add+delete_blog_option [$scenario]: done\n" );
}
// ---------------------------------------------------------------------------
// 4. get_blog_post()
// ---------------------------------------------------------------------------
// Build a map of blog_id => post_id from the seeded data.
$bench_post_ids = [];
foreach ( $test_blog_ids as $blog_id ) {
$bench_post_ids[ $blog_id ] = (int) get_blog_option( $blog_id, 'bench_test_post_id' );
}
foreach ( [ 'cold' => true, 'warm' => false ] as $scenario => $cold ) {
$results['get_blog_post'][ $scenario ] = bench_measure(
"get_blog_post ($scenario)",
static function ( int $blog_id ) use ( $bench_post_ids ) {
get_blog_post( $blog_id, $bench_post_ids[ $blog_id ] );
},
$test_blog_ids,
$iterations,
$runs,
$cold
);
fwrite( STDERR, " get_blog_post [$scenario]: done\n" );
}
// ---------------------------------------------------------------------------
// 5. WP_Site::__get('blogname')
// ---------------------------------------------------------------------------
foreach ( [ 'cold' => true, 'warm' => false ] as $scenario => $cold ) {
$results['wp_site_blogname'][ $scenario ] = bench_measure(
"WP_Site::blogname ($scenario)",
static function ( int $blog_id ) {
// Access blogname through WP_Site object (triggers get_details() internally).
$site = get_site( $blog_id );
if ( $site ) {
$_ = $site->blogname; // phpcs:ignore WordPress.CodeAnalysis.AssignmentInCondition
}
},
$test_blog_ids,
$iterations,
$runs,
$cold
);
fwrite( STDERR, " WP_Site::blogname [$scenario]: done\n" );
}
// ---------------------------------------------------------------------------
// Cleanup
// ---------------------------------------------------------------------------
fwrite( STDERR, "Cleaning up test sites...\n" );
foreach ( $test_blog_ids as $blog_id ) {
wpmu_delete_blog( $blog_id, true );
}
// ---------------------------------------------------------------------------
// Output
// ---------------------------------------------------------------------------
$meta = [
'php_version' => PHP_VERSION,
'wp_version' => get_bloginfo( 'version' ),
'is_multisite' => is_multisite(),
'iterations' => $iterations,
'runs' => $runs,
'timestamp' => gmdate( 'c' ),
];
$output = json_encode( [ 'meta' => $meta, 'results' => $results ], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES );
if ( $output_file ) {
file_put_contents( $output_file, $output );
fwrite( STDERR, "Results written to: $output_file\n" );
} else {
echo $output . "\n";
}
fwrite( STDERR, "Benchmark complete.\n" );
#!/usr/bin/env node
/**
* Runner for the multisite benchmark script.
*
* Usage:
* node tests/performance/benchmarks/run.js baseline
* node tests/performance/benchmarks/run.js patched
*
* Spawns `npm run env:cli -- eval-file ...` inside Docker, captures JSON from
* stdout, and writes it to artifacts/<mode>-results.json. Progress/error
* messages from the PHP script flow through to stderr so you can watch live.
*/
'use strict';
const { spawnSync } = require( 'node:child_process' );
const { writeFileSync, mkdirSync } = require( 'node:fs' );
const { join } = require( 'node:path' );
const mode = process.argv[ 2 ];
if ( ! [ 'baseline', 'patched' ].includes( mode ) ) {
console.error( 'Usage: node run.js <baseline|patched>' );
process.exit( 1 );
}
const artifactsDir = join( process.cwd(), 'artifacts' );
const outputFile = join( artifactsDir, `${ mode }-results.json` );
mkdirSync( artifactsDir, { recursive: true } );
console.log( `Running ${ mode } benchmark…` );
console.log( `Output: ${ outputFile }\n` );
// The project root is mounted at /var/www inside the container.
const phpScript = '/var/www/tests/performance/benchmarks/multisite-options.php';
const result = spawnSync(
'npm',
[
'run', '--silent', 'env:cli', '--',
'eval-file', phpScript,
'--path=/var/www/src',
],
{
stdio: [ 'ignore', 'pipe', 'inherit' ], // capture stdout, pass stderr through
cwd: process.cwd(),
env: process.env,
}
);
if ( result.error ) {
console.error( 'Failed to spawn process:', result.error.message );
process.exit( 1 );
}
if ( result.status !== 0 ) {
console.error( `\nBenchmark process exited with code ${ result.status }` );
process.exit( result.status || 1 );
}
const raw = result.stdout.toString().trim();
// Validate JSON before writing.
let parsed;
try {
parsed = JSON.parse( raw );
} catch ( e ) {
console.error( '\nPHP script did not produce valid JSON.' );
console.error( 'First 500 chars of output:' );
console.error( raw.substring( 0, 500 ) );
process.exit( 1 );
}
writeFileSync( outputFile, JSON.stringify( parsed, null, 2 ) );
console.log( `\n✅ ${ mode } results saved to: artifacts/${ mode }-results.json` );
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment