Quadratic `compile()` / `ast.parse()` time for modules with many f-strings (PEP 701 tokenizer `update_fstring_expr` rescans the whole remaining buffer per replacement field)
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 77.2k
- Forks
- 35.9k
- PR merge metrics
- PR metrics pending
Description
Bug report
Bug description:
Summary
Since the PEP 701 f-string tokenizer landed in 3.12, parsing/compiling a module that contains many f-string replacement fields is O(n²) in the total source size, where the constant is driven by the number of {...} fields. This is pure front-end (tokenizer) time — it reproduces with compile(src, "<x>", "exec") and ast.parse(src) and is unaffected by any optimization level.
It is not visible on ordinary hand-written code, but it is catastrophic for large machine-generated modules. A ~20 MB generated module in our codebase (~10k f-strings, up to ~230 plain {field}s each) compiles in:
| Python | compile() of the 20 MB module |
|---|---|
| 3.9.18 | ~2.3 s |
| 3.12.4 | ~229 s |
| 3.13.5 | ~376 s (worse) |
Environment
- Reproduced on CPython 3.12.4 and 3.13.5 (Linux x86-64).
- Confirmed by source inspection to be present on
main(3.14-dev) as well (see Root cause).
Minimal reproducer
Dependency-free. Run under 3.11 (or 3.9) vs 3.12+ and compare — the f-string compile time roughly quadruples each time the size doubles on 3.12+, but only doubles on ≤3.11.
import sys, time
def gen(n_fstrings, fields_each=100):
# one module of `n_fstrings` f-strings, each with `fields_each` PLAIN replacement fields
# (no format-spec, no conversion, no nesting). Names need not exist -- compile() only parses.
field = "".join("{x%d}" % i for i in range(fields_each))
return "\n".join('s%d = f"%s"' % (j, field) for j in range(n_fstrings))
print(sys.version)
prev = None
for n in (500, 1000, 2000, 4000):
src = gen(n)
t0 = time.perf_counter()
compile(src, "<gen>", "exec")
dt = time.perf_counter() - t0
ratio = "" if prev is None else " (%.1fx for 2x size)" % (dt / prev)
print("%5d f-strings, %5.0f KB: %7.3f s%s" % (n, len(src) / 1024, dt, ratio))
prev = dt
Observed output
Python 3.9.18 — linear (~2x per size doubling):
500 f-strings, 298 KB: 0.199 s
1000 f-strings, 595 KB: 0.404 s (2.0x for 2x size)
2000 f-strings, 1190 KB: 0.787 s (1.9x for 2x size)
4000 f-strings, 2382 KB: 1.573 s (2.0x for 2x size)
Python 3.12.4 — quadratic (~4x per size doubling):
500 f-strings, 298 KB: 0.592 s
1000 f-strings, 595 KB: 2.109 s (3.6x for 2x size)
2000 f-strings, 1190 KB: 8.484 s (4.0x for 2x size)
4000 f-strings, 2382 KB: 32.497 s (3.8x for 2x size)
So the same 2.4 MB source is ~21x slower on 3.12 than 3.9 (1.57 s → 32.5 s), and the gap keeps widening with size.
For comparison, rewriting the f-strings to str.format() (same source size) stays linear/fast on 3.12 (~0.78 s at the 4000 row) — i.e. the cost is specifically in the f-string tokenization path.
Root cause
The PEP 701 tokenizer buffers each f-string replacement-field expression by copying from the current cursor to the end of the remaining source buffer on every relevant token, using strlen(tok->cur) + strncpy(..., tok->cur, size):
3.12.4 — Parser/tokenizer.c, update_fstring_expr():
static int
update_fstring_expr(struct tok_state *tok, char cur)
{
Py_ssize_t size = strlen(tok->cur); // <-- length to END OF BUFFER, not end of expr
tokenizer_mode *tok_mode = TOK_GET_MODE(tok);
switch (cur) {
case 0:
...
strncpy(tok_mode->last_expr_buffer + tok_mode->last_expr_size, tok->cur, size); // O(remaining)
...
case '{':
...
tok_mode->last_expr_buffer = PyMem_Malloc(size);
...
strncpy(tok_mode->last_expr_buffer, tok->cur, size); // O(remaining)
...
case '}': case '!': case ':':
if (tok_mode->last_expr_end == -1) {
tok_mode->last_expr_end = strlen(tok->start); // O(remaining)
}
...
}
}
It is called once per { / } / ! / : inside an f-string. Because size = strlen(tok->cur) is the distance to EOF, each replacement field near the top of a large module scans/copies almost the entire remaining file. Summed over all fields:
Σ_fields remaining_bytes_after_field ≈ O(source_size × num_fields) ≈ O(n²)
A perf / gdb profile of the 20 MB case spends ~99% of time in __strlen_evex / __strncpy_evex reached from update_fstring_expr.
The same code (whole-remaining-buffer strlen(tok->cur) + strncpy) is still present, just relocated/renamed, in every current branch:
- 3.12:
Parser/tokenizer.c:466—update_fstring_expr(strlen(tok->cur)@470,strncpy@487/@500) - 3.13:
Parser/lexer/lexer.c:176—_PyLexer_update_fstring_expr(strlen(tok->cur)@180) - main (3.14-dev):
Parser/lexer/string.c:124—_PyLexer_update_ftstring_expr(strlen(tok->cur)@128,strncpy@145/@158)
Possible direction (for maintainers to evaluate)
update_fstring_expr runs unconditionally for every field, but the buffer it builds (last_expr_buffer) appears to be consumed only by set_fstring_expr() to produce the = self-documenting-expression debug metadata — and set_fstring_expr() early-returns unless tok_mode->f_string_debug is set. So:
- In the common case (
f_string_debug == 0, i.e. no=specifier — which is the vast majority of f-strings, and all of the generated code above), the whole per-fieldstrlen/strncpywork is discarded. Gatingupdate_fstring_expronf_string_debugwould make that case linear. - Even when debug is used, the copy is sized to
strlen(tok->cur)(to EOF) rather than to the actual expression length; capturing only the expression span (start already known; end set on}/:/!) would remove the quadratic factor there too.
Not a duplicate of
- gh-97912 (quadratic in number of local variables /
nlocals²) — different mechanism, in the compiler not the tokenizer, and fixed before 3.12.0. Settingoptimize=2does not help here. - gh-119118 (
tokenize.generate_tokens()performance regression in 3.12) — that is the pure-PythontokenizeAPI and a different trigger (one huge single-line dict). This report is the C tokenizer used bycompile()/ast.parse(), triggered by many f-string replacement fields viaupdate_fstring_expr. (Similar "3.12 tokenizer change + generated input + repeated scan → superlinear" shape, hence worth cross-referencing as precedent, but a distinct code path.)
CPython versions tested on:
3.12
Operating systems tested on:
Linux
Linked PRs
- gh-156756
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start with Parser/lexer/string.c on main, then compare the corresponding code in Parser/lexer/lexer.c and Parser/tokenizer.c for 3.12. Run the dependency-free reproducer with compile() and ast.parse() across affected versions. Done means the reported generated-module workload no longer shows quadratic scaling, while f-string parsing behavior remains covered by the relevant parser tests.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- c, python
- Domain
- compilers, performance
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Stale
- Clarity
- Clearly specified
- Newbie friendliness
- 35/100