Вопросы о выполнении различных реализаций strlen

Я реализовал функцию strlen() по-разному, включая SSE2 assembly, SSE4.2 assembly и SSE2 intrinsic, я также провел несколько экспериментов над ними с strlen() in <string.h> и strlen() in glibc. Однако их производительность в миллисекундах (время) неожиданна.

Моя экспериментальная среда: CentOS 7.0 + gcc 4.8.5 + Intel Xeon

Ниже приведены мои реализации:

  • strlen с помощью сборки SSE2

    long strlen_sse2_asm(const char* src){
    long result = 0;
    asm(
        "movl %1, %%edi\n\t"
        "movl $-0x10, %%eax\n\t"
        "pxor %%xmm0, %%xmm0\n\t"
        "lloop:\n\t"
            "addl $0x10, %%eax\n\t"
            "movdqu (%%edi,%%eax), %%xmm1\n\t"
            "pcmpeqb %%xmm0, %%xmm1\n\t"
            "pmovmskb %%xmm1, %%ecx\n\t"
            "test %%ecx, %%ecx\n\t"
            "jz lloop\n\t"
    
        "bsf %%ecx, %%ecx\n\t"
        "addl %%ecx, %%eax\n\t"
        "movl %%eax, %0"
        :"=r"(result)
        :"r"(src)
        :"%eax"
        );
    return result;
    }
    

2. strlen с использованием сборки SSE4.2

long strlen_sse4_2_asm(const char* src){
long result = 0;
asm(
    "movl %1, %%edi\n\t"
    "movl $-0x10, %%eax\n\t"
    "pxor %%xmm0, %%xmm0\n\t"
    "lloop2:\n\t"
        "addl $0x10, %%eax\n\t"
        "pcmpistri $0x08,(%%edi, %%eax), %%xmm0\n\t"
        "jnz lloop2\n\t"

        "add %%ecx, %%eax\n\t"
        "movl %%eax, %0"

    :"=r"(result)
    :"r"(src)
    :"%eax"
    );
return result;
}

3. strlen с использованием встроенного SSE2

long strlen_sse2_intrin_align(const char* src){
if (src == NULL || *src == '\0'){
    return 0;
}
const __m128i zero = _mm_setzero_si128();
const __m128i* ptr = (const __m128i*)src;

if(((size_t)ptr&0xF)!=0){
    __m128i xmm = _mm_loadu_si128(ptr);
    unsigned int mask = _mm_movemask_epi8(_mm_cmpeq_epi8(xmm,zero));
    if(mask!=0){
        return (const char*)ptr-src+(size_t)ffs(mask);
    }
    ptr = (__m128i*)(0x10+(size_t)ptr & ~0xF);
}
for (;;ptr++){
    __m128i xmm = _mm_load_si128(ptr);
    unsigned int mask = _mm_movemask_epi8(_mm_cmpeq_epi8(xmm,zero));
    if (mask!=0)
        return (const char*)ptr-src+(size_t)ffs(mask);
}

}
  1. Я также просмотрел версию, реализованную в ядре linux, следующая ее реализация

    size_t strlen_inline_asm(const char* str){
    int d0;
    size_t res;
    asm volatile("repne\n\t"
    "scasb"
    :"=c" (res), "=&D" (d0)
    : "1" (str), "a" (0), "" (0xffffffffu)
    : "memory");
    
    return ~res-1;
    }
    

По моему опыту, я также добавил стандартную библиотеку и сравнил их производительность. Следующим является код функции main:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <xmmintrin.h>
#include <x86intrin.h>
#include <emmintrin.h>
#include <time.h>
#include <unistd.h>
#include <sys/time.h>
int main()
{
    struct timeval tpstart,tpend;
    int i=0;
    for(;i<1023;i++){
            test_str[i] = 'a';
    }
    test_str[i]='\0';
    gettimeofday(&tpstart,NULL);
    for(i=0;i<10000000;i++)
            strlen(test_str);
    gettimeofday(&tpend,NULL);
    printf("strlen from stirng.h--->%lf\n",(tpend.tv_sec-tpstart.tv_sec)*1000+(tpend.tv_usec-tpstart.tv_usec)/1000.0);

    gettimeofday(&tpstart,NULL);
    for(i=0;i<10000000;i++)
            strlen_inline_asm(test_str);
    gettimeofday(&tpend,NULL);
    printf("strlen_inline_asm--->%lf\n",(tpend.tv_sec-tpstart.tv_sec)*1000+(tpend.tv_usec-tpstart.tv_usec)/1000.0);

    gettimeofday(&tpstart,NULL);
    for(i=0;i<10000000;i++)
            strlen_sse2_asm(test_str);
    gettimeofday(&tpend,NULL);
    printf("strlen_sse2_asm--->%lf\n",(tpend.tv_sec-tpstart.tv_sec)*1000+(tpend.tv_usec-tpstart.tv_usec)/1000.0);

    gettimeofday(&tpstart,NULL);
    for(i=0;i<10000000;i++)
            strlen_sse4_2_asm(test_str);
    gettimeofday(&tpend,NULL);
    printf("strlen_sse4_2_asm--->%lf\n",(tpend.tv_sec-tpstart.tv_sec)*1000+(tpend.tv_usec-tpstart.tv_usec)/1000.0);

    gettimeofday(&tpstart,NULL);
    for(i=0;i<10000000;i++)
            strlen_sse2_intrin_align(test_str);
    gettimeofday(&tpend,NULL);
    printf("strlen_sse2_intrin_align--->%lf\n",(tpend.tv_sec-tpstart.tv_sec)*1000+(tpend.tv_usec-tpstart.tv_usec)/1000.0);

    return 0;
}

Результат: (ms)

strlen from stirng.h--->23.518000
strlen_inline_asm--->222.311000
strlen_sse2_asm--->782.907000
strlen_sse4_2_asm--->955.960000
strlen_sse2_intrin_align--->3499.586000

У меня есть некоторые вопросы по этому поводу:

  • Почему strlen из string.h работает так быстро? Я думаю, что его код должен быть идентифицирован как strlen_inline_asm, потому что я скопировал код из /linux-4.2.2/arch/x86/lib/string_32.c [http://lxr.oss.org.cn/source/arch/x86/lib/string_32.c#L164]
  • Почему SSE2 intrinsic и SSE2 assembly настолько отличаются по производительности?
  • Может ли кто-нибудь помочь мне разобрать код, чтобы я мог увидеть, что функция strlen статической библиотеки была преобразована компилятором? Я использовал gcc -s, но не нашел разборки strlen from the <string.h>
  • Я думаю, что мой код может быть не очень хорошо, я был бы признателен, если бы вы могли помочь мне улучшить свой код, особенно сборки.

Спасибо.

Ответы

Ответ 1

Как я уже сказал в комментариях, ваша самая большая ошибка - это бенчмаркинг с помощью -O0. Я точно обсуждал, почему тестирование с -O0 является ужасной идеей в первой части другого сообщения.

Тесты должны быть выполнены с по крайней мере -O2, желательно с теми же оптимизациями, что и ваш полный проект, если вы пытаетесь проверить, какой источник делает самый быстрый asm.

-O0 объясняет, что inline asm работает быстрее, чем C с внутренними (или регулярными скомпилированными C, для реализации C strlen, заимствованных из glibc).

IDK -O0 будет по-прежнему оптимизировать цикл прохода, который неоднократно отбрасывает результат библиотеки strlen или если он каким-то образом избегает какой-либо другой огромной потери производительности. Не интересно догадываться, что именно произошло в таком ошибочном тесте.


Я затянул вашу версию inline-asm SSE2. В основном только потому, что я недавно играл с ограничениями ввода/вывода gcc inline asm и хотел посмотреть, как это будет выглядеть, если бы я написал его, чтобы позволить компилятору выбрать, какие регистры использовать для временных файлов, и избегать ненужных инструкций.

Тот же встроенный asm работает для 32 и 64-разрядных целей. При компиляции функции stand-along не нужно сохранять/восстанавливать любые регистры даже в 32-битном режиме:

#include <immintrin.h>

size_t strlen_sse2_asm(const char* src){

  // const char *orig_src = src; // for a pointer-increment with a "+r" (src) output operand

  size_t result = 0;
  unsigned int tmp1;
  __m128i zero = _mm_setzero_si128(), vectmp;

  asm(
    "\n.Lloop:\n\t"
        "movdqu   (%[src], %[res]), %[vectmp]\n\t"  // result reg is used as the loop counter
        "pcmpeqb  %[zerovec], %[vectmp]\n\t"
        "pmovmskb %[vectmp], %[itmp]\n\t"
        "add      $0x10, %[res]\n\t"
        "test     %[itmp], %[itmp]\n\t"
        "jz  .Lloop\n\t"

    "bsf %[itmp], %[itmp]\n\t"
    "add %q[itmp], %q[res]\n\t"   // q modifier to get quadword register.
    // q is needed because add %edx, %rax doesn't work.  But in 32bit mode, q gives a 32bit reg, so the same code works
    : [res] "+r"(result), [vectmp] "=&x" (vectmp), [itmp] "=&r" (tmp1)

    : [zerovec] "x" (zero), // There might already be a zeroed vector reg when inlining
      [src] "r"(src)
    :
    );
  return result;
  // return result + tmp1;  // doing the add outside the asm makes gcc sign or zero-extend tmp1.
  // No benefit anyway, since gcc doesn't know that tmp1 is the offset within a 16B chunk or anything.
}

Он должен поддерживать минимальное значение регистра при встраивании и не привязывать какие-либо специальные регистры (например, ecx, которые необходимы для смены переменных).

Если бы вы могли сбрить еще один uop из внутреннего цикла, это было бы до 4 uops, которые могли бы выдаваться по одному за цикл. Как бы то ни было, 5 uops означает, что каждая итерация занимает 2 цикла для выхода из интерфейса, на процессоры Intel SnB.

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

AVX полностью решает проблему. AVX позволяет сбрасывать нагрузку даже без предварительной проверки выравнивания. 3-операнд неразрушающий vpcmpeqb избегает превращения нулевого вектора в зависимую от цикла зависимость. AVX2 позволяет сразу проверять 32B.

Unrolling поможет в любом случае, но помогает больше без AVX. Выровняйте границу 64B или что-то еще, а затем загрузите всю строку кэша в четыре вектора 16B. Выполнение комбинированной проверки результата POR их всех вместе может быть хорошим, так как pmovmsk + compare-and-branch составляет 2 раза.

Использование SSE4.1 PTEST не помогает (по сравнению с pmovmsk/test/jnz), потому что оно 2 uops и не может с макросплавкой использовать способ test.

PTEST может непосредственно протестировать, чтобы весь вектор 16B был полностью нулевым или все-одним (с использованием ANDNOT → CF part), но не если один из байтовых элементов равен нулю. (Таким образом, мы не можем избежать pcmpeqb).


Посмотрите Руководство Agner Fog для оптимизации asm, а остальные ссылки на wiki. Большинство оптимизаций (Agner Fog, и Intel и AMD) будут упоминать оптимизацию memcpy и strlen, в частности, IIRC.

Ответ 2

Если вы читаете источник функции strlen в glibc, вы можете видеть, что функция не тестирует строку char на char, а longword by longword со сложными побитовыми операциями: http://www.stdlib.net/~colmmacc/strlen.c.html. Я думаю, это объясняет его скорость, но факт, что он даже быстрее, чем команды rep в сборке, действительно вызывает удивление.