Deep Engineering

MEASUREMENT

bench/strfmt/fastpath.py

The script that produced the numbers in the article, and the record of the run. The file is read from the repository at build time — this is the code that was run, not a copy of it.

Cited in
/en/interview/python/strings
How to run it
python3.11 bench/strfmt/basics.py
python3.12 bench/strfmt/basics.py
python3.13 bench/strfmt/basics.py
python3.14 bench/strfmt/basics.py

python3.13 bench/strfmt/cost.py
python3.14 bench/strfmt/cost.py

python3.11 bench/strfmt/practice.py
python3.12 bench/strfmt/practice.py
python3.13 bench/strfmt/practice.py
python3.14 bench/strfmt/practice.py

python3.11 bench/strfmt/example.py      # и 3.12, 3.13, 3.14
python3.11 bench/strfmt/fastpath.py     # и 3.12, 3.13, 3.14

The run below is recorded in Russian. It is a lab record, kept in the language it was written in; the numbers, the tables and the code read the same either way.

Record of the run

Замеры для урока «Строки и форматирование»

Скрипт Что делает
basics.py наблюдения без времени: неизменяемость и +=, срезы, методы split/join/find/index/isdigit, спецификации формата и округление к чётному, str и bytes, что компилируется на разных версиях (кавычки внутри f-строки, t-строки)
cost.py цена склейки n кусков: join, s += x, s += x при второй ссылке, io.StringIO; цена одного форматирования: f-строка, str.format, %, сложение
practice.py источник ответов двух практических задач урока
example.py полный короткий пример из «Базы»: копируется в пустой каталог и запускается как есть (python3 example.py)
fastpath.py когда быстрый путь s += x срабатывает: три формы цикла (в том числе s += x; k += 1 в одну строку), один шаг на строке в миллион символов при второй ссылке, посчитанном хеше, sys.intern, подклассе str, символе вне ASCII; опкод += после прогрева
python3.11 bench/strfmt/basics.py
python3.12 bench/strfmt/basics.py
python3.13 bench/strfmt/basics.py
python3.14 bench/strfmt/basics.py

python3.13 bench/strfmt/cost.py
python3.14 bench/strfmt/cost.py

python3.11 bench/strfmt/practice.py
python3.12 bench/strfmt/practice.py
python3.13 bench/strfmt/practice.py
python3.14 bench/strfmt/practice.py

python3.11 bench/strfmt/example.py      # и 3.12, 3.13, 3.14
python3.11 bench/strfmt/fastpath.py     # и 3.12, 3.13, 3.14

Записи в runs/: без суффикса — CPython 3.13.7, остальные — с суффиксом версии (3.11.15, 3.12.3, 3.14.7). Все прогоны — Linux x86_64, одна машина (2 ядра); сверено 14.10.2026. Числа cost.py и fastpath.py — наблюдения этой конфигурации: на другой машине и версии меняются и числа, и отношения, и, как показал fastpath.py, даже набор условий быстрого пути, — мерить у себя. Раздел 7 basics.py и скрипты example.py, fastpath.py добавлены по внешнему аудиту урока 14.10.2026; разделы 1–6 basics.py при перезапуске не изменились.

Что получилось

Наблюдения (разделы 1–5 одинаковы на 3.11–3.14): s[0] = 'П' — TypeError; после t = s; s += '!' имя t видит старую строку; p[10:20] — '', p[10] — IndexError; '-'.join([1, 2]) — TypeError; find — −1, index — ValueError; '-42'.isdigit() — False; f'{2.5:.0f}' — '2', round(2.5) — 2, а Decimal с ROUND_HALF_UP — 3; f'{2.675:.2f}' и round(2.675, 2) — 2.67, потому что за литералом хранится 2.67499…; 'привет' — 6 символов и 12 байт в UTF-8, '👍' — 1 и 4.

Раздел 7 (одинаков на 3.11–3.14): p[:100] — 'Python', p[4:100] — 'on': границы среза урезаются, результат не обязательно пуст. len считает кодовые точки: é одной точкой — 1, буквой с комбинирующим знаком U+0301 — 2, после NFC — снова 1; палец с оттенком кожи — 2, эмодзи «семья» — 7; развёрнутая 'cafe\u0301' начинается с отрывшегося знака. p.strip() is p, p.replace('z', 'y') is p, p[:100] is p — True, p.upper() is p.upper() — False: совпавший результат CPython вернул тем же объектом (наблюдение реализации, не контракт).

Быстрый путь (fastpath.py, 3.11–3.14): шаг s += x на строке в миллион символов — 0,1 мкс без помех и 240–690 мкс со второй ссылкой, посчитанным hash(s), после sys.intern, у подкласса str; ASCII-строка плюс кусок с 'é' — 101–116 мкс, строка из 'é' плюс ASCII — 0,1–0,2. Цикл s += x; k += 1 в одну строку: ×1,0 и ×1,1 на 3.11/3.12, ×32,3 на 3.13.7 и ×35,5 на 3.14.7 при ×10 кусков — компилятор 3.13 сливает запись и чтение в STORE_FAST_LOAD_FAST, и += специализируется в BINARY_OP_ADD_UNICODE вместо BINARY_OP_INPLACE_ADD_UNICODE.

Версии (раздел 6): f"{d["k"]}" на 3.11 — SyntaxError: f-string: unmatched '[', с 3.12 — '1'; t"x={1 + 1}" до 3.13 включительно — SyntaxError, на 3.14 — объект Template, выражение в котором уже вычислено (2).

Склейка (медианы, 3.13.7, нс на кусок при 2 000 / 20 000 кусков по десять символов): join 49,8 / 35,5; s += x 82,9 / 52,5; s += x со второй ссылкой 215,7 / 2 942,0 — рост в 13,6 раза при ×10 кусков; io.StringIO 42,0 / 49,4. На 3.14.7 — та же картина, рост 9,9.

Форматирование (медианы, 3.13.7, нс): f-строка 167,1, str.format 355,9, % 271,5, сложение через str() 190,5. На 3.14.7 сложение (131,6) быстрее f-строки (152,2); f-строка быстрее format и % на обеих версиях.

Script

213 lines
"""Быстрый путь `s += x` в CPython: когда он срабатывает и когда нет.

ЗАЧЕМ. cost.py показывает два случая: `s += x` в локальной переменной
линеен, а с одной лишней ссылкой на строку — квадратичен. Отсюда легко
вывести правило «условий два: локальная переменная и нет второй ссылки».
Внешний аудит урока (14.10.2026) указал, что этого мало: в исходнике
CPython проверяется больше. Здесь это проверено прогоном, а не чтением.

ЧТО МЕРЯЕТСЯ:

  1. склейка n кусков по десять символов, нс на кусок, n = 2 000 и 20 000,
     в трёх формах одного и того же цикла: `s += x` на своей строке;
     `s += x; k += 1` на одной строке; те же два оператора на двух строках.
     Если нс на кусок при ×10 кусков растёт примерно в десять раз —
     склейка квадратична.

  2. один шаг `s += x` на строке в миллион символов, мкс. Если шаг стоит
     доли микросекунды — строку дописали на месте; если сотни
     микросекунд — скопировали миллион символов. Случаи: ничего лишнего;
     вторая ссылка; перед шагом посчитан hash(s); строка прошла через
     sys.intern; строка — экземпляр подкласса str; ASCII-строка и кусок
     с не-ASCII символом; строка с 'é' и ASCII-кусок. Всё, что готовит
     случай, делается до замера, а замеряется только сам `s += x` — на
     своей строке.

  3. какой опкод выбрал интерпретатор для `+=` после прогрева и какая
     инструкция стоит за ним (dis с adaptive=True).

КАК. В разделе 1 формы меряются вперемежку, ROUNDS кругов; печатаются
медианы. В разделе 2 — медиана STEPS шагов после WARMUP прогревочных.

Запускать: python3.11 / 3.12 / 3.13 / 3.14. Числа — этой машины;
переносится картина «на месте / копия», а не микросекунды.
"""
import dis
import statistics
import sys
import time

print("PY", sys.version.split()[0])
print()

ROUNDS = 7
PIECE = "abcdefghij"
pc = time.perf_counter


# ── 1 ─────────────────────────────────────────────────────────────────────
def own_line(n):
    s = ""
    for _ in range(n):
        s += PIECE
    return len(s)


def one_line(n):
    s = ""
    k = 0
    for _ in range(n):
        s += PIECE; k += 1  # noqa: E702 — форма кода и есть предмет замера
    return len(s) + k


def two_lines(n):
    s = ""
    k = 0
    for _ in range(n):
        s += PIECE
        k += 1
    return len(s) + k


def timed(fn, n):
    t0 = pc()
    fn(n)
    return pc() - t0


print("1) склейка n кусков по десять символов, нс на кусок, медианы", ROUNDS, "кругов")
SIZES = (2_000, 20_000)
FORMS = [
    ("s += x", own_line),
    ("s += x; k += 1  (одна строка)", one_line),
    ("s += x и k += 1 (две строки)", two_lines),
]
samples = {(name, n): [] for name, _ in FORMS for n in SIZES}
for _ in range(ROUNDS):
    for n in SIZES:
        for name, fn in FORMS:
            samples[(name, n)].append(timed(fn, n) / n * 1e9)
print(f"   {'форма':<32}" + "".join(f"{n:>10}" for n in SIZES) + f"{'×10':>8}")
for name, _ in FORMS:
    med = [statistics.median(samples[(name, n)]) for n in SIZES]
    print(f"   {name:<32}" + "".join(f"{m:>10.1f}" for m in med) + f"{med[1] / med[0]:>8.1f}")
print()

# ── 2 ─────────────────────────────────────────────────────────────────────
N = 1_000_000
WARMUP, STEPS = 5, 25
LATIN = "abcdefghié"   # кусок с 'é' — символ вне ASCII


class Sub(str):
    pass


def step_plain():
    s = "a" * N
    out = []
    for _ in range(WARMUP + STEPS):
        t0 = pc()
        s += PIECE
        t1 = pc()
        out.append(t1 - t0)
    return out


def step_alias():
    s = "a" * N
    out = []
    for _ in range(WARMUP + STEPS):
        keep = s  # noqa: F841 — вторая ссылка на ту же строку
        t0 = pc()
        s += PIECE
        t1 = pc()
        out.append(t1 - t0)
    return out


def step_hashed():
    s = "a" * N
    out = []
    for _ in range(WARMUP + STEPS):
        hash(s)
        t0 = pc()
        s += PIECE
        t1 = pc()
        out.append(t1 - t0)
    return out


def step_interned():
    s = "a" * N
    out = []
    for _ in range(WARMUP + STEPS):
        s = sys.intern(s)
        t0 = pc()
        s += PIECE
        t1 = pc()
        out.append(t1 - t0)
    return out


def step_subclass():
    s = "a" * N
    out = []
    for _ in range(WARMUP + STEPS):
        s = Sub(s)
        t0 = pc()
        s += PIECE
        t1 = pc()
        out.append(t1 - t0)
    return out


def step_ascii_plus(piece):
    def run():
        s = "a" * N
        out = []
        for _ in range(WARMUP + STEPS):
            s = s[:N - 1] + "a"  # новая ASCII-строка, одна ссылка, хеша нет
            t0 = pc()
            s += piece
            t1 = pc()
            out.append(t1 - t0)
        return out
    return run


def step_latin_plus_ascii():
    s = "é" * N
    out = []
    for _ in range(WARMUP + STEPS):
        t0 = pc()
        s += PIECE
        t1 = pc()
        out.append(t1 - t0)
    return out


print(f"2) один шаг s += x на строке в {N:_} символов, мкс, медиана {STEPS} шагов".replace("_", " "))
CASES = [
    ("ничего лишнего", step_plain),
    ("есть вторая ссылка", step_alias),
    ("перед шагом посчитан hash(s)", step_hashed),
    ("s = sys.intern(s) перед шагом", step_interned),
    ("s — экземпляр подкласса str", step_subclass),
    ("ASCII-строка += ASCII-кусок", step_ascii_plus(PIECE)),
    ("ASCII-строка += кусок с 'é'", step_ascii_plus(LATIN)),
    ("строка из 'é' += ASCII-кусок", step_latin_plus_ascii),
]
for label, fn in CASES:
    med = statistics.median(fn()[WARMUP:]) * 1e6
    print(f"   {label:<32}{med:>10.1f}")
print()

# ── 3 ─────────────────────────────────────────────────────────────────────
print("3) опкод += после прогрева и следующая за ним инструкция")
for fn in (own_line, one_line, two_lines):
    ins = list(dis.get_instructions(fn, adaptive=True))
    at = next(k for k, i in enumerate(ins) if i.argrepr == "+=")  # первый += — это s += x
    print(f"   {fn.__name__:<10} {ins[at].opname:<32} -> {ins[at + 1].opname}")