Почему сравнение строк в python так быстро?

Мне стало любопытно понять внутренности того, как сравнение строк работает на python, когда я решал следующую проблему с примером алгоритма:

Учитывая две строки, верните длину самого длинного общего префикса

Решение 1: charByChar

Моя интуиция сказала мне, что оптимальным решением было бы начать с одного курсора в начале обоих слов и итерации вперед, пока префиксы больше не совпадут. Что-то вроде

def charByChar(smaller, bigger):
  assert len(smaller) <= len(bigger)
  for p in range(len(smaller)):
    if smaller[p] != bigger[p]:
      return p
  return len(smaller)

Чтобы упростить код, функция предполагает, что длина первой строки smaller всегда меньше или равна длине второй строки, bigger.

Решение 2: binarySearch

Другим методом является разделение двух строк на две подстроки. Если префиксы равны, мы знаем, что общая точка префикса по крайней мере равна средней точке. В противном случае общая точка префикса по крайней мере не больше, чем средняя точка. Затем мы можем найти длину префикса.

Ака бинарный поиск.

def binarySearch(smaller, bigger):
  assert len(smaller) <= len(bigger)
  lo = 0
  hi = len(smaller)

  # binary search for prefix
  while lo < hi:
    # +1 for even lengths
    mid = ((hi - lo + 1) // 2) + lo

    if smaller[:mid] == bigger[:mid]:
      # prefixes equal
      lo = mid
    else:
      # prefixes not equal
      hi = mid - 1

  return lo

Сначала я предположил, что binarySearch будет медленнее, потому что сравнение строк будет сравнивать все символы несколько раз, а не только символы префикса, как в charByChar.

Сюрпризно, binarySearch оказался намного быстрее после некоторого предварительного бенчмаркинга.

Рисунок A

lcp_fixed_suffix

Выше показано, как влияет производительность, поскольку длина префикса увеличивается. Длина суффикса остается постоянной на 50 символов.

Этот график показывает две вещи:

  • Как и ожидалось, оба алгоритма выполняют линейно хуже по мере увеличения длины префикса.
  • Производительность charByChar ухудшается с гораздо большей скоростью.

Почему binarySearch намного лучше? Я думаю, это потому, что

  • Сравнение строк в binarySearch предположительно оптимизировано интерпретатором/процессором за кулисами.
  • charByChar фактически создает новые строки для каждого доступного символа, и это создает значительные накладные расходы.

Чтобы проверить это, я сравнивал производительность сравнения и нарезки строки, помеченной cmp и slice соответственно ниже.

Рисунок B

cmp

Этот график показывает две важные вещи:

  • Как и ожидалось, сравнение и нарезка линейно увеличиваются с длиной.
  • Стоимость сравнения и нарезки увеличивается очень медленно с длиной относительно производительности алгоритма. Рисунок A. Обратите внимание, что обе цифры соответствуют строкам длиной 1 миллиард символов. Поэтому стоимость сравнения 1 символа 1 миллиард раз намного больше, чем сравнение 1 миллиарда символов. Но это все еще не отвечает, почему...

CPython

В попытке выяснить, как интерпретатор cpython оптимизирует сравнение строк, я сгенерировал байтовый код для следующей функции.

In [9]: def slice_cmp(a, b): return a[0] == b[0]

In [10]: dis.dis(slice_cmp)
            0 LOAD_FAST                0 (a)
            2 LOAD_CONST               1 (0)
            4 BINARY_SUBSCR
            6 LOAD_FAST                1 (b)
            8 LOAD_CONST               1 (0)
           10 BINARY_SUBSCR
           12 COMPARE_OP               2 (==)
           14 RETURN_VALUE

Я ткнул код cpython и нашел следующее два фрагментов кода, но я не уверен, что это происходит при сравнении строк.

Вопрос

  • Где в cpython происходит сравнение строк?
  • Есть ли оптимизация ЦП? Есть ли специальная инструкция x86, которая выполняет сравнение строк? Как я могу узнать, какие команды сборки создаются cpython? Вы можете предположить, что я использую последнюю версию python3, Intel Core i5, OS X 10.11.6.
  • Почему сравнение длинной строки намного быстрее, чем сравнение каждого из символов?

Бонусный вопрос: когда charByChar более совершенен?

Если префикс достаточно мал по сравнению с остатком длины строки, в какой-то момент стоимость создания подстрок в charByChar становится меньше стоимости сравнения подстрок в binarySearch.

Чтобы описать это отношение, я проанализировал анализ времени выполнения.

Анализ времени выполнения

Чтобы упростить приведенные ниже уравнения, предположим, что smaller и bigger имеют одинаковый размер, и я буду называть их как s1 и s2.

charByChar

charByChar(s1, s2) = costOfOneChar * prefixLen

Где

costOfOneChar = cmp(1) + slice(s1Len, 1) + slice(s2Len, 1)

Где cmp(1) - это стоимость сравнения двух строк длины 1 char.

slice - это стоимость доступа к char, эквивалент charAt(i). Python имеет неизменяемые строки, и доступ к char фактически создает новую строку длины 1. slice(string_len, slice_len) - это сокращение длины строки string_len до фрагмента размера slice_len.

Итак,

charByChar(s1, s2) = O((cmp(1) + slice(s1Len, 1)) * prefixLen)

BinarySearch

binarySearch(s1, s2) = costOfHalfOfEachString * log_2(s1Len)

log_2 - это количество раз, чтобы разделить строки пополам, до тех пор, пока не получится строка длиной 1. Где

costOfHalfOfEachString = slice(s1Len, s1Len / 2) + slice(s2Len, s1Len / 2) + cmp(s1Len / 2)

Таким образом, большой-O of binarySearch будет расти согласно

binarySearch(s1, s2) = O((slice(s2Len, s1Len) + cmp(s1Len)) * log_2(s1Len))

Основываясь на нашем предыдущем анализе стоимости

Если мы предположим, что costOfHalfOfEachString является приблизительно costOfComparingOneChar, тогда мы можем называть их как x.

charByChar(s1, s2) = O(x * prefixLen)
binarySearch(s1, s2) = O(x * log_2(s1Len))

Если приравнять их

O(charByChar(s1, s2)) = O(binarySearch(s1, s2))
x * prefixLen = x * log_2(s1Len)
prefixLen = log_2(s1Len)
2 ** prefixLen = s1Len

So O(charByChar(s1, s2)) > O(binarySearch(s1, s2), когда

2 ** prefixLen = s1Len

Таким образом, включение вышеприведенной формулы я привело к регенерации тестов на рисунке A, но с строками общей длины 2 ** prefixLen, ожидающими, что производительность двух алгоритмов будет примерно одинаковой.

img

Однако, очевидно, charByChar работает намного лучше. С небольшим количеством проб и ошибок производительность двух алгоритмов примерно одинакова, когда s1Len = 200 * prefixLen

img

Почему отношение 200x?

Ответ 1

TL: DR: сравнение срезов - это некоторые служебные данные Python + высоко оптимизированный memcmp (если только обработка UTF-8?). В идеале, используйте срез, чтобы найти первое несоответствие в пределах менее 128 байтов или что-то в этом роде, а затем цикл char за раз.

Или, если это опция и проблема важна, создайте измененную копию оптимизированного asm memcmp, который возвращает позицию первого разницы вместо равного/не равного; он будет работать так же быстро, как один == для всех строк. У Python есть способы вызвать собственные функции C/asm в библиотеках.

Это разочаровывающее ограничение того, что CPU может сделать это невероятно быстро, но Python не позволяет (AFAIK) предоставить вам доступ к оптимизированному циклу сравнения, который сообщает вам позицию несоответствия, а не просто равную/большую/меньшую.


Это абсолютно нормально, что служебные данные интерпретатора доминируют над стоимостью реальной работы в простом цикле Python с CPython. Построение алгоритма из оптимизированных строительных блоков стоит того, даже если это означает выполнение более полной работы. Вот почему NumPy хорош, но чередование по элементам по элементам ужасно. Разница в скорости может быть чем-то вроде 20-100, для CPython и скомпилированного цикла C (asm) для сравнения одного байта за раз (составленные цифры, но, вероятно, с точностью до порядка).

Сравнение блоков памяти для равенства, вероятно, является одним из самых больших несоответствий между циклами Python и работает со всем списком/срезом. Это обычная проблема с высоко оптимизированными решениями (например, большинство реализаций libc (включая OS X) имеют ручной векторизованный asm memcmp с ручным кодированием, который использует SIMD для сравнения 16 или 32 байта параллельно и работает намного быстрее, чем байт -at-time-цикл в C или сборке). Таким образом, существует еще один фактор от 16 до 32 (если пропускная способность памяти не является узким местом), умножая коэффициент разницы в скорости от 20 до 100 между контурами Python и C. Или в зависимости от того, насколько оптимизирован ваш memcmp, возможно, "только" 6 или 8 байтов за цикл.

При использовании данных, горячих в кешках L2 или L1d для буферов среднего размера, разумно ожидать 16 или 32 байта за цикл для memcmp на процессоре Haswell или более позднем. (именование i3/i5/i7 началось с Nehalem; только i5 недостаточно, чтобы рассказать нам о вашем CPU.)

Я не уверен, что вам или вашим обеим сравнениям приходится обрабатывать UTF-8 и проверять классы эквивалентности или различные способы кодирования одного и того же символа. В худшем случае, если ваш цикл Python char -at-a-time должен проверять наличие многобайтовых символов, но ваш анализ фрагментов может просто использовать memcmp.


Написание эффективной версии в Python:

Мы просто полностью боремся с языком, чтобы получить эффективность: ваша проблема почти такая же, как и стандартная библиотечная функция C memcmp, за исключением того, что вы хотите, чтобы позиция первой разницы вместо -/0/+ результат, указывающий, какая строка больше. Цикл поиска идентичен, это просто разница в том, что делает функция после нахождения результата.

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

# I don't actually know Python; consider this pseudo-code
# or leave an edit if I got this wrong :P
chunksize = min(8192, len(smaller))
# possibly round chunksize down to the next lowest power of 2?
start = 0
while start+chunksize < len(smaller):
    if smaller[start:start+chunksize] == bigger[start:start+chunksize]:
        start += chunksize
    else:
        if chunksize <= 128:
            return char_at_a_time(smaller[start:start+chunksize],  bigger[start:start+chunksize])
        else:
            chunksize /= 8        # from the same start

# TODO: verify this logic for corner cases like string length not a power of 2
# and/or a difference only in the last character: make sure it does check to the end

Я выбрал 8192, потому что ваш CPU имеет кеш-память 32kiB L1d, поэтому общий размер кеша из двух 8-килобайтовых фрагментов составляет 16k, половина вашего L1d. Когда цикл обнаружит несоответствие, он будет повторно проверять последние 8kiB в 1k кусках, и эти сравнения будут перебирать данные, которые все еще горячие в L1d. (Заметим, что если == обнаружено несоответствие, возможно, он коснулся только данных до этой точки, а не всего 8k. Но предварительная выборка HW будет продолжать несколько дальше.)

Коэффициент 8 должен быть хорошим балансом между использованием больших ломтиков для локализации быстро и без необходимости прохождения нескольких проходов по тем же данным. Конечно, это настраиваемый параметр, а также размер блока. Чем больше несоответствие между Python и asm, тем меньше этот фактор должен уменьшить итерации цикла Python.)

Надеемся, что 8k достаточно велико, чтобы скрыть накладные расходы на Python/slice; аппаратная предварительная выборка должна по-прежнему работать во время накладных расходов Python между вызовами memcmp от интерпретатора, поэтому нам не нужна гранулярность, чтобы она была огромной. Но для действительно больших строк, если 8k не насыщает полосу пропускания памяти, то, возможно, сделает ее 64k (ваш кэш L2 равен 256kiB, i5 действительно так говорит).

Как точно memcmp так быстро:

Я запускаю это на Intel Core i5, но я бы предположил, что получаю те же результаты на большинстве современных процессоров.

Даже в C, Почему memcmp намного быстрее, чем проверка цикла? memcmp быстрее, чем цикл сравнения байтов в момент времени, потому что даже компиляторы C не велики в (или полностью неспособны) авто-векторизации поисковых циклов.

Даже без аппаратной поддержки SIMD оптимизированный memcmp может проверять 4 или 8 байтов за раз (размер слова/ширина регистра) даже на простом CPU без 16-байтового или 32-байтового SIMD.

Но большинство современных процессоров и всех x86-64 имеют инструкции SIMD. SSE2 является базовым для x86-64 и доступен как расширение в 32-битном режиме.

SSE2 или AVX2 memcmp могут использовать pcmpeqb/pmovmskb для сравнения параллельно 16 или 32 байта. (Я не буду подробно разбираться в том, как писать memcmp в x86 asm или с помощью C intrinsics. Google это отдельно и/или искать эти инструкции asm в задании набора инструкций x86, например http://felixcloutier.com/x86/index.html. См. также wiki для ссылок asm и производительности. Например Почему Skylake намного лучше, чем Broadwell-E для однопоточной пропускной способности памяти? имеет некоторую информацию об ограничении пропускной способности одноядерной памяти.)

Я нашел старую версию с 2005 года Apple x86-64 memcmp (на языке ассемблера синтаксиса AT & T) в своей сети с открытым исходным кодом сайт. Это определенно может быть лучше; для больших буферов он должен выровнять один указатель источника и использовать только movdqu для другого, позволяя movdqu, затем pcmpeqb с операндом памяти вместо 2x movdqu, даже если строки смещены относительно друг друга. xorl $0xFFFF,%eax/jnz также не является оптимальным для процессоров, где cmp/jcc может использовать плавкий предохранитель, но xor / jcc не может.

Развертывание для проверки всей строки в 64-байтовом кэше сразу также будет скрывать накладные расходы на цикл. (Это та же идея, что и большой кусок, а затем зацикливается на нем, когда вы находите хит). Glibc AVX2- movbe версия делает это с помощью vpand, чтобы совместить результаты сравнения в основном цикле с большим буфером, причем конечный комбайн является vptest, который также устанавливает флаги из результата. (Меньший размер кода, но не менее, чем vpand/vpmovmskb/cmp/jcc, но не имеет недостатков и, возможно, более низкая латентность, чтобы уменьшить отклонения от ветвления - неверно предсказанные на выходе цикла). Glibc выполняет динамическую диспетчеризацию процессора при динамическом времени соединения; он выбирает эту версию для процессоров, которые ее поддерживают.

Надеюсь, Apple memcmp лучше в эти дни; Тем не менее, я не вижу источника для него в самом последнем каталоге Libc. Надеюсь, они отправят во время работы версию AVX2 для Haswell и более поздних процессоров.

Цикл LLoopOverChunks в связанной версии я будет работать только с 1 итерацией (16 байт с каждого входа) на 2,5 цикла на Haswell; 10 скомпилированных доменов. Но это намного быстрее, чем 1 байт за цикл для наивного цикла C, или намного хуже, чем для цикла Python.

Цикл Glibc L(loop_4x_vec): - это 18 совместимых доменов uops и, таким образом, может работать только чуть меньше 32 байт (с каждого входа) за такт, когда данные горячие в кеше L1d. В противном случае это будет узким местом по пропускной способности L2. Это могло бы быть 17 uops, если бы они не использовали дополнительную инструкцию внутри цикла, уменьшающую отдельный счетчик циклов, и вычисляли конечный указатель вне цикла.


Поиск инструкций/горячих точек в собственном коде интерпретатора Python

Как я мог развернуть, чтобы найти инструкции C и инструкции процессора, которые вызывает мой код?

В Linux вы можете запустить perf record python ..., затем perf report -Mintel, чтобы узнать, какие функции CPU затрачивают больше всего времени, и какие инструкции в этих функциях были самыми горячими. Вы получите результаты, подобные тому, что я опубликовал здесь: Почему float() быстрее, чем int()?. (Перейдите к любой функции, чтобы увидеть текущие машинные инструкции, которые были запущены, показаны как язык ассемблера, потому что perf имеет встроенный дизассемблер.)

Для более тонкого представления, которое отображает график вызовов для каждого события, см. linux perf: как интерпретировать и находить горячие точки.

(Когда вы хотите оптимизировать программу, вы хотите знать, какие вызовы функций дороги, поэтому вы можете попытаться избежать их в первую очередь. Профилирование только для "собственного" времени найдет горячие точки, но вы не всегда будут знать, какие разные вызывающие лица вызвали заданный цикл для запуска большей части итераций. См. ответ Майка Данлэйви на этот главный вопрос.)

Но для этого конкретного случая профилирование интерпретатора, использующего версию для сравнения фрагментов над большими строками, должно, мы надеемся, найти цикл memcmp, где я думаю, что он будет тратить большую часть своего времени. (или для версии char -at-a-time найдите код интерпретатора, который "горячий".)

Затем вы можете непосредственно увидеть, какие инструкции asm находятся в цикле. Из имен функций, предполагая, что ваш двоичный код имеет какие-либо символы, вы, вероятно, можете найти источник. Или, если у вас есть версия Python, построенная с информацией об отладке, вы можете напрямую перейти к источнику из профиля. (Не отладка с оптимизацией отключена, просто с полными символами).

Ответ 2

Это зависит от реализации и зависит от оборудования. Не зная вашу целевую машину и конкретное распространение, я не мог сказать точно. Тем не менее, я сильно подозреваю, что базовое оборудование, как и большинство, имеет инструкции блока памяти. Между прочим, это может сравнивать пару произвольно длинных строк (вплоть до пределов адресации) параллельно и конвейерно. Например, он может сравнивать 8-байтовые фрагменты на одном фрагменте за такт. Это намного быстрее, чем поиск байт-уровня.