Строки изнутри: три ширины, четыре случая раскладки и цена склейки
У строки размер объекта при одинаковой длине определяется самым старшим кодом символа в ней: одна кириллическая буква среди латиницы удваивает место под каждый символ, один эмодзи — учетверяет. Отсюда же и два других вопроса: почему `is` для строк иногда срабатывает и почему `s += x` в цикле то линеен, то квадратичен — условий у быстрого пути два, и второго в коде не видно.
Полное техническое изложение
TL;DR
Размер строки при одинаковой длине определяется содержимым. Ширина выбирается по самому старшему символу и применяется ко всем сразу: двадцать латинских букв — 61 байт, те же двадцать с одной кириллической — 98, с одним эмодзи — 140.
Случаев раскладки четыре, а ширин три. ASCII вынесено отдельно с укороченным
заголовком — 40 байт против 56 в этой 64-битной сборке. Поэтому одна é увеличивает строку с 61 до 77
байт, не меняя числа байтов на символ: вырос заголовок.
«Интернирована» — не одно состояние, а четыре. Литерал, похожий на имя,
интернируется; строка, собранная во время работы, — нет. Отсюда is, который
работает на литералах и ломается на первой строке из файла.
У быстрой склейки два условия, и второго в коде не видно. Первое — что
накопитель локальная переменная. Второе — что на строку нет лишних ссылок.
Функция с добавленной строкой keep = s получает ТОТ ЖЕ опкод и работает в сто
семьдесят семь раз медленнее.
join в этом замере не быстрее — но надёжнее. 0,6 мс против 0,8 мс, то есть примерно
столько же. Разница в том, что его время ни от чего из перечисленного не
зависит.
Ширину задаёт один символ
CPython по PEP 393 хранит строку в той из трёх ширин, которой хватает самому старшему символу. Ширина одна на всю строку: один эмодзи переводит в четыре байта каждый символ, включая все латинские буквы, которым хватило бы одного.
Длина во всех четырёх положениях переключателя одна и та же — двадцать. Меняется один символ из двадцати.
Ширин при этом три, а случаев, которые видно по размеру, четыре: ASCII вынесено
в отдельную структуру с заголовком в 40 байт вместо 56 (числа — из этой
64-битной сборки). У ASCII-строки её представление в UTF-8
совпадает с ней самой, и кешировать нечего; у любой другой поля под этот кеш
есть. Поэтому одна é увеличивает строку с 61 до 77 байт, не меняя числа байтов
на символ.
На миллионе строк по шестнадцать символов это 54,4 мебибайта для латиницы и 118,3 для эмодзи — одна и та же информация, записанная разными ширинами.
Почему is иногда «работает»
CPython держит таблицу строк, в которой значение существует в одном экземпляре, и кладёт туда часть строк сам. Какую часть — нигде не обещано.
Одиночные символы заведены при старте не все, а только однобайтовые: chr(0x100),
построенный дважды, даёт два разных объекта, а chr(0xFF) — один и тот же.
На 3.12.3, 3.13.7 и 3.14.7 наблюдается так: литерал, похожий на имя, интернируется; литерал с пробелом или знаком — нет; строка, собранная во время работы, — нет.
literal = "some_attribute_name"
built = "".join(["some_attribute", "_name"])
literal == built # True
literal is built # FalseОпасность не в том, что is даёт неверный ответ, а в том, что он даёт верный,
пока строки берутся из исходника. Код, сравнивающий строки через is, пройдёт
все тесты, написанные на литералах, и сломается на первой строке из файла, из
сети или из базы — молча, ответив False: «не тот же объект», хотя == дал бы
True.
Отдельно стоит "ab" + "cd" is "abcd", который даёт True. Это не
интернирование, а свёртка констант: сложение двух литералов выполняет
компилятор, и в байт-коде остаётся одна константа. Замените литералы на
переменные — и is даст False.
Почему склейка то быстрая, то нет
«Строки неизменяемы, поэтому склейка в цикле квадратична» — верное рассуждение
с неверным следствием: обычный цикл со s += x работает линейно, потому что
строка растёт на месте.
Условий у этого два. Первое — форма кода: накопитель обязан быть локальной переменной функции, и результат обязан присваиваться обратно в неё же. Второе проверяется во время работы: на строку не должно быть лишних ссылок.
Второе и есть то, чего не видно. Добавьте в цикл строку keep = s, которая
ничего не вычисляет, — опкод останется тем же самым, а двадцать тысяч шагов
вместо 0,8 миллисекунды займут 140,7.
Проверяется это не отношением времён — оно на такой машине гуляет, — а ценой
одного шага. У быстрого пути она не меняется вовсе: 43, 42, 40, 39 наносекунд
при росте N от 2500 до 20 000. У медленного растёт вместе с длиной: 201,
442, 962, 4644. Каждый шаг копирует всё, что уже накоплено.
Что из этого делать
Брать "".join(). В этом замере он не быстрее склейки по месту — 0,6 мс против
0,8, один порядок, — но надёжнее: его время не зависит ни от того, где лежит
накопитель, ни от того, сохранил ли кто-то ссылку, ни от того, вынесут ли завтра
этот код в метод класса.
Не сравнивать строки через is. Ни одна версия не обещает, какие строки
интернируются сами, и границы этого поведения уже двигали между 3.12 и 3.13.
И помнить про ширину там, где строк много: один символ вне латиницы стоит не своих четырёх байт, а четырёх байт на каждый символ строки.
У строки размер объекта при одинаковой длине определяется тем, что в ней лежит, — и определяется СИСТЕМАТИЧЕСКИ: самым старшим кодом символа, а не историей объекта.
Это противоречит тому, чему учит любой другой контейнер, и потому проскакивает мимо. Строка из двадцати символов: двадцать латинских букв — это 61 байт, те же двадцать с одной кириллической — 98, с одним эмодзи — 140. Ни одна буква, кроме заменённой, не изменилась.
Строка при этом не единственный такой тип, и сказать это стоит сразу, чтобы
дальше не путать разные причины. У целого размер зависит от величины числа: 0
занимает 28 байт, 2**1000 — 160. У списка при одинаковой длине он зависит от
того, как список построили: list(range(100)) — 856 байт, а тот же по длине,
собранный через append, — 920. Разница в том, что у строки это не история и не
запас, а прямое следствие содержимого: те же двадцать символов при том же
алфавите всегда дают то же число.
У статьи три части про устройство и четвёртая — задачи. Каждая из трёх отвечает
на свой вопрос. Почему одинаковые по
длине строки занимают разное место. Почему is для строк иногда срабатывает —
и почему на это нельзя опираться. И почему s += x в цикле то линеен, то
квадратичен: условий у быстрого пути два, и второе в коде не видно вовсе.
Часть I. Три ширины и четыре случая раскладки
Ширина выбирается по самому старшему символу
PEP 393 назвал и задачу, и решение одной фразой:
The Unicode string type is changed to support multiple internal
representations, depending on the character with the largest Unicode
ordinal (1, 2, or 4 bytes)
Тип строки изменён так, чтобы поддерживать несколько внутренних представлений в зависимости от символа с наибольшим кодом (1, 2 или 4 байта)
Ключевое здесь — «с наибольшим кодом». Ширина не выбирается для каждого символа отдельно: она одна на всю строку, и задаёт её самый старший из символов. Один эмодзи посреди латиницы переводит в четыре байта КАЖДЫЙ символ, включая все латинские буквы, которым хватило бы одного.
«Учетверяет» при этом относится к буферу символов, а не к размеру объекта
целиком: в getsizeof входит ещё заголовок и завершающий ноль, поэтому у
короткой строки итог вырастает не ровно вчетверо — 61 байт против 140 на двадцати
символах.
Длина во всех четырёх положениях переключателя одна и та же — двадцать. Меняется один символ из двадцати, и вместе с ним меняется ширина всех.
Четвёртый случай — и он не ширина
Ширин в PEP 393 три: один, два и четыре байта на символ. А случаев, которые видно по размеру объекта, четыре, и четвёртый — не ширина: ASCII-строка лежит в отдельной структуре с более коротким заголовком. Дальше в статье «четыре» значит именно это — четыре случая раскладки компактной строки, а не четыре ширины:
2. ЧЕТЫРЕ ПРЕДСТАВЛЕНИЯ: БАЙТ НА СИМВОЛ И ЗАГОЛОВОК
---------------------------------------------------
представление байт/символ заголовок что помещается в эту ширину
ASCII 1 40 латиница, цифры, знаки — U+0000…U+007F
Latin-1 1 56 буквы с надстрочными знаками, U+0080…U+00FF
UCS-2 2 56 кириллица, греческий, иврит — до U+FFFF
UCS-4 4 56 эмодзи и всё остальное выше U+FFFF
getsizeof('') = 41 Б — один заголовок ASCII и ноль
getsizeof('x' * 10) = 51 Б — заголовок 40, десять байт, ноль
getsizeof('é' * 10) = 67 Б — байт на символ тот же, заголовок 56
разница заголовков = 16 Б — поля под кешированное
представление в UTF-8, которых у ASCII нет
А вот одиночная 'é' даёт 61 Б, а не 58: это статическая
строка, у которой представление в UTF-8 уже посчитано и лежит рядом.
Мерить заголовок по строке в один символ поэтому нельзя.
ASCII и Latin-1 отличаются не шириной символа — она у обеих один байт, — а заголовком: 40 против 56. Шестнадцать байт разницы — это два поля под кешированное представление в UTF-8, которых у ASCII-строки нет, потому что её UTF-8 совпадает с ней самой байт в байт.
Здесь стоит развести две разные вещи, которые легко склеить. Каноническое
представление — то, в котором строка хранится и по которому считается
len, — это один, два или четыре байта на символ. Кеш UTF-8 — отдельное
представление той же строки, которое может лежать рядом, если его кто-то
запрашивал. Один байт на символ в первом смысле не означает «строка хранится в
UTF-8»: у Latin-1 канонический байт и байт UTF-8 — разные вещи, и именно
поэтому у неё есть поля под кеш.
Отсюда и то, что удивляет в первую очередь: одна é в строке из двадцати
латинских букв увеличивает её с 61 до 77 байт, не меняя байтов на символ.
Выросло не содержимое, а заголовок.
Заодно прогон предупреждает о ловушке, в которую легко попасть при самостоятельной
проверке: мерить заголовок по строке в один символ нельзя. Одиночная é даёт не
58 байт, а 61, потому что это статическая строка, у которой представление в
UTF-8 уже посчитано и лежит рядом.
Три ширины и правило «по самому старшему символу» — часть PEP 393, принятого и реализованного с 3.3. Это можно считать свойством языка в той мере, в какой PEP служит нормативным документом.
Чего PEP не задаёт — конкретных 40 и 56 байт. Это размеры структур в измеренной сборке: CPython, 64 бита — прогон печатает разрядность первой строкой. На другой разрядности или в другой сборке числа будут другими, а вот то, что у ASCII заголовок КОРОЧЕ на два поля, останется.
И ещё одна граница, о которой стоит знать заранее: всё, что здесь меряется,
живёт в кодовых позициях Unicode, а не в том, что читатель видит глазами.
len("é") равен единице, если это одна кодовая позиция, и двум, если то же
самое записано буквой и комбинирующим знаком; видимый символ — это графемный
кластер, и ни len, ни PEP 393 с ним не работают.
Как ширина определена в замере
Скрипт не полагается на одно лишь чтение поля структуры: смещение поля — предположение, а неверное число выглядит точно так же, как верное. Поэтому ширина считается дважды и оба ответа печатаются рядом.
Первый способ — измерение, никакого знания о внутренностях не требует:
per_char = sys.getsizeof(ch * 11) - sys.getsizeof(ch * 10)Второй — чтение поля kind в заголовке объекта через ctypes.
Прогон печатает оба и отдельной строкой говорит, совпали они или нет. На
3.12.3, 3.13.7 и 3.14.7 совпали.
Во что это обходится
Миллион коротких строк — обычный масштаб для журнала, кеша или разобранного файла. Вот что там значит выбор ширины:
5. ЧТО ИЗ ЭТОГО СЛЕДУЕТ ДЛЯ МИЛЛИОНА КОРОТКИХ СТРОК
---------------------------------------------------
1000000 строк по 16 символов, только сами строки, без контейнера:
ASCII 57 Б на строку → 54.4 МиБ
Latin-1 73 Б на строку → 69.6 МиБ
UCS-2 90 Б на строку → 85.8 МиБ
UCS-4 124 Б на строку → 118.3 МиБ
Первая и последняя строки — это одна и та же информация, записанная четырьмя разными ширинами, и между ними больше чем вдвое. И уменьшить эту разницу оптимизацией кода нельзя — её задают сами данные: строка с единственным эмодзи в конце стоит как строка из одних эмодзи.
Часть II. Интернирование
Четыре состояния, а не два
«Интернирована или нет» — упрощение, из-за которого наблюдения не складываются.
Состояний четыре, и они перечислены в InternalDocs/string_interning.md —
единственном документе InternalDocs, который есть и в 3.13, и в 3.14:
| значение поля | что означает |
|---|---|
| 0 | не интернирована |
| 1 | интернирована, смертна |
| 2 | интернирована и бессмертна |
| 3 | статическая: заведена при старте интерпретатора |
Проверить состояние можно двумя способами: вызовом приватной sys._is_interned
(появилась в 3.13) и чтением двух младших битов поля state в заголовке
объекта. Прогон печатает оба.
Три состояния из четырёх называют одним словом «интернирована», и от этого
теряется главное: интернирование и бессмертие — разные вещи. Документация
sys.intern предупреждает об этом прямо:
Interned strings are not immortal; you must keep a reference to the return
value of intern() around to benefit from it
Интернированные строки не бессмертны; чтобы получить от intern() пользу, надо держать ссылку на возвращённое значение
То есть состояние 1 — «интернирована и смертна»: строка лежит в таблице, но
исчезнет вместе с последней ссылкой. Состояние 2 — «интернирована и
бессмертна». Состояние 3 — «статическая», заведена при старте интерпретатора и
не принадлежит никакому коду. Начиная с 3.14 первые два различает и отдельная
функция sys._is_immortal; до неё различить их можно было только по полю.
Что интернируется само
Прогон bench/strings/interning.py на 3.13.7; на 3.12.3 три из этих строк дают
другое состояние — почему, разобрано ниже:
2. ЧТО ИНТЕРНИРУЕТСЯ САМО
-------------------------
строка состояние поля sys._is_interned
литерал, похожий на имя 1 интернирована да
литерал с пробелами и знаком 0 не интернирована нет
литерал из цифр 1 интернирована да
пустая строка 3 статическая да
один символ из Latin-1 3 статическая да
литерал в 4096 символов 1 интернирована да
собрана join во время работы 0 не интернирована нет
Что отсюда видно — не правило языка, а поведение этой сборки: интернируется литерал, похожий на имя. Пробел или восклицательный знак — и строка остаётся обычной. Пустая строка в одном экземпляре заведена при старте интерпретатора.
А вот «любой одиночный символ существует в одном экземпляре» — неверно, и
граница лежит не там, где её ожидают. Статические одиночные строки заведены
только на однобайтовые символы. Прогон строит символы во время работы, чтобы
литерал и свёртка константы не подменили ответ. Таблица снята на 3.13.7; на
3.12.3 обе колонки с is те же, а поле state читается нулём у всех семи
символов — состояние «статическая» у одиночных строк появилось в 3.13:
6. «ОДИН СИМВОЛ» — ЭТО НЕ ЛЮБОЙ СИМВОЛ
--------------------------------------
Статические одиночные строки заведены не на все символы, а только
на однобайтовые: U+0000…U+00FF. Проверяется это построением во время
работы — chr(n) и join, — чтобы ни литерал, ни свёртка константы не
подменили ответ.
символ chr is chr join is chr состояние _is_interned _is_immortal
U+0061 'a' True True 3 да —
U+00E9 'é' True True 3 да —
U+00FF 'ÿ' True True 3 да —
U+0100 'Ā' False False 0 нет —
U+03B1 'α' False False 0 нет —
U+0416 'Ж' False False 0 нет —
U+1F600 '😀' False False 0 нет —
Последняя колонка пуста не случайно: sys._is_immortal появилась только в 3.14,
а прогон снят на 3.13.7.
Граница ровно между U+00FF и U+0100. Кириллическая Ж, построенная дважды, —
два разных объекта, и is на ней даёт False. То есть «одиночный символ» —
это про Latin-1, а не про Unicode.
И главное: строка, собранная во время работы, не интернировалась ни в одном из проверенных случаев. Отсюда разница, на которой ломается код:
literal = "some_attribute_name"
built = "".join(["some_attribute", "_name"])
literal == built # True
literal is built # FalseГде is ломается незаметно
Опасность не в том, что is даёт неверный ответ. Опасность в том, что он даёт
ВЕРНЫЙ ответ всё время, пока строки берутся из исходника, и перестаёт — как
только они приходят откуда-то ещё:
4. ГДЕ ЭТО ЛОМАЕТСЯ НЕЗАМЕТНО
-----------------------------
Одна и та же по смыслу строка, полученная двумя путями:
из исходника: состояние 1
из декодирования: состояние 0
равны: True
тождественны: False
Код, который сравнивает строки через is, пройдёт все тесты, написанные на
литералах, и сломается на первой строке, пришедшей из файла, из сети или из
базы. Ошибка при этом не выглядит ошибкой: сравнение просто отвечает «не
равны».
Язык на эту тему не обещает ничего. Он говорит только, что is сравнивает
тождественность:
The operators is and is not test for an object's identity: x is y is true
if and only if x and y are the same object
Операторы is и is not проверяют тождественность объекта: x is y истинно тогда и только тогда, когда x и y — один и тот же объект
Ни слова о том, что равные литералы дают один объект. Всё, что об этом наблюдается, — поведение реализации, и оно менялось.
Одно и то же, а состояния разные
Вот прямое доказательство того, что границы «интернировано» двигают между
версиями. Литерал "some_attribute_name", тот же самый:
| версия | состояние поля interned |
|---|---|
| 3.12.3 | 2 — интернирована и бессмертна |
| 3.13.7 | 1 — интернирована, смертна |
| 3.14.7 | 1 — интернирована, смертна |
Для кода из этого не следует ничего: строка интернирована в обоих случаях, и
is для двух таких литералов отвечает одинаково. Следует другое: «интернировано»
— не одно состояние, а семейство, и его устройство — не то, на чём стоит
строить сравнение.
Ни одна версия не обещает, какие строки интернируются сами. Обещано только
то, что делает sys.intern, и обещано там скромно: Interning strings is useful to gain a little performance on dictionary lookup
(Интернирование строк полезно, чтобы немного выиграть на поиске в словаре). Всё остальное в этой части — наблюдение на трёх
сборках, и формулировать его надо так: «на 3.13.7 эта строка не
интернировалась», а не «такие строки не интернируются».
Часть III. Склейка
Два условия, а не одно
«Строки неизменяемы, поэтому склейка в цикле квадратична» — верное рассуждение
с неверным следствием: обычный цикл со s += x работает линейно. Причина —
специализация BINARY_OP_INPLACE_ADD_UNICODE, и условий у неё два.
Сразу о границах этого раздела. Всё, что здесь описано, — оптимизация CPython, а не свойство строк: неизменяемость никуда не делась, менять строку на месте позволено ровно потому, что на неё больше никто не смотрит. Ни одна версия такого поведения не обещает, а условия у него узкие: конкретная форма кода, конкретное число ссылок и конкретное имя специализации, которое между выпусками меняется. Числа ниже сняты на 3.13.7, опкоды прочитаны на ней же.
Первое условие — форма кода. Специализация выбирается по тому, что стоит
СЛЕДУЮЩЕЙ инструкцией, и записано это в 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);То есть накопитель обязан быть локальной переменной функции, и результат обязан присваиваться обратно в неё же. Накопитель в элементе списка или в атрибуте объекта этому не удовлетворяет, и там выбирается обычное сложение.
Второе условие проверяется уже во время работы, и цель его названа в том же файле:
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.
Если у left осталось ровно две ссылки (одна со стека, одна в локальных переменных), то уменьшение счётчика оставляет только локальную ссылку — и PyUnicode_Append знает, что строку можно менять на месте
Одна строка кода, которая ничего не делает
Второе условие — то, что показывает второе положение переключателя на схеме выше. Две функции:
def local_var():
s = ""
for _ in range(N):
s += X
return s
def extra_reference():
s = ""
for _ in range(N):
keep = s
s += X
return sРазница — строка keep = s, которая ничего не вычисляет и никуда не
передаётся. Прогон печатает опкоды всех четырёх функций после прогрева, и у
этих двух он ОДИН И ТОТ ЖЕ:
3. ПОЧЕМУ ТАК: ДВА УСЛОВИЯ, А НЕ ОДНО
-------------------------------------
Первое условие — форма кода. Специализация выбирается по тому, что
стоит СЛЕДУЮЩЕЙ инструкцией, и видна в разобранном байт-коде:
функция опкод сложения после прогрева
local_var BINARY_OP_INPLACE_ADD_UNICODE
extra_reference BINARY_OP_INPLACE_ADD_UNICODE
list_slot BINARY_OP_ADD_UNICODE
attribute BINARY_OP_ADD_UNICODE
А время — нет. В том же прогоне первая функция проходит двадцать тысяч шагов за 0,8 миллисекунды, вторая — за 140,7.
Зависит ли цена одного шага от уже накопленного
Проверять квадратичность отношением времени на этой машине нельзя: отношение между соседними размерами гуляет от 4,4 до 9,7, и на длинах в сотни килобайт в него, скорее всего, входит ещё и смена стратегии выделения памяти — замер её не отделяет. Зато прекрасно проверяется другое — цена ОДНОГО шага:
2. ЦЕНА ОДНОГО ШАГА: ЗАВИСИТ ЛИ ОНА ОТ УЖЕ НАКОПЛЕННОГО
-------------------------------------------------------
Вот в чём разница на самом деле. У линейного накопления цена одного
шага постоянна; у квадратичного каждый шаг копирует всё накопленное,
и потому дорожает вместе с длиной.
N локальная: мс нс на шаг элемент списка: мс нс на шаг
2500 0.11 43 0.50 201
5000 0.21 42 2.21 442
10000 0.40 40 9.62 962
20000 0.79 39 92.88 4644
Левая колонка «нс на шаг» не меняется: длина накопителя на цену шага не влияет
вовсе. Правая растёт вместе с N — каждый шаг копирует всё, что уже
накоплено. Это и есть разница между «строка выросла на месте» и «строка
скопирована целиком», предъявленная, а не названная.
Числа этого раздела сняты одним запуском 3.13.7 и сравниваются только между собой. Насколько именно быстро растёт правая колонка, замер не утверждает: на больших длинах в неё входит ещё и смена стратегии выделения памяти, а она здесь не отделена.
Что из этого делать
Комментарий в исходнике сам называет, ради чего оптимизация сделана:
This attempts to avoid quadratic behavior when one neglects to use str.join()
(Это попытка избежать квадратичного поведения, когда str.join() забыли применить)
То есть быстрый путь — подстраховка на случай, когда написано
не лучшим способом, а не разрешение писать так всегда.
Числа из того же прогона:
| способ | двадцать тысяч кусков |
|---|---|
s += X, s — локальная | 0,8 мс |
"".join(список) | 0,6 мс |
StringIO.write | 0,7 мс |
В ЭТОМ замере join выигрывает мало, и дело не в выигрыше: 0,6 мс против 0,8 —
один порядок. Другого порядка отношение получится на других данных: куски другой
длины, другое их число, другой алфавит — и разрыв может уйти в любую сторону,
поэтому «join быстрее» и «join медленнее» одинаково не следуют отсюда.
Что следует — join НАДЁЖНЕЕ. Его время не зависит ни от того, где лежит
накопитель, ни от того, сохранил ли кто-то ссылку на строку, ни от того,
вынесут ли завтра этот код в метод класса. Склейка по месту зависит от всего
перечисленного, и ломается молча.
Часть IV. Практика
Практика · что напечатает
import sys
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("αβ"))Практика · оцените
История версий
| Версия | Изменение | Что это значит для кода |
|---|---|---|
| 3.12 | Литерал, похожий на имя, интернируется в состояние 2 — «интернирована и бессмертна». Узнать состояние из Python нечем: sys._is_interned ещё нет, остаётся только читать поле в заголовке объекта. | |
| 3.13 | Появляется sys._is_interned — приватная, но официальный ответ интерпретатора. Тот же литерал интернируется теперь в состояние 1, «интернирована и смертна»: у интернированных строк снова есть счётчик ссылок. Для кода не меняется ничего — меняется то, что «интернировано» перестаёт быть одним состоянием. | |
| 3.14 | Состояния те же, что в 3.13. Раскладка во всех четырёх случаях на трёх проверенных версиях одна и та же: вывод bench/strings/layout.py на 3.12.3, 3.13.7 и 3.14.7 совпадает побайтово, кроме первых трёх строк, где стоят версия, компилятор и разрядность. |
Чем измерено
Числа этой статьи получены этими скриптами. Каждый открывается прямо отсюда — вместе с записью прогона.
Байты и состояния — сравнимы между версиями, записи на всех трёх:
Время — одна запись, одна сборка:
Опора под задачи раздела «Практика»:
Python 3.12.3 (GCC 13.3.0), 3.13.7 (Clang 20.1.4), 3.14.7 (Clang 22.1.3); Intel Xeon 2,80 ГГц, 2 vCPU. Байты между версиями сравнимы — это раскладка объекта. Время нет: у сборок разные компиляторы и разные флаги.
Фрагменты Python/bytecodes.c приводятся по тегу v3.13.7 дословно.
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
На самом деле
- Он пропорционален длине, умноженной на ширину, а ширину задаёт САМЫЙ СТАРШИЙ символ строки. Двадцать латинских букв — 61 байт; те же двадцать с одной
é— 77; с одной кириллической — 98; с одним эмодзи — 140. Длина во всех четырёх случаях равна двадцати. Практическое следствие: строка с единственным эмодзи в конце занимает столько же, сколько строка из одних эмодзи той же длины. - Ширин действительно три: один, два и четыре байта на символ. А случаев, которые видно по размеру объекта, четыре: ASCII вынесено в отдельную структуру с укороченным заголовком — 40 байт против 56 в этой 64-битной сборке, — потому что у ASCII-строки её представление в UTF-8 совпадает с ней самой и кешировать нечего. Отсюда то, что удивляет первым: одна
éувеличивает строку из двадцати латинских букв с 61 до 77 байт, НЕ меняя числа байтов на символ. Выросло не содержимое, а заголовок. - Для литералов, похожих на имена, на трёх проверенных сборках — да, и именно поэтому такой код проходит все тесты. Стоит строке прийти не из исходника, и всё меняется:
"user_id"из файла или из сети имеет состояние 0, не интернирована, иisс литералом даётFalseпри равенствеTrue. Язык на эту тему не обещает ничего: The operators is and is not test for an object's identity: x is y is true if and only if x and y are the same object — и ни слова о литералах. - Даёт
Trueпо другой причине: сложение двух ЛИТЕРАЛОВ выполняет компилятор, а не интерпретатор, и в байт-коде остаётся одна константа"abcd". Это свёртка констант; интернирование только доводит дело до конца. Проверяется заменой литералов на переменные:left + rightпри тех же значениях даётisравнымFalse. - Состояний четыре, и они перечислены в
InternalDocs/string_interning.md: 0 — не интернирована, 1 — интернирована и смертна, 2 — интернирована и бессмертна, 3 — статическая. Границы между ними двигают: литерал"some_attribute_name"на 3.12.3 имеет состояние 2, а на 3.13.7 и 3.14.7 — состояние 1. Для кода из этого не следует ничего, и в этом всё дело: устройство интернирования — не то, на чём стоит строить сравнение. - В обычном цикле — нет: цена одного шага не меняется вовсе (43, 42, 40, 39 нс при росте
Nот 2500 до 20 000), потому что строка растёт на месте. Оптимизация названа вPython/bytecodes.cпрямо: This attempts to avoid quadratic behavior when one neglects to use str.join(). Там, где быстрый путь не сработал, цена шага растёт вместе с длиной: 201, 442, 962, 4644 нс — каждый шаг копирует всё накопленное. Насколько быстро она растёт, замер не утверждает. - Это только первое из двух условий. Второе проверяется во время работы — сколько ссылок на строку, — и ломается независимо от первого. Функция, в которой в цикле добавлена одна ничего не делающая строка
keep = s, получает ТОТ ЖЕ опкодBINARY_OP_INPLACE_ADD_UNICODE, а работает 140,7 мс против 0,8 мс. Ни по коду, ни по разобранному байт-коду разницы не видно. - Он не быстрее: в том же прогоне 0,6 мс против 0,8 мс — то есть примерно столько же. Он НАДЁЖНЕЕ. Время
joinне зависит ни от того, где лежит накопитель, ни от того, сохранил ли кто-то ссылку на строку, ни от того, вынесут ли завтра этот код в метод класса. Склейка по месту зависит от всего перечисленного и ломается молча — без ошибки, без предупреждения, с тем же самым байт-кодом.
По версиям
- 3.12
- Литерал, похожий на имя, интернируется в состояние 2 — «интернирована и бессмертна». Узнать состояние из Python нечем:
sys._is_internedещё нет, остаётся только читать поле в заголовке объекта.< - 3.13
- Появляется
sys._is_interned— приватная, но официальный ответ интерпретатора. Тот же литерал интернируется теперь в состояние 1, «интернирована и смертна»: у интернированных строк снова есть счётчик ссылок. Для кода не меняется ничего — меняется то, что «интернировано» перестаёт быть одним состоянием.< - 3.14
- Состояния те же, что в 3.13. Раскладка во всех четырёх случаях на трёх проверенных версиях одна и та же: вывод
bench/strings/layout.pyна 3.12.3, 3.13.7 и 3.14.7 совпадает побайтово, кроме первых трёх строк, где стоят версия, компилятор и разрядность.<
Что разобрано
- Часть I. Три ширины и четыре случая раскладки
- Ширина выбирается по самому старшему символу
- Четвёртый случай — и он не ширина
- Как ширина определена в замере
- Во что это обходится
- Часть II. Интернирование
- Четыре состояния, а не два
- Что интернируется само
- Где `is` ломается незаметно
- Одно и то же, а состояния разные
- Часть III. Склейка
- Два условия, а не одно
- Одна строка кода, которая ничего не делает
- Зависит ли цена одного шага от уже накопленного
- Что из этого делать
- Часть IV. Практика
- История версий
- Чем измерено
Частые заблуждения
Размер строки пропорционален её длине
Он пропорционален длине, умноженной на ширину, а ширину задаёт САМЫЙ СТАРШИЙ символ строки. Двадцать латинских букв — 61 байт; те же двадцать с одной é — 77; с одной кириллической — 98; с одним эмодзи — 140. Длина во всех четырёх случаях равна двадцати. Практическое следствие: строка с единственным эмодзи в конце занимает столько же, сколько строка из одних эмодзи той же длины.
Случаев раскладки столько же, сколько ширин, — три
Ширин действительно три: один, два и четыре байта на символ. А случаев, которые видно по размеру объекта, четыре: ASCII вынесено в отдельную структуру с укороченным заголовком — 40 байт против 56 в этой 64-битной сборке, — потому что у ASCII-строки её представление в UTF-8 совпадает с ней самой и кешировать нечего. Отсюда то, что удивляет первым: одна é увеличивает строку из двадцати латинских букв с 61 до 77 байт, НЕ меняя числа байтов на символ. Выросло не содержимое, а заголовок.
Одинаковые строковые литералы всегда дают один объект, поэтому is для них работает
Для литералов, похожих на имена, на трёх проверенных сборках — да, и именно поэтому такой код проходит все тесты. Стоит строке прийти не из исходника, и всё меняется: "user_id" из файла или из сети имеет состояние 0, не интернирована, и is с литералом даёт False при равенстве True. Язык на эту тему не обещает ничего: The operators is and is not test for an object's identity: x is y is true if and only if x and y are the same object
(Операторы is и is not проверяют тождественность объекта: x is y истинно тогда и только тогда, когда x и y — один и тот же объект) — и ни слова о литералах.
"ab" + "cd" is "abcd" даёт True, потому что результат интернируется
Даёт True по другой причине: сложение двух ЛИТЕРАЛОВ выполняет компилятор, а не интерпретатор, и в байт-коде остаётся одна константа "abcd". Это свёртка констант; интернирование только доводит дело до конца. Проверяется заменой литералов на переменные: left + right при тех же значениях даёт is равным False.
Строка либо интернирована, либо нет
Состояний четыре, и они перечислены в InternalDocs/string_interning.md: 0 — не интернирована, 1 — интернирована и смертна, 2 — интернирована и бессмертна, 3 — статическая. Границы между ними двигают: литерал "some_attribute_name" на 3.12.3 имеет состояние 2, а на 3.13.7 и 3.14.7 — состояние 1. Для кода из этого не следует ничего, и в этом всё дело: устройство интернирования — не то, на чём стоит строить сравнение.
Склейка через s += x в цикле всегда квадратична
В обычном цикле — нет: цена одного шага не меняется вовсе (43, 42, 40, 39 нс при росте N от 2500 до 20 000), потому что строка растёт на месте. Оптимизация названа в Python/bytecodes.c прямо: This attempts to avoid quadratic behavior when one neglects to use str.join()
(Это попытка избежать квадратичного поведения, когда str.join() забыли применить). Там, где быстрый путь не сработал, цена шага растёт вместе с длиной: 201, 442, 962, 4644 нс — каждый шаг копирует всё накопленное. Насколько быстро она растёт, замер не утверждает.
Быстрый путь склейки включается, когда накопитель — локальная переменная
Это только первое из двух условий. Второе проверяется во время работы — сколько ссылок на строку, — и ломается независимо от первого. Функция, в которой в цикле добавлена одна ничего не делающая строка keep = s, получает ТОТ ЖЕ опкод BINARY_OP_INPLACE_ADD_UNICODE, а работает 140,7 мс против 0,8 мс. Ни по коду, ни по разобранному байт-коду разницы не видно.
"".join() нужен потому, что он быстрее склейки
Он не быстрее: в том же прогоне 0,6 мс против 0,8 мс — то есть примерно столько же. Он НАДЁЖНЕЕ. Время join не зависит ни от того, где лежит накопитель, ни от того, сохранил ли кто-то ссылку на строку, ни от того, вынесут ли завтра этот код в метод класса. Склейка по месту зависит от всего перечисленного и ломается молча — без ошибки, без предупреждения, с тем же самым байт-кодом.
Проверьте себя
Строка из ста символов, все латинские, кроме последнего — эмодзи. Сколько байт на символ она тратит?
Источники и что читать дальше
5 ИСТОЧНИКОВ
- PEP 393 — Flexible String RepresentationPEP. Документ, которым введены три ширины. Формулирует и задачу, и решение: «The Unicode string type is changed to support multiple internal representations, depending on the character with the largest Unicode ordinal (1, 2, or 4 bytes)» (Тип строки изменён так, чтобы поддерживать несколько внутренних представлений в зависимости от символа с наибольшим кодом — 1, 2 или 4 байта). Там же перечислены четыре структуры — PyASCIIObject, PyCompactUnicodeObject и две некомпактные формы — и сказано, чем ASCII отличается от прочих однобайтовых строк. Статус Final с 3.3.https://peps.python.org/pep-0393/
- InternalDocs/string_interning.mdИсходный код CPython. Единственный документ InternalDocs, который есть и в 3.13, и в 3.14. Перечисляет четыре значения поля interned, которые читаются в прогоне: 0 — не интернирована, 1 — интернирована и смертна, 2 — интернирована и бессмертна, 3 — статическая. Без этого списка два бита в заголовке объекта читались бы как «да/нет», и расхождение между 3.12 и 3.13 выглядело бы шумом.https://github.com/python/cpython/blob/v3.13.7/InternalDocs/string_interning.md
- Python/bytecodes.c — специализация BINARY_OP_INPLACE_ADD_UNICODEИсходный код CPython. Оба условия быстрой склейки, дословно. Первое — форма кода: `assert(next_instr->op.code == STORE_FAST);` и `DEOPT_IF(*target_local != left);`. Второе — комментарий про число ссылок: «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» (Если у left осталось ровно две ссылки — одна со стека, одна в локальных переменных, — то уменьшение счётчика оставляет только локальную ссылку, и PyUnicode_Append знает, что строку можно менять на месте). Там же названа и цель оптимизации: «This attempts to avoid quadratic behavior when one neglects to use str.join()» (Это попытка избежать квадратичного поведения, когда str.join() забыли применить). Читалось по тегу v3.13.7.https://github.com/python/cpython/blob/v3.13.7/Python/bytecodes.c
- sys.intern — что обещано и что нетОфициальная документация. Обещано ускорение поиска: «Interning strings is useful to gain a little performance on dictionary lookup» (Интернирование строк полезно, чтобы немного выиграть на поиске в словаре). Не обещано ничего про то, какие строки интернируются сами, — и это ровно тот вопрос, ответ на который меняли между версиями. Отсюда правило статьи: наблюдаемое поведение описывается с указанием версий, а не как правило языка.https://docs.python.org/3/library/sys.html#sys.intern
- Модель данных: оператор isОфициальная документация. Приводится как источник того, чего в языке НЕТ: гарантии, что равные строки тождественны. Язык говорит только, что `is` сравнивает тождественность объектов — «The operators is and is not test for an object's identity: x is y is true if and only if x and y are the same object» (Операторы is и is not проверяют тождественность объекта: x is y истинно тогда и только тогда, когда x и y — один и тот же объект). Ни слова о том, что литералы с одинаковым содержимым дают один объект; всё, что об этом наблюдается, — поведение реализации.https://docs.python.org/3/reference/expressions.html#is