Почему наивная конкатенация строк становится квадратичной выше определенной длины?

Построение строки посредством повторяющейся конкатенации строк является анти-шаблоном, но мне все еще любопытно, почему его производительность переключается с линейной на квадратичную после того, как длина строки превышает примерно 10 ** 6:

# this will take time linear in n with the optimization
# and quadratic time without the optimization
import time
start = time.perf_counter()
s = ''
for i in range(n):
    s += 'a'
total_time = time.perf_counter() - start
time_per_iteration = total_time / n

Например, на моей машине (Windows 10, python 3.6.1):

  • для 10 ** 4 < n < 10 ** 6 time_per_iteration почти идеально постоянный на уровне 170 ± 10 мкс.
  • для 10 ** 6 < n time_per_iteration почти идеально линейный, достигая 520 мкс на n == 10 ** 7.

Линейный рост в time_per_iteration эквивалентен квадратичному росту в total_time.

Линейная сложность является результатом оптимизации в более поздних версиях CPython (2.4 +), которые повторно используют исходное хранилище, если нет ссылки остаются на исходный объект. Но я ожидал, что линейная производительность будет продолжаться бесконечно, а не переключиться на квадратичную в какой-то момент.

Мой вопрос основан на этом комментарии. По какой-то странной причине работает

python -m timeit -s"s=''" "for i in range(10**7):s+='a'"

занимает невероятно много времени (намного дольше, чем квадратичный), поэтому я так и не получил реальных результатов синхронизации от timeit. Поэтому вместо этого я использовал простой цикл, как указано выше, для получения значений производительности.

Обновлять:

Мой вопрос с таким же успехом мог бы быть озаглавлен «Каким образом список, подобный append, может иметь O(1) производительность без чрезмерного распределения?». Наблюдая за константой time_per_iteration на строках небольшого размера, я предположил, что оптимизация строки должна быть избыточной. Но realloc (неожиданно для меня) довольно успешно избегает копирования памяти при расширении небольших блоков памяти.


person max    schedule 11.06.2017    source источник
comment
Используйте полный модуль timiet, чтобы вы могли контролировать number выполнение кода: timeit.timeit('for i in range(10**7): s += "a"', 's=""', number=1) Я считаю, что номер по умолчанию - 1e6, поэтому он не завершается   -  person juanpa.arrivillaga    schedule 11.06.2017
comment
Кроме того, что бы это ни стоило, на моей машине это заняло около 1,1 секунды, а переключение на 10 ** 8 заняло около 12 секунд, поэтому кажется, что на моем Python 3.5.2 оптимизация все еще сохраняется для больших чисел.   -  person juanpa.arrivillaga    schedule 11.06.2017
comment
@ juanpa.arrivillaga значение по умолчанию при вызове из командной строки (что я и сделал) должно определяться автоматически в зависимости от того, сколько времени занимает каждое выполнение. И я запускаю python 3.6.1, поэтому что-то странное. Можете ли вы попробовать мой цикл с time.perf_counter() вместо timeit? А какая у вас платформа?   -  person max    schedule 11.06.2017
comment
Ага, попробовал. У меня линейная производительность. Учитывая приведенные ниже ответы, возможно, общая память, доступная на машине, влияет на вероятность того, что системный распределитель обнаружит неиспользуемую память рядом со строкой? У меня около 8 гигабайт свободной памяти ...   -  person juanpa.arrivillaga    schedule 11.06.2017
comment
@ juanpa.arrivillaga Сомневаюсь, что дело в объеме свободной памяти, так как у меня 20 ГБ свободно из 32 ГБ. Какую ОС вы используете? И как высоко вы можете подняться, прежде чем он достигнет квадратичной формы? :)   -  person max    schedule 11.06.2017


Ответы (3)


В конце концов, распределители памяти платформы C (например, malloc()) являются основным источником памяти. Когда CPython пытается перераспределить строковое пространство для увеличения своего размера, на самом деле система C realloc() определяет детали того, что происходит. Если строка с самого начала «короткая», вполне вероятно, что системный распределитель обнаружит неиспользуемую память рядом с ней, поэтому увеличение размера - это всего лишь вопрос обновления распределителем библиотеки C некоторых указателей. Но после повторения этого несколько раз (опять же в зависимости от деталей распределителя платформы C) на нем не хватит места. В этот момент realloc() нужно будет скопировать всю строку на новый, более крупный блок свободной памяти. Это источник квадратичного поведения.

Обратите внимание, например, что рост списка Python сталкивается с теми же компромиссами. Однако списки разработаны для увеличения, поэтому CPython сознательно запрашивает больше памяти, чем действительно необходимо в данный момент. Величина этого превышения доступности увеличивается по мере роста списка, достаточно для того, чтобы realloc() до сих пор не нужно было копировать весь список. Но при оптимизации строк не происходит превышения доступности, поэтому случаи, когда realloc() необходимо копировать, гораздо более часты.

person Tim Peters    schedule 11.06.2017
comment
Ааааааааааааааа нет избыточного количества! Я почему-то предположил, что это так; но в этом есть смысл: это просто оптимизация, которая в любом случае не гарантируется языком, поэтому она не такая сложная, как list.append(). Однако из любопытства, помимо использования памяти, есть ли какие-либо недостатки в избыточном выделении памяти при оптимизации строк? - person max; 11.06.2017
comment
Строки неизменяемы, поэтому превышение доступности будет пустой тратой места. Это просто деталь реализации, что этот (довольно необычный) способ конкатенации строк может быть оптимизирован. - person MSeifert; 11.06.2017
comment
Разве вам еще не нужно копировать память? Вряд ли проблема в распределении. - person user541686; 12.06.2017
comment
@Mehrdad Это зависит от ситуации: если realloc видит, что за текущим массивом есть свободное место, он может (попытаться) потребовать это пространство и не нуждается в копировании. Если после массива нет свободного места, определенно необходимо скопировать весь массив в то место памяти, где может поместиться массив большего размера. - person MSeifert; 12.06.2017
comment
@MSeifert: Но есть ли у вас какие-нибудь доказательства, чтобы поверить в то, что realloc называется, не говоря уже о полезном? Строки неизменны; интерпретатор не может просто случайным образом изменить длину первого, чтобы добавить к нему вторую. Чтобы сделать это, сначала нужно доказать, что счетчик ссылок первого стал нулевым. Это было бы чрезвычайно сложно в сценарии многопоточности, поскольку интерпретатор может останавливать сколь угодно долго между соответствующими инструкциями байт-кода и может захотеть использовать память для чего-то другого. Если вы действительно обдумаете это, я не думаю, что это вообще имеет смысл. - person user541686; 12.06.2017
comment
@Mehrdad Да и да (по крайней мере, для CPython, и вопрос помечен как CPython). Существует множество проверок безопасности, которые гарантируют, что этого не произойдет, если что-то пойдет не так. Но пример в вопросе специально разработан для перехода к пути перераспределения. - person MSeifert; 12.06.2017
comment
@MSeifert: Большое спасибо, это круто. Должен сказать, что CPython меня очень впечатлил. Но я должен сказать, что это означает, что CPython, вероятно, не делает то, что кто-то сказал некоторое время назад, т.е. выполнение 100 инструкций за раз и затем переключение потоков - в противном случае нужно было бы выделить конструкцию новой строки перед назначением переменной, содержащей старую, верно? (INPLACE_ADD предшествует _2 _...) Хотя я полагаю, что он мог бы заглянуть вперед, чтобы увидеть, сможет ли он сначала выполнить 1 дополнительную инструкцию? - person user541686; 12.06.2017
comment
@Mehrdad, обратите внимание, что все ссылки, представленные в этих ответах, подчеркивают, что эта оптимизация специфична для CPython и очень ограничена. Это ключ к пониманию вашего возражения против потоков: в CPython GIL (Global Interpreter Lock) гарантирует, что только один поток запускает внутренние компоненты CPython во время битов реализации C, которые решают, является ли изменение размера безопасным (и, если это так, продолжайте делать перераспределение). Что касается потоков в CPython, то это всего лишь одна атомарная операция. - person Tim Peters; 12.06.2017
comment
@TimPeters: Но я также говорю только о CPython в своих комментариях. Я думал, что он выполняет 100 инструкций за раз перед переключением потоков, исключая явное ожидание (см. Комментарий). - person user541686; 12.06.2017
comment
@Mehrdad, я не вижу актуальности. Алгоритм переключения потоков в CPython различается в зависимости от выпуска. Начиная с версии 3.2, он использует подход, основанный на времени, а не подход на основе количества опкодов PVM (docs.python.org/3.6/library/sys.html#sys.setswitchinterval). Тем не менее, мне это кажется несущественным (в высшей степени актуален GIL, но не алгоритм, используемый для решения, когда переключать потоки). - person Tim Peters; 12.06.2017
comment
TimPeters: Это было актуально с точки зрения ограничения того, какие оптимизации, как я думал, интерпретатор CPython действительно может делать (как я объяснил выше), и я не знал, что это варьируется между выпусками. В противном случае да, я согласен, это не имеет отношения к делу. :) Кроме того, я думаю, что вы или @MSeifert (или оба) должны добавить эти ссылки на исходный код в свои ответы, это здорово, чтобы доказать, что на самом деле делает Python, а не только то, что он потенциально может сделать! - person user541686; 12.06.2017

[XXXXXXXXXXXXXXXXXX............]
 \________________/\__________/
     used space      reserved
                      space

При увеличении структуры данных непрерывного массива (показанной выше) путем добавления к ней линейная производительность может быть достигнута, если дополнительное пространство, зарезервированное при перераспределении массива, пропорционально текущему размеру массива. Очевидно, что для больших строк эта стратегия не применяется, скорее всего, с целью не тратить слишком много памяти. Вместо этого фиксированный объем дополнительного пространства резервируется во время каждого перераспределения, что приводит к квадратичной временной сложности. Чтобы понять, откуда берется квадратичная производительность в последнем случае, представьте, что превышение доступности вообще не выполняется (что является граничным случаем этой стратегии). Затем на каждой итерации должно выполняться перераспределение (требующее линейного времени), а полное время выполнения является квадратичным.

person Leon    schedule 11.06.2017
comment
most probably with the purpose of not wasting too much memory: Я полагаю, что превышение доступности полезно только в том случае, если существует высокая вероятность того, что рост продолжится в будущем (чтобы оправдать использование дополнительной памяти). И хотя рост очень часто будет продолжаться после list.append(), вероятно, считалось маловероятным, что конкатенация строк повторится много раз, поскольку это антипаттерн? - person max; 11.06.2017
comment
@max, CPython никогда не предназначался для изменения размера строк на месте. Эта оптимизация была добавлена ​​более чем через десять лет после первого выпуска Python. Некоторые разработчики ядра были против даже тогда. Обратите внимание, что возможность перераспределять память требует затрат памяти, даже если она никогда не использовалась (!): Тогда объект должен хранить не только текущий размер, но и сумму, выделенную в данный момент. Выдувание дополнительных 8 байтов для хранения символа "k" на случай, если кто-то захочет расширить его позже, не очень привлекательно ;-) Умножьте это на миллионы (программы, использующие миллионы строк, довольно распространены). - person Tim Peters; 12.06.2017

TL; DR: Просто потому, что конкатенация строк оптимизирована при определенных обстоятельствах, не означает, что это обязательно O(1), это просто не всегда O(n). Производительность определяется в конечном итоге вашей системой, и она может быть умной (будьте осторожны!). Списки, которые "гарантируют" амортизацию O(1) операций добавления, по-прежнему намного быстрее и лучше избегают перераспределения.


Это чрезвычайно сложная проблема, потому что ее сложно «измерить количественно». Если вы прочитали объявление:

Конкатенации строк в операторах формы s = s + "abc" и s += "abc" теперь выполняются более эффективно при определенных обстоятельствах.

Если вы присмотритесь к нему поближе, то заметите, что в нем упоминаются «определенные обстоятельства». Сложность состоит в том, чтобы выяснить, каковы эти определенные обстоятельства. Одно сразу очевидно:

  • Если что-то еще содержит ссылку на исходную строку.

Иначе было бы небезопасно менять s.

Но другое условие:

  • Если система может выполнить перераспределение в O(1) - это означает, что нет необходимости копировать содержимое строки в новое место.

Вот тут-то и получилось сложно. Потому что система отвечает за перераспределение. Это то, чем вы можете управлять из Python. Однако ваша система умна. Это означает, что во многих случаях вы действительно можете выполнить перераспределение без необходимости копировать содержимое. Возможно, вы захотите взглянуть на ответ @TimPeters, который объясняет некоторые из них более подробно.

Я подойду к этой проблеме с точки зрения экспериментаторов.

Вы можете легко проверить, сколько перераспределений действительно требует копии, проверив, как часто меняется идентификатор (потому что _ 8_ в CPython возвращает адрес памяти):

changes = []
s = ''
changes.append((0, id(s)))
for i in range(10000):
    s += 'a'
    if id(s) != changes[-1][1]:
        changes.append((len(s), id(s)))

print(len(changes))

Это дает разный номер для каждого прогона (или почти каждого прогона). На моем компьютере их где-то около 500. Даже для range(10000000) на моем компьютере всего 5000.

Но если вы думаете, что это действительно хорошо для «избегания» копий, вы ошибаетесь. Если вы сравните его с количеством изменений размера, которое требуется list (lists намеренно перераспределены, поэтому append амортизируется O(1)):

import sys

changes = []
s = []
changes.append((0, sys.getsizeof(s)))
for i in range(10000000):
    s.append(1)
    if sys.getsizeof(s) != changes[-1][1]:
        changes.append((len(s), sys.getsizeof(s)))

len(changes)

Для этого нужно всего 105 перераспределений (всегда).


Я упомянул, что realloc может быть умным, и я намеренно сохранил «размеры», когда повторные блоки выполнялись в списке. Многие распределители C пытаются избежать фрагментации памяти, и, по крайней мере, на моем компьютере распределитель делает что-то другое в зависимости от текущего размера:

# changes is the one from the 10 million character run

%matplotlib notebook   # requires IPython!

import matplotlib.pyplot as plt
import numpy as np

fig = plt.figure(1)
ax = plt.subplot(111)

#ax.plot(sizes, num_changes, label='str')
ax.scatter(np.arange(len(changes)-1), 
           np.diff([i[0] for i in changes]),   # plotting the difference!
           s=5, c='red',
           label='measured')
ax.plot(np.arange(len(changes)-1), 
        [8]*(len(changes)-1),
        ls='dashed', c='black',
        label='8 bytes')
ax.plot(np.arange(len(changes)-1), 
        [4096]*(len(changes)-1),
        ls='dotted', c='black',
        label='4096 bytes')
ax.set_xscale('log')
ax.set_yscale('log')
ax.set_xlabel('x-th copy')
ax.set_ylabel('characters added before a copy is needed')
ax.legend()
plt.tight_layout()

введите описание изображения здесь

Обратите внимание, что ось абсцисс представляет количество «сделанных копий», а не размер строки!

Этот график был для меня очень интересен, потому что он показывает четкие закономерности: для небольших массивов (до 465 элементов) шаги постоянны. Он должен перераспределяться для каждых 8 добавленных элементов. Затем ему нужно выделить новый массив для каждого добавленного символа, а затем примерно на 940 все ставки отключены до (примерно) одного миллиона элементов. Затем кажется, что он распределяется блоками по 4096 байт.

Я предполагаю, что распределитель C использует разные схемы распределения для объектов разного размера. Маленькие объекты распределяются блоками по 8 байтов, затем для массивов большего размера, но все же малых он перестает занимать превышение доступности, а затем для массивов среднего размера он, вероятно, размещает их там, где они «могут поместиться». Затем для огромных (сравнительно говоря) массивов он выделяется блоками по 4096 байт.

Я предполагаю, что 8 и 4096 байтов не случайны. 8 байт - это размер int64 (или float64 он же double), и я нахожусь на 64-битном компьютере с python, скомпилированным для 64-битных. А 4096 - это размер страницы моего компьютера. Я предполагаю, что существует множество «объектов», которые должны иметь эти размеры, поэтому имеет смысл, что компилятор использует эти размеры, потому что это может избежать фрагментации памяти.

Вы, наверное, знаете, но просто чтобы убедиться: для O(1) (амортизированного) добавочного поведения превышение доступности должно зависеть от размера. Если превышение доступности является постоянным, оно будет O(n**2) (чем больше превышение доступности, тем меньше постоянный коэффициент, но он все равно квадратичный).

Итак, на моем компьютере поведение во время выполнения всегда будет O(n**2), за исключением длин (примерно) от 1 000 до 1 000 000 - там оно действительно кажется неопределенным. В моем тестовом прогоне он смог добавить много (десять-) тысяч элементов, даже не нуждаясь в копировании, так что это, вероятно, будет "выглядеть как O(1)" по времени.

Обратите внимание, что это только моя система. Это могло выглядеть совершенно иначе на другом компьютере или даже с другим компилятором на моем компьютере. Не относитесь к этому слишком серьезно. Я предоставил код для построения графиков самостоятельно, так что вы можете проанализировать свою систему самостоятельно.


Вы также задали вопрос (в комментариях), будут ли недостатки, если вы перераспределите строки. Это действительно просто: строки неизменяемы. Таким образом, любой байт с превышением доступности тратит ресурсы. Есть лишь несколько ограниченных случаев, когда он действительно растет, и они считаются деталями реализации. Разработчики, вероятно, не упускают места, чтобы детали реализации работали лучше, некоторые разработчики python также считают, что добавление этой оптимизации было плохой идеей < / а>.

person MSeifert    schedule 11.06.2017
comment
Речь Алекса потрясающая :) Что касается количества перераспределений, я все же считаю, что оно действительно впечатляюще мало. 105 vs 5000 не представляет большого труда, потому что ему по-прежнему удается избежать существенного влияния на время постепенного наращивания строкового символа за символом: он по-прежнему линейный, а не квадратичный, как должен быть. Но в любом случае, я согласен, что полагаться на это - абсолютно ужасная практика, поэтому меня интересовали только образовательные цели. Re: интеллектуальный realloc, который подстраивается под шаблоны запросов к памяти: круто! Хотя я полагаю, что это еще не сделано ни в одной из основных ОС (win / linux)? - person max; 12.06.2017
comment
@MSeifert, просто чтобы понять, насколько это зависит от системы, в моем окне Win10 только что под Python 3.6.1 ваш строковый цикл перераспределен примерно 180 раз для 10 тысяч итераций и около 2400 раз для 10 миллионов итераций (по сравнению с примерно 50 и 105 раз для цикла списка). - person Tim Peters; 12.06.2017
comment
@max Я обновил пост. После некоторого осмотра интеллектуальное распределение памяти на основе прошлой истории распределения является скорее теоретической концепцией, чем фактической реализацией. :( Я также включил график, который показывает, почему он квадратичен для строк, превышающих 1 миллион элементов на моем компьютере (и на него приятно смотреть, я думаю, xD). - person MSeifert; 12.06.2017
comment
@MSeifert, к сведению, если вы используете текущую версию Python 3, почти все выделения памяти сначала передаются собственному распределителю небольших блоков CPython (obmalloc.c). Если размер запроса не превышает 512 байт, obmalloc обрабатывает его самостоятельно. Затем obmalloc округляет размер до ближайшего числа, кратного 8 байтам. Вот почему вы видите прыжок каждые 8, пока струна остается короткой. Выше 512 байт вы видите, что делает библиотека C вашей платформы (malloc () и realloc ()). - person Tim Peters; 12.06.2017
comment
Отличный ответ! Спустя годы, на Python 3.9 в Windows 10, я получаю почти те же шаблоны, что и в вашем графике, за исключением 16 байтов вместо 8. Но в Colab это выглядит совершенно иначе: превышение доступности на 8 байт до длины 464, затем примерно на 25% до длины 10 миллионов. - person Stefan Pochmann; 17.10.2020