Deep Engineering

ЗАМЕР

bench/strings/practice.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: на длинах в сотни килобайт в него входит смена стратегии выделения памяти, а этот замер её не отделяет. Поэтому таблица построена не на отношениях, а на цене одного шага: она у быстрого пути постоянна, у медленного растёт — и это всё, что здесь утверждается.

Скрипт

60 строк
"""Опора под задачи раздела «Практика» статьи про строки.

ЗАЧЕМ ОТДЕЛЬНЫЙ СКРИПТ. Задача в статье спрашивает «что напечатает вот этот
код», и ответ на неё обязан быть выводом настоящей программы. Здесь лежит ровно
тот код, который видит читатель, и ровно тот вывод, из которого взяты варианты
ответа; `scripts/validate-practice.mjs` сверяет то и другое построчно.

ПОЧЕМУ КРАТНОСТЬ ПЕЧАТАЕТСЯ. Вторая задача просит оценить, во сколько раз один
символ увеличивает строку из двадцати. Деление на стороне редакции — шаг, на
котором вкрадывается ошибка, которую нечем поймать, поэтому кратность печатает
сам скрипт.

ПОЧЕМУ ВТОРАЯ ЗАДАЧА ПРО БАЙТЫ, А НЕ ПРО ВРЕМЯ. Разница во времени между
быстрым и медленным путём склейки на этой машине воспроизводится плохо: та же
пара измерений в разных прогонах даёт от ста до ста восьмидесяти. Просить
читателя назвать число, которое само по себе гуляет вдвое, нечестно. Байты не
гуляют вовсе.

ЗАПУСК:

    python3.13 bench/strings/practice.py
"""

import platform
import sys


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('] ')}")

    head(1, "ЗАДАЧА 1: ЧТО НАПЕЧАТАЕТ ЭТОТ КОД")

    left = "ab"
    right = "cd"

    one = "abcd"
    two = left + right
    three = "ab" + "cd"

    print(one == two, one is two)
    print(one == three, one is three)
    print(sys.getsizeof("ab"), sys.getsizeof("αβ"))

    head(2, "ЗАДАЧА 2: ВО СКОЛЬКО РАЗ ОДИН СИМВОЛ УВЕЛИЧИВАЕТ СТРОКУ")
    latin = "x" * 20
    with_emoji = "\U0001F600" + "x" * 19
    print(f"  двадцать латинских букв            {sys.getsizeof(latin):>6d} Б")
    print(f"  те же двадцать, одна — эмодзи      {sys.getsizeof(with_emoji):>6d} Б")
    print(f"  длина у обеих строк                {len(latin)} и {len(with_emoji)}")
    print(f"  кратность                          {sys.getsizeof(with_emoji) / sys.getsizeof(latin):.1f}")


main()