СоХабр закрыт.

С 13.05.2019 изменения постов больше не отслеживаются, и новые посты не сохраняются.

Сравнение строк: strcmp или посимвольно. Benchmark в черновиках

C
Я искал ответ на вопрос «что быстрее»
strcmp(in, "first") == 0

или
strlen(in) == 5 && in[0] == 'f' && in[1] == 'i' && in[2] == 'r' && in[3] == 's' && in[4] == 't'


И, кажется, нашёл…


Задача


Проверить, не является ли строка «специальным маркером». Всего маркеров пять: «first», «last», «even», «odd», «exit», причём по «exit» программа завершается.

За те несколько дней что я изучаю C, я встречал два подхода к сравнению: функцией strcmp и побайтово (ака посимвольно). Мнения знакомых и коллег о том, какой подход работает быстрее, разделились. Значит нужен benchmark.

Исходники


Решение, использующее strcmp, я буду называть bench_str:
#include <stdio.h>
#include <string.h>

int main() {
    char in[100] = "";

    while (scanf("%s", in)) {
        if (strcmp(in, "first") == 0) {
            printf("F\n");
        } else if (strcmp(in, "last") == 0) {
            printf("L\n");
        } else if (strcmp(in, "odd") == 0) {
            printf("O\n");
        } else if (strcmp(in, "even") == 0) {
            printf("E\n");
        } else if (strcmp(in, "exit") == 0) {
            return 0;
        } else {
            printf("unknown\n");
        }
    }

    return 0;
}


Решение, использующее побайтовое сравнение, я буду называть bench_char:
#include <stdio.h>
#include <string.h>

int main() {
    char in[100] = "";

    while (scanf("%s", in)) {
        if (strlen(in) == 5 && in[0] == 'f' && in[1] == 'i' && in[2] == 'r' && in[3] == 's' && in[4] == 't') {
            printf("F\n");
        } else if (strlen(in) == 4 && in[0] == 'l' && in[1] == 'a' && in[2] == 's' && in[3] == 't') {
            printf("L\n");
        } else if (strlen(in) == 3 && in[0] == 'o' && in[1] == 'd' && in[2] == 'd') {
            printf("O\n");
        } else if (strlen(in) == 4 && in[0] == 'e' && in[1] == 'v' && in[2] == 'e' && in[3] == 'n') {
            printf("E\n");
        } else if (strlen(in) == 4 && in[0] == 'e' && in[1] == 'x' && in[2] == 'i' && in[3] == 't') {
            return 0;
        } else {
            printf("unknown\n");
        }
    }

    return 0;
}


Компилятся и работают они одинаково корректно:
$ gcc -Wall -o ./bin/bench_str bench_str.c && ./bin/bench_str
some
unknown
first
F
last
L
exit

$ gcc -Wall -o ./bin/bench_char bench_char.c && ./bin/bench_char
any 
unknown
odd
O
even
E
exit


Входные данные


Используйте ваш любимый скриптовый язык для создания файла, содержащего изрядное число входных строк. Мне понадобилось 10M строк для того, чтобы время исполнения занимало несколько секунд. На меньших интервалах разница в скорости может быть не так заметна, и влияние прочих процессов, отжирающих ваш CPU, будет сказываться сильнее.

Я сделал make_input.php:
<?php
$variants = array(
    "first",
    "last",
    "even",
    "odd",
    "final",
    "long",
    "event",
    "omnipotent",
    "bad",
    "very bad",
    "ugly",
);

for ($i = 0; $i < 10000000; $i++) {
    $str = $variants[array_rand($variants)];
    echo $str . PHP_EOL;
}

echo "exit" . PHP_EOL;


$ php make_input.php >/tmp/input


Обратите внимание, поскольку input читается бесконечно while (scanf("%s", in)), последней строкой в файле идёт «exit» — иначе программа зациклится.

Таким набором я пытался добиться максимального разнообразия входных строк: есть неподходящие по длине, есть начинающиеся с «неправильных» букв, есть строки с «неправильными» концами, ну и, наконец, есть подходящие строки.

Push the button, Max!


Выполняем! Я перенаправил вывод в /dev/null, чтобы не тратить ресурсы на вывод результата на экран: это тоже вносит погрешность и занимает приличное время. Если не собираетесь перенаправлять вывод, я рекомендую уменьшить число входных строк на порядок или два.

Итак, на сцене побайтовое сравнение:
$ time ./bin/bench_char </tmp/input 1>/dev/null

real    0m2.962s
user    0m2.908s
sys     0m0.048s


I'd like to see the Great Leslie try THAT one! ©

На сцене strcmp:
$ time ./bin/bench_str </tmp/input 1>/dev/null

real    0m2.495s
user    0m2.448s
sys     0m0.036s


Конечно, от запуска к запуску результаты немного варьируются, но в целом картина не меняется: strcmp примерно на полсекунды быстрее, а это около 20%! И я даже молчу о читаемости кода.

В качестве крайних кейсов можно оставить только корректные строки или только некорректные.

В случае использования только корректных строк, время выполнения обеих реализаций сокращается, и преимущество strcmp падает до 15%:
$ time ./bin/bench_char </tmp/input 1>/dev/null

real    0m2.026s
user    0m1.992s
sys     0m0.028s

$ time ./bin/bench_str </tmp/input 1>/dev/null

real    0m1.739s
user    0m1.720s
sys     0m0.012s


В случае использования только некорректных строк, время выполнения bench_char практически не меняется, а вот bench_str выполняется немного дольше.В целом преимущество strcmp падает примерно до 10%:
$ time ./bin/bench_char </tmp/input 1>/dev/null

real    0m2.986s
user    0m2.936s
sys     0m0.044s

$ time ./bin/bench_str </tmp/input 1>/dev/null

real    0m2.722s
user    0m2.676s
sys     0m0.032s


Картинка к этому делу:

Case #1: только правильные строки
Case #2: микс
Case #3: только неправильные

Если кто-то знает почему я не прав и в каком случае будет обратная картина — будет очень интересно расширить кругозор.
Но если кто-то подробно расскажет почему так, будет вообще суперски!

Спасибо за внимание!

комментарии (30)

+6
+7 –1
tronix286 ,  
На чистом асме! Все остальное — тормоза.
+2
+3 –1
sebres ,   * (был изменён)
Кстати, некоторые правда не знают, что современные компиляторы почти все оптимизируют strcmp как inline-asm или builtin. Например GCC (если не воткнуто -fno-builtin ...) вложит на том месте 10 строчек байт кода (на асме, без вызова функции).
А например такие как strcpy или memcpy будут вообще в 3-х строчках (для x86 типа "rep movsd / 4 + rep movsb").
И про скорость — тот же rep movsd выполняется всего за 3 + count of dwords циклов (CPI).
+9
mayorovp ,   * (был изменён)
А почему, собственно, результат должен был быть другим? strcmp — это функция, специально написанная и оптимизированная для сравнения строк — почему же она должна работать медленнее?

Если уж сравнивать посимвольно, то надо писать не так
strlen(in) == 5 && in[0] == 'f' && in[1] == 'i' && in[2] == 'r' && in[3] == 's' && in[4] == 't'
, а так
in[0] == 'f' && in[1] == 'i' && in[2] == 'r' && in[3] == 's' && in[4] == 't' && in[5] == 0
+2
k06a ,   * (был изменён)
Для Little-Endian можно еще вот так:
if ((*(uint64_t *)in & 0x0000ffffffffffff) == *(uint64_t *)"first0\0")
0
k06a ,  
Однажды заморачивался с оптимизацией strlen, вот что получилось: codereview.stackexchange.com/a/30348/28874
0
k06a ,  
Я к тому, что можно аналогичный strcmp забахать
0
agmt ,   * (был изменён)
Нельзя. Происходит обращение к неинициализированным данным (в общем случае). Умный компилятор, конечно, может себе такое позволить, т.к. *скорее всего* выделяет место с выравниванием (а в данном случае можно вызвать и другую strcmp, т.к. данные на стеке). В любом случае, что можно сделать безопасно, скорее всего уже есть в strcmp (и мне сильно кажется, что strcmp как раз так и поступает, а при сравнении с константой можно быть полностью уверенным в безопасности).
0
k06a ,   * (был изменён)
Для этого маской и отсекаются неинициализированные данные. Обращение может и есть, но они никак не используются в логике.
0
mayorovp ,  
Если эти данные случайно окажутся на границе выделенной и невыделенной памяти, то программа вылетит.
0
k06a ,  
Вроде бы вылет будет только в случае, если это произойдет на границе страниц памяти с правами доступа не разрешающими чтение…
0
mayorovp ,  
Я примерно это и написал. Не думаю, что где-нибудь найдется выделенная страница памяти, которую можно исполнять или записывать, но нельзя читать.
0
k06a ,  
Можно только если синтезировать ситуацию специально :))
+2
gentee ,  
Лучше тогда сравнивать не посимвольно, а как int. Типа *(int*)in == 0x45464748, за один раз сразу четыре символа сравниваются. И strlen нужно один раз в начале цикла считать, так как она на каждый вызов строку заново проходит.
+1
Stiver ,  
Можно сделать finite-state machine с посимвольным прохождением строки. Для общего случая будет скорее всего самым быстрым решением.
+3
bogolt ,  
А почему вы скомпилировали код без оптимизаций? Добавьте -O3 и проверьте снова.

Еще существует функция memcmp для побайтного сравнения.
–1
freebin ,  
Вынес strlen в начало цикла, скомпилил с -O3, побайтовое стало шустрее.

$ time ./bin/bench_str </tmp/input 1>/dev/null

real    0m2.256s
user    0m2.216s
sys     0m0.036s

$ time ./bin/bench_char </tmp/input 1>/dev/null

real    0m1.818s
user    0m1.776s
sys     0m0.020s


Посыпаю буйну голову пеплом :)
0
maksqwe ,   * (был изменён)
Может из-за того что в случаи с «bench_char» используется strlen(), а потом еще от 3 до 5 сравнений символов? Давайте заглянем в реализацию этой функции, под рукой MSVC 10:

size_t __cdecl strlen (const char * str) {
        const char *eos = str;
        while( *eos++ ) ;
        return( eos - str - 1 );
}


и на реализацию strcmp()
int __cdecl strcmp (const char * src, const char * dst)
{
        int ret = 0 ;

        while( ! (ret = *(unsigned char *)src - *(unsigned char *)dst) && *dst)
                ++src, ++dst;

        if ( ret < 0 )
                ret = -1 ;
        else if ( ret > 0 )
                ret = 1 ;

        return( ret );
}


Думаю, очевидно что в случае с «bench_char» делается много лишней работы по сравнении с strcmp()
0
saterenko ,  
Очень странное сравнение. У вас накладные расходы, связанные с начитыванием и выводом данных, повторных вызовов strlen гораздо выше, чем время, затраченное на посимвольное или strcmp сравнение. Вообще strlen() + посимвольное сравнение всегда будет заведомо медленнее чем strcmp хотя бы потому, что в первом случае происходит двойной «проход» по памяти: сначала для вычисления длины, а потом для сравнения, а обращение к памяти — это очень медленно. Возможно разница будет мизерная, если срока целиком поместиться в кеш первого уровня, но это далеко не общий случай.
–1
freebin ,  
Насчёт «накладные расходы, связанные с начитыванием и выводом данных» — данные же идентичные. Вы имеете в виду, что считывание и вывод идут неравномерно?

strlen я вынес в начало цикла, стало так habrahabr.ru/post/233459/#comment_7870701
0
saterenko ,  
Я к тому, что вы измеряете не только работу алгоритма, но и всё «обслуживание», которое занимает на порядки больше времени чем сам алгоритм, а из-за этого страдает точность. Ну взять хотя бы начитывание тестовых данных, оно зависит от скорости работы диска и нет ни каких гарантий, что во время проведения экспериментов эта скорость не будет меняться. По-хорошему надо начитать всё в память, засечь время, по циклу прогнать алгоритм, засечь время.
0
maksqwe ,  
Все верно, кеши дисков, системные обращения к файлам… много разных факторов влияющих на результат.

Можно сделать что-то типа этого:
    getAllData();

    timeval tv1;
    timeval tv2;
    gettimeofday(&tv1, 0);
    int64 tp1 = tv1.tv_sec + tv1._usec;

    work();

    gettimeofday(&tv2, 0);
    int64 tp2 = tv2.tv_sec + tv2._usec;
    int64 microsec = tp2 - tp1;
0
saterenko ,  
double tp1 = tv1.tv_sec + (tv1._usec / 1000000.0);

double tp2 = tv2.tv_sec + (tv2._usec / 1000000.0);
0
Ogra ,  
Всего маркеров пять: «first», «last», «even», «odd», «exit», причём по «exit» программа завершается.

Используйте сравнения 32 битных int, маркеры соответственно почти те же, разве что first до firs сократится. Скорость — куда тому strcmp.
0
DISaccount ,  
А с «odd» что делать?
+1
Ogra ,  
У «odd» будет терминальный ноль, и на самом деле только odd и будет проверена правильно. Остальные — нет.
–1
freebin ,  
Я, конечно, неправильное место выбрал для такой беседы — это не на хабре надо было обсуждать. Но других я не знал, извините.

Спасибо всем за наводки, кое-что уже встало на свои места (ошибка с многократным strlen, упущенная оптимизация), а кое что ещё буду курить и курить :)
0
jerom ,   * (был изменён)
Неплохо бы засечь время выполнения такого кода (только убрать все слова, начинающиеся на e, кроме последнего exit:
while (scanf("%s", in)) {
        if (in[0] == 'e' ) {
            return 0;
        } else {
            printf("unknown\n");
        }
    }


Чтобы понять, какое минимальное время может быть достигнуто в этом тесте.

Ну и скорость «проверки как int32» тоже интересно увидеть. Особенно на словах типа firsz и lasz.
0
+1 –1
RPG ,  
Я искал ответ на вопрос «что быстрее»

Не стоит терять время. На производительность влияет целая куча факторов:
— При бенчмарке данные оседают в кэше, причём некоторые — в L1, а в реальной жизни операция strcmp выполняется редко и за каждой порцией данных нужно лезть в память (а порой и на диск).
— strcmp — библиотечная функция, но её производительность будет зависеть как от используемого компилятора, так и от рантайм библиотеки. То есть на связке Linux + gcc + glibc вы получите один результат, а на windows + icc или VS — совершенно другой! Ну и как тут можно что-то сравнивать?
— От инлайнов пухнет бинарник, из-за чего есть риск вылететь за пределы кэша. В частности, icc считает себя умнее разработчиков ОС и запихивает в бинарник «свою» жирную «оптимизированную» SSE-версию strcmp, которая мало того что медленнее реализации в glibc, так ещё и съедает драгоценный кэш инструкций.
— Причуды планировщика ОС также вносят свою лепту в бенчмарк, из-за чего погрешность может достигать 10% и выше. Тест, работающий секунду — не тест. Иногда программисты забывают, что в синтетических тестах компилятор способен выкинуть кое-какие инструкции, тут тоже легко отстрелить конечность.
— Разработчики процессоров оптимизируют и затачивают свои микрооптимизации под тот или другой вид бенчмарков. На разных процессорах и разных архитектурах вы будете получать очень неожиданные результаты. Например, SSE оптимизация выиграет на i7, но просядет на Atom. Примеры нагуглить несложно.

А потом спросите себя — зачем это нужно? До тех пор, пока этот кусок кода не всплывёт в профилировщике, пишите понятный код в ущерб производительности, а не наоборот.

P.S. Для решения конкретно вашей задачи ещё придумали Perfect Hash (гуглите gperf). В больших программах с миллионом параметров он иногда даже используется (например, в компиляторе).
+1
phprus ,  
if (strlen(in) == 5 && in[0] == 'f' && in[1] == 'i' && in[2] == 'r' && in[3] == 's' && in[4] == 't') {

Заем тут вообще strlen? Условие вида:
if (in[0] == 'f' && in[1] == 'i' && in[2] == 'r' && in[3] == 's' && in[4] == 't' && in[5] == '\0') {

будет работать корректно, даже если буфер in короче 5 байт, так как правый операнд && вычисляется только при истинности левого, а '\0' не будет равен перечисленным символам, по этому дальше проверка проводиться не будет.
0
ZyXI ,  
В Vim в ряде мест для ускорения сравнения (а может и для чего‐нибудь ещё) используется switch по первой букве. Т.е. сначала выбираете группу строк с одинаковым первым символом, а потом используете strcmp или что вам там нужно для сравнения внутри группы. Кроме того, как правильно сказали выше, компилятор strcmp знает, любит и может заменить на всё, что ему угодно. Ну и наличие strlen() при таких результатах подсказывает, что посимвольное сравнение могло бы быть и быстрее, если бы вы не использовали strlen:

==========  ===========  ================  =================================
Компилятор  Оптимизации  Результат strlen  Результат посимвольного сравнения
==========  ===========  ================  =================================
clang-3.3   -O0          16,0              13,6
clang-3.3   -O3          15,3              12,4
gcc-4.7.3   -O0          16,6              12,8
gcc-4.7.3   -O3          16,7              12,8
pathcc⊃1;     -O0          15,5              12,5
pathcc⊃1;     -O3          15,7              12,9
tcc-0.9.26  -O0          15,8              11,9
tcc-0.9.26  -O3          15,5              12,9
pcc⊃2;        -O0          15,9              13,7
pcc⊃2;        -O3          16,3              13,4
==========  ===========  ================  =================================

⊃1; 5.0.1_pre20131115
⊃2; 1.0.1_pre20131013


Т.е. у меня посимвольное сравнение выигрывает всегда, только не надо использовать strlen.