Почему этот код в 6.5 раз медленнее с включенной оптимизацией?

Я по какой-то причине хотел strlen функцию glibc strlen и обнаружил, что она работает намного медленнее с включенной оптимизацией в GCC, и я понятия не имею, почему.

Вот мой код:

#include <time.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>

int main() {
    char *s = calloc(1 << 20, 1);
    memset(s, 65, 1000000);
    clock_t start = clock();
    for (int i = 0; i < 128; ++i) {
        s[strlen(s)] = 'A';
    }
    clock_t end = clock();
    printf("%lld\n", (long long)(end-start));
    return 0;
}

На моей машине это выводит:

$ gcc test.c && ./a.out
13336
$ gcc -O1 test.c && ./a.out
199004
$ gcc -O2 test.c && ./a.out
83415
$ gcc -O3 test.c && ./a.out
83415

Каким-то образом включение оптимизаций заставляет его выполняться дольше.

Ответы

Ответ 1

Тестирование вашего кода в Godbolt Compiler Explorer дает следующее объяснение:

  • в -O0 или без оптимизаций сгенерированный код вызывает функцию библиотеки C strlen
  • в -O1 сгенерированный код использует простое встроенное расширение, используя rep scasb.
  • в -O2 и выше сгенерированный код использует более сложное встроенное расширение.

Сравнительный анализ вашего кода неоднократно показывает существенное изменение от одного прогона к другому, но увеличение количества итераций показывает, что:

  • код -O1 намного медленнее, чем реализация библиотеки C: 32240 против 3090
  • код -O2 быстрее, чем -O1 но все же существенно медленнее, чем код библиотеки: 8570 против 3090.

Это поведение специфично для gcc и glibc. Тот же тест на OS/X с clang и Apple Libc не показывает существенной разницы, что неудивительно, поскольку Godbolt показывает, что clang генерирует вызов библиотеки C strlen на всех уровнях оптимизации.

Это можно считать ошибкой в gcc/glibc, но более обширный бенчмаркинг может показать, что накладные расходы на вызов strlen оказывают более важное влияние, чем недостаточная производительность встроенного кода для небольших строк. Строки, на которых вы производите тестирование, необычайно велики, поэтому фокусирование теста на сверхдлинных строках может не дать значимых результатов.

Я улучшил этот тест и протестировал различные длины строк. Из тестов linux с gcc (Debian 4.7.2-5) 4.7.2 видно, что он работает на процессоре Intel (R) Core (TM) i3-2100 @3.10 ГГц, что встроенный код, сгенерированный -O1, всегда медленнее, в 10 раз для строк средней длины, в то время как -O2 лишь немного быстрее, чем libc strlen для очень коротких строк и вдвое быстрее для более длинных строк. Исходя из этих данных, версия strlen библиотеки GNU C довольно эффективна для большинства длин строк, по крайней мере, на моем конкретном оборудовании. Также следует помнить, что кэширование оказывает существенное влияние на измерения производительности.

Вот обновленный код:

#include <stdlib.h>
#include <string.h>
#include <time.h>

void benchmark(int repeat, int minlen, int maxlen) {
    char *s = malloc(maxlen + 1);
    memset(s, 'A', minlen);
    long long bytes = 0, calls = 0;
    clock_t clk = clock();
    for (int n = 0; n < repeat; n++) {
        for (int i = minlen; i < maxlen; ++i) {
            bytes += i + 1;
            calls += 1;
            s[i] = '\0';
            s[strlen(s)] = 'A';
        }
    }
    clk = clock() - clk;
    free(s);
    double avglen = (minlen + maxlen - 1) / 2.0;
    double ns = (double)clk * 1e9 / CLOCKS_PER_SEC;
    printf("average length %7.0f -> avg time: %7.3f ns/byte, %7.3f ns/call\n",
           avglen, ns / bytes, ns / calls);
}

int main() {
    benchmark(10000000, 0, 1);
    benchmark(1000000, 0, 10);
    benchmark(1000000, 5, 15);
    benchmark(100000, 0, 100);
    benchmark(100000, 50, 150);
    benchmark(10000, 0, 1000);
    benchmark(10000, 500, 1500);
    benchmark(1000, 0, 10000);
    benchmark(1000, 5000, 15000);
    benchmark(100, 1000000 - 50, 1000000 + 50);
    return 0;
}

Вот вывод:

chqrlie> gcc -std=c99 -O0 benchstrlen.c && ./a.out
average length       0 -> avg time:  14.000 ns/byte,  14.000 ns/call
average length       4 -> avg time:   2.364 ns/byte,  13.000 ns/call
average length      10 -> avg time:   1.238 ns/byte,  13.000 ns/call
average length      50 -> avg time:   0.317 ns/byte,  16.000 ns/call
average length     100 -> avg time:   0.169 ns/byte,  17.000 ns/call
average length     500 -> avg time:   0.074 ns/byte,  37.000 ns/call
average length    1000 -> avg time:   0.068 ns/byte,  68.000 ns/call
average length    5000 -> avg time:   0.064 ns/byte, 318.000 ns/call
average length   10000 -> avg time:   0.062 ns/byte, 622.000 ns/call
average length 1000000 -> avg time:   0.062 ns/byte, 62000.000 ns/call
chqrlie> gcc -std=c99 -O1 benchstrlen.c && ./a.out
average length       0 -> avg time:  20.000 ns/byte,  20.000 ns/call
average length       4 -> avg time:   3.818 ns/byte,  21.000 ns/call
average length      10 -> avg time:   2.190 ns/byte,  23.000 ns/call
average length      50 -> avg time:   0.990 ns/byte,  50.000 ns/call
average length     100 -> avg time:   0.816 ns/byte,  82.000 ns/call
average length     500 -> avg time:   0.679 ns/byte, 340.000 ns/call
average length    1000 -> avg time:   0.664 ns/byte, 664.000 ns/call
average length    5000 -> avg time:   0.651 ns/byte, 3254.000 ns/call
average length   10000 -> avg time:   0.649 ns/byte, 6491.000 ns/call
average length 1000000 -> avg time:   0.648 ns/byte, 648000.000 ns/call
chqrlie> gcc -std=c99 -O2 benchstrlen.c && ./a.out
average length       0 -> avg time:  10.000 ns/byte,  10.000 ns/call
average length       4 -> avg time:   2.000 ns/byte,  11.000 ns/call
average length      10 -> avg time:   1.048 ns/byte,  11.000 ns/call
average length      50 -> avg time:   0.337 ns/byte,  17.000 ns/call
average length     100 -> avg time:   0.299 ns/byte,  30.000 ns/call
average length     500 -> avg time:   0.202 ns/byte, 101.000 ns/call
average length    1000 -> avg time:   0.188 ns/byte, 188.000 ns/call
average length    5000 -> avg time:   0.174 ns/byte, 868.000 ns/call
average length   10000 -> avg time:   0.172 ns/byte, 1716.000 ns/call
average length 1000000 -> avg time:   0.172 ns/byte, 172000.000 ns/call

Ответ 2

GCC рядный strlen модели гораздо медленнее, чем это может сделать с SSE2 pcmpeqb/pmovmskb и bsf, учитывая расклад 16 байт из calloc. Эта "оптимизация" на самом деле является пессимизацией.

Мой простой рукописный цикл, использующий 16-байтовое выравнивание, в 5 раз быстрее, чем -O3 gcc -O3 для больших буферов, и в 2 раза быстрее для коротких строк. (И быстрее, чем вызов strlen для коротких строк). Я добавил комментарий к https://gcc.gnu.org/bugzilla/show_bug.cgi?id=88809, чтобы предложить это для того, что gcc должен встроить в -O2 / -O3, когда это возможно. (С предложением увеличить до 16 байтов, если мы знаем только 4-байтовое выравнивание для начала.)


Когда gcc знает, что имеет 4-байтовое выравнивание для буфера (гарантируется calloc), он выбирает встроенный strlen как скалярный битовый разряд 4 байта за раз, используя целочисленные регистры GP (-O2 и выше).

(Чтение 4 байтов за раз безопасно только в том случае, если мы знаем, что не можем перейти на страницу, которая не содержит строковых байтов и, следовательно, может не отображаться. Безопасно ли читать за пределами буфера в том же самом страница на x86 и x64? (TL: DR да, в asm это так, так что компиляторы могут генерировать код, который делает это, даже если в исходном коде C используется UB. В реализации libc также используются преимущества strlen ссылки на glibc strlen и краткое описание того, как он работает так быстро для больших строк.)

На -O1 gcc всегда (даже без известного выравнивания) выбирает встроенный strlen как repnz scasb, что очень медленно (около 1 байта за такт на современных процессорах Intel). К сожалению, "быстрые строки" относятся только к rep stos и rep movs, но не к repz/repnz. Их микрокод является простым 1 байтом за раз, но у них все еще есть некоторые издержки запуска. (https://agner.org/optimize/)

(Мы можем проверить это, "спрятав" указатель от компилятора, сохранив/перезагрузив s в volatile void *tmp. Gcc должен сделать нулевые предположения о значении указателя, которое считывает обратно из volatile, уничтожая любую информацию выравнивания.)


GCC имеет некоторые параметры настройки x86 как -mstringop-strategy=libcall против unrolled_loop против rep_byte для встраивания строковых операций в целом ( а не только STRLEN, memcmp бы еще один важных один, что может быть сделано с повторением или петлей). Я не проверял, какой эффект они имеют здесь.

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

-minline-all-stringops
По умолчанию GCC включает строковые операции только тогда, когда известно, что место назначения выровнено по крайней мере с 4-байтовой границей. Это позволяет больше встраивать и увеличивать размер кода, но может улучшить производительность кода, которая зависит от быстрых memcpy, strlen и memset для коротких длин.

GCC также имеет атрибуты для каждой функции, которые вы, очевидно, можете использовать для управления этим, например, __attribute__((no-inline-all-stringops)) void foo() {... }, но я с этим не поиграл. (Это противоположность inline-all. Это не означает, что inline none, просто возвращается только к встраиванию, когда известно 4-байтовое выравнивание.)


Обе стратегии gcc inline strlen не в состоянии использовать преимущества 16-байтового выравнивания и довольно плохи для x86-64

Если регистр с малыми строками не является очень распространенным, при выполнении одного 4-байтового фрагмента, то выровненные 8-байтовые фрагменты будут идти в два раза быстрее, чем 4-байтовые.

И 4-байтовая стратегия имеет гораздо более медленную очистку, чем необходимо для нахождения байта внутри dword, содержащего нулевой байт. Он обнаруживает это путем поиска байта с установленным bsf битом, поэтому он должен просто замаскировать другие биты и использовать bsf (прямое сканирование битов). Это имеет 3 цикла задержки на современных процессорах (Intel и Ryzen). Или компиляторы могут использовать rep bsf чтобы он tzcnt как tzcnt на процессорах, поддерживающих BMI1, что более эффективно для AMD. bsf и tzcnt дают одинаковый результат для ненулевых входных данных.

4-байтовый цикл GCC выглядит так, как будто он скомпилирован из чистого C или некоторой независимой от цели логики, не использующей преимущества битового сканирования. gcc использует andn для его оптимизации при компиляции для x86 с BMI1, но он все равно меньше 4 байтов за цикл.

SSE2 pcmpeqb + bsf намного лучше для коротких и длинных входов. x86-64 гарантирует, что SSE2 доступен, а x86-64 System V имеет alignof(maxalign_t) = 16 поэтому calloc всегда будет возвращать указатели, которые выровнены как минимум на 16 байт.


Я написал замену для блока strlen для проверки производительности

Как и ожидалось, на Skylake он примерно в 4 раза быстрее, а не на 4 байта за раз.

(Я скомпилировал исходный источник в asm с -O3, затем отредактировал asm, чтобы посмотреть, какова должна быть производительность с этой стратегией для встроенного расширения strlen. Я также перенес его в встроенный asm внутри источника C; %23include+ %23include+ %23include+ //+Comment this line to *not* hide it, allowing gcc!'s strlen inlining __attribute__((noinline,+noclone))%0Avoid+*hide_alignment(void*+p) { //volatile void *tmp+=+p%3B++//not needed return+p; } int main() {%0A++++Char+*s =+Calloc(1+<< 20,+1);%0A+++ s = hide_alignment(s);%0A+++ memset(s,+65,+1000000);%0A++++Clock_t start =+Clock();%0A+++ for (int я = 0%3B+i <+128%3B+++i) { #ifndef+USE_ASM%0A+++s[strlen(s)%5D+= !'A!'; #else char+*stmp+= s; unsigned mask; asm volatile(%0A+++ "pxor %%xmm0,%%xmm0\n\t"%0A+++ ".p2align 4\n\t" ".Lstrlen16:\n\t%22++++++++//+16-byte at a time strlen #ifdef __AVX__%0A+++ "vpcmpeqb (%[p]), %%xmm0, %%xmm1\n\t" #else%0A+++ "movdqa(%[p]), %%xmm1\n\t"%0A+++ "pcmpeqb %%xmm0, %%xmm1\n\t" #endif %0A+++ "add $16, %[p]\n\t"%0A+++ "pmovmskb %%xmm1, %[mask]\n\t"%0A+++ "test %[mask], %[mask]\n\t"%0A+++ "jz .Lstrlen16\n\t" %0A+++ "bsf %[mask], %[mask]\n\t"%0A+++ "movb $!'A!',+-16(%[p], %q[mask])" %09++++: [p]"+r"(stmp), %09++++ [mask]"=r"(mask) //dummy output %09++++://no read-only inputs %09++++: "xmm0", "xmm1", "memory%22++//hard-code these regs, this isn!'t intended to be super+polished. ); #endif%0A+++ }%0A++++Clock_t end =+Clock();%0A++++printf("%lld\n", (long long)(end-start));%0A+++ return 0; } '),l:'5',n:'0',o:'C++ source #1',t:'0')),header:(),k:31.200647768944577,l:'4',m:100,n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:g83,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'1',libraryCode:'1',trim:'1'),lang:c++,libs:!((name:gsl,ver:'100')),options:'-UUSE_ASM -xc -std%3Dgnu99+-O3 -march=skylake',source:1),l:'5',n:'0',o:'x86-64 gcc 8.3+(Editor #1,+Compiler+#1)+C++',t:'0')),header:(),k:35.46601889772211,l:'4',m:100,n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:g83,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'1',libraryCode:'1',trim:'1'),lang:c++,libs:!((name:gsl,ver:'100')),options:'-xc -std%3Dgnu99+-O3 -march=skylake -DUSE_ASM',source:1),l:'5',n:'0',o:'x86-64 gcc 8.3+(Editor #1,+Compiler+#2)+C++',t:'0')),k:33.33333333333333,l:'4',n:'0',o:'',s:0,t:'0')),l:'2',m:100,n:'0',o:'',t:'0')),version:4 rel=noreferrer>см. Эту версию на Godbolt.)

    # at this point gcc has 's' in RDX, 'i' in ECX

    pxor       %xmm0, %xmm0         # zeroed vector to compare against
    .p2align 4
.Lstrlen16:                         # do {
#ifdef __AVX__
    vpcmpeqb   (%rdx), %xmm0, %xmm1
#else
    movdqa     (%rdx), %xmm1
    pcmpeqb    %xmm0, %xmm1           # xmm1 = -1 where there was a 0 in memory
#endif

    add         $16, %rdx             # ptr++
    pmovmskb  %xmm1, %eax             # extract high bit of each byte to a 16-bit mask
    test       %eax, %eax
    jz        .Lstrlen16            # }while(mask==0);
    # RDX points at the 16-byte chunk *after* the one containing the terminator
    # EAX = bit-mask of the 0 bytes, and is known to be non-zero
    bsf        %eax, %eax           # EAX = bit-index of the lowest set bit

    movb       $'A', -16(%rdx, %rax)

Обратите внимание, что я оптимизировал часть очистки strlen в режиме адресации магазина: я исправляю перерегулирование со смещением -16, и это просто нахождение конца строки, а не вычисление длины и последующее индексирование, как GCC был уже делает после включения его 4-байтового цикла.

Чтобы получить фактическую длину строки (вместо указателя на конец), rax-16 -start и затем добавьте rax-16 (возможно, с LEA, чтобы добавить 2 регистра + константу, но 3-компонентный LEA имеет большую задержку. )

Благодаря AVX, позволяющему загружать +C ompare в одну инструкцию без разрушения обнуленного регистра, весь цикл составляет всего 4 моп, а не 5 (тестирование макросов /jz в один моп на Intel и AMD. vpcmpeqb с не -индексированный источник памяти может сохранять его микросинтеграцией по всему конвейеру, так что это только 1 моп с доменом слияния для внешнего интерфейса.)

(Обратите внимание, что смешивание 128-битного AVX с SSE не вызывает сбоев даже в Haswell, если вы начинаете с чистого верхнего уровня. Поэтому я не стал менять другие инструкции на AVX, только Это имело значение. Похоже, был некоторый незначительный эффект, когда pxor был на самом деле немного лучше, чем vpxor на моем рабочем столе, хотя для тела цикла AVX. Это казалось несколько повторяемым, но это странно, потому что нет разницы в размере кода и, следовательно, различий выравнивания.)

pmovmskb - это однопользовательская инструкция. Он имеет 3-тактную задержку на Intel и Ryzen (хуже на Bulldozer-family). Для коротких строк отключение через модуль SIMD и обратно к целому числу является важной частью цепочки критических зависимостей пути для задержки от байтов входной памяти до готовности адреса хранения. Но только SIMD имеет сравнения упакованных целых чисел, поэтому скаляр должен был бы выполнять больше работы.

Для очень маленького строкового случая (например, от 0 до 3 байтов) можно было бы достичь немного более низкой задержки для этого случая, используя чистый скаляр (особенно в семействе Bulldozer), но при этом все строки от 0 до 15 байтов берут Один и тот же путь ветвления (ветвь цикла никогда не используется) очень удобен для большинства коротких строк.

Быть очень хорошим для всех строк до 15 байтов кажется хорошим выбором, когда мы знаем, что у нас есть 16-байтовое выравнивание. Более предсказуемое ветвление очень хорошо. (И обратите внимание, что при pmovmskb задержка pmovmskb влияет только на то, насколько быстро мы можем обнаружить ошибочные прогнозы ветвления, чтобы выйти из цикла; предсказание ветвления + спекулятивное выполнение скрывает задержку независимого pmovmskb в каждой итерации.

Если мы ожидали, что более длинные строки будут общими, мы могли бы немного развернуть, но в этот момент вам нужно просто вызвать функцию libc, чтобы она могла отправлять AVX2, если она доступна во время выполнения. Развертывание на более чем 1 вектор усложняет очистку, повреждая простые случаи.


На моей машине i7-6700k Skylake с максимальной турбо-частотой 4,2 ГГц (и energy_performance_preference= performance), с gcc 8.2 на Arch Linux, я получаю несколько непротиворечивую синхронизацию, потому что тактовая частота моего процессора увеличивается во время memset. Но, возможно, не всегда максимальный турбо; Skylake hw разряжается, когда управление памятью ограничено. perf stat показал, что я обычно получаю правильную частоту около 4.0 ГГц при выполнении этого, чтобы усреднить вывод stdout и посмотреть сводку perf на stderr.

perf stat -r 100 ./a.out | awk '{sum+= $1}  END{print sum/100;}'

В итоге я скопировал свой asm в оператор in-asm GNU C, %23include+ %23include+ %23include+ //+Comment this line to *not* hide it, allowing gcc!'s strlen inlining __attribute__((noinline,+noclone))%0Avoid+*hide_alignment(void*+p) { //volatile void *tmp+=+p%3B++//not needed return+p; } int main() {%0A++++Char+*s =+Calloc(1+<< 20,+1);%0A+++ s = hide_alignment(s);%0A+++ memset(s,+65,+1000000);%0A++++Clock_t start =+Clock();%0A+++ for (int я = 0%3B+i <+128%3B+++i) { #ifndef+USE_ASM%0A+++s[strlen(s)%5D+= !'A!'; #else char+*stmp+= s; unsigned mask; asm volatile(%0A+++ "pxor %%xmm0,%%xmm0\n\t"%0A+++ ".p2align 4\n\t" ".Lstrlen16:\n\t%22++++++++//+16-byte at a time strlen #ifdef __AVX__%0A+++ "vpcmpeqb (%[p]), %%xmm0, %%xmm1\n\t" #else%0A+++ "movdqa(%[p]), %%xmm1\n\t"%0A+++ "pcmpeqb %%xmm0, %%xmm1\n\t" #endif %0A+++ "add $16, %[p]\n\t"%0A+++ "pmovmskb %%xmm1, %[mask]\n\t"%0A+++ "test %[mask], %[mask]\n\t"%0A+++ "jz .Lstrlen16\n\t" %0A+++ "bsf %[mask], %[mask]\n\t"%0A+++ "movb $!'A!',+-16(%[p], %q[mask])" %09++++: [p]"+r"(stmp), %09++++ [mask]"=r"(mask) //dummy output %09++++://no read-only inputs %09++++: "xmm0", "xmm1", "memory%22++//hard-code these regs, this isn!'t intended to be super+polished. ); #endif%0A+++ }%0A++++Clock_t end =+Clock();%0A++++printf("%lld\n", (long long)(end-start));%0A+++ return 0; } '),l:'5',n:'0',o:'C++ source #1',t:'0')),header:(),k:31.200647768944577,l:'4',m:100,n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:g83,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'1',libraryCode:'1',trim:'1'),lang:c++,libs:!((name:gsl,ver:'100')),options:'-UUSE_ASM -xc -std%3Dgnu99+-O3 -march=skylake',source:1),l:'5',n:'0',o:'x86-64 gcc 8.3+(Editor #1,+Compiler+#1)+C++',t:'0')),header:(),k:35.46601889772211,l:'4',m:100,n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:g83,filters:(b:'0',binary:'1',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'1',libraryCode:'1',trim:'1'),lang:c++,libs:!((name:gsl,ver:'100')),options:'-xc -std%3Dgnu99+-O3 -march=skylake -DUSE_ASM',source:1),l:'5',n:'0',o:'x86-64 gcc 8.3+(Editor #1,+Compiler+#2)+C++',t:'0')),k:33.33333333333333,l:'4',n:'0',o:'',s:0,t:'0')),l:'2',m:100,n:'0',o:'',t:'0')),version:4 rel=noreferrer>чтобы я мог поместить код в проводник компилятора Godbolt.

Для больших струн той же длины, что и в вопросе: время на ~ 4 ГГц Skylake

  • ~ 62100 clock_t времени clock_t: -O1 rep scas: (clock() немного устарел, но я не стал его менять.)
  • ~ 15900 clock_t единиц времени: -O3 gcc 4-байтовая стратегия цикла: среднее из 100 циклов =. (Или, может быть, ~ 15800 с -march=native для andn)
  • ~ 1880 clock_t единиц времени: -O3 с вызовами функции glibc strlen с использованием AVX2
  • ~ 3190 clock_t единиц времени: (128-битные векторы AVX1, 4-тактный цикл) рукописный встроенный asm, который gcc может/должен встроить.
  • ~ 3230 clock_t единиц времени: (SSE2 5-й цикл) рукописный встроенный asm, который gcc может/должен встроить.

Мой рукописный ассм также должен быть очень хорош для коротких строк, потому что он не должен специально разветвляться. Известное выравнивание очень хорошо для strlen, и libc не может этим воспользоваться.

Если мы ожидаем, что большие строки будут редкостью, то в 1,7 раза медленнее, чем libc для этого случая. Длина в 1 Мбайт означает, что он не будет оставаться горячим в кэш-памяти L2 (256 КБ) или L1d (32 КБ) на моем ЦП, поэтому даже в узких местах кеш-памяти L3 версия libc была быстрее. (Вероятно, развернутый цикл и 256-битные векторы не засоряют ROB с таким количеством мопов на байт, поэтому OoO exec может видеть дальше и получать больше параллелизма памяти, особенно на границах страницы.)

Но пропускная способность кэша L3, вероятно, является узким местом, мешающим версии с 4 мегапикселями работать с 1 итерацией в такт, поэтому мы видим меньшую выгоду от AVX, сохраняющего нам моп в цикле. С горячими данными в кеше L1d мы должны получить 1,25 цикла на одну итерацию против 1.

Но хорошая реализация AVX2 может считывать до 64 байтов за цикл (загрузка 2x 32 байта), используя vpminub для объединения пар перед проверкой на нули и возвращением, чтобы найти, где они были. Промежуток между этим и libc открывается шире для размеров от ~ 2k до ~ 30 kB или около того, которые остаются горячими в L1d.

Некоторое тестирование только для чтения с длиной = 1000 показывает, что glibc strlen действительно примерно в 4 раза быстрее, чем мой цикл для строк среднего размера, горячих в кеше L1d. Этого достаточно для того, чтобы AVX2 смог развернуть большой развернутый цикл, но все же легко помещается в кэш L1d. (Только для чтения избегайте пересылок в магазине, поэтому мы можем выполнять много итераций)

Если ваши строки такие большие, вы должны использовать строки явной длины вместо того, чтобы вообще нуждаться в strlen, поэтому вставка простого цикла все еще кажется разумной стратегией, если она действительно хороша для коротких строк, а не для полного мусора для средних (например, 300 байт) и очень длинные (> размер кэша) строки.


Сравнительный анализ небольших строк с этим:

Я столкнулся с некоторыми странностями, пытаясь получить ожидаемые результаты:

Я попытался s[31] = 0 чтобы обрезать строку перед каждой итерацией (допуская короткую постоянную длину). Но тогда моя версия SSE2 была почти такой же скорости, как версия GCC. Торговые киоски были узким местом! Хранилище байтов, за которым следует более широкая загрузка, заставляет пересылку хранилищ идти по медленному пути, который объединяет байты из буфера хранилища с байтами из кэша L1d. Эта дополнительная задержка является частью переносимой циклом цепи депозита через последний 4-байтовый или 16-байтовый фрагмент строки, чтобы вычислить индекс хранилища для следующей итерации.

Более медленный 4-байтовый код GCC может не отставать, обрабатывая более ранние 4-байтовые блоки в тени этой задержки. (Выполнение не по порядку довольно фантастично: медленный код иногда может не повлиять на общую скорость вашей программы).

В конце концов я решил эту проблему, сделав версию только для чтения и используя встроенный asm, чтобы компилятор не strlen вывести strlen из цикла.

Но пересылка магазина - потенциальная проблема с использованием 16-байтовых загрузок. Если другие переменные C хранятся за концом массива, мы можем столкнуться с остановкой SF из-за загрузки с конца массива дальше, чем с более узкими хранилищами. Для недавно скопированных данных мы подходим, если они были скопированы с 16-байтовыми или более широкими выровненными хранилищами, но glibc memcpy для маленьких копий выполняет 2-кратные перекрывающиеся нагрузки, которые покрывают весь объект, от начала и до конца объекта. Затем он сохраняет оба, снова накладывающихся друг на друга, обрабатывая регистр памяти memmove src dst бесплатно. Таким образом, второй 16-байтовый или 8-байтовый фрагмент короткой строки, который был только что записан, может дать нам стойку SF для чтения последнего фрагмента. (Тот, который имеет зависимость данных для вывода.)

Просто работать медленнее, так что вы не дойдете до конца, пока он не будет готов, в общем, плохо, поэтому здесь нет отличного решения. Я думаю, что большую часть времени вы не собираетесь strlen буфера, который вы только что написали, обычно вы собираетесь strlen ввода, который вы только читаете, так что киоски пересылки магазина не проблема. Если бы что-то еще просто написало это, то, надеюсь, эффективный код не отбросил бы длину и не вызвал бы функцию, которая требовала бы ее пересчета.


Другие странности, которые я до конца не выяснил:

Выравнивание кода в 2 раза отличается только для чтения, размер = 1000 (s[1000] = 0;). Но самый внутренний цикл asm сам выровнен с .p2align 4 или .p2align 5. Увеличение выравнивания циклы может замедлить его в 2 раза!

# slow version, with *no* extra HIDE_ALIGNMENT function call before the loop.
# using my hand-written asm, AVX version.
  i<1280000 read-only at strlen(s)=1000 so strlen time dominates the total runtime (not startup overhead)
  .p2align 5 in the asm inner loop. (32-byte code alignment with NOP padding)

gcc -DUSE_ASM -DREAD_ONLY -DHIDE_ALIGNMENT -march=native -O3 -g strlen-microbench.c &&
 time taskset -c 3 perf stat -etask-clock,context-switches,cpu-migrations,page-faults,cycles,branches,branch-misses,instructions,uops_issued.any,uops_executed.thread -r 100 ./a.out |
 awk '{sum+= $1}  END{print sum/100;}'

 Performance counter stats for './a.out' (100 runs):

             40.92 msec task-clock                #    0.996 CPUs utilized            ( +-  0.20% )
                 2      context-switches          #    0.052 K/sec                    ( +-  3.31% )
                 0      cpu-migrations            #    0.000 K/sec                  
               313      page-faults               #    0.008 M/sec                    ( +-  0.05% )
       168,103,223      cycles                    #    4.108 GHz                      ( +-  0.20% )
        82,293,840      branches                  # 2011.269 M/sec                    ( +-  0.00% )
         1,845,647      branch-misses             #    2.24% of all branches          ( +-  0.74% )
       412,769,788      instructions              #    2.46  insn per cycle           ( +-  0.00% )
       466,515,986      uops_issued.any           # 11401.694 M/sec                   ( +-  0.22% )
       487,011,558      uops_executed.thread      # 11902.607 M/sec                   ( +-  0.13% )

         0.0410624 +- 0.0000837 seconds time elapsed  ( +-  0.20% )

40326.5   (clock_t)

real    0m4.301s
user    0m4.050s
sys     0m0.224s

Примечание ветвь пропускает определенно ненулевой, по сравнению с почти точно ноль для быстрой версии. И выпущенные мопы намного выше, чем быстрая версия: они могут долго спекулировать по неверному пути при каждом из этих промахов ветвления.

Вероятно, внутренние и внешние петлевые ветки совмещают друг друга, или нет.

Счетчик команд почти идентичен, просто различается некоторыми NOP во внешнем цикле перед внутренним циклом. Но IPC сильно отличается: без проблем быстрая версия выполняет в среднем 4,82 инструкции за такт для всей программы. (Большая часть этого находится в самом внутреннем цикле, выполняющем 5 инструкций за цикл, благодаря тесту /jz, в котором макрос объединяет 2 инструкции в 1 моп.) И обратите внимание, что uops_executed намного выше, чем uops_issued: это означает, что микросинтеза работает хорошо, чтобы получить больше мопов через узкое место переднего плана.

fast version, same read-only strlen(s)=1000 repeated 1280000 times

gcc -DUSE_ASM -DREAD_ONLY -UHIDE_ALIGNMENT -march=native -O3 -g strlen-microbench.c &&
 time taskset -c 3 perf stat -etask-clock,context-switches,cpu-migrations,page-faults,cycles,branches,branch-misses,instructions,uops_issued.any,uops_executed.thread -r 100 ./a.out |
 awk '{sum+= $1}  END{print sum/100;}' 

 Performance counter stats for './a.out' (100 runs):

             21.06 msec task-clock                #    0.994 CPUs utilized            ( +-  0.10% )
                 1      context-switches          #    0.056 K/sec                    ( +-  5.30% )
                 0      cpu-migrations            #    0.000 K/sec                  
               313      page-faults               #    0.015 M/sec                    ( +-  0.04% )
        86,239,943      cycles                    #    4.094 GHz                      ( +-  0.02% )
        82,285,261      branches                  # 3906.682 M/sec                    ( +-  0.00% )
            17,645      branch-misses             #    0.02% of all branches          ( +-  0.15% )
       415,286,425      instructions              #    4.82  insn per cycle           ( +-  0.00% )
       335,057,379      uops_issued.any           # 15907.619 M/sec                   ( +-  0.00% )
       409,255,762      uops_executed.thread      # 19430.358 M/sec                   ( +-  0.00% )

         0.0211944 +- 0.0000221 seconds time elapsed  ( +-  0.10% )

20504  (clock_t)

real    0m2.309s
user    0m2.085s
sys     0m0.203s

Я думаю, что это просто предсказание ветвлений, а не другие вещи, связанные с интерфейсом, которые являются проблемой. Инструкции теста/ветвления не разбиваются через границу, которая предотвратит макрослияние.

Изменение .p2align 5 на .p2align 4 меняет их на противоположное: -UHIDE_ALIGNMENT становится медленным.

%23include+ %23include+ %23include+ #ifdef HIDE_ALIGNMENT //resulting in gcc not+Choosing to inline strlen __attribute__((noinline,+noclone)) #endif%0Avoid+*hide_alignment(void*+p) { //volatile void *tmp+=+p%3B++//not needed, just a non-inline function works return+p; } int main() {%0A++++Char+*s =+Calloc(1+<< 20,+1);%0A+++ s = hide_alignment(s);%0A+++ memset(s, !'B!',+100000); %0A+++ s[1000%5D+= 0%3B++//if with read-only: fixed-size string %0A++++Clock_t start =+Clock();%0A+++ for (int я = 0%3B+i <+1280000%3B+++i) { #ifndef+USE_ASM%0A++#ifndef READ_ONLY%0A+++ s[strlen(s)%5D+= !'A!';%0A++#else%0A+++ (void)*((volatile+Char*) s + strlen(s))%3B++//read from that address%0A+++ asm("":: "r"(s):"memory")%3B+//+Compiler+assumes s[0..?%5D+is modified%0A++#endif #else char+*stmp+= s; unsigned mask; asm volatile( //needs volatile because the output operands are unused // "vzeroupper\n\t"%0A+++ "pxor %%xmm0,%%xmm0\n\t"%0A+++ ".p2align 5\n\t" ".Lstrlen16:\n\t%22++++++++//+16-byte at a time strlen #ifdef __AVX__%0A+++ "vpcmpeqb (%[p]), %%xmm0, %%xmm1\n\t" #else%0A+++ "movdqa(%[p]), %%xmm1\n\t"%0A+++ "pcmpeqb %%xmm0, %%xmm1\n\t" #endif %0A+++ "add $16, %[p]\n\t"%0A+++ "pmovmskb %%xmm1, %[mask]\n\t"%0A+++ "test %[mask], %[mask]\n\t"%0A+++ "jz .Lstrlen16\n\t" %0A+++ "bsf %[mask], %[mask]\n\t" #ifndef READ_ONLY%0A+++ "movb $!'A!',+-16(%[p], %q[mask])" #endif %09++++: [p]"+r"(stmp), %09++++ [mask]"=r"(mask) //dummy output %09++++://no read-only inputs %09++++: "xmm0", "xmm1", "memory%22++//hard-code these regs, this isn!'t intended to be super+polished. ); %23endif+//+USE_ASM%0A+++ }%0A++++Clock_t end =+Clock();%0A++++printf("%lld\n", (long long)(end-start));%0A+++ return 0; } '),l:'5',n:'0',o:'C++ source #1',t:'0')),header:(),k:31.200647768944577,l:'4',m:100,n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:g82,filters:(b:'0',binary:'0',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'1',libraryCode:'1',trim:'1'),lang:c++,libs:!((name:gsl,ver:'100')),options:'-UHIDE_ALIGNMENT -DUSE_ASM -DREAD_ONLY -xc -std%3Dgnu99+-O3 -march=skylake -pie -fpie',source:1),l:'5',n:'0',o:'x86-64 gcc 8.2+(Editor #1,+Compiler+#1)+C++',t:'0')),header:(),k:35.46601889772211,l:'4',m:100,n:'0',o:'',s:0,t:'0'),(g:!((h:compiler,i:(compiler:g82,filters:(b:'0',binary:'0',commentOnly:'0',demangle:'0',directives:'0',execute:'1',intel:'1',libraryCode:'1',trim:'1'),lang:c++,libs:!((name:gsl,ver:'100')),options:'-DHIDE_ALIGNMENT -DUSE_ASM -DREAD_ONLY -xc -std%3Dgnu99+-O3 -march=skylake -pie -fpie',source:1),l:'5',n:'0',o:'x86-64 gcc 8.2+(Editor #1,+Compiler+#2)+C++',t:'0')),k:33.33333333333333,l:'4',n:'0',o:'',s:0,t:'0')),l:'2',m:100,n:'0',o:'',t:'0')),version:4 rel=noreferrer>Эта бинарная ссылка Godbolt воспроизводит одинаковое заполнение, которое я наблюдаю с gcc8.2.1 в Arch Linux для обоих случаев: 2x 11-байтовый nopw + 3-байтовый nop во внешнем цикле для быстрого случая. Он также имеет точный источник, который я использовал локально.


Короткие стрельчатые микро-тесты только для чтения:

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

strlen=33, поэтому терминатор находится рядом с началом 3-го 16-байтового вектора. (Делает мою версию как можно более плохой по сравнению с 4-байтовой версией.) -DREAD_ONLY, и i<1280000 как цикл повторения внешнего цикла.

  • 1933 clock_t: мой асм: хорошее и стабильное время в лучшем случае (не шумное и не подпрыгивающее при повторном запуске среднего). Равный перф с/без -DHIDE_ALIGNMENT, в отличие от более длинных. Ветвление цикла гораздо проще предсказать с помощью этого гораздо более короткого паттерна. (strlen = 33, а не 1000).
  • 3220 clock_t: gcc -O3 strlen. (-DHIDE_ALIGNMENT)
  • 6100 clock_t: gcc -O3 4-байтовый цикл
  • 37200 clock_t: gcc -O1 repz scasb

Так что для коротких строк мой простой встроенный цикл превосходит вызов библиотечной функции к strlen который должен пройти через PLT (call + jmp [mem]), а затем запустить запуск strlen, который не может зависеть от выравнивания.

Были незначительные ошибки в ветвлении, как 0,05% для всех версий с strlen(s)=33. Версия repz scasb имела 0,46%, но это из-за меньшего количества общих веток. Нет внутреннего цикла, чтобы набрать много правильно предсказанных ветвей.

С предикторами ветвления и горячим кешем, repz scasb более чем в 10 раз хуже, чем вызов glibc strlen для 33-байтовой строки. Это было бы не так плохо в реальных случаях использования, когда strlen мог пропускать ветки или даже отсутствовать в кеше кода и зависать, но прямая repz scasb не будет. Но 10x огромен, и это для довольно короткой строки.

Ответ 3

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

Вы можете попробовать этот код, и вы увидите, что все различные испытания, которые вы проведете, приведут к его запуску в одно и то же время, которое, я считаю, будет равняться нулю секунд, поскольку цикл слишком мал для измерения производительности оптимизации GCC.

#include <time.h>
#include <string.h>
#include <stdlib.h>
#include <stdio.h>
int main() {
        char *s = (char*)calloc(1 << 20, 1);
        memset(s, 65, 1000000);
        s[1000000] = 0;
        clock_t start = clock();
        for (int i = 0; i < 128;++i) { 
             s[strlen(s)] = 'A';
        } 
        clock_t end = clock();
        printf("%lld\n", (long long)(end-start));
        return 0;
}