MEASUREMENT
bench/strings/concat.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/python/data-structures/string-internals
- How to run it
Записи прогонов — в `runs/`. У `layout.py` и `interning.py` записи на трёх версиях: там меряются байты и состояния, то есть раскладка объекта, а её между версиями сравнивать можно. У `concat.py` запись одна: время между версиями не сравнивается вовсе (корневой `bench/README.md`). ## Два независимых способа прочитать ширину Скрипты не полагаются на одну лишь `ctypes`-адресацию полей: смещение поля — предположение, а неверное число выглядит как верное. Поэтому ширина считается дважды:
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
Замеры: строка изнутри — три ширины, интернирование и склейка
У строки размер объекта при ОДИНАКОВОЙ длине задаётся самым старшим кодом
символа — систематически, без истории объекта и без запаса. Единственным типом с
переменным размером она при этом не является (контрпримеры — блок 6 layout.py),
а вот сравнение через is, которое иногда срабатывает по причинам, ни в чём не
обещанным, — это уже про неё одну.
| скрипт | что показывает |
|---|---|
layout.py |
три ширины PEP 393 и четыре случая раскладки: байт на символ и ширина заголовка, измеренные и прочитанные из поля state; некомпактная форма у подкласса str; контрпримеры к «str — единственный такой тип» |
interning.py |
четыре состояния поля interned, а не два; что интернируется само и что нет; где is ломается незаметно; на какие одиночные символы заведены статические строки, а на какие нет |
concat.py |
время: s += x в семи местах, цена одного шага против длины накопителя, две ступени специализации BINARY_OP_INPLACE_ADD_UNICODE |
Запуск:
for v in 3.12 3.13 3.14; do echo "== $v"; python$v bench/strings/layout.py; done
for v in 3.12 3.13 3.14; do echo "== $v"; python$v bench/strings/interning.py; done
python3.13 bench/strings/concat.py
Записи прогонов — в runs/. У layout.py и interning.py записи на трёх
версиях: там меряются байты и состояния, то есть раскладка объекта, а её между
версиями сравнивать можно. У concat.py запись одна: время между версиями не
сравнивается вовсе (корневой bench/README.md).
Два независимых способа прочитать ширину
Скрипты не полагаются на одну лишь ctypes-адресацию полей: смещение поля —
предположение, а неверное число выглядит как верное. Поэтому ширина считается
дважды:
# измерением — знания о структуре не требует
per_char = sys.getsizeof(ch * 11) - sys.getsizeof(ch * 10)
# чтением поля state в заголовке PyASCIIObject
kind = (ctypes.c_uint32.from_address(id(s) + 32).value >> 2) & 0b111
layout.py печатает оба и отдельной строкой говорит, совпали они или нет.
На 3.12.3, 3.13.7 и 3.14.7 совпали.
Что нашлось между версиями
Литерал, похожий на имя, в 3.12 бессмертен, в 3.13 и 3.14 — нет.
Поле interned у "some_attribute_name" читается как 2 («интернирована и
бессмертна») на 3.12.3 и как 1 («интернирована») на 3.13.7 и 3.14.7. Список
значений — в InternalDocs/string_interning.md; это единственный документ
InternalDocs/, который есть и в 3.13, и в 3.14.
Практического следствия для кода у этого нет — и именно поэтому оно полезно: два прогона одного скрипта показывают, что «интернировано» не одно состояние, а семейство, и что его границы двигают между версиями.
sys._is_interned появилась в 3.13. До неё единственным способом узнать
состояние было чтение поля. Функция приватная, но это официальный ответ
интерпретатора, и interning.py печатает его рядом с полем везде, где он есть.
Две ступени быстрой склейки
s += x в цикле работает линейно не всегда, и условий два, а не одно.
Первое — форма кода. Специализация выбирается по тому, что стоит следующей
инструкцией, и это видно в разобранном байт-коде: у накопителя в локальной
переменной опкод BINARY_OP_INPLACE_ADD_UNICODE, у накопителя в элементе
списка или в атрибуте — обычный BINARY_OP_ADD_UNICODE. Условие записано
прямо в Python/bytecodes.c:
tier1 op(_BINARY_OP_INPLACE_ADD_UNICODE, (left, right --)) {
assert(next_instr->op.code == STORE_FAST);
PyObject **target_local = &GETLOCAL(next_instr->op.arg);
DEOPT_IF(*target_local != left);
Второе — число ссылок во время работы. Функция, у которой в цикле стоит
лишняя строка keep = s, получает ТОТ ЖЕ опкод, а работает на два порядка
медленнее. Комментарий там же объясняет, почему:
If
lefthas only two references remaining (one from the stack, one in the locals), DECREFingleftleaves only the locals reference, soPyUnicode_Appendknows that the string is safe to mutate.
concat.py печатает обе ступени: таблицу опкодов после прогрева и таблицу
времени, в которой две функции с одинаковым опкодом стоят рядом.
Чего этот замер не утверждает
Насколько быстро растёт цена шага при склейке без быстрого пути. Сами числа воспроизводимы неодинаково: в записи прогона стоят 0,50 / 2,21 / 9,62 / 92,88 мс, и первая колонка от прогона к прогону гуляет сильнее прочих — от 0,43 до 0,50 мс. Отношение между соседними строками при этом скачет от 4,4 до 9,7: на длинах в сотни килобайт в него входит смена стратегии выделения памяти, а этот замер её не отделяет. Поэтому таблица построена не на отношениях, а на цене одного шага: она у быстрого пути постоянна, у медленного растёт — и это всё, что здесь утверждается.
Script
191 lines"""Почему `s += x` в цикле иногда быстрый, а иногда квадратичный.
ЗАЧЕМ ЭТОТ СКРИПТ. «Строки неизменяемы, поэтому склейка в цикле квадратична» —
верное рассуждение с неверным следствием: на практике `s += x` в обычном цикле
работает линейно. Причина — специализация `BINARY_OP_INPLACE_ADD_UNICODE`, и у
неё ДВА условия, а не одно. Первое — форма кода: следующей инструкцией должно
стоять `STORE_FAST` в ту же локальную переменную. Второе — во время работы: у
строки должно остаться ровно столько ссылок, чтобы её можно было менять на
месте. Блок 3 предъявляет оба: у двух функций опкод совпадает, а время
различается в сотню с лишним раз, и различает их именно второе условие.
ЗДЕСЬ ПРЕДЪЯВЛЯЕТСЯ И ТО И ДРУГОЕ: линейное поведение там, где быстрый путь
срабатывает, и квадратичное — там, где он не срабатывает. Второе важнее:
именно оно объясняет, почему один и тот же приём в одном месте кода безобиден,
а в другом кладёт программу.
ПРАВИЛО ЗАМЕРА. Всё меряется ВНУТРИ ОДНОГО запуска ОДНОГО интерпретатора, и
сравниваются только числа из одного прогона (bench/README.md). Между версиями
время не сравнивается вовсе. Поэтому запись прогона одна, без суффикса версии.
МЕТОДИКА. `timeit`, лучшее из девяти раундов. Берётся лучшее, а не среднее: шум
на этой машине односторонний.
ПОЧЕМУ КОД МЕРЯЕТСЯ ФУНКЦИЕЙ, А НЕ СТРОКОЙ В timeit. Быстрый путь зависит от
того, сколько ссылок на строку. У переменной модуля ссылка лежит в словаре
глобалей, и это меняет ответ. Чтобы разница между «локальная» и «не локальная»
была тем, что меряется, а не случайностью оформления, каждый случай — отдельная
функция, а `timeit` вызывает её.
ЗАПУСК:
python3.13 bench/strings/concat.py
"""
import dis
import io
import platform
import sys
import timeit
ROUNDS = 9
N = 20_000
PIECE = "abcdefgh"
SETUP = f"""
from io import StringIO
N = {N}
X = {PIECE!r}
def local_var():
s = ""
for _ in range(N):
s += X
return s
def extra_reference():
s = ""
for _ in range(N):
keep = s
s += X
return s
def list_slot():
box = [""]
for _ in range(N):
box[0] += X
return box[0]
def attribute():
class Box:
pass
b = Box()
b.s = ""
for _ in range(N):
b.s += X
return b.s
def prepend():
s = ""
for _ in range(N):
s = X + s
return s
def join_list():
parts = []
for _ in range(N):
parts.append(X)
return "".join(parts)
def stringio():
buf = StringIO()
for _ in range(N):
buf.write(X)
return buf.getvalue()
"""
def best(stmt: str, setup: str, number: int) -> float:
return min(timeit.Timer(stmt, setup).repeat(ROUNDS, number)) / number
def head(n: int, title: str) -> None:
line = f"{n}. {title}"
print(f"\n{line}\n{'-' * len(line)}")
def main() -> None:
print(f"Python {platform.python_version()}")
print(f"Сборка: {sys.version.split('[')[-1].rstrip('] ')}")
print("\nВсе числа сняты одним запуском одного интерпретатора: сравнивать")
print("их можно только между собой, с другими версиями — нельзя.")
print(f"Склеивается {N} кусков по {len(PIECE)} символов. Лучшее из {ROUNDS} раундов.")
head(1, "ОДИН И ТОТ ЖЕ ПРИЁМ В СЕМИ МЕСТАХ")
cases = [
("s += X, s — локальная", "local_var()", 20),
("s += X, но есть вторая ссылка", "extra_reference()", 3),
("box[0] += X, элемент списка", "list_slot()", 3),
("b.s += X, атрибут объекта", "attribute()", 3),
("s = X + s, приписывание слева", "prepend()", 3),
('"".join(список)', "join_list()", 20),
("StringIO.write", "stringio()", 20),
]
print(" где лежит строка всего, мс нс на кусок")
fast = None
results = {}
for name, call, number in cases:
sec = best(call, SETUP, number)
results[name] = sec
if fast is None:
fast = sec
print(f" {name:<38s} {sec * 1e3:>8.1f} {sec / N * 1e9:>13.0f}")
keep = results["s += X, но есть вторая ссылка"]
print(f"\n Первая и вторая строки различаются в {keep / fast:.0f} раз. Разница в коде")
print(" между ними — одна строка `keep = s`, которая ничего не делает.")
print(" Она только сохраняет ссылку — и этого хватает, чтобы быстрый путь")
print(" перестал применяться.")
head(2, "ЦЕНА ОДНОГО ШАГА: ЗАВИСИТ ЛИ ОНА ОТ УЖЕ НАКОПЛЕННОГО")
print(" Вот в чём разница на самом деле. У линейного накопления цена одного")
print(" шага постоянна; у квадратичного каждый шаг копирует всё накопленное,")
print(" и потому дорожает вместе с длиной.")
print(" N локальная: мс нс на шаг элемент списка: мс нс на шаг")
for n in (2_500, 5_000, 10_000, 20_000):
setup = SETUP.replace(f"N = {N}", f"N = {n}")
f = best("local_var()", setup, 50)
s_ = best("list_slot()", setup, 5)
print(f" {n:<8d} {f * 1e3:>13.2f} {f / n * 1e9:>11.0f} {s_ * 1e3:>21.2f} {s_ / n * 1e9:>11.0f}")
print("\n Левая колонка «нс на шаг» не меняется: длина накопителя на цену")
print(" шага не влияет вовсе. Правая растёт вместе с N — это и есть")
print(" квадратичность, предъявленная, а не названная.")
print(" Насколько именно быстро растёт правая колонка, этот замер не")
print(" утверждает: на больших длинах в неё входит ещё и смена стратегии")
print(" выделения памяти, а она здесь не отделена.")
head(3, "ПОЧЕМУ ТАК: ДВА УСЛОВИЯ, А НЕ ОДНО")
print(" Первое условие — форма кода. Специализация выбирается по тому, что")
print(" стоит СЛЕДУЮЩЕЙ инструкцией, и видна в разобранном байт-коде:")
ns: dict = {}
exec(SETUP, ns) # noqa: S102 — код свой, не из ввода
print(" функция опкод сложения после прогрева")
for name in ("local_var", "extra_reference", "list_slot", "attribute"):
fn = ns[name]
fn()
buf = io.StringIO()
dis.dis(fn, file=buf, adaptive=True)
hits = sorted({w for line in buf.getvalue().splitlines()
for w in line.split() if w.startswith("BINARY_OP")})
print(f" {name:<30s} {' '.join(hits)}")
print("\n У двух первых функций опкод ОДИН И ТОТ ЖЕ, а время из блока 1 —")
print(" разное. Значит, опкодом дело не кончается: внутри него есть второе")
print(" условие, и оно про число ссылок. Комментарий в Python/bytecodes.c")
print(" говорит это прямо:")
print(' "If `left` has only two references remaining (one from the stack,')
print(' one in the locals), DECREFing `left` leaves only the locals')
print(' reference, so PyUnicode_Append knows that the string is safe')
print(' to mutate."')
print("\n Строка `keep = s` добавляет третью ссылку — и строка перестаёт быть")
print(" безопасной для изменения на месте. Опкод тот же, путь внутри другой.")
head(4, "ЧТО ИЗ ЭТОГО ДЕЛАТЬ")
print(" Три способа собрать ту же строку, числа из блока 1:")
for name in ("s += X, s — локальная", '"".join(список)', "StringIO.write"):
print(f" {name:<38s} {results[name] * 1e3:>8.1f} мс")
print("\n join не быстрее склейки по месту — он НАДЁЖНЕЕ: его время не зависит")
print(" от того, где лежит накопитель, и от того, сохранил ли кто-то ссылку.")
main()