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

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

__forceinline   // Since Size is usually known,
                // most useless code will be optimized out
                // if the function is inlined.

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)

person einpoklum    schedule 07.10.2014    source источник
comment
@dirkk: Хорошо, я буду, но имейте в виду, что это долго ...   -  person einpoklum    schedule 08.10.2014
comment
(почти) устройство Даффа во плоти.   -  person Michael Dorgan    schedule 08.10.2014
comment
@MichaelDorgan: он выглядит как Устройство Даффа, но на самом деле это не так.   -  person Paul R    schedule 08.10.2014
comment
Вы правы - провалов нет, и каждый переключатель реализован как копия. Тем не менее, мне это напомнило :)   -  person Michael Dorgan    schedule 08.10.2014
comment
Да, это тоже была моя первая мысль.   -  person Paul R    schedule 08.10.2014
comment
@MichaelDorgan: Я тоже думал, что он / она делал что-то загадочное и волшебное, но при ближайшем рассмотрении это довольно просто. Мне это показалось аранжировкой для органа ...   -  person einpoklum    schedule 08.10.2014
comment
Мне очень нравятся выразительно расположенные switch ветки. Смотрится неплохо. 10/10 совершит :)   -  person dom0    schedule 08.10.2014
comment
важной недостающей функцией в этой реализации является неправильная подпись. Ожидается совпадение с: void *memcpy(void * restrict s1, const void * restrict s2, size_t n);   -  person chux - Reinstate Monica    schedule 08.10.2014
comment
Даже с оптимизирующим компилятором может не различать switch (Size) с его 32 случаями совпадения Size диапазон 0<=Size<32. Может switch (Size&31)? Избегайте внутреннего генерирования if size > 31.   -  person chux - Reinstate Monica    schedule 08.10.2014
comment
@tmyklebu: Не совсем обзор кода, так как это не мой код. Отредактирую, чтобы уточнить немного больше.   -  person einpoklum    schedule 08.10.2014
comment
@einpoklum: Теперь, когда вы задаете более конкретные вопросы, становится понятнее. Его можно найти здесь или на codereview.   -  person tmyklebu    schedule 08.10.2014
comment
Обратите внимание, что ограничение помогает только для частей вашего кода без встроенных функций. Ограничивать внутренними функциями бесполезно.   -  person Z boson    schedule 08.10.2014
comment
Оптимизировать выровненный цикл копирования, основанный на силе двух, «легко» (я имею в виду, что нужно время, чтобы поэкспериментировать, но не уделять особого внимания деталям или необычным методам). Большая часть удовольствия от реализации memcpy - это максимально эффективное устранение несоосности. Эта реализация неоптимальна в том смысле, что при невыровненных буферах она будет выдавать хранилища, пересекающие строки кэша и страницы, и выполняет много лишней работы по очистке.   -  person Stephen Canon    schedule 08.10.2014
comment
@StephenCanon: Нам, простым смертным, не все так просто ... не у всех есть золотой значок за тег C и репутация 50k :-( Кроме того, у меня обычно буферы memcpy размером более 1 МБ, поэтому возиться с краями не так уж и интересно для я (хотя знаю, что для других случаев это критично).   -  person einpoklum    schedule 08.10.2014
comment
@einpoklum: Я намеренно веду себя бойко. Несмотря на то, что это «легко», все же требуется немало усилий, особенно если вы не делали этого раньше. На данный момент я отправил по крайней мере 7 коммерческих memcpy реализаций, так что я признаю, что у меня несколько больше опыта, чем у большинства людей. знак равно   -  person Stephen Canon    schedule 08.10.2014
comment
@einpoklum, я обновил свой ответ на основе комментариев Стивена Кэнона, а также на основе общих комментариев Агнера Фога о перемещении памяти в его руководстве по оптимизации сборки. Агнер обсуждает несколько случаев смещения памяти. Я бы прочитал этот раздел в его руководстве.   -  person Z boson    schedule 09.10.2014
comment
@StephenCanon, может быть легко реализовать копию степени двойки, но это не обязательно означает, что стандартная библиотека, которую вы используете, делает даже это эффективно (ну, может быть, ваш, но встроенный GCC и EGLIBC еще можно улучшить).   -  person Z boson    schedule 09.10.2014
comment
@einpoklum, из любопытства, почему ты не принял мой ответ? Чего мне не хватает на ваш вопрос? Я не вдавался в подробности (например, как отрегулировать несоосность), но действительно ли вы ожидаете, что кто-то сделает это за вас?   -  person Z boson    schedule 17.11.2015
comment
@Zboson: По сути, потому что я думал, что основная часть вопроса сводится к обобщению ответов, но я думаю, вы заслужили свое согласие :-)   -  person einpoklum    schedule 17.11.2015
comment
Я исправил инструкции по перемещению AVX-512, добавил в свой ответ больше процессоров, поддерживающих AVX-512. Надеюсь, будет полезно.   -  person Maxim Masiutin    schedule 01.07.2017
comment
@ L.f .: Спасибо за перевод :-)   -  person einpoklum    schedule 03.04.2019
comment
@einpoklum Удовольствие! ;-)   -  person L. F.    schedule 03.04.2019


Ответы (4)


Я занимался измерением пропускной способности памяти для процессоров Intel с различными операциями, и одна из них - memcpy. Я делал это на Core2, Ivy Bridge и Haswell. Я выполнил большинство своих тестов, используя C / 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 - это asmlib от Agner Fog. Функции copy_unroll1 и copy_unroll8 определены ниже.

Из этой таблицы мы видим, что встроенный memcpy GCC плохо работает на 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().LOADis _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 работает плохо. Это потому, что он не использует невременные хранилища.

Я изменил функции 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 называет это расширенным представлением movsb (ERMSB). Это описано в руководства по оптимизации Intel в разделе 3.7.6 Расширенная работа REP MOVSB ​​и STOSB (ERMSB).

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

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

  1. Инструкция REP MOVS.
  2. Если данные выровнены: чтение и запись в цикле с наибольшим доступным размером регистра.
  3. Если размер постоянный: встроенные инструкции перемещения.
  4. Если данные не выровнены: сначала переместите столько байтов, сколько требуется, чтобы выровнять место назначения. Затем считайте невыровненные и выровненные записи в цикле с наибольшим доступным размером регистра.
  5. Если данные не выровнены: чтение выровнено, сдвинуть, чтобы компенсировать несовпадение, и запись выровнена.
  6. Если размер данных слишком велик для кеширования, используйте невременную запись для обхода кеша. При необходимости сместите, чтобы компенсировать перекос ».

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

person Z boson    schedule 08.10.2014
comment
Несколько примечаний / вопросов: 1. размер больше половины строки кэша на самом большом уровне, верно? 2. Понял, что вы думаете об оптимизации первого и второго порядка, но предположим, что я выберу ваш вариант unroll8; там выравнивание важно? Я предполагаю, что в вашем тесте использовались выровненные буферы. 3. Помогает ли omp_parallel из-за наличия 2-х модулей загрузки / сохранения? Будет ли он производить две нити? 4. Разве использование OpenMP здесь не похоже на читерство? - person einpoklum; 08.10.2014
comment
@einpoklum, я имею ввиду половину размера самого медленного кеша. В системе с кэшем L3 объемом 8 МБ размер вдвое меньше, чем на 4 МБ. Не могу сказать, что знаю это эмпирическое правило на собственном опыте. Я кое-что прочитал. Но нет сомнений в том, что невременные хранилища имеют существенное значение, когда размер намного больше, чем самый медленный кеш (например, для 1 ГБ). - person Z boson; 08.10.2014
comment
@einpoklum, для выравнивания стоит попробовать и посмотреть. Я сравнивал только выровненные и невыровненные инструкции с выровненной памятью и получил лучшие результаты с выровненными инструкциями. Мои буферы выровнены по 4096 байтам. Помните, что я пытаюсь максимально приблизиться к теоретическому максимуму. Как только я достигну этого, я смогу оптимизировать для меньшего количества идей, но я сомневаюсь, что сделаю это, потому что, как и вы, это только для образовательных целей. - person Z boson; 08.10.2014
comment
@einpoklum, я установил количество потоков равным количеству физических ядер, а затем связал потоки. Чтобы понять, зачем читать вопрос, ответы и комментарии в заголовке stackoverflow.com/questions/25179738/. Но я не считаю обманом использование нескольких потоков. Это действительно может быть использовано для повышения эффективности (скорости) memcpy для больших массивов (особенно для системы NUMA). Однако для небольших массивов накладные расходы OpenMP преобладают, и результат на самом деле будет хуже. - person Z boson; 08.10.2014
comment
@einpoklum, см. этот вопрос / ответ, чтобы узнать больше о memset (такая же логика для memcpy) в системе с одним и несколькими сокетами (NUMA) stackoverflow.com/questions/11576670/. - person Z boson; 08.10.2014
comment
Обратите внимание, что на Ivybridge и Haswell с большими буферами, чтобы соответствовать MLC, вы можете победить movntdqa, используя rep movsb; movntdqa влечет RFO в ООО, rep movsb - нет. - person Stephen Canon; 08.10.2014
comment
@StephenCanon, MLC означает кеш среднего уровня? Кэш L2? RFO = Готовность к владению? а LLC = кеш последнего уровня? Думаю, именно этот термин я имею в виду, когда говорю, что кеш-память самая медленная. Я имею в виду размер ООО. - person Z boson; 08.10.2014
comment
@StephenCanon, вы сказали, что буферы слишком велики, чтобы поместиться в MLC. Я предполагаю, что это также означает больше, чем LLC. Значит, вы имеете в виду, что я могу сделать лучше, чем movntdqa, для моего корпуса объемом 1 ГБ? Мне нужно провести небольшое исследование. Спасибо! - person Z boson; 08.10.2014
comment
Да, rep movsb значительно быстрее, чем movntdqa при потоковой передаче в память на Ivybridge и Haswell (но имейте в виду, что до Ivybridge это было медленно!) - person Stephen Canon; 08.10.2014
comment
@StephenCanon, хорошо, я вижу раздел 17.9 Перемещение блоков данных (все процессоры) в руководстве по оптимизации сборки Agner Fog, в котором описывается rep movsb и многие другие полезные моменты. Как-то пропустил этот очень актуальный раздел. - person Z boson; 08.10.2014
comment
@Zboson: Также есть некоторые обсуждения в руководстве Intel по оптимизации. - person Stephen Canon; 08.10.2014
comment
@StephenCanon, хорошее замечание. Я нашел раздел 3.7.7 Enhanced REP MOVSB ​​and STOSB operation (ERMSB) в руководстве Intel Optmization, а затем раздел 3.7.7.1 Рекомендации по Memcpy. Это отличная информация. - person Z boson; 09.10.2014
comment
@StephenCanon, я наконец начал изучать enhanced rep movsb stackoverflow.com/q/43343231/2542702. - person Z boson; 11.04.2017
comment
@StephenCanon - большинство тестов, которые я видел, показывают, что rep movsb не быстрее, чем правильно написанная копия с хранилищами NT. На IvB он кажется наиболее конкурентоспособным (но все же в целом медленнее), в то время как на Haswell и более новых чипах он в целом кажется примерно на 20% медленнее (в зависимости от множества факторов, включая взаимодействие с эвристикой управления питанием) . Обычно кажется, что это что-то среднее между не-NT-решениями, которые вообще не используют NT-хранилища, и полноценными NT-вещами, но я, конечно, никогда не видел случая, когда это было бы значительно быстрее. - person BeeOnRope; 08.05.2017

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

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

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

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

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

Размер кода

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

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

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

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

Прогнозирование ветвей

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

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

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

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

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

Решения

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

Использовать устройство Даффа

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

Например, собранный код для случаев длины 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 инструкции из 3. Обратите внимание, что основа для 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. Источник и место назначения полностью не совмещены (т. Е. Фактически не выровнены, и случай (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 соответственно). Godbolt link.

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

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

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

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

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

person BeeOnRope    schedule 09.05.2017
comment
Перекрытие магазинов - очень хорошая идея. Например, вам нужно скопировать 15 байт. Вы просто копируете 2 блока по 8 байтов с перекрытием в один байт. mov rax, [RSI + 0]; mov rbx, [RSI + 7]; mov [rdi + 0], rax; mov [rdi + 7], rbx Вот насколько неэффективно Microsoft memcpy копирует 15 байтов: mov r8, qword ptr [rdx]; mov ecx, dword ptr 8 [rdx]; movzx r9d, word ptr 12 [rdx]; movzx r10d, байт ptr 14 [rdx]; - затем скопируйте эти значения обратно - таким образом, Microsoft использует 5 ходов при загрузке и 5 ходов в хранилище, в то время как мы используем только 2 хода при загрузке и 2 хода в хранилище с использованием перекрывающихся ходов. - person Maxim Masiutin; 09.05.2017
comment
Я прочитал на странице agner.org/optimize, что следует избегать динамических переходов (по таблице / индексу) на все стоит у современных процессоров. Итак, такой код, как lea r9, OFFSET __ImageBase; mov ecx, [(IMAGEREL MoveSmall) + r9 + r8 * 4]; добавить rcx, r9; jmp rcx --- становится очень медленным на современных процессорах. Есть ли у вас какое-нибудь представление об этом? - person Maxim Masiutin; 09.05.2017
comment
Возможно, следующий код должен быть быстрее, по крайней мере, для случаев до 16 байт: cmp ecx, 0 jz exit mov al, [esi] mov [edi], al cmp ecx, 1 je exit mov al, [esi + 1] mov [edi + 1], al cmp ecx, 2 je exit mov al, [esi + 2] mov [edi + 2], al cmp ecx, 3 je exit mov al, [esi + 3] mov [edi + 3] , al cmp ecx, 4 je exit ... и так далее - person Maxim Masiutin; 09.05.2017
comment
@MaximMasiutin - да, перекрывающиеся магазины - это хорошо, но у него есть некоторые ограничения - например, вам все равно нужно разветвляться менее чем на 8 байтов, если вы выполняете 8-байтовые перемещения. В конкретном случае приложения вы можете обойти это, если разрешите несколько байтов заполнения в конце области, в которую вы копируете, и в этом случае вы можете скопировать неважные байты после конца. - person BeeOnRope; 09.05.2017
comment
@MaximMasiutin - возможно, вы захотите уточнить цитату, но исходя из того, что вы сказали, Агнер просто говорит, что косвенные прыжки медленные. Фактически, непрямые прыжки, безусловно, имеют потенциал быть медленными, но они не являются медленными по своей сути, если они хорошо спрогнозированы. Если они хорошо спрогнозированы, они могут быть быстрыми, как и другие типы прыжков. Я подробно объяснил, почему такие скачки могут быть непредсказуемыми выше. - person BeeOnRope; 09.05.2017
comment
@MaximMasiutin - ваша цепочка прыжков наверное хуже непрямого прыжкового подхода. В основном вы должны смотреть на предсказуемость каждой последовательности. В общем, ваша последовательность будет непредсказуемой, когда последовательность непредсказуема, и в остальном нормально - точно так же, как непрямой прыжок. Неправильно предсказанная ветвь примерно так же плоха, независимо от того, является она косвенной или нет, поэтому обычно вы не добьетесь успеха в предсказании, изменив его на серию условных ветвей. Вы теряете кучу: больше инструкций, копирование по одному байту за раз, больше потребляемых ресурсов предсказания ветвлений и т. Д. - person BeeOnRope; 09.05.2017
comment
Еще один совет: если мы не можем выровнять и источник, и место назначения, выровняйте только место назначения и используйте невыровненные нагрузки (vmovdqu) и выровненные хранилища (vmovdqa). Поскольку у нас есть две единицы загрузки, но только одна единица магазина, выгода от выровненного магазина должна быть выше, чем от выровненной нагрузки. ;-) - person Maxim Masiutin; 09.05.2017
comment
Я только начинаю читать этот ответ ... (1) +1 уже за упоминание проблемы с размером кода. Однако вы уверены, что компилятор ничего не сделает с этим? (2) Что вы имеете в виду под конфигурацией памяти? есть ли у нас подходящие модули? Или вы имеете в виду точные сроки? Как это поможет? По поводу архитектуры - вы спрашиваете только из-за наличия AVX, AVX-2, AVX-512 или по другим причинам? - person einpoklum; 09.05.2017
comment
(3) Что касается предсказания ветвления - фактически, всякий раз, когда вы копируете что-то фиксированной длины - а короткие копии, скорее всего, имеют фиксированную длину - компилятор должен (?) Просто отбросить ветвь, когда она встраивается. Для длинных копий, неизвестных во время компиляции - хотя теоретически они могут иметь произвольную длину, вполне разумно предположить, что в общем случае будет длина, кратная 32, то есть случай переключения для 0x0. Я знаю, что все это домыслы, но это не домыслы ... - person einpoklum; 09.05.2017
comment
@einpoklum - компилятор ничего не делает с этим (кроме достаточно хорошей компиляции, но это все еще 32 отдельных случая), и я освещаю это в своем ответе, включая ссылку на сгенерированную сборку на x86 для gcc и clang (см. сноска 2). - person BeeOnRope; 09.05.2017
comment
@einpoklum - под конфигурацией памяти я имею в виду множество вещей, но самые большие из них - это задержка памяти и пропускная способность по сравнению с частотой процессора. Например, в прошлом многие системы не могли обеспечить полную пропускную способность памяти с помощью одного ядра, поскольку максимальный размер передачи * аппаратные буферы MLP / задержка ‹пропускная способность. В настоящее время системы Intel представляют собой смесь: некоторые могут достичь максимальной полосы пропускания с одним ядром (например, мой 6700HQ): системы с относительно низкой пропускной способностью памяти и / или относительно высокой частотой. На чьей они стороне - очень важно для NT или не для NT. - person BeeOnRope; 09.05.2017
comment
@einpoklum - ну, компилятор определенно не собирается встраивать весь memcpy, указанный выше, и, насколько мне известно, они не делают частичного встраивания (т.е. встраивают начальную часть функции, а затем вызывают остальную часть вне линии) - я хотел сказать, что вы можете разделить функцию, чтобы дать возможность встраивания. Конечно, копии нестандартной длины чрезвычайно распространены. Я не знаю, какие из них более распространены, но можно с уверенностью сказать, что большинство копий очень короткие, а многие копии имеют произвольную длину. Практически каждая структура данных C ++ скрывает небольшие копии переменной длины. - person BeeOnRope; 09.05.2017
comment
@BeeOnRope: Перекомпилятор заботится о большом количестве кода - я сейчас увидел ссылку, да. Все-таки мой комментарий по поводу звонка с фиксированной длиной (пока) в силе. Возвращение к достижению полной пропускной способности с одним ядром - у меня сложилось ошибочное впечатление, что во всех разумных случаях для этого требуется более одного ядра; Благодарю. - person einpoklum; 09.05.2017
comment
@einpoklum - последние чипы Intel могут управлять скоростью около 30 ГБ / с на одном ядре, а многие чипы имеют примерно такую ​​же полосу пропускания. Для более крупных компонентов с четырехканальной памятью вам наверняка понадобится более одного ядра. В принципе, вы можете получить полную BW из одного ядра, вам определенно нужны NT-хранилища. Если вы не можете этого сделать, вы можете обнаружить, что обычные хранилища работают быстрее (но только для одного ядра, когда вы перейдете к большему количеству ядер, NT в конечном итоге выиграет, поскольку это экономит пропускную способность). - person BeeOnRope; 09.05.2017
comment
@BeeOnRope: на процессорах Intel с большим количеством ядер на ядро ​​ пропускная способность для L3 и / или RAM на самом деле ниже, чем на четырехъядерном настольном компьютере. Задержка по кольцевой шине выше, но количество буферов для отслеживания невыполненных запросов фиксировано. Таким образом, максимальный параллелизм зафиксирован и не может поддерживать полную загрузку канала на большом Xeon. - person Peter Cordes; 03.07.2017
comment
@PeterCordes - чтобы было ясно, вы говорите о максимальной пропускной способности от одного ядра в системе, которая в противном случае простаивает, верно? Я также мог бы интерпретировать per-core как общую пропускную способность per-core со всеми активными ядрами одновременно, которая также намного ниже на больших чипах, но только потому, что вы, по сути, Имея полосу пропускания DRAM, фиксированную конфигурацией канала / скорости памяти, разделенную между всеми ядрами в сокете, вы получаете гораздо меньше на каждое ядро ​​(но вы это знаете). - person BeeOnRope; 03.07.2017
comment
@BeeOnRope. Да, в системе, которая в противном случае не работала. Хороший аргумент в отношении двусмысленности. - person Peter Cordes; 03.07.2017
comment
См. в этой ветке форума Intel для более подробного обсуждения. - person Peter Cordes; 03.07.2017
comment
В системе с несколькими сокетами ситуация может быть еще хуже, потому что запросы, которые отсутствуют в локальном L3, должны отслеживать L3 другого сокета для поддержания согласованности. До Haswell это могло действительно отстой, если бы все ядра на другом сокете находились в состоянии C1E, поэтому он переходит в состояние пакета C1E, и тактовая частота uncore падает. См. Сообщение Джона МакКэлпина в конце эта ветка. Haswell позволил тактовой частоте uncore оставаться на высоком уровне, даже когда все ядра спали. - person Peter Cordes; 03.07.2017
comment
@PeterCordes - верно. Известно, что задержка для DRAM на неядерных частях сервера часто намного хуже, чем в клиентских частях, поэтому для сценариев с ограниченным параллелизмом (например, 1 активное ядро) я могу представить, что это значительно снижает пропускную способность DRAM. Хотя, я думаю, задержка L3 довольно схожа для разных частей - несколько дополнительных кольцевых остановок могут добавить в среднем пару циклов, но я думаю, это небольшое влияние? Поэтому я бы возложил ответственность за большую часть дополнительной задержки в случае DRAM на вещи, расположенные дальше по потоку, такие как контроллер памяти серверной части, а не на кольцевую шину. - person BeeOnRope; 03.07.2017
comment
@PeterCordes - хороший момент о слежении за несколькими сокетами. Я думал только о единственном сокете. - person BeeOnRope; 03.07.2017
comment
@BeeOnRope: Я помню, как измерял более низкую тактовую пропускную способность L3 на HSW и SKL Xeon, чем на моем настольном компьютере. (На виртуальной машине Google Cloud, но с учетом наилучших показателей, исходя из предположения, что это не конфликтные случаи. Виртуальная машина SKL имела низкий уровень шума при измерениях, так как это было до того, как они стали общедоступными. :) Они почти наверняка были на двухпроцессорное оборудование с огромным количеством ядер на чип. (как 28 для SKL-X). Я должен пойти и проверить свои записи ... - person Peter Cordes; 03.07.2017
comment
Вот несколько Haswell и Haswell-EP числа, которые, кажется, показывают задержку в 40 циклов для клиентской части и 50 циклов для серверной части (без сомнения, может быть здесь тоже присутствует некоторая доля промахов TLB), но они цикличны, и серверная часть имеет более низкую тактовую частоту, поэтому, измеренный во времени, разрыв, вероятно, будет несколько больше. - person BeeOnRope; 03.07.2017
comment
... но все же это что-то вроде максимальной разницы в 5 нс, в то время как задержки памяти часто находятся в диапазоне разницы в 30-40 нс между сервером и клиентом (например, клиентские части составляют около 50 нс, а серверные части - ближе к 85 нс). - person BeeOnRope; 03.07.2017

Во-первых, основной цикл использует загрузку / сохранение невыровненного вектора 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 декодированных мкопов)
  • на текущих процессорах Core iX вы можете выполнить только две одновременные загрузки / сохранения перед остановкой [*]
  • обычно даже такой неразвернутый цикл AVX может привести к перегрузке доступной полосы пропускания DRAM [*]

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

person Paul R    schedule 07.10.2014
comment
Цикл только один, потому что второй развернут полностью. Я знаю, что делает код, я не об этом спрашивал. - person einpoklum; 08.10.2014
comment
Оператор switch не является развернутым циклом - это всего лишь 32 различных пути кода в зависимости от того, сколько байтов осталось скопировать. - person Paul R; 08.10.2014
comment
while ( (size--) > 0) *(Dst++) = *(Src++); это то, что он делает, не так ли? :-) - person einpoklum; 08.10.2014
comment
Обратите внимание на разные размеры копий (1, 2, 4, 8 байтов) - это не развернутый скалярный цикл, это всего лишь 31 другая небольшая оптимизированная копия для очистки остаточных байтов. Называйте это как хотите, но вы упускаете суть - в общем случае тяжелую работу выполняет цикл AVX. - person Paul R; 08.10.2014
comment
Хорошо - есть несколько причин, по которым, возможно, не стоит вручную развертывать этот первый цикл - я скоро отредактирую свой ответ, чтобы расширить их. - person Paul R; 08.10.2014
comment
Цикл не разворачивается, потому что это не так. Если бы он был развернут, результаты для небольших массивов сильно отличались бы. Для Core2-Haswell я получаю лучшие результаты, развернув этот цикл четыре или восемь раз. На Haswell без разворачивания получается менее 50% пика (я получаю около 47%). При восьмикратном развертывании на Haswell получается около 98%. - person Z boson; 08.10.2014
comment
@Zboson: вы также специально отключаете автоматическое развертывание цикла компилятором (например, используя -O2, -Os или -fno-unroll-loops)? - person Paul R; 08.10.2014
comment
@PaulR, нет, но я только что это комментировал. GCC разворачивает цикл только в том случае, если я указываю -funroll-loops. В этом случае он раскручивается восемь раз, и результаты намного лучше. Но я все равно предпочитаю раскатывать вручную. - person Z boson; 08.10.2014
comment
Я не понимаю вашего комментария о текущих процессорах Core iX, вы можете выполнить только две одновременные загрузки / сохранения перед остановкой. - person Z boson; 08.10.2014
comment
Конечно - ручное развертывание часто лучше, чем автоматическое - я просто не знал, сравнивали ли вы подобное с подобным. - person Paul R; 08.10.2014
comment
@Zboson: ну, как правило, есть две единицы загрузки / сохранения, поэтому, если вы одновременно выполняете загрузку и сохранение, любые дальнейшие инструкции загрузки / сохранения будут останавливаться до тех пор, пока один из них не будет удален. - person Paul R; 08.10.2014
comment
Да, gut на Core2-Ivy Bridge для memcopy он может выполнять одно 16-байтовое чтение и одно 16-байтовое хранилище за один дерзкий цикл. В Haswell это одно 32-байтовое чтение и одна 32-байтовая запись за такт. Обратите внимание, что полоса пропускания чтения, чтения, записи (например, в функции триады STREAM) отличается. - person Z boson; 08.10.2014
comment
@Zboson: конечно, это нормально при чтении / записи кеша L1 / L2, но для доступа к DRAM, как только вы получите промах кеша, потребуется много тактовых циклов, прежде чем загрузка или сохранение будут удалены. - person Paul R; 08.10.2014
comment
Я согласен, что для доступа к DRAM разворачивание бесполезно. Для доступа к DRAM для больших размеров вместо развертывания невременных хранилищ следует использовать. - person Z boson; 08.10.2014
comment
Кстати, знаете ли вы, почему вневременные хранилища больше не используются? EGLIBC их не использует. Их использует asmlib Агнера Фога. Не понимаю, почему их больше не используют. Я хотел задать ТАК вопрос по этому поводу. - person Z boson; 08.10.2014
comment
Хорошо - это объясняет путаницу - я добавлю уточняющее замечание к моему ответу, чтобы пояснить, что некоторые из аргументов применимы к memcpy в / из DRAM. - person Paul R; 08.10.2014
comment
Мне никогда не везло с невременными хранилищами, но я не склонен писать такие вещи, как замены memcpy, поэтому я не особо на это смотрел. - person Paul R; 08.10.2014
comment
Да, я попытался прояснить это в начале своего ответа. Общая memcpy функция должна по-разному оптимизировать для малых и больших. - person Z boson; 08.10.2014
comment
@Zboson: Я прокомментировал ваш ответ о NT-хранилищах, но здесь я расскажу подробнее: семантика x86 NT-хранилищ некорректна для использования в memcpy; они катастрофически медленны, когда достигают L1, и им требуется чтение для владения, когда они пропускают L3. Таким образом, vmovaps намного быстрее для маленьких копий, а rep movs намного быстрее для больших копий (на Ivybridge и более поздних версиях). Кроме того, помните, что в магазинах NT требуется забор, что не составляет большого труда, но это еще одна деталь, о которой следует помнить. - person Stephen Canon; 08.10.2014
comment
@StephenCanon, хорошо, я не знал о rep movs. Спасибо за информацию. Мне нужно узнать о rep movs. - person Z boson; 08.10.2014
comment
@StephenCanon, это относится и к Sandy Bridge, или только к Ivy Bridge и Haswell? - person Z boson; 08.10.2014
comment
@Zboson: только IVB и более поздние версии. Это одно из основных микроархитектурных различий между IVB и SNB. Intel называет эту функцию «ERMSB» (расширенное представление movsb / stosb). - person Stephen Canon; 08.10.2014
comment
@PaulR: Что бы вы ответили на аргументы в пользу развертывания, приведенные здесь? - person einpoklum; 09.10.2014
comment
@einpoklum: Я полагаюсь на Zboson в этом вопросе, поскольку он изучил реализацию memcpy гораздо более подробно, чем я, но обратите внимание, что мои комментарии в основном касались больших копий, где пропускная способность DRAM имеет тенденцию быть ограничивающим фактором, тогда как я думаю, что Zb основное внимание уделялось более мелким копиям, где пропускная способность намного выше и развертывание петель с большей вероятностью принесет пользу. Также обратите внимание, что базовый уровень Zb равен -fno-unroll-loops, поэтому он сравнивает ручную развертку с автоматическим развертыванием компилятором. Тем не менее, интересная дискуссия. - person Paul R; 09.10.2014
comment
@einpoklum: упс - моя проблема - я пропустил тот факт, что здесь была ссылка на другой вопрос - я думал, вы имели в виду здесь, как в вышеупомянутом обсуждении. Я вернусь к вам... - person Paul R; 09.10.2014
comment
@PaulR, для пояснения я не указываю -fno-unroll-loops. Я просто использую -O3. Как я прочитал ваш ответ, похоже, что компилятор развернется, если сочтет целесообразным, используя только, например, -O3. Однако я никогда не наблюдал этого с внутренними функциями. Таким образом, единственный способ получить развертку - это явно указать компилятору на это с помощью -funroll-loops (или развернуть ее вручную, как это сделал я). - person Z boson; 14.10.2014
comment
@Zboson: спасибо за разъяснения - я думаю, что старые версии gcc по-разному вели себя в отношении разворачивания цикла и -O3 - в наши дни кажется, что он отключен по умолчанию, по крайней мере, для целей x86. - person Paul R; 14.10.2014
comment
@Zboson и PaulR о: ... как правило, есть две единицы загрузки / сохранения, поэтому, если вы одновременно выполняете загрузку и сохранение, тогда любые дальнейшие инструкции загрузки / сохранения будут останавливаться до тех пор, пока один из них не будет удален - это определенно не так действуют современные (т.е. последние 20 лет) большие ядра OoO. Конечно, есть только два порта загрузки, но это только ограничивает количество нагрузок, которое может быть выдано за цикл. Сами нагрузки попадают в очередь загрузки, и независимо от того, попадают ли они в L1, L2, ... или пропускают весь путь до DRAM, процессор продолжает работать и выполнять инструкции, которые не зависят от нагрузки. - person BeeOnRope; 09.05.2017
comment
В частности, последние процессоры имеют окно не по порядку (размер ROB), состоящее примерно из 200 инструкций, поэтому вы можете выполнять большую работу даже после пропуска DRAM. Что наиболее важно, вы можете продолжать выдавать больше нагрузок, которые также могут пропадать (например, на последних версиях Intel таким образом может одновременно выполняться до 10 нагрузок). Вот почему, например, указатель, преследующий нагрузку, которая случайным образом пропускает в память, будет почти на порядок медленнее, чем нагрузка, которая случайным образом обращается к тем же местам, но чьи адреса хранятся в массиве: последний сценарий имеет высокий MLP CPU можно воспользоваться. - person BeeOnRope; 09.05.2017

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

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

Как вы знаете, с тех пор, как в 1993 году был выпущен первый процессор Pentium, 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. Это связано с высокими начальными затратами на запуск РЭП МОВСБ (около 35 циклов).

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

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

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

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

Вы также можете прочитать о механизме выполнения вне очереди в "Справочном руководстве по оптимизации архитектур 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, and, cmp, or, 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) andp *, (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-architecture-optimisation-manual.pdf

Раздел 2.2.2 (Механизм нарушения порядка в микроархитектуре Haswelll): «Планировщик контролирует отправку микроопераций на порты отправки. Имеется восемь портов отправки для поддержки ядра выполнения вне очереди. Четыре из восьми портов предоставлены ресурсы выполнения для вычислительных операций. Остальные 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, быстрый LEA, медленный LEA, MUL, Shuf, Blend, 128bMov, Add, CVT
  • Порт 2 и порт 3: Load_Addr, Store_addr
  • Порт 4: Store_data
  • Порт 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 нагрузки + 1 * 32B Store)
  • Кэш второго уровня: 64 байта
  • Кэш третьего уровня: 32 байта.

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

Если ваш ЦП имеет бит AVX 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» снижает производительность, а простое «add 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) (второе полугодие 2017 г.); Процессоры Cannonlake (вторая половина 2017 года), процессоры Skylake-X - Core i9-7 ××× X, i7-7 ××× X, i5-7 ××× X - выпущены в июне 2017 года.

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

person Maxim Masiutin    schedule 08.05.2017
comment
Могу ли я сделать это с помощью каких-то оболочек C / C ++? Или я должен писать код сборки? - person einpoklum; 08.05.2017
comment
Компиляторы Microsoft и Intel имеют оболочки C, но, на мой взгляд, ассемблерный код, будь то встроенный или в отдельном файле .asm, должен быть предпочтительнее. Вопрос в том, какова ваша цель - скорость memcpy () или переносимость / простота. - person Maxim Masiutin; 08.05.2017
comment
@MaximMasiutin - ваша попытка смешать SSE и 64-битные mov инструкции не работает, потому что ALU не выполняет загрузки. Даже на самых продвинутых процессорах x86 есть только две единицы нагрузки, поэтому за цикл может быть выдано не более двух единиц нагрузки. Загрузки всех размеров (8 бит, 16 бит, 32 бит, ..., 256) идут в эти единицы, поэтому вы обычно просто хотите использовать самые большие загрузки, доступные для большей части копии. - person BeeOnRope; 09.05.2017
comment
@BeeOnRope - я уже в этом разобрался. Как я уже упоминал в своем комментарии: на практике, когда я проводил тесты скорости на моем процессоре Intel Core i5 6600 (Skylake, 14 нм, выпущен в сентябре 2015 года) с памятью DDR4, используя общие 64-разрядные регистры для копирования памяти, производительность снижается. . Кроме того, достаточно использовать только 2 регистра XMM - добавление 3-го не увеличивает производительности. Вероятно, полоса пропускания памяти между ЦП и его кешем ограничена - я тестировал очень маленькие блоки, которые полностью помещаются в кэш L1, который составляет 32 КБ для данных и 32 КБ для инструкций на моем ЦП. - так что достаточно всего 2 XMM. - person Maxim Masiutin; 09.05.2017
comment
Верно, но форма вашего ответа теоретическая, это должно сработать, но на практике это не так. Однако правда заключается в теории, а на практике это не работает. Разве это не полезная информация? Кроме того, вы делаете вывод, что ваш смешанный метод GP / SIMD не работает из-за пропускной способности, но это не совсем правильно: он не работает, потому что основан на неправильной модели машины. Конечно, если вы тестируете большие буферы, у вас будет ограниченная полоса пропускания, поэтому даже плохие реализации, созданные на основе ошибочной теории, могут связать хорошие, но проверьте это на небольшом буфере, и вы увидите, что ваша теория неверна. - person BeeOnRope; 09.05.2017
comment
@BeeOnRope, большое спасибо за то, что указали на это. Переписал соответствующий раздел. Еще раз спасибо. - person Maxim Masiutin; 09.05.2017