| 1 |
<?php |
| 2 |
/** |
| 3 |
* Optimizer — the autopilot loop. |
| 4 |
* |
| 5 |
* @package XSpeed |
| 6 |
*/ |
| 7 |
|
| 8 |
namespace XSpeed; |
| 9 |
|
| 10 |
defined( 'ABSPATH' ) || exit; |
| 11 |
|
| 12 |
/** |
| 13 |
* Apply an optimization plan one step at a time, verifying after each. |
| 14 |
* |
| 15 |
* The loop is deliberately boring, because the interesting part is what it |
| 16 |
* refuses to do: |
| 17 |
* |
| 18 |
* - **One change at a time.** A batch that fails tells you nothing about |
| 19 |
* which setting did it, and leaves you reverting work that was fine. |
| 20 |
* - **Purge before verifying.** No settings change purges the page cache on |
| 21 |
* its own (#205), so a check run against an unpurged site verifies the page |
| 22 |
* as it was BEFORE the change and passes anything. |
| 23 |
* - **Revert only the failing step.** A break at step 9 must not discard the |
| 24 |
* eight verified wins before it, and must not abort the four steps after. |
| 25 |
* - **Never leave a step applied-but-unverified.** If sampling itself fails, |
| 26 |
* that is not permission to assume success. |
| 27 |
* |
| 28 |
* Every side effect is injected rather than called directly, so the whole loop |
| 29 |
* is unit-testable without a WordPress install or a live site: the tests drive |
| 30 |
* it with an applier that records, and a sampler that breaks on cue. |
| 31 |
* |
| 32 |
* @since 1.2.0 |
| 33 |
*/ |
| 34 |
final class Optimizer { |
| 35 |
|
| 36 |
/** |
| 37 |
* Timing samples per state (before / after), medians compared. |
| 38 |
* |
| 39 |
* One sample cannot drive a decision: on the site that motivated this, |
| 40 |
* render time across runs with identical config spanned more than 2×. |
| 41 |
* Three is the floor at which a median starts to mean something without |
| 42 |
* making each step cost a page-load storm. |
| 43 |
*/ |
| 44 |
public const PERF_SAMPLES = 3; |
| 45 |
|
| 46 |
/** |
| 47 |
* A regression must clear BOTH of these to trigger a revert — a |
| 48 |
* relative floor so slow sites aren't reverted over jitter that is |
| 49 |
* large in ms but small in proportion, and an absolute floor so fast |
| 50 |
* sites aren't reverted over a 20ms wobble that is huge in percent. |
| 51 |
* Anything inside the floor reports `unchanged`, never `worse`: |
| 52 |
* confidently reverting on noise is a worse failure than keeping a |
| 53 |
* mild regression, because it is invisible and self-assured. |
| 54 |
*/ |
| 55 |
public const PERF_REGRESSION_PCT = 30.0; |
| 56 |
public const PERF_REGRESSION_MIN_MS = 150.0; |
| 57 |
|
| 58 |
/** |
| 59 |
* Run a plan. |
| 60 |
* |
| 61 |
* @param array<int,array<string,mixed>> $steps Ordered steps from Optimize_Plan::build(). |
| 62 |
* @param array<string,mixed> $baseline Sample taken before anything changed. |
| 63 |
* @param array{ |
| 64 |
* apply:callable, // (array $step): ?string — a non-empty string refuses the step |
| 65 |
* revert:callable, |
| 66 |
* purge:callable, |
| 67 |
* sample:callable, |
| 68 |
* time?:callable, |
| 69 |
* now?:callable |
| 70 |
* } $io Injected side effects. |
| 71 |
* @param int $budget_seconds Wall-clock cap; 0 = uncapped. |
| 72 |
* @return array{applied:array<int,array<string,mixed>>,reverted:array<int,array<string,mixed>>,skipped:array<int,array<string,string>>} |
| 73 |
*/ |
| 74 |
public static function run( array $steps, array $baseline, array $io, int $budget_seconds = 0 ): array { |
| 75 |
$apply = $io['apply']; |
| 76 |
$revert = $io['revert']; |
| 77 |
$purge = $io['purge']; |
| 78 |
$sample = $io['sample']; |
| 79 |
// Optional timing probe: returns one render time in ms, or null. |
| 80 |
// Kept separate from `sample` so the integrity contract (exactly |
| 81 |
// one sample per step) is untouched, and a caller without a probe |
| 82 |
// gets the old behavior with every verdict `unknown`. |
| 83 |
$time = isset( $io['time'] ) && is_callable( $io['time'] ) ? $io['time'] : null; |
| 84 |
$now = $io['now'] ?? static function () { |
| 85 |
return time(); |
| 86 |
}; |
| 87 |
|
| 88 |
$started = (int) $now(); |
| 89 |
$applied = array(); |
| 90 |
$reverted = array(); |
| 91 |
$skipped = array(); |
| 92 |
|
| 93 |
foreach ( $steps as $i => $step ) { |
| 94 |
// Budget check BEFORE applying, never mid-step: stopping between |
| 95 |
// "wrote the setting" and "verified it" is the one state this |
| 96 |
// loop must never end in. |
| 97 |
if ( $budget_seconds > 0 && ( (int) $now() - $started ) >= $budget_seconds ) { |
| 98 |
foreach ( array_slice( $steps, $i ) as $rest ) { |
| 99 |
$skipped[] = array( |
| 100 |
'id' => (string) $rest['id'], |
| 101 |
'why' => __( 'Ran out of time before this step.', 'xspeed' ), |
| 102 |
); |
| 103 |
} |
| 104 |
break; |
| 105 |
} |
| 106 |
|
| 107 |
$before = self::snapshot_of( $step ); |
| 108 |
|
| 109 |
// Pre-change timing baseline for THIS step. Taken fresh each |
| 110 |
// step rather than reused from the last one, because the |
| 111 |
// previous step just changed the site. |
| 112 |
$pre_perf = null !== $time ? self::collect_times( $time, self::PERF_SAMPLES ) : null; |
| 113 |
|
| 114 |
/* |
| 115 |
* `apply` may refuse. Page caching is the one step whose write is |
| 116 |
* gated on shared state another plugin can own — Cache::toggle() |
| 117 |
* returns a reason and changes nothing. Reporting that step as |
| 118 |
* applied-and-verified told the user the opposite of what |
| 119 |
* happened: the run claimed "Turn on page caching · verified" |
| 120 |
* over a site whose cache was still off. |
| 121 |
* |
| 122 |
* A refusal is a skip, not a revert: nothing was written, so |
| 123 |
* there is nothing to undo, nothing to purge, and no sample to |
| 124 |
* spend. |
| 125 |
*/ |
| 126 |
$refusal = $apply( $step ); |
| 127 |
if ( is_string( $refusal ) && '' !== $refusal ) { |
| 128 |
$skipped[] = array( |
| 129 |
'id' => (string) $step['id'], |
| 130 |
'why' => $refusal, |
| 131 |
); |
| 132 |
continue; |
| 133 |
} |
| 134 |
$purge(); |
| 135 |
|
| 136 |
$current = $sample(); |
| 137 |
|
| 138 |
// A sample we could not take is NOT a pass. Treating an |
| 139 |
// unreachable site as "probably fine" is how an autopilot leaves |
| 140 |
// a site broken and reports success. |
| 141 |
if ( ! is_array( $current ) ) { |
| 142 |
$revert( $step, $before ); |
| 143 |
$purge(); |
| 144 |
$reverted[] = array( |
| 145 |
'id' => (string) $step['id'], |
| 146 |
'why' => __( 'Could not load the page to check it, so the change was undone.', 'xspeed' ), |
| 147 |
); |
| 148 |
continue; |
| 149 |
} |
| 150 |
|
| 151 |
$check = Optimize_Verifier::compare( $baseline, $current ); |
| 152 |
if ( ! $check['ok'] ) { |
| 153 |
$revert( $step, $before ); |
| 154 |
$purge(); |
| 155 |
$reverted[] = array( |
| 156 |
'id' => (string) $step['id'], |
| 157 |
'why' => implode( ' ', $check['failures'] ), |
| 158 |
); |
| 159 |
continue; |
| 160 |
} |
| 161 |
|
| 162 |
// Integrity holds — now ask whether the change HELPED. "Verified" |
| 163 |
// used to stop at "the HTML still renders", which kept a change |
| 164 |
// that made the site measurably slower and reported it as a win. |
| 165 |
// (#310 — defer_js regressed TBT on every measured run and was |
| 166 |
// marked verified.) |
| 167 |
$post_perf = null !== $time ? self::collect_times( $time, self::PERF_SAMPLES ) : null; |
| 168 |
$measurement = self::measure( $pre_perf, $post_perf ); |
| 169 |
|
| 170 |
if ( 'worse' === $measurement['improved'] ) { |
| 171 |
$revert( $step, $before ); |
| 172 |
$purge(); |
| 173 |
$reverted[] = array( |
| 174 |
'id' => (string) $step['id'], |
| 175 |
'why' => sprintf( |
| 176 |
/* translators: 1: median render time before, 2: after, 3: percent change. */ |
| 177 |
__( 'The page got measurably slower: median render time %1$dms before, %2$dms after (+%3$d%%), beyond what run-to-run noise explains. The change was undone.', 'xspeed' ), |
| 178 |
(int) $measurement['before_ms'], |
| 179 |
(int) $measurement['after_ms'], |
| 180 |
(int) $measurement['change_pct'] |
| 181 |
), |
| 182 |
'measurement' => $measurement, |
| 183 |
); |
| 184 |
continue; |
| 185 |
} |
| 186 |
|
| 187 |
$applied[] = array( |
| 188 |
'id' => (string) $step['id'], |
| 189 |
'change' => (string) $step['label'], |
| 190 |
// Two separate claims where there used to be one. `renders` |
| 191 |
// is the integrity check (what `verified` really meant); |
| 192 |
// `improved` is the measured performance verdict — |
| 193 |
// better / unchanged / unknown here, since `worse` was |
| 194 |
// reverted above. Neither implies the other. |
| 195 |
'renders' => true, |
| 196 |
'improved' => $measurement['improved'], |
| 197 |
'measurement' => $measurement, |
| 198 |
// Kept for consumers reading the old field; means renders. |
| 199 |
'verified' => true, |
| 200 |
); |
| 201 |
} |
| 202 |
|
| 203 |
return array( |
| 204 |
'applied' => $applied, |
| 205 |
'reverted' => $reverted, |
| 206 |
'skipped' => $skipped, |
| 207 |
); |
| 208 |
} |
| 209 |
|
| 210 |
/** |
| 211 |
* Run the timing probe N times and reduce to a median + spread. |
| 212 |
* |
| 213 |
* @param callable $time Probe returning one render time in ms, or null. |
| 214 |
* @return array{median_ms:float,spread_ms:float,samples:int}|null Null when no probe run returned a number. |
| 215 |
*/ |
| 216 |
private static function collect_times( callable $time, int $n ): ?array { |
| 217 |
$times = array(); |
| 218 |
for ( $i = 0; $i < $n; $i++ ) { |
| 219 |
$t = $time(); |
| 220 |
if ( is_numeric( $t ) && (float) $t > 0 ) { |
| 221 |
$times[] = (float) $t; |
| 222 |
} |
| 223 |
} |
| 224 |
if ( array() === $times ) { |
| 225 |
return null; |
| 226 |
} |
| 227 |
sort( $times ); |
| 228 |
$count = count( $times ); |
| 229 |
$middle = (int) floor( $count / 2 ); |
| 230 |
$median = ( 0 === $count % 2 ) |
| 231 |
? ( $times[ $middle - 1 ] + $times[ $middle ] ) / 2 |
| 232 |
: $times[ $middle ]; |
| 233 |
|
| 234 |
return array( |
| 235 |
'median_ms' => $median, |
| 236 |
'spread_ms' => $times[ $count - 1 ] - $times[0], |
| 237 |
'samples' => $count, |
| 238 |
); |
| 239 |
} |
| 240 |
|
| 241 |
/** |
| 242 |
* The performance verdict for one step. |
| 243 |
* |
| 244 |
* `worse` only when the after-median regresses past BOTH noise floors; |
| 245 |
* symmetric rule for `better`; inside the floor is `unchanged`; and a |
| 246 |
* state we could not time is `unknown` — never a guess in either |
| 247 |
* direction. The raw medians and spreads ride along so the verdict is |
| 248 |
* auditable rather than an oracle. |
| 249 |
* |
| 250 |
* @param array{median_ms:float,spread_ms:float,samples:int}|null $pre Before the change. |
| 251 |
* @param array{median_ms:float,spread_ms:float,samples:int}|null $post After it. |
| 252 |
* @return array{improved:string,before_ms:float|null,after_ms:float|null,before_spread_ms:float|null,after_spread_ms:float|null,change_pct:float|null,metric:string} |
| 253 |
*/ |
| 254 |
private static function measure( ?array $pre, ?array $post ): array { |
| 255 |
$out = array( |
| 256 |
'metric' => 'render_time', |
| 257 |
'improved' => 'unknown', |
| 258 |
'before_ms' => null !== $pre ? round( $pre['median_ms'] ) : null, |
| 259 |
'after_ms' => null !== $post ? round( $post['median_ms'] ) : null, |
| 260 |
'before_spread_ms' => null !== $pre ? round( $pre['spread_ms'] ) : null, |
| 261 |
'after_spread_ms' => null !== $post ? round( $post['spread_ms'] ) : null, |
| 262 |
'change_pct' => null, |
| 263 |
); |
| 264 |
if ( null === $pre || null === $post || $pre['median_ms'] <= 0 ) { |
| 265 |
return $out; |
| 266 |
} |
| 267 |
|
| 268 |
$delta = $post['median_ms'] - $pre['median_ms']; |
| 269 |
$pct = 100.0 * $delta / $pre['median_ms']; |
| 270 |
$out['change_pct'] = round( $pct, 1 ); |
| 271 |
|
| 272 |
$past_floor = abs( $delta ) >= self::PERF_REGRESSION_MIN_MS |
| 273 |
&& abs( $pct ) >= self::PERF_REGRESSION_PCT; |
| 274 |
|
| 275 |
if ( ! $past_floor ) { |
| 276 |
$out['improved'] = 'unchanged'; |
| 277 |
} elseif ( $delta > 0 ) { |
| 278 |
$out['improved'] = 'worse'; |
| 279 |
} else { |
| 280 |
$out['improved'] = 'better'; |
| 281 |
} |
| 282 |
|
| 283 |
return $out; |
| 284 |
} |
| 285 |
|
| 286 |
/** |
| 287 |
* The values to put back if this step has to be undone. |
| 288 |
* |
| 289 |
* Every step in the catalog turns something ON, so the inverse is the |
| 290 |
* same keys set to false. Kept as its own method so a future step whose |
| 291 |
* inverse is not simply `false` has one obvious place to say so, rather |
| 292 |
* than the revert path quietly writing the wrong thing. |
| 293 |
* |
| 294 |
* @param array<string,mixed> $step Step definition. |
| 295 |
* @return array<string,mixed> |
| 296 |
*/ |
| 297 |
private static function snapshot_of( array $step ): array { |
| 298 |
$out = array(); |
| 299 |
foreach ( (array) $step['values'] as $key => $value ) { |
| 300 |
$out[ $key ] = is_bool( $value ) ? ! $value : false; |
| 301 |
} |
| 302 |
return $out; |
| 303 |
} |
| 304 |
} |
| 305 |
|