Skip to content

gh-155525: Fix quadratic complexity in f-string tokenization - #156756

Open
gwosti wants to merge 2 commits into
python:mainfrom
gwosti:perf/fstring-expression-span
Open

gh-155525: Fix quadratic complexity in f-string tokenization#156756
gwosti wants to merge 2 commits into
python:mainfrom
gwosti:perf/fstring-expression-span

Conversation

@gwosti

@gwosti gwosti commented Sep 1, 2026

Copy link
Copy Markdown

Retain the start of each f-string and t-string replacement expression in the tokenizer buffer instead of allocating a temporary buffer and copying the remaining source for every replacement field. If the tokenizer buffer is resized, preserve the expression start as an offset and restore it afterward.

Microbenchmarks

  • Optimized builds configured with --enable-optimizations --with-lto, Linux x86-64
  • Pinned to one CPU; one warmup and seven measured runs, medians
Benchmark workload main this PR speedup
compile() 800 f-strings / 100 fields 1.314s 0.300s 4.38
ast.parse() 800 f-strings / 100 fields 1.474s 0.369s 4.00
geomean 4.18
Benchmark script
import ast
import gc
import statistics
import time

ROWS = (200, 400, 800)
FIELDS_PER_FSTRING = 100
RUNS = 7


def make_source(rows):
    fields = "".join(f"{{x{i}}}" for i in range(FIELDS_PER_FSTRING))
    return "".join(
        f"value_{i} = f'{fields}'\n"
        for i in range(rows)
    )


def compile_source(source):
    return compile(source, "<benchmark>", "exec")


def parse_source(source):
    return ast.parse(source, "<benchmark>", "exec")


def benchmark(func, source):
    func(source)  # Warmup
    samples = []

    for _ in range(RUNS):
        gc.collect()
        start = time.perf_counter()
        func(source)
        samples.append(time.perf_counter() - start)

    return statistics.median(samples), min(samples), max(samples)


for rows in ROWS:
    source = make_source(rows)

    for name, func in (
        ("compile()", compile_source),
        ("ast.parse()", parse_source),
    ):
        median, minimum, maximum = benchmark(func, source)
        print(
            f"{rows=:4} {name:11} "
            f"median={median:.6f}s "
            f"range=[{minimum:.6f}s, {maximum:.6f}s]"
        )

@python-cla-bot

python-cla-bot Bot commented Sep 1, 2026

Copy link
Copy Markdown

All commit authors signed the Contributor License Agreement.

CLA signed

Comment thread Parser/lexer/string.c
const char *expression = tok_mode->last_expr_start;
assert(expression != NULL);
assert(expression <= tok->start);
Py_ssize_t expression_size = tok->start - expression;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless I am missing something, this also changes behaviour for a : after a !=: on main the ':' case only set last_expr_end when it was still -1, so for f'{a!=b=:>10}' the debug text was cut at the ! and t'{a!=b:>10}'.interpolations[0].expression was 'a'. Now we always take everything up to tok->start, which is the right thing, but can we add a test for both cases so we don't lose it?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless I am missing something

Nope, spot on. Added tests for both cases.

Comment thread Lib/test/test_fstring.py Outdated
source = ''.join(
f"value_{i} = f'{fields}'\n" for i in range(1_000)
)
compile(source, '<string>', 'exec')

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: this only checks that compile does not blow up, so a regression here would only show up as a slow test. Maybe we can exec it with the x* names defined and check a couple of the value_* results (same for the t-string one)?

Comment thread Parser/lexer/state.h
Py_ssize_t last_expr_size;
Py_ssize_t last_expr_end;
char* last_expr_buffer;
const char* last_expr_start;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: can we add a comment here saying that this points into tok->buf and relies on _PyTok_ReaderUnderflow never resetting the buffer while INSIDE_FSTRING(tok)? That invariant is what makes this work and it lives a bit far from here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants