Flatten deep BooleanAnd chains in resolveType() and the falsey context - #6214
Flatten deep BooleanAnd chains in resolveType() and the falsey context#6214SanderMuller wants to merge 1 commit into
Conversation
|
Please look at this in the context of my single pass PR if it's still relevant after it |
|
I'm talking about #5857 |
8a71fc9 to
b3dabee
Compare
BooleanOrHandler flattens deep chains in both resolveType() and specifyTypes(); BooleanAndHandler only flattened specifyTypes(), and only in a truthy context. The two missing halves each recursed into the left operand and re-narrowed the whole left chain at every level, so a chain of N arms cost O(N^2) scope operations - once while resolving the chain's boolean type, and once more while narrowing its falsey side. Both additions mirror their BooleanOr counterparts. resolveType() threads the truthy scope arm by arm: the chain is false if any arm is false, true if every arm is true. The falsey specifyTypes() path is the De Morgan mirror of the flattened truthy BooleanOr chain - at least one arm is false, so the arms' narrowings intersect - and like it, deep chains trade the per-pair conditional-holder augments for linear time. A mixed truthy-and-false context still takes the recursive path, which re-derives empty holders from the falsey narrowing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
b3dabee to
8dc651b
Compare
|
Checked it against #5857, and you are right: this is not needed after it. Measured rather than assumed, same reproducer and method as the table above (level 8, single file, cold cache, CPU as user+sys, PHP 8.5.8):
#5857 is linear on it (per doubling x1.29, x1.77), so the blowup this PR targets is already gone there. The same holds for the string-literal variant ( It is not just redundant, it is inapplicable: #5857 deletes both methods this PR patches. So I would close this, and I am happy to do that — your call on one thing first: do you want the fix in a Two artifacts here are worth keeping either way, and I have verified both against #5857 rather than assuming:
Say the word and I will open a separate PR with just those two against One correction to my own report while I am here, since it is wrong on the record: I filed phpstan/phpstan#15004 blaming |
|
I'll take the tests file myself into #5857, no further action required. I hope to release #5857 at some point in August or early September. After that I plan to rewrite the entirety of ExprHandlers and some surrounding code for the Turbo extension (if it's going to yield performance improvements). Thank you. |
The nsrt fixture and the bench corpus entry from the mainline flattening fix; the single-pass composition handles the chain without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
The nsrt fixture and the bench corpus entry from the mainline flattening fix; the single-pass composition handles the chain without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
The nsrt fixture and the bench corpus entry from the mainline flattening fix; the single-pass composition handles the chain without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
The nsrt fixture and the bench corpus entry from the mainline flattening fix; the single-pass composition handles the chain without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
The nsrt fixture and the bench corpus entry from the mainline flattening fix; the single-pass composition handles the chain without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
The nsrt fixture and the bench corpus entry from the mainline flattening fix; the single-pass composition handles the chain without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
The nsrt fixture and the bench corpus entry from the mainline flattening fix; the single-pass composition handles the chain without it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DaBZjgksga4c5s6Q9FniY7
Closes phpstan/phpstan#15004
A long
&&chain of!==comparisons against a literal union is quadratic. The 400-clause reproducer from the issue goes from 18.4s of CPU to 1.0s, and the growth curve flattens to linear.The report's root cause was wrong, and I wrote it
I reported this as the
TypeCombinator::removecounterpart of theintersectblowup #5935 fixed, and proposed mirroring that fast path intoremove. I built that first. It made things slightly slower (0.90x at N=400), and the counters say why: of 279,295removecalls at N=200, the fast path could serve 40,381, and every one of them still had to rebuild the union — there was no cheap "value is not a member" case to win at all. It only added a keying attempt in front of the existing work. Dropped it.Subtractive probe on where the time really goes: stubbing
doRemove()to return its input removes 63% at N=400, but what remains still grows x4.54 per doubling. Soremoveis expensive but not the shape of the problem — the number of narrowing operations is, and that number is quadratic.What it actually is
BooleanOrHandlerflattens deep chains in bothresolveType()andspecifyTypes().BooleanAndHandlerflattened onlyspecifyTypes(), and only when$context->true(). The two missing halves each recursed into the left operand and re-narrowed the whole left chain at every level.The asymmetry is visible as a straight measurement — same reproducer, same method, only the operator differs (3 rounds, medians):
$x !== 1 && ...$x === 1 || ...And instrumenting the handler on
2.2.xshows exactly which path runs: for the 200-arm chain the truthy side flattens 582 times, while the recursive path is entered 407 times at depths 193-198 — all of them in a falsey context (false()set,truthy()clear). That is theelseside of the chain, plusresolveType.The change
Both additions mirror their
BooleanOrcounterparts:resolveTypeForFlattenedBooleanAnd()threads the truthy scope arm by arm: false if any arm is false, true if every arm is true,boolotherwise.specifyTypesForFlattenedFalseyBooleanAnd()is the De Morgan mirror of the flattened truthyBooleanOrchain: at least one arm is false, so the arms' narrowings intersect. Like that path, a deep chain trades the per-pair conditional-holder augments for linear time.Every non-
truecontext that is not the null context takes the flattened falsey path. I first gated it more narrowly ($context->false() && !$context->truthy()) to keep mixed truthy-and-false contexts on the recursive path, and dropped that guard once the mutation gate showed it was unobservable — see the CI note below.I measured the trade-off rather than leaning on the precedent: instrumenting
2.2.xto count how often the deep-falsey recursive path actually builds a non-nullbranchUnionAugment, it fires 3 times across the whole test suite and twice on the 1144-file corpus. So it is a real trade-off, not a proven no-op - but in both samples the resulting output is unchanged (suite green, corpus byte-identical). If you would rather keep the augment for deep chains, it can be built once from the flattened arms instead; I did not do that because it reintroduces per-pair work and nothing measurable depends on it.Numbers
Interleaved base/PR per N, 3 rounds, medians, level 8, single file, cold cache, CPU as user+sys, PHP 8.5.8:
Growth per doubling of N: x3.41 (100->200) and x5.80 (200->400) on base; x1.21 and x1.65 with this change. The PR's 1.04s at N=400 matches the
||shape's 0.98s, so the two operators now cost the same.The same holds for string literals rather than ints (
$x !== 's1' && ...): 17.30s -> 1.01s at N=400, per-doubling x5.88 -> x1.63. Worth noting because the issue claimed the plain-string version was already handled by the flattening — it was not, it is equally quadratic on2.2.x.No regression
phpcsclean.2.2.x, and CPU is 3.2% lower (medians of 2 interleaved rounds, 134.6s -> 130.3s).Tests
nsrt/deep-boolean-and-chain.phppins the narrowing on both sides of chains longer thanBOOLEAN_EXPRESSION_MAX_PROCESS_DEPTH, against a short chain on the recursive path for comparison, plus a negated chain, a mixed-arm chain whose falsey side cannot narrow, and a null-check chain. It passes on2.2.xtoo — it is a characterisation test, since the change is meant to leave inference untouched.tests/bench/data/and-chain-resolve-type-blowup.phpadds the instanceof-arm shape, mirroringor-chain-resolve-type-blowup.phpand following how7eab3d2added its bench in the same commit as the fix. It is a strong discriminator: 17.27s unflattened, 9.17s with onlyresolveType()flattened, 1.08s with both.I first also added an
and-chain-falsey-blowup.phpand then deleted it, for two reasons worth stating: it was a near-duplicate of the existingand-chain-truthy-blowup.php(same 100-arm!==shape), and instrumenting confirmed that existing bench already enters the new flattened falsey path, so mine guarded nothing new. It is also the bench that shows this change most clearly in CI -and-chain-truthy-blowup.phpcomes out at -81.88% against the committed baseline.Note the new variant will not be in the committed phpbench baselines until they are regenerated.
About the CI reds
Benchmark / Test (PHP 8.5)flagsbug-13352.php+11.09% andbug-14624.php+15.89% against the committed baseline. That is baseline drift, not this change: A/B on one machine, two rounds, medians, gives 2.07s vs 2.08s and 1.91s vs 1.91s - 1.00x for both. The same job is red on unrelated branches.Mutation Testingwas red twice, and both were mine. Worth writing out, because the second one changed the code.First, two
LooseBooleanMutatormutants. That mutator appends->toBoolean()toisTrue()/isFalse()receivers, and itscanMutate()deliberately skips a receiver that is already a->toBoolean()call — but I had assigned that call to a variable first, so it could not see through it and produced two no-op mutants no test can kill. Fixed by inliningtoBoolean()at both check sites.Then one
TrueTruthyFalseFalseyTypeSpecifierContextMutatormutant on my narrower gate:$context->false()->$context->falsey(). I could not kill it, and the reason is structural.CONTEXT_FALSEYisCONTEXT_FALSE | CONTEXT_FALSEY_BUT_NOT_FALSE, and a context with the second bit but not the first is not produced by any factory nor bynegate(), sofalse()andfalsey()agree on every reachable context. Instrumenting which contexts reach the depth gate across the whole suite gives onlyCONTEXT_TRUTHY(4x) andCONTEXT_FALSEY(7x); constructing mixed contexts on purpose ((chain) !== true,=== false) reaches0b1110, but!$context->truthy()masks the swap there too, so every phrasing of that guard yields an equivalent mutant.I checked whether the guard did anything before removing it: routing the mixed context through the flattened path instead produces identical inference on the shape I could build. So the guard was unobservable, and the gate is now just
!$context->null()—null()is not a mutator target. The trade-off this widens is the same one the plain falsey path already makes (holders the recursive path re-derives), on a rarer context. Happy to restore the narrower guard and let that mutant escape if you would rather keep it.