ЗАМЕР
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
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: на длинах в сотни килобайт в него входит смена стратегии выделения памяти, а этот замер её не отделяет. Поэтому таблица построена не на отношениях, а на цене одного шага: она у быстрого пути постоянна, у медленного растёт — и это всё, что здесь утверждается.
Скрипт
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()