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}")