perf: make ReindentFilter._get_offset linear in tokens instead of quadratic - #871
Open
zhangj23 wants to merge 1 commit into
Open
perf: make ReindentFilter._get_offset linear in tokens instead of quadratic#871zhangj23 wants to merge 1 commit into
zhangj23 wants to merge 1 commit into
Conversation
…dratic
_get_offset built the text preceding a token by flattening the ENTIRE statement
from the start on every call, then kept only the last line of it. Called once per
token that needs an offset, that is quadratic in the token count.
It now walks backwards from the token, collecting only as much preceding text as
is guaranteed to contain the final line. Groups are yielded whole rather than
descended into, so str() on the result still spells the same text.
The walk stops after the third token containing a line-boundary character rather
than the second, because str.splitlines() ignores a *trailing* boundary
("a\nb\n" ends on the line "b"), and in the worst case a "\r\n" is split across a
token edge and counts only once. Trimming each token to its own last line as it is
collected looks like a further win but is wrong for exactly that reason - it
changed 219 of 20,435 differential cases when I tried it.
_flatten_up_to_token is a public-ish method, so if it has been replaced -- by a
subclass, on the class, or on the instance -- the original path is used instead of
this walk. All three are verified to still see the override called, with
byte-identical output.
A token spliced into the tree without a parent cannot be located by walking
parents. StripCommentsFilter does exactly that, so the walk signals the condition
and the caller falls back to flattening. Treating it as "nothing precedes this
token" instead mis-indented a CASE containing a stripped comment:
sqlparse.format('select case/*\nc\n*/when a=1 then 2 else 3 end from t',
reindent=True, strip_comments=True)
master: 'select case\n when a=1 then 2\n else 3\n...'
unfixed: 'select case\n when a=1 then 2\n else 3\n...'
Measured with time.process_time, alternating base/patched subprocesses, min-of-3,
on nested function calls (SELECT COALESCE(a,b,0) x n FROM t):
n base new speedup base growth new growth
200 137.2ms 45.4ms 3.02x x3.10 x2.09
400 458.6ms 98.6ms 4.65x x3.34 x2.17
800 1683.0ms 203.5ms 8.27x x3.67 x2.06
Base grows ~x3.5 per doubling, the patch ~x2.1: the growth rate drops from
quadratic to linear, so the speedup keeps increasing with statement size. Also
2.7x on a 400-row INSERT ... VALUES.
TRADE-OFF, measured and disclosed: the cost is now proportional to the length of
the preceding line MULTIPLIED BY the nesting depth, because a deeply nested group
re-walks the same long preceding line once per level. A 200 KB literal followed by
30 *flat* grouped expressions is actually 1.27x FASTER; the regression needs depth.
Nesting those expressions 40 deep after a 200 KB literal measures 0.57x, i.e. ~2x
slower than master. Statements whose tokens mostly do not
need an offset (a long flat WHERE ... AND chain, a large IN list, chained UNIONs,
a wide flat SELECT list) measure 0.92-1.08x, unchanged within noise.
Output is unchanged: 20,435 differential comparisons against master (4,087 SQL
statements x 5 formatting configs) with 0 mismatches. The corpus covers every line
boundary str.splitlines() recognises, boundaries inside string literals and
comments, leading and trailing boundaries, runs of three or more, CRLF split across
token edges, deep paren nesting, 200-column selects, and 4,000 randomly assembled
fragments.
Suite: 494 passed, 2 xfailed, 1 xpassed, rc=0 - identical to master.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
ReindentFilter._get_offsetbuilds the text preceding a token by flattening the entire statement from the start, then keeps only the last line of it. It is called once per token that needs an offset, so reindenting is quadratic in the token count.This walks backwards from the token instead, collecting only as much preceding text as is guaranteed to contain that final line. Groups are yielded whole rather than descended into, so
str()on the result still spells the same text.Numbers
time.process_time, alternating base and patched subprocesses, min of 3, on nested function calls (SELECT COALESCE(a,b,0) x n FROM t):The growth columns are the substance:
mastergrows about 3.6x per doubling while this grows about 2x, so the quadratic term is gone and the speedup keeps increasing with statement size. A 400-rowINSERT ... VALUESmeasures about 2.7x.Not every statement benefits. Statements whose tokens mostly do not need an offset, such as a long flat
WHERE ... ANDchain, a largeINlist, chainedUNIONs, or a wide flatSELECTcolumn list, measure 0.92x to 1.08x, which is unchanged within noise. The win is specifically for nested and grouped constructs, since those are what call_get_offset.The trade-off, measured
Cost is now proportional to the length of the preceding line multiplied by the nesting depth, because a deeply nested group re-walks the same long preceding line once per level. So there is a shape that regresses.
A 200 KB literal followed by 30 flat grouped expressions is actually 1.27x faster. Nesting those same expressions 40 deep after a 200 KB literal measures 0.57x, roughly 2x slower than master. That is the honest worst case, and I would rather state it than have it found later.
Behaviour
Three details here are load-bearing and each took a correction to get right.
str.splitlines()ignores a trailing boundary, so"a\nb\n"ends on the line"b", and a token ending in a break does not start a fresh line. The walk therefore stops after the third token containing a boundary rather than the second, which guarantees the two boundaries needed even when a\r\nis split across a token edge and counts only once. Trimming each token to its own last line looks like a further win but is wrong for exactly this reason: it changed 219 of 20,435 differential cases when I tried it.A token spliced into the tree without a parent cannot be located by walking parents.
StripCommentsFilterdoes exactly that, so the walk signals the condition and falls back to flattening. Treating it as "nothing precedes this token" instead mis-indented aCASEcontaining a stripped comment:_flatten_up_to_tokenis effectively part of the surface, so if it has been replaced by a subclass, on the class, or on the instance, the original path is used instead of this walk. All three are verified to still see the override called, with byte-identical output.Differential testing against
master: 20,435 comparisons, being 4,087 SQL statements crossed with 5 formatting configs, 0 mismatches. The corpus covers every line boundarysplitlines()recognises, boundaries inside string literals and comments, leading and trailing boundaries, runs of three or more,\r\nsplit across token edges, deep paren nesting, 200-column selects, and 4,000 randomly assembled fragments.Checks
pytestgives 494 passed, 2 xfailed, 1 xpassed, exit code 0, matchingmasterexactly.ruff check sqlparse/is clean.One small unrelated cleanup in this diff: the
HAS_LINE_BREAKcharacter class uses\u2028and\u2029escapes rather than the literal separator characters, since literal U+2028 and U+2029 in source are invisible and easy to mangle.