ЗАМЕР
bench/strings/layout.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
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: на длинах в сотни килобайт в него входит смена стратегии выделения памяти, а этот замер её не отделяет. Поэтому таблица построена не на отношениях, а на цене одного шага: она у быстрого пути постоянна, у медленного растёт — и это всё, что здесь утверждается.
Скрипт
182 строк"""Почему одна и та же по длине строка занимает разное место.
ЗАЧЕМ ЭТОТ СКРИПТ. У строки размер объекта при ОДИНАКОВОЙ длине задаётся самым
старшим кодом символа — систематически, без истории объекта и без запаса.
Единственным типом с переменным размером она при этом не является: у целого
размер зависит от величины числа, у списка при равной длине — от того, как его
построили. Контрпримеры печатает блок 6, чтобы это не приходилось брать на слово.
Причина самого механизма — PEP 393: CPython хранит строку не в одной кодировке, а
в той из трёх ширин, которой хватает самому старшему символу, и переключается на
следующую, как только такой символ появился. Одна буква «ё» посреди латиницы
удваивает буфер символов, один эмодзи — учетверяет; размер объекта целиком растёт
чуть иначе, потому что в него входит ещё заголовок.
КАК ЗДЕСЬ ОПРЕДЕЛЯЕТСЯ ШИРИНА. Двумя независимыми способами, и оба печатаются
рядом:
1. ИЗМЕРЕНИЕМ. `sys.getsizeof(ch * (n + 1)) - sys.getsizeof(ch * n)` — это
ровно байт на символ, и никакого знания о внутренностях не требует.
2. ЧТЕНИЕМ ПОЛЯ. Бит-поле `state` в `PyASCIIObject`, прочитанное по адресу
объекта. Способ зависит от раскладки структуры и от сборки.
Второй способ без первого опасен: смещение поля — предположение, и ошибиться в
нём легко, а неверное число выглядит как верное. Поэтому скрипт печатает оба и
отдельной строкой говорит, совпали они или нет. Если не совпали — верить надо
измерению, а `ctypes`-чтение считать сломанным.
ЧТО ЗДЕСЬ НЕ МЕРЯЕТСЯ. Время. Байты — раскладка объекта, их между версиями
сравнивать можно; время между версиями не сравнивается вовсе (bench/README.md),
и его меряет `concat.py` отдельно.
ЗАПУСК:
for v in 3.12 3.13 3.14; do python$v bench/strings/layout.py; done
"""
import ctypes
import platform
import struct
import sys
# PyASCIIObject: ob_refcnt(8) ob_type(8) length(8) hash(8) state(4).
STATE_OFFSET = 32
def state_bits(s: str) -> dict:
raw = ctypes.c_uint32.from_address(id(s) + STATE_OFFSET).value
return {
"interned": raw & 0b11,
"kind": (raw >> 2) & 0b111,
"compact": (raw >> 5) & 1,
"ascii": (raw >> 6) & 1,
}
def bytes_per_char(ch: str) -> int:
"""Байт на символ — измерением, без всякого знания о структуре."""
return sys.getsizeof(ch * 11) - sys.getsizeof(ch * 10)
def head(n: int, title: str) -> None:
line = f"{n}. {title}"
print(f"\n{line}\n{'-' * len(line)}")
SAMPLES = [
("ASCII", "a", "латиница, цифры, знаки — U+0000…U+007F"),
("Latin-1", "é", "буквы с надстрочными знаками, U+0080…U+00FF"),
("UCS-2", "ж", "кириллица, греческий, иврит — до U+FFFF"),
("UCS-4", "😀", "эмодзи и всё остальное выше U+FFFF"),
]
def main() -> None:
print(f"Python {platform.python_version()}")
print(f"Сборка: {sys.version.split('[')[-1].rstrip('] ')}")
print(f"Указатель: {struct.calcsize('P')} Б — числа заголовков ниже относятся к этой разрядности")
print("\nЗдесь меряются байты — раскладка объекта, а не время.")
print("Числа этого файла сравнимы между версиями; время — нет и не меряется.")
head(1, "ОДНА БУКВА МЕНЯЕТ РАЗМЕР ВСЕЙ СТРОКИ")
base = "x" * 20
variants = [
("двадцать латинских букв", base),
("те же двадцать, одна заменена на é", "é" + base[1:]),
("те же двадцать, одна заменена на ж", "ж" + base[1:]),
("те же двадцать, одна заменена на α", "α" + base[1:]),
("те же двадцать, одна заменена на 😀", "😀" + base[1:]),
]
print(" строка длина getsizeof")
first = sys.getsizeof(base)
for name, s in variants:
size = sys.getsizeof(s)
mult = f"×{size / first:.2f}" if s is not base else "—"
print(f" {name:<36s} {len(s):>5d} {size:>9d} Б {mult}")
print("\n Длина одна и та же во всех строках. Меняется содержимое, и вместе")
print(" с ним — ширина, которой CPython хранит КАЖДЫЙ символ.")
print(" Кириллическая ж и греческая α дают одно и то же: обе лежат в одном")
print(" диапазоне, и выбор ширины зависит только от него, а не от алфавита.")
head(2, "ЧЕТЫРЕ ПРЕДСТАВЛЕНИЯ: БАЙТ НА СИМВОЛ И ЗАГОЛОВОК")
print(" представление байт/символ заголовок что помещается в эту ширину")
agree = True
rows = []
for name, ch, what in SAMPLES:
per = bytes_per_char(ch)
# Заголовок — то, что останется, если вычесть символы и завершающий ноль.
header = sys.getsizeof(ch * 10) - 10 * per - per
bits = state_bits(ch)
expected_kind = {1: 1, 2: 2, 4: 4}[per]
if bits["kind"] != expected_kind:
agree = False
rows.append((name, per, header, bits, what))
print(f" {name:<14s} {per:>11d} {header:>10d} {what}")
ten_ascii = sys.getsizeof("x" * 10)
ten_latin = sys.getsizeof("é" * 10)
print(f"\n getsizeof('') = {sys.getsizeof(''):>4d} Б — один заголовок ASCII и ноль")
print(f" getsizeof('x' * 10) = {ten_ascii:>4d} Б — заголовок 40, десять байт, ноль")
print(f" getsizeof('é' * 10) = {ten_latin:>4d} Б — байт на символ тот же, заголовок 56")
print(f" разница заголовков = {ten_latin - ten_ascii:>4d} Б — поля под кешированное")
print(" представление в UTF-8, которых у ASCII нет")
print(f"\n А вот одиночная 'é' даёт {sys.getsizeof('é')} Б, а не 58: это статическая")
print(" строка, у которой представление в UTF-8 уже посчитано и лежит рядом.")
print(" Мерить заголовок по строке в один символ поэтому нельзя.")
head(3, "ПОЛЕ state, ПРОЧИТАННОЕ ИЗ ОБЪЕКТА")
print(" Второй, независимый способ узнать ширину: бит-поле в заголовке.")
print(" представление kind compact ascii kind, ожидаемый по замеру")
for name, per, _header, bits, _what in rows:
print(f" {name:<14s} {bits['kind']:>4d} {bits['compact']:>8d} {bits['ascii']:>6d}"
f" { ({1: 1, 2: 2, 4: 4}[per]) :>22d}")
print(f"\n измерение и чтение поля совпали: {'да' if agree else 'НЕТ — верить измерению'}")
head(4, "НЕКОМПАКТНАЯ ФОРМА: ПОДКЛАСС str")
class MyStr(str):
pass
plain = "abc"
sub = MyStr("abc")
print(f" 'abc' getsizeof={sys.getsizeof(plain):>4d} Б compact={state_bits(plain)['compact']}")
print(f" MyStr('abc') getsizeof={sys.getsizeof(sub):>4d} Б compact={state_bits(sub)['compact']}")
print("\n У компактной формы символы лежат сразу за заголовком: один объект,")
print(" одно выделение памяти, и getsizeof считает всё. У подкласса compact=0 —")
print(" заголовок шире, а символы лежат отдельным выделением, которого в этом")
print(f" числе уже нет. То есть {sys.getsizeof(sub)} Б — это нижняя оценка, а не размер.")
head(5, "ЧТО ИЗ ЭТОГО СЛЕДУЕТ ДЛЯ МИЛЛИОНА КОРОТКИХ СТРОК")
n = 1_000_000
print(f" {n} строк по 16 символов, только сами строки, без контейнера:")
for name, ch, _what in SAMPLES:
one = sys.getsizeof(ch * 16)
print(f" {name:<14s} {one:>4d} Б на строку → {one * n / 1024 ** 2:>8.1f} МиБ")
print("\n Разница между первой и последней строкой — это одна и та же")
print(" информация, записанная четырьмя разными ширинами.")
head(6, "СТРОКА — НЕ ЕДИНСТВЕННЫЙ ТАКОЙ ТИП")
print(" Ходовое «str — единственный встроенный тип, размер которого зависит")
print(" от содержимого» неверно: у целого он зависит от величины, а у")
print(" контейнеров — от истории. Вот контрпримеры, один запуск:")
print(" выражение len getsizeof")
same_len_ints = [("0", 0), ("2**30", 2**30), ("2**100", 2**100), ("2**1000", 2**1000)]
for expr, value in same_len_ints:
print(f" int {expr:<32s} — {sys.getsizeof(value):>9d} Б")
# Контрпример со списком нарочно взят такой, который не зависит от версии:
# два способа построить список одной длины. Способ через del даёт на 3.12
# другое число — он опирается на поведение, которое менялось, — и тащить
# это расхождение в файл про раскладку СТРОКИ незачем.
by_append: list[int] = []
for i in range(100):
by_append.append(i)
exact = list(range(100))
print(f" list(range(100)) {len(exact):>3d} {sys.getsizeof(exact):>10d} Б")
print(f" тот же по длине, собран append {len(by_append):>3d} {sys.getsizeof(by_append):>10d} Б")
print("\n Так что точная формулировка другая: у str размер при ОДИНАКОВОЙ")
print(" длине определяется максимальным кодом символа — систематически и")
print(" без всякой истории объекта. У int он зависит от величины числа, а не")
print(" от длины записи; у списка при равной длине — от того, как его")
print(" строили: тот же список, собранный append, несёт запас.")
main()