Deep Engineering

ЗАМЕР

bench/strings/concat.py

Скрипт, которым получены числа в статье, и запись прогона. Файл читается на сборке из репозитория — это тот самый код, который запускали, а не его копия.

Цитируется в статье
/ru/python/data-structures/string-internals
Как запустить
Записи прогонов — в `runs/`. У `layout.py` и `interning.py` записи на трёх
версиях: там меряются байты и состояния, то есть раскладка объекта, а её между
версиями сравнивать можно. У `concat.py` запись одна: время между версиями не
сравнивается вовсе (корневой `bench/README.md`).

## Два независимых способа прочитать ширину

Скрипты не полагаются на одну лишь `ctypes`-адресацию полей: смещение поля —
предположение, а неверное число выглядит как верное. Поэтому ширина считается
дважды:

Запись прогона

Замеры: строка изнутри — три ширины, интернирование и склейка

У строки размер объекта при ОДИНАКОВОЙ длине задаётся самым старшим кодом символа — систематически, без истории объекта и без запаса. Единственным типом с переменным размером она при этом не является (контрпримеры — блок 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 left has only two references remaining (one from the stack, one in the locals), DECREFing left leaves only the locals reference, so PyUnicode_Append knows 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: на длинах в сотни килобайт в него входит смена стратегии выделения памяти, а этот замер её не отделяет. Поэтому таблица построена не на отношениях, а на цене одного шага: она у быстрого пути постоянна, у медленного растёт — и это всё, что здесь утверждается.

Скрипт

191 строк
"""Почему `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()