Что отсутствует/не оптимально в этой реализации memcpy?

Я заинтересовался написанием memcpy() в качестве учебного упражнения. Я не буду писать целый трактат о том, что я сделал и о чем не думал, но здесь о реализации парней:

__forceinline   //因为通常Size已知,内联后编译器可以优化掉大部分无用代码
void* myMemcpy(char* Dst, const char* Src, size_t Size)
{
        void* start = Dst;
        for ( ; Size >= sizeof(__m256i); Size -= sizeof(__m256i) )
        {
                __m256i ymm = _mm256_loadu_si256(((const __m256i* &)Src)++);
                _mm256_storeu_si256(((__m256i* &)Dst)++, ymm);
        }

#define CPY_1B *((uint8_t * &)Dst)++ = *((const uint8_t * &)Src)++
#define CPY_2B *((uint16_t* &)Dst)++ = *((const uint16_t* &)Src)++
#define CPY_4B *((uint32_t* &)Dst)++ = *((const uint32_t* &)Src)++
#if defined _M_X64 || defined _M_IA64 || defined __amd64
#define CPY_8B *((uint64_t* &)Dst)++ = *((const uint64_t* &)Src)++
#else
#define CPY_8B _mm_storel_epi64((__m128i *)Dst, _mm_loadu_si128((const __m128i *)Src)), ++(const uint64_t* &)Src, ++(uint64_t* &)Dst
#endif
#define CPY16B _mm_storeu_si128((__m128i *)Dst, _mm_loadu_si128((const __m128i *)Src)), ++(const __m128i* &)Src, ++(__m128i* &)Dst

    switch (Size) {
    case 0x00:                                                      break;
    case 0x01:      CPY_1B;                                         break;
    case 0x02:              CPY_2B;                                 break;
    case 0x03:      CPY_1B; CPY_2B;                                 break;
    case 0x04:                      CPY_4B;                         break;
    case 0x05:      CPY_1B;         CPY_4B;                         break;
    case 0x06:              CPY_2B; CPY_4B;                         break;
    case 0x07:      CPY_1B; CPY_2B; CPY_4B;                         break;
    case 0x08:                              CPY_8B;                 break;
    case 0x09:      CPY_1B;                 CPY_8B;                 break;
    case 0x0A:              CPY_2B;         CPY_8B;                 break;
    case 0x0B:      CPY_1B; CPY_2B;         CPY_8B;                 break;
    case 0x0C:                      CPY_4B; CPY_8B;                 break;
    case 0x0D:      CPY_1B;         CPY_4B; CPY_8B;                 break;
    case 0x0E:              CPY_2B; CPY_4B; CPY_8B;                 break;
    case 0x0F:      CPY_1B; CPY_2B; CPY_4B; CPY_8B;                 break;
    case 0x10:                                      CPY16B;         break;
    case 0x11:      CPY_1B;                         CPY16B;         break;
    case 0x12:              CPY_2B;                 CPY16B;         break;
    case 0x13:      CPY_1B; CPY_2B;                 CPY16B;         break;
    case 0x14:                      CPY_4B;         CPY16B;         break;
    case 0x15:      CPY_1B;         CPY_4B;         CPY16B;         break;
    case 0x16:              CPY_2B; CPY_4B;         CPY16B;         break;
    case 0x17:      CPY_1B; CPY_2B; CPY_4B;         CPY16B;         break;
    case 0x18:                              CPY_8B; CPY16B;         break;
    case 0x19:      CPY_1B;                 CPY_8B; CPY16B;         break;
    case 0x1A:              CPY_2B;         CPY_8B; CPY16B;         break;
    case 0x1B:      CPY_1B; CPY_2B;         CPY_8B; CPY16B;         break;
    case 0x1C:                      CPY_4B; CPY_8B; CPY16B;         break;
    case 0x1D:      CPY_1B;         CPY_4B; CPY_8B; CPY16B;         break;
    case 0x1E:              CPY_2B; CPY_4B; CPY_8B; CPY16B;         break;
    case 0x1F:      CPY_1B; CPY_2B; CPY_4B; CPY_8B; CPY16B;         break;
    }
#undef CPY_1B
#undef CPY_2B
#undef CPY_4B
#undef CPY_8B
#undef CPY16B
        return start;
}

Комментарий переводится как "Размер обычно известен, поскольку компилятор может оптимизировать встроенный код наиболее бесполезно".

Я хотел бы улучшить, если это возможно, эту реализацию, но, возможно, не так много улучшений. Я вижу, что он использует SSE/AVX для больших кусков памяти, а затем вместо цикла по последним <32 байтам выполняется эквивалент развертывания вручную с некоторыми изменениями. Итак, вот мои вопросы:

  • Зачем развернуть цикл для последних нескольких байтов, но не частично развернуть первый (и теперь единственный) цикл?
  • Как насчет вопросов выравнивания? Разве они не важны? Должен ли я по-разному обрабатывать первые несколько байтов вплоть до некоторого кванта выравнивания, а затем выполнять 256-битные операции для выровненных последовательностей байтов? И если да, то как определить соответствующий квант выравнивания?
  • Какая самая важная недостающая особенность в этой реализации (если есть)?

Особенности/принципы, упомянутые в ответах до сих пор

  • Вы должны __restrict__ ваши параметры. (@Chux)
  • Пропускная способность памяти является ограничивающим фактором; сравните вашу реализацию с этим. (@Zboson)
  • Для небольших массивов вы можете ожидать приблизиться к пропускной способности памяти; для больших массивов - не так много. (@Zboson)
  • Несколько потоков (может быть | есть) необходимы для насыщения пропускной способности памяти. (@Zboson)
  • Вероятно, целесообразно по-разному оптимизировать копии больших и малых размеров. (@Zboson)
  • (Выравнивание важно? Не указано явно!)
  • Компилятор должен быть более четко осведомлен о "очевидных фактах", которые он может использовать для оптимизации (например, тот факт, что Size <32 после первого цикла). (@Chux)
  • Существуют аргументы для развертывания ваших вызовов SSE/AVX (@BenJackson, здесь) и аргументы против этого (@PaulR)
  • Временные передачи (с помощью которых вы говорите ЦПУ, что вам не нужно кэшировать целевое местоположение) должны быть полезны для копирования больших буферов. (@Zboson)

Ответы

Ответ 1

Я изучал пропускную способность памяти для процессоров Intel с различными операциями, а один из них - memcpy. Я сделал это на Core2, Ivy Bridge и Haswell. Я сделал большинство своих тестов, используя C/С++ с внутренними функциями (см. Код ниже - но я в настоящее время переписываю свои тесты в сборке).

Чтобы написать собственную эффективную функцию memcpy, важно знать, какой может быть абсолютная лучшая пропускная способность. Эта полоса пропускания зависит от размера массивов, которые будут скопированы, и поэтому эффективная функция memcpy должна оптимизировать по-разному для малых и больших (и, возможно, промежуточных). Чтобы упростить задачу, я оптимизировал для небольших массивов 8192 байта и больших массивов объемом 1 ГБ.

Для небольших массивов максимальная ширина чтения и записи для каждого ядра:

Core2-Ivy Bridge             32 bytes/cycle
Haswell                      64 bytes/cycle

Это ориентир, который вы должны стремиться к малым массивам. Для моих тестов я предполагаю, что массивы выровнены с 64-байтами и что размер массива кратен 8*sizeof(float)*unroll_factor. Вот мои текущие результаты memcpy для размера 8192 байта (Ubuntu 14.04, GCC 4.9, EGLIBC 2.19):

                             GB/s     efficiency
    Core2 ([email protected] GHz)  
        builtin               35.2    41.3%
        eglibc                39.2    46.0%
        asmlib:               76.0    89.3%
        copy_unroll1:         39.1    46.0%
        copy_unroll8:         73.6    86.5%
    Ivy Bridge ([email protected] GHz)                        
        builtin              102.2    88.7%
        eglibc:              107.0    92.9%
        asmlib:              107.6    93.4%
        copy_unroll1:        106.9    92.8%
        copy_unroll8:        111.3    96.6%
    Haswell ([email protected] GHz)
        builtin:              68.4    82.2%     
        eglibc:               39.7    47.7%
        asmlib:               73.2    87.6%
        copy_unroll1:         39.6    47.6%
        copy_unroll8:         81.9    98.4%

asmlib Agner Fog asmlib. Функции copy_unroll1 и copy_unroll8 определены ниже.

Из этой таблицы видно, что встроенный GCC memcpy не работает на Core2 и что memcpy в EGLIBC не работает на Core2 или Haswell. Недавно я просмотрел головную версию GLIBC, и производительность Haswell была намного лучше. Во всех случаях разворот получает лучший результат.

void copy_unroll1(const float *x, float *y, const int n) {
    for(int i=0; i<n/JUMP; i++) {
        VECNF().LOAD(&x[JUMP*(i+0)]).STORE(&y[JUMP*(i+0)]);
    }
}

void copy_unroll8(const float *x, float *y, const int n) {
for(int i=0; i<n/JUMP; i+=8) {
    VECNF().LOAD(&x[JUMP*(i+0)]).STORE(&y[JUMP*(i+0)]);
    VECNF().LOAD(&x[JUMP*(i+1)]).STORE(&y[JUMP*(i+1)]);
    VECNF().LOAD(&x[JUMP*(i+2)]).STORE(&y[JUMP*(i+2)]);
    VECNF().LOAD(&x[JUMP*(i+3)]).STORE(&y[JUMP*(i+3)]);
    VECNF().LOAD(&x[JUMP*(i+4)]).STORE(&y[JUMP*(i+4)]);
    VECNF().LOAD(&x[JUMP*(i+5)]).STORE(&y[JUMP*(i+5)]);
    VECNF().LOAD(&x[JUMP*(i+6)]).STORE(&y[JUMP*(i+6)]);
    VECNF().LOAD(&x[JUMP*(i+7)]).STORE(&y[JUMP*(i+7)]);
}

}

Где VECNF().LOAD - _mm_load_ps() для SSE или _mm256_load_ps() для AVX, VECNF().STORE - _mm_store_ps() для SSE или _mm256_store_ps() для AVX, а JUMP - 4 для SSE или 8 для AVX.

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

void copy_stream(const float *x, float *y, const int n) {
    #pragma omp parallel for        
    for(int i=0; i<n/JUMP; i++) {
        VECNF v = VECNF().load_a(&x[JUMP*i]);
        stream(&y[JUMP*i], v);
    }
}

Где stream есть _mm_stream_ps() для SSE или _mm256_stream_ps() для AVX

Вот результаты memcpy на моем E5-1620 @3,6 ГГц с четырьмя потоками для 1 ГБ с максимальной пропускной способностью основной памяти 51,2 ГБ/с.

                         GB/s     efficiency
    eglibc:              23.6     46%
    asmlib:              36.7     72%
    copy_stream:         36.7     72%

Еще раз EGLIBC работает плохо. Это связано с тем, что он не использует невременные хранилища.

Я modfied функции eglibc и asmlib memcpy для параллельной работы, как это

void COPY(const float * __restrict x, float * __restrict y, const int n) {
    #pragma omp parallel
    {
        size_t my_start, my_size;
        int id = omp_get_thread_num();
        int num = omp_get_num_threads();
        my_start = (id*n)/num;
        my_size = ((id+1)*n)/num - my_start;
        memcpy(y+my_start, x+my_start, sizeof(float)*my_size);
    }
}

Общая функция memcpy должна учитывать массивы, которые не привязаны к 64 байтам (или даже к 32 или к 16 байтам) и где размер не кратен 32 байтам или коэффициент разворота. Кроме того, должно быть принято решение о том, когда использовать невременные магазины. Общее правило состоит в том, чтобы использовать невременные хранилища для размеров, превышающих половину самого большого уровня кеша (обычно L3). Но тезисы - это детали второго порядка, которые, я думаю, следует решать после оптимизации для идеальных случаев больших и малых. Там не так много смысла беспокоиться о коррекции несоосности или не идеальных размеров, если идеальный случай работает плохо.

Обновление

Основываясь на комментариях Стивена Канона, я узнал, что на Ivy Bridge и Haswell более эффективно использовать rep movsb, чем movntdqa (инструкция, не связанная с временным хранилищем). Intel называет это расширенным rep movsb (ERMSB). Это описано в Руководства по оптимизации Intel в разделе 3.7.6 Расширенные операции REP MOVSB ​​и STOSB (ERMSB).

Кроме того, в руководстве Agner Fog "Оптимизация подпрограмм в сборке" в разделе 17.9. Перемещение блоков данных (все процессоры) он пишет:

"Существует несколько способов перемещения больших блоков данных. Наиболее распространенными методами являются:

  • инструкция REP MOVS.
  • Если данные выровнены: чтение и запись в цикле с наибольшим доступным размером регистра.
  • Если размер постоянный: встроенные инструкции перемещения.
  • Если данные смещены: сначала перемещайте столько байтов, сколько необходимо для назначения адресата выровнены. Затем читайте неравнозначные и записывайте выравнивание в цикле с наибольшим доступным размер регистра.
  • Если данные смещены: считывание выровнено, сдвиг для компенсации несоосности и записи выровнены.
  • Если размер данных слишком велик для кеширования, используйте невременную запись для обхода кеша. Сдвиг для компенсации несоосности, если это необходимо.

Общая memcpy должна учитывать каждую из этих точек. Кроме того, с Ivy Bridge и Haswell кажется, что точка 1 лучше, чем точка 6 для больших массивов. Для Intel и AMD необходимы разные технологии и для каждой итерации технологий. Я думаю, что ясно, что писать собственную общую эффективную функцию memcpy может быть довольно сложно. Но в особых случаях, которые я рассмотрел, я уже успел сделать лучше, чем GCC, встроенный memcpy или тот, что в EGLIBC, поэтому предположение о том, что вы не можете сделать лучше, чем стандартные библиотеки, неверно.

Ответ 2

На этот вопрос нельзя ответить точно без некоторых дополнительных деталей, таких как:

  • Какова целевая платформа (архитектура ЦП, чаще всего, но конфигурация памяти также играет роль)?
  • Каково распределение и предсказуемость 1 длины копий (и в меньшей степени распределение и предсказуемость выравниваний)?
  • Будет ли когда-нибудь статически известен размер копии во время компиляции?

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

Заявление о переключении на 32 корпуса

Оператор переключения в 32 случаях - это симпатичный способ обработки конечных байтов от 0 до 31 и, вероятно, очень хороший результат - но он может работать плохо в реальном мире, по крайней мере, из-за двух факторов.

Размер кода

Один только этот оператор switch занимает несколько сотен байтов кода для тела, в дополнение к таблице поиска из 32 записей, необходимой для перехода в правильное местоположение для каждой длины. Стоимость этого не будет memcpy в memcpy тесте memcpy на полноразмерном процессоре, потому что все по-прежнему умещается на самом быстром уровне кеша: но в реальном мире вы выполняете и другой код, и есть конфликт для UOP кэш и кэш данных и данных L1.

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

Вдобавок к этому коммутатору требуется таблица поиска с 256 байтами из 32 записей для целей перехода 4. Если вы когда-нибудь пропустили DRAM в этом поиске, вы говорите о штрафе в 150+ циклов: сколько не пропущенных дел вам нужно, чтобы сделать switch стоящим, учитывая, что оно, вероятно, сэкономит несколько или два на самый? Опять же, это не будет отображаться в микробенчмарке.

При всей своей ценности этот memcpy не является чем-то необычным: такого рода "исчерпывающее перечисление дел" встречается даже в оптимизированных библиотеках. Я могу заключить, что либо их разработка была в основном основана на микробенчмарках, либо она того стоит для большого куска универсального кода, несмотря на недостатки. Тем не менее, безусловно, есть сценарии (инструкции и/или давление в кеше данных), где это неоптимально.

Прогнозирование отрасли

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

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

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

Для этих коротких длин предсказуемый ряд длин работает очень хорошо для switch поскольку косвенный переход в основном бесплатный. В частности, типичный эталонный тест memcpy "проходит" по сериям длин, используя одну и ту же длину несколько раз для каждого суб-теста, чтобы сообщить результаты для легкого построения графиков "время против длины". switch отлично работает в этих тестах, часто сообщая о результатах, таких как 2 или 3 цикла, для небольшой длины в несколько байтов.

В реальном мире ваши длины могут быть небольшими, но непредсказуемыми. В этом случае косвенная ветвь будет часто неверно предсказывать 5 с штрафом ~ 20 циклов на современных процессорах. По сравнению с лучшим случаем пары циклов это на порядок хуже. Таким образом, стеклянная челюсть здесь может быть очень серьезной (т.е. Поведение switch в этом типичном случае может быть на порядок хуже, чем в лучшем случае, в то время как при больших длинах вы обычно видите разницу в 50% максимум между разные стратегии).

Решения

Итак, как вы можете сделать лучше, чем выше, по крайней мере, в условиях, когда switch разваливается?

Используйте Duff Device

Одно из решений проблемы размера кода состоит в том, чтобы объединить корпуса переключателей вместе, устройство duff -style.

Например, собранный код для случаев длины 1, 3 и 7 выглядит так:

Длина 1

    movzx   edx, BYTE PTR [rsi]
    mov     BYTE PTR [rcx], dl
    ret

Длина 3

    movzx   edx, BYTE PTR [rsi]
    mov     BYTE PTR [rcx], dl
    movzx   edx, WORD PTR [rsi+1]
    mov     WORD PTR [rcx+1], dx

Длина 7

    movzx   edx, BYTE PTR [rsi]
    mov     BYTE PTR [rcx], dl
    movzx   edx, WORD PTR [rsi+1]
    mov     WORD PTR [rcx+1], dx
    mov     edx, DWORD PTR [rsi+3]
    mov     DWORD PTR [rcx+3], edx
    ret

Это может быть объединено в один случай с различными переходами:

    len7:
    mov     edx, DWORD PTR [rsi-6]
    mov     DWORD PTR [rcx-6], edx
    len3:
    movzx   edx, WORD PTR [rsi-2]
    mov     WORD PTR [rcx-2], dx
    len1:
    movzx   edx, BYTE PTR [rsi]
    mov     BYTE PTR [rcx], dl
    ret

Этикетки ничего не стоят, они объединяют футляры и удаляют две из трех ret инструкций. Обратите внимание, что здесь изменились основы для rsi и rcx: они указывают на последний байт для копирования из/в, а не на первый. Это изменение бесплатно или очень дешево в зависимости от кода перед переходом.

Вы можете расширить это для более длинных длин (например, вы можете прикрепить длины 15 и 31 к цепочке выше), и использовать другие цепочки для недостающих длин. Полное упражнение оставлено читателю. Вероятно, вы можете получить только 50% -ное уменьшение размера при таком подходе, и гораздо лучше, если вы объедините его с чем-то еще, чтобы уменьшить размеры от 16 до 31.

Этот подход помогает только с размером кода (и, возможно, с размером таблицы переходов, если вы уменьшите размер, как описано в 4, и получите менее 256 байт, разрешив таблицу поиска размером в байт. Он ничего не делает для предсказуемости.

Перекрывающиеся магазины

Одна хитрость, которая помогает как для размера кода, так и для предсказуемости, заключается в использовании перекрывающихся хранилищ. То есть memcpy размером от 8 до 15 байтов может быть выполнен без ветвления с двумя 8-байтовыми хранилищами, причем второе хранилище частично перекрывает первое. Например, чтобы скопировать 11 байтов, вы должны сделать 8-байтовую копию в относительной позиции 0 и 11 - 8 == 3. Некоторые байты в середине будут "скопированы дважды", но на практике это хорошо, поскольку 8-байтовая копия имеет ту же скорость, что и 1, 2 или 4-байтовая копия.

Код C выглядит так:

  if (Size >= 8) {
    *((uint64_t*)Dst) = *((const uint64_t*)Src);
    size_t offset = Size & 0x7;
    *(uint64_t *)(Dst + offset) = *(const uint64_t *)(Src + offset);
  }

... и соответствующая сборка не проблемная

    cmp     rdx, 7
    jbe     .L8
    mov     rcx, QWORD PTR [rsi]
    and     edx, 7
    mov     QWORD PTR [rdi], rcx
    mov     rcx, QWORD PTR [rsi+rdx]
    mov     QWORD PTR [rdi+rdx], rcx

В частности, обратите внимание, что вы получаете ровно две загрузки, два хранилища и одно and (в дополнение к cmp и jmp, существование которых зависит от того, как вы организуете окружающий код). Это уже связано или лучше, чем большинство сгенерированных компилятором подходов для 8-15 байтов, которые могут использовать до 4 пар загрузки/хранения.

Старые процессоры подвергались некоторому штрафу за такие "пересекающиеся магазины", но более новые архитектуры (по крайней мере, в последнее десятилетие) справляются с ними без штрафа 6. Это имеет два основных преимущества:

  1. Поведение свободно от ветвей для разных размеров. По сути, это квантует ветвление так, что многие значения выбирают один и тот же путь. Все размеры от 8 до 15 (или от 8 до 16, если хотите) выбирают один и тот же путь и не подвергаются давлению неверного прогноза.

  2. По крайней мере 8 или 9 различных случаев от switch объединены в один случай с долей общего размера кода.

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

То, что сработает лучше всего, зависит от распределения по ветким, но в целом этот метод "наложения" работает очень хорошо.

центровка

Существующий код не касается выравнивания.

На самом деле, это, вообще говоря, не является юридическим или C или C++, поскольку указатели char * просто приводятся к более крупным типам и разыменовываются, что недопустимо - хотя на практике он генерирует коды, которые работают на современных компиляторах x86 (но на самом деле не получится для платформы с более строгими требованиями к выравниванию).

Кроме того, часто лучше обращаться с выравниванием специально. Есть три основных случая:

  1. Источник и пункт назначения уже выровнены. Даже оригинальный алгоритм будет хорошо работать здесь.
  2. Источник и пункт назначения относительно выровнены, но абсолютно не выровнены. То есть есть значение A которое может быть добавлено как к источнику, так и к месту назначения, так что оба будут выровнены.
  3. Источник и пункт назначения полностью выровнены (т.е. Фактически не выровнены, а case (2) не применяется).

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

Это также, вероятно, работает плохо в случае (3), так как в общем случае в случае полностью выровненного положения вы можете выбрать либо выравнивание места назначения или источника, а затем продолжить "полулинирование".

Штрафы за выравнивание со временем уменьшались, и на самых последних чипах они скромны для кода общего назначения, но все же могут быть серьезными для кода с большим количеством загрузок и хранилищ. Для больших копий это, вероятно, не имеет большого значения, поскольку в конечном итоге пропускная способность DRAM будет ограничена, но для меньших копий смещение может снизить пропускную способность на 50% и более.

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

Нет раскатывания

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

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

Кроме того, развертывание часто можно разумным образом сочетать с "введением" нашего "внешнего" кода, выполняя работу лучше, чем компилятор.

Известные размеры

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

Это особенно очевидно с известными длинами в memcpy. В этом случае, если длина мала, компиляторы просто вставят несколько инструкций, чтобы выполнить копирование эффективно и на месте. Это не только позволяет избежать накладных расходов при вызове функции, но и всех проверок размера и т.д., А также генерирует эффективный код для копии во время компиляции, во многом как большой switch в приведенной выше реализации, - но без затрат на switch,

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

Если вы просто реализуете memcpy2 как библиотечную функцию, это сложно скопировать. Вы можете получить часть пути, разделив метод на маленькую и большую части: небольшая часть появляется в заголовочном файле и выполняет некоторые проверки размера и, возможно, просто вызывает существующий memcpy если размер маленький или делегирует библиотеку. рутина, если она большая. С помощью магии встраивания вы можете попасть в то же место, что и встроенный memcpy.

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


1 Обратите внимание, что здесь я делаю различие между "распределением" размеров - например, вы можете сказать "равномерно распределенный между 8 и 24 байтами" - и "предсказуемостью" фактической последовательности размеров (например, имеют ли размеры предсказуемый образец)? Вопрос о предсказуемости несколько тонкий, поскольку он зависит от реализации, поскольку, как описано выше, некоторые реализации по своей природе более предсказуемы.

2 В частности, ~ 750 байтов инструкций в clang и ~ 600 байтов в gcc для одного тела, поверх 256-байтовой таблицы поиска перехода для тела коммутатора, в которой было 180–250 инструкций (gcc и clang соответственно). Годболт ссылка.

3 В основном 200 слитных операций из эффективного кэш-памяти размером 1000 команд. В то время как последние x86 имели размер кэша uop около 1500 моп, вы не можете использовать все это вне предельно выделенного заполнения вашей кодовой базы из-за ограничительных правил назначения кода в кэш.

4 Варианты переключения имеют разную скомпилированную длину, поэтому переход не может быть рассчитан напрямую. Для чего бы это ни стоило, это можно было бы сделать по-другому: они могли бы использовать 16-разрядное значение в таблице поиска за счет отказа от использования источника памяти для jmp, сократив его размер на 75%.

5 В отличие от условного предсказания ветвления, который имеет типичный прогноз предсказания наихудшего случая ~ 50% (для совершенно случайных ветвей), трудно предсказуемое косвенное ветвление может легко приблизиться к 100%, так как вы не подбрасываете монету, вы выбирая для почти бесконечного набора целей отрасли. Это происходит в реальном мире: если memcpy используется для копирования небольших строк с длинами, равномерно распределенными между 0 и 30, код switch будет ошибочно предсказываться в ~ 97% случаев.

6 Конечно, могут быть штрафы за смещение магазинов, но они также, как правило, невелики и становятся меньше.

7 Например, memcpy для стека, за которым следуют некоторые манипуляции и копия в другом месте, может быть полностью удалена, непосредственно перемещая исходные данные в их окончательное местоположение. Даже такие вещи, как malloc и memcpy могут быть полностью исключены.

Ответ 3

Во-первых, основной цикл использует неустановленные векторные загрузки/хранилища AVX для копирования 32 байтов за раз, пока не будет < Осталось 32 байта для копирования:

    for ( ; Size >= sizeof(__m256i); Size -= sizeof(__m256i) )
    {
        __m256i ymm = _mm256_loadu_si256(((const __m256i* &)Src)++);
        _mm256_storeu_si256(((__m256i* &)Dst)++, ymm);
    }

Затем заключительная инструкция switch обрабатывает остаточные 0..31 байта как можно эффективнее, используя комбинацию 8/4/2/1 байтовых копий, если это необходимо. Обратите внимание, что это не развернутый цикл - это всего 32 различных оптимизированных пути кода, которые обрабатывают остаточные байты, используя минимальное количество загрузок и хранилищ.

Что касается того, почему основной 32-байтовый цикл AVX не разворачивается вручную, для этого существует несколько возможных причин:

  • большинство компиляторов автоматически разворачивают малые циклы (в зависимости от размера цикла и переключателей оптимизации)
  • чрезмерная разворачивание может привести к тому, что из кэша LSD выйдут небольшие циклы (обычно только 28 декодированных μops)
  • на текущих процессорах Core iX вы можете выпускать только две параллельные нагрузки/хранилища до того, как вы остановитесь [*]
  • обычно даже не развернутый цикл AVX, подобный этому, может насыщать доступную пропускную способность DRAM [*]

[*] обратите внимание, что последние два комментария выше относятся к случаям, когда источник и/или адресат не находятся в кеше (т.е. запись/чтение в/из DRAM), и поэтому время ожидания загрузки/хранения велико.

Ответ 4

Использование преимуществ ERMSB

Также попробуйте использовать REP MOVSB ​​для больших блоков.

Как вы знаете, начиная с первого процессора Pentium, выпущенного в 1993 году, Intel стала быстрее выполнять простые команды и сложные команды (например, REP MOVSB). Таким образом, REP MOVSB ​​стал очень медленным, и больше не было причин использовать его. В 2013 году Intel решила пересмотреть REP MOVSB. Если процессор имеет бит CPUID ERMSB (Enhanced REP MOVSB), команды REP MOVSB ​​выполняются иначе, чем на старых процессорах, и должны быть быстрыми. На практике он выполняется только для больших блоков, 256 байтов и более, и только при выполнении определенных условий:

  • как исходный, так и целевой адреса должны быть выровнены с 16-байтовой границей;
  • область источника не должна пересекаться с областью назначения;
  • длина должна быть кратной 64 для повышения производительности;
  • направление должно быть вперед (CLD).

См. Руководство Intel по оптимизации, раздел 3.7.6 Расширенные операции REP MOVSB ​​и STOSB (ERMSB) http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf

Intel рекомендует использовать AVX для блоков размером менее 2048 байт. Для больших блоков Intel рекомендует использовать REP MOVSB. Это связано с тем, что высокие первоначальные затраты на запуск REP MOVSB ​​(около 35 циклов).

Я провел тесты скорости, а для блоков более 2048 байт и выше производительность REP MOVSB ​​является непревзойденной. Однако для блоков размером менее 256 байт REP MOVSB ​​работает очень медленно, даже медленнее, чем обычный MOV RAX взад и вперед в цикле.

Пожалуйста, не то, что ERMSB влияет только на MOVSB, а не на MOVSD (MOVSQ), поэтому MOVSB ​​немного быстрее, чем MOVSD (MOVSQ).

Таким образом, вы можете использовать AVX для реализации memcpy(), и если размер блока превышает 2048 байт и все условия выполнены, тогда вызовите REP MOVSB ​​- так что реализация memcpy() будет непревзойденной.

Использование преимуществ механизма выполнения вне очереди

Вы также можете прочитать о движке Ex-of-Order Execution Engine в "Справочном руководстве по оптимизации архитектур Intel® 64 и IA-32" http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf раздел 2.1.2 и воспользоваться преимуществами.

Например, в серии процессоров Intel SkyLake (запущен в 2015 году):

  • 4 исполнительных блока для Арифметической логической единицы (ALU) (add, и, cmp или, test, xor, movzx, movsx, mov, (v) movdqu, (v) movdqa, (v) movap *, ( v) movup),
  • 3 исполнительных блока для Vector ALU ((v) pand, (v) por, (v) pxor, (v) movq, (v) movq, (v) movap *, (v) movup *, (v) (v) orp *, (v) paddb/w/d/q, (v) blendv *, (v) blendp *, (v) pblendd)

Таким образом, мы можем занимать выше единицы (3 + 4) параллельно, если мы используем операции только для регистров. Мы не можем использовать 3 + 4 инструкции параллельно для копирования памяти. Мы можем использовать одновременно максимум две 32-байтные инструкции для загрузки из памяти и одну 32-байтную инструкцию для хранения из памяти и даже если мы работаем с кешем уровня 1.

Обратитесь к руководству Intel еще раз, чтобы понять, как выполнить самую быструю реализацию memcpy: http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf

Раздел 2.2.2 (Механизм внезадачности микроархитектуры Хасуэлла): "Планировщик контролирует отправку микроопераций в порты диспетчеризации. Существует восемь портов отправки для поддержки внеочередного исполнения Четыре из восьми портов предоставили ресурсы выполнения для вычислительных операций. Остальные 4 порта поддерживают операции памяти до двух 256-битной нагрузки и одну операцию хранения в 256 бит в цикле.

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

Раздел 2.2.4.1 (Расширенные операции загрузки и сохранения) имеет следующую информацию: Кэш данных L1 может обрабатывать две 256-разрядные (32 байта) нагрузки и одну 256-битную (32 байта) операции хранения каждого цикла. Унифицированный L2 может обслуживать один цикл кэша (64 байта) каждого цикла. Кроме того, доступно 72 буфера нагрузки и 42 буфера хранения для поддержки выполнения микроопераций в полете.

Другие разделы (2.3 и т.д., посвященные Sandy Bridge и другим микроархитектурам) в основном повторяют вышеприведенную информацию.

В разделе 2.3.4 ( "Ядро выполнения" ) приводятся дополнительные сведения.

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

  • Порт 0: ALU, Shift, Mul, STTNI, Int-Div, 128b-Mov, Blend, 256b-Mov
  • Порт 1: ALU, Fast LEA, Slow LEA, MUL, Shuf, Blend, 128bMov, Add, CVT
  • Порт 2 и порт 3: Load_Addr, Store_addr
  • Порт 4: Store_data​​li >
  • Порт 5: ALU, Shift, Branch, Fast LEA, Shuf, Blend, 128b-Mov, 256b-Mov

Раздел 2.3.5.1 (Обзор операции загрузки и хранения) также может быть полезен для понимания того, как сделать быструю копию памяти, а также в разделе 2.4.4.1 (Загрузка и хранение).

Для других архитектур процессоров это снова - два блока нагрузки и один блок хранения. Таблица 2-4 (Параметры кэша микроархитектуры Skylake) содержит следующую информацию:

Пиковая пропускная способность (байты/цикл):

  • Кэш данных первого уровня: 96 байт (2x32B Load + 1 * 32B Store)
  • Кэш второго уровня: 64 байта
  • Третий уровень: 32 байта.

Я также провел тесты скорости на моем процессоре Intel Core i5 6600 (Skylake, 14 нм, выпущенном в сентябре 2015 года) с памятью DDR4, и это подтвердило теорию. Например, мой тест показал, что использование общих 64-битных регистров для копирования памяти, даже много регистров параллельно, снижает производительность. Кроме того, достаточно использовать только 2 регистра XMM - добавление третьего не повышает производительность.

Если ваш процессор имеет бит AVID CPUID, вы можете воспользоваться преимуществами больших, 256-битных (32 байта) регистров YMM для копирования памяти, чтобы заняться двумя блоками полной нагрузки. Поддержка AVX была впервые представлена ​​Intel с процессорами Sandy Bridge, которые были отправлены в первом квартале 2011 года, а затем AMD с процессором Bulldozer в третьем квартале 2011 года.

// first cycle  
vmovdqa ymm0, ymmword ptr [rcx+0]      // load 1st 32-byte part using first load unit
vmovdqa ymm1, ymmword ptr [rcx+20h]    // load 2nd 32-byte part using second load unit

// second cycle
vmovdqa ymmword ptr [rdx+0], ymm0      // store 1st 32-byte part using the single store unit

// third cycle
vmovdqa ymmword ptr [rdx+20h], ymm1    ; store 2nd 32-byte part - using the single store unit (this instruction will require a separate cycle since there is only one store unit, and we cannot do two stores in a single cycle)

add ecx, 40h // these instructions will be used by a different unit since they don't invoke load or store, so they won't require a new cycle
add edx, 40h

Кроме того, есть преимущество в скорости, если вы выполняете цикл с помощью этого кода не менее 8 раз. Как я писал ранее, добавление большего количества регистров, кроме ymm0 и ymm1, не увеличивает производительность, потому что есть только два блока нагрузки и один блок хранения. Добавление циклов, таких как "dec r9 jnz @@again", ухудшает производительность, но простого "добавить ecx/edx" нет.

Наконец, если ваш процессор имеет расширение AVX-512, вы можете использовать 512-битные (64-байтные) регистры для копирования памяти:

vmovdqu64   zmm0, [rcx+0]           ; load 1st 64-byte part
vmovdqu64   zmm1, [rcx+40h]         ; load 2nd 64-byte part 

vmovdqu64   [rdx+0], zmm0           ; store 1st 64-byte part
vmovdqu64   [rdx+40h], zmm1         ; store 2nd 64-byte part 

add     rcx, 80h
add     rdx, 80h    

AVX-512 поддерживается следующими процессорами: Xeon Phi x200, выпущенный в 2016 году; Процессоры Skylake EP/EX Xeon "Purley" (Xeon E5-26xx V5) (H2 2017); Процессоры Cannonlake (H2 2017), процессоры Skylake-X - Core i9-7 ××× X, i7-7 ××× X, i5-7 ××× X - выпущены в июне 2017 года.

Обратите внимание, что память должна быть выровнена по размеру используемых регистров. Если это не так, используйте "невыровненные" инструкции: vmovdqu и moveups.