Deep Engineering

MEASUREMENT

bench/strings/layout.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

182 lines
"""Почему одна и та же по длине строка занимает разное место.

ЗАЧЕМ ЭТОТ СКРИПТ. У строки размер объекта при ОДИНАКОВОЙ длине задаётся самым
старшим кодом символа — систематически, без истории объекта и без запаса.
Единственным типом с переменным размером она при этом не является: у целого
размер зависит от величины числа, у списка при равной длине — от того, как его
построили. Контрпримеры печатает блок 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()