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
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
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()