Deep Engineering

MEASUREMENT

bench/strfmt/cost.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

131 lines
"""Цена склейки строк в цикле и цена одного форматирования.

ЗАЧЕМ. Документация прямо говорит, что склейка неизменяемых
последовательностей в цикле квадратична, и советует str.join. А в CPython
обычный `s += x` в функции оказывается линейным — у интерпретатора есть
быстрый путь, который дописывает строку на месте, если на неё больше никто
не ссылается. Стоит появиться второй ссылке — быстрый путь выключается, и
квадрат возвращается. Здесь видны все три случая.

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

  1. склейка n кусков по десять символов, нс на кусок, n = 2 000 и 20 000:
     "".join(parts); s += x в локальной переменной; s += x, когда на ту же
     строку ссылается ещё одно имя; io.StringIO. Если нс на кусок при ×10
     кусков растёт примерно в десять раз — склейка квадратична.

  2. одно форматирование строки из имени и числа, нс: f-строка,
     str.format, оператор % и сложение через str().

КАК. Формы меряются вперемежку, ROUNDS кругов; печатаются медианы.

Запускать: python3.13 / 3.14. Числа — этой машины; переносятся отношения.
"""
import io
import statistics
import sys
import time

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

ROUNDS = 7
PIECE = "abcdefghij"


def via_join(n):
    t0 = time.perf_counter()
    parts = []
    for _ in range(n):
        parts.append(PIECE)
    "".join(parts)
    return time.perf_counter() - t0


def via_iadd(n):
    t0 = time.perf_counter()
    s = ""
    for _ in range(n):
        s += PIECE
    return time.perf_counter() - t0


def via_iadd_alias(n):
    t0 = time.perf_counter()
    s = ""
    for _ in range(n):
        alias = s  # вторая ссылка на ту же строку — дописать на месте нельзя
        s += PIECE
    del alias
    return time.perf_counter() - t0


def via_stringio(n):
    t0 = time.perf_counter()
    buf = io.StringIO()
    for _ in range(n):
        buf.write(PIECE)
    buf.getvalue()
    return time.perf_counter() - t0


print("1) склейка n кусков по десять символов, нс на кусок, медианы", ROUNDS, "кругов")
SIZES = (2_000, 20_000)
FORMS = [
    ('"".join(parts)', via_join),
    ("s += x", via_iadd),
    ("s += x, есть вторая ссылка", via_iadd_alias),
    ("io.StringIO", via_stringio),
]
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(fn(n) / n * 1e9)
print(f"   {'форма':<30}" + "".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:<30}" + "".join(f"{m:>10.1f}" for m in med) + f"{med[1] / med[0]:>8.1f}")
print()

print("2) одно форматирование: имя и число, нс, медианы", ROUNDS, "кругов")
name, n = "Аня", 42
REPS = 200_000


def fmt_f():
    t0 = time.perf_counter()
    for _ in range(REPS):
        f"{name}: {n}"
    return time.perf_counter() - t0


def fmt_format():
    t0 = time.perf_counter()
    for _ in range(REPS):
        "{}: {}".format(name, n)
    return time.perf_counter() - t0


def fmt_percent():
    t0 = time.perf_counter()
    for _ in range(REPS):
        "%s: %d" % (name, n)
    return time.perf_counter() - t0


def fmt_plus():
    t0 = time.perf_counter()
    for _ in range(REPS):
        name + ": " + str(n)
    return time.perf_counter() - t0


FMT = [("f-строка", fmt_f), ("str.format", fmt_format), ("оператор %", fmt_percent), ("+ и str()", fmt_plus)]
fs = {name_: [] for name_, _ in FMT}
for _ in range(ROUNDS):
    for name_, fn in FMT:
        fs[name_].append(fn() / REPS * 1e9)
for name_, _ in FMT:
    print(f"   {name_:<14}{statistics.median(fs[name_]):>8.1f}")