Deep Engineering

MEASUREMENT

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

Script

165 lines
"""Интернирование: почему `is` для строк иногда «работает» и почему на это нельзя опираться.

ЗАЧЕМ ЭТОТ СКРИПТ. `a is b` для двух равных строк иногда True, иногда False, и
разница не случайна: CPython держит таблицу строк, в которой каждое значение
существует в одном экземпляре, и кладёт туда часть строк сам. Какую часть —
нигде не обещано и от версии к версии менялось. Отсюда единственный практический
вывод, который скрипт и предъявляет: поведение воспроизводимое, но не
гарантированное, и сравнивать строки через `is` нельзя даже там, где это
сегодня срабатывает.

КАК ЗДЕСЬ ОПРЕДЕЛЯЕТСЯ, ИНТЕРНИРОВАНА ЛИ СТРОКА. Тоже двумя способами:

1. `sys._is_interned(s)` — появилась в 3.13, приватная, но это ОФИЦИАЛЬНЫЙ
   ответ интерпретатора.
2. Два младших бита поля `state` в заголовке объекта — они же дают и ЧЕТЫРЕ
   состояния, а не два: 0 — не интернирована, 1 — интернирована, 2 —
   интернирована и бессмертна, 3 — статическая. Значения перечислены в
   `InternalDocs/string_interning.md`.

Там, где есть оба, печатаются оба. На 3.12 `sys._is_interned` нет, и остаётся
только поле — об этом скрипт говорит прямо, а не молчит.

ГДЕ ГРАНИЦА «ОДНОГО СИМВОЛА». Блок 6 отдельно проверяет ходовое «любой
одиночный символ существует в одном экземпляре»: статические одиночные строки
заведены только на однобайтовые символы, U+0000…U+00FF, и граница видна ровно
между U+00FF и U+0100. Проверка строит символы во время работы через `chr(n)` и
`join`, иначе ответ подменили бы литерал и свёртка константы. `sys._is_immortal`
там печатается только начиная с 3.14 — в 3.13 этой функции ещё нет, и состояния
«интернирована» и «бессмертна» приходится разделять по полю `state`.

ЧТО ЗДЕСЬ НЕ МЕРЯЕТСЯ. Время. Цену, которую интернирование окупает — поиск в
словаре по тому же объекту против равного, — меряет `concat.py` внутри одного
запуска.

ЗАПУСК:

    for v in 3.12 3.13 3.14; do python$v bench/strings/interning.py; done
"""

import ctypes
import platform
import sys

STATE_OFFSET = 32
NAMES = {0: "не интернирована", 1: "интернирована", 2: "бессмертная", 3: "статическая"}


def interned_bits(s: str) -> int:
    return ctypes.c_uint32.from_address(id(s) + STATE_OFFSET).value & 0b11


def official(s: str) -> str:
    fn = getattr(sys, "_is_interned", None)
    return "—" if fn is None else ("да" if fn(s) else "нет")


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('] ')}")
    has_official = hasattr(sys, "_is_interned")
    print(f"\nsys._is_interned: {'есть' if has_official else 'НЕТ в этой версии, остаётся только поле state'}")

    head(1, "ЧЕТЫРЕ СОСТОЯНИЯ, А НЕ ДВА")
    print("  Младшие два бита поля state, по InternalDocs/string_interning.md:")
    for code, name in NAMES.items():
        print(f"    {code}{name}")

    head(2, "ЧТО ИНТЕРНИРУЕТСЯ САМО")
    literal_id = "some_attribute_name"
    literal_not_id = "some attribute name!"
    at_runtime = "".join(["some_attribute", "_name"])
    number_like = "12345"
    empty = ""
    single = "q"
    long_id = "a" * 4096
    cases = [
        ("литерал, похожий на имя", literal_id),
        ("литерал с пробелами и знаком", literal_not_id),
        ("литерал из цифр", number_like),
        ("пустая строка", empty),
        ("один символ из Latin-1", single),
        ("литерал в 4096 символов", long_id),
        ("собрана join во время работы", at_runtime),
    ]
    print("  строка                          состояние поля   sys._is_interned")
    for name, s in cases:
        code = interned_bits(s)
        print(f"  {name:<30s} {code} {NAMES[code]:<16s} {official(s):>6s}")
    print("\n  Равенство и тождественность — разные вещи, и вот где они расходятся:")
    print(f"    'some_attribute_name' == собранная join:  {literal_id == at_runtime}")
    print(f"    'some_attribute_name' is собранная join:  {literal_id is at_runtime}")

    head(3, "ТОТ ЖЕ ВОПРОС, ЗАДАННЫЙ ЧЕТЫРЬМЯ СПОСОБАМИ")
    print("  Все четыре строки равны. Тождественны — не все.")
    a = "hello_world_test"
    b = "hello_" "world_test"          # склейка литералов — работа компилятора
    c = "hello_" + "world_test"        # сложение констант — свёртка при компиляции
    parts = ["hello_", "world_test"]
    d = "".join(parts)                 # сборка во время работы
    e = sys.intern("".join(parts))     # то же, но с явным интернированием
    for name, s in (("литерал", a), ('соседние литералы "x" "y"', b),
                    ('сложение литералов "x" + "y"', c),
                    ("join во время работы", d), ("sys.intern(join)", e)):
        print(f"  {name:<28s} == a: {s == a!s:<5s} is a: {s is a!s:<5s} "
              f"состояние {interned_bits(s)} {NAMES[interned_bits(s)]}")
    print("\n  Третья строка — не интернирование, а свёртка констант: компилятор")
    print("  сложил два литерала в один ещё до запуска, и дальше это просто литерал.")

    head(4, "ГДЕ ЭТО ЛОМАЕТСЯ НЕЗАМЕТНО")
    print("  Одна и та же по смыслу строка, полученная двумя путями:")
    from_source = "user_id"
    from_data = "user_id".encode().decode()
    print(f"    из исходника:      состояние {interned_bits(from_source)}")
    print(f"    из декодирования:  состояние {interned_bits(from_data)}")
    print(f"    равны:       {from_source == from_data}")
    print(f"    тождественны: {from_source is from_data}")
    print("\n  Код, который сравнивает через is, на литералах пройдёт все тесты")
    print("  и сломается на первой строке, пришедшей из файла или из сети.")

    head(5, "БЕССМЕРТНЫЕ И СТАТИЧЕСКИЕ")
    print("  Состояния 2 и 3 — это строки, которые интерпретатор не освобождает")
    print("  никогда: имена, которыми пользуется он сам.")
    samples = ["__init__", "self", "utf-8", "a", "", "3"]
    print("  строка        состояние  счётчик ссылок")
    for s in samples:
        code = interned_bits(s)
        rc = sys.getrefcount(s)
        shown = f"{rc}" if rc < 2**30 else "бессмертна (счётчик не растёт)"
        print(f"  {s!r:<13s} {code} {NAMES[code]:<16s} {shown}")
    print("\n  'utf-8' стоит здесь для контраста: дефис не даёт ей стать именем,")
    print("  и строка, которую интерпретатор использует постоянно, живёт")
    print("  на обычном счётчике ссылок.")

    head(6, "«ОДИН СИМВОЛ» — ЭТО НЕ ЛЮБОЙ СИМВОЛ")
    print("  Статические одиночные строки заведены не на все символы, а только")
    print("  на однобайтовые: U+0000…U+00FF. Проверяется это построением во время")
    print("  работы — chr(n) и join, — чтобы ни литерал, ни свёртка константы не")
    print("  подменили ответ.")
    print("  символ          chr is chr  join is chr  состояние  _is_interned  _is_immortal")
    # Греческая α стоит рядом с кириллической Ж не для полноты: английская
    # страница статьи не имеет права нести кириллицу в отображаемом тексте, и
    # без греческого примера ей нечего было бы показать в этой строке.
    boundary = [0x61, 0xE9, 0xFF, 0x100, 0x3B1, 0x416, 0x1F600]
    immortal_fn = getattr(sys, "_is_immortal", None)
    for cp in boundary:
        a = chr(cp)
        b = chr(cp)
        joined = "".join([chr(cp)])
        imm = "—" if immortal_fn is None else ("да" if immortal_fn(a) else "нет")
        print(f"  U+{cp:04X} {a!r:>7s} {a is b!s:>11s} {joined is a!s:>12s} "
              f"{interned_bits(a):>10d} {official(a):>13s} {imm:>13s}")
    print("\n  Граница ровно между U+00FF и U+0100. Ниже неё два независимо")
    print("  построенных символа — один и тот же объект; выше — разные, и")
    print("  сравнивать их через is нельзя даже для одного символа.")
    if immortal_fn is None:
        print("  sys._is_immortal в этой версии нет — столбец пуст.")


main()