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