Почему memcpy() и memmove() быстрее, чем указатели?

Я копирую N байтов от pSrc до pDest. Это можно сделать в одном цикле:

for (int i = 0; i < N; i++)
    *pDest++ = *pSrc++

Почему это медленнее, чем memcpy или memmove? Какие трюки они используют, чтобы ускорить его?

Ответ 1

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

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

Ответ 2

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

void simple_memory_copy(void* dst, void* src, unsigned int bytes)
{
  unsigned char* b_dst = (unsigned char*)dst;
  unsigned char* b_src = (unsigned char*)src;
  for (int i = 0; i < bytes; ++i)
    *b_dst++ = *b_src++;
}

<сильные > Улучшения

Первое улучшение, которое можно сделать, - это выровнять один из указателей на границе слова (по слову я я имею в виду собственный целочисленный размер, обычно 32 бита /4 байта, но может быть 64 бита /8 байтов на новых архитектурах) и использовать текстовые инструкции по перемещению/копированию. Для этого требуется использовать байтовую копию в байтах до выравнивания указателя.

void aligned_memory_copy(void* dst, void* src, unsigned int bytes)
{
  unsigned char* b_dst = (unsigned char*)dst;
  unsigned char* b_src = (unsigned char*)src;

  // Copy bytes to align source pointer
  while ((b_src & 0x3) != 0)
  {
    *b_dst++ = *b_src++;
    bytes--;
  }

  unsigned int* w_dst = (unsigned int*)b_dst;
  unsigned int* w_src = (unsigned int*)b_src;
  while (bytes >= 4)
  {
    *w_dst++ = *w_src++;
    bytes -= 4;
  }

  // Copy trailing bytes
  if (bytes > 0)
  {
    b_dst = (unsigned char*)w_dst;
    b_src = (unsigned char*)w_src;
    while (bytes > 0)
    {
      *b_dst++ = *b_src++;
      bytes--;
    }
  }
}

Различные архитектуры будут работать по-разному на основе правильного выравнивания указателя источника или назначения. Например, на процессоре XScale я получил лучшую производительность, совместив указатель назначения, а не указатель источника.

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

На этом этапе код заканчивается написанием в Assembly, а не C (или С++), так как вам нужно вручную разместить инструкции загрузки и хранения, чтобы получить максимальную выгоду от скрытия и пропускной способности времени.

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

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

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

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

Факторы

Основными факторами, влияющими на то, как быстро копировать память, являются:

  • Задержка между процессором, его кэшами и основной памятью.
  • Размер и структура строк кэша процессора.
  • Инструкции по перемещению/копированию памяти процессора (время ожидания, пропускная способность, размер регистра и т.д.).

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

Ответ 3

memcpy может копировать более одного байта сразу в зависимости от архитектуры компьютера. Большинство современных компьютеров могут работать с 32 бит и более в одной инструкции процессора.

Из один пример реализации:

    00026          * For speedy copying, optimize the common case where both pointers
    00027          * and the length are word-aligned, and copy word-at-a-time instead
    00028          * of byte-at-a-time. Otherwise, copy by bytes.

Ответ 4

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

  • Используйте более крупные единицы, такие как 32-битные слова вместо байтов. Вы также можете (или, возможно, придется) справиться с выравниванием здесь. Вы не можете читать или записывать 32-битное слово в нечетную ячейку памяти, например, на некоторых платформах, а на других платформах вы платите значительное снижение производительности. Чтобы исправить это, адрес должен быть единицей, делящейся на 4. Вы можете взять это до 64 бит для 64-битных процессоров или даже выше, используя SIMD (инструкции для одной инструкции, несколько данных) (MMX, SSE и т.д.)

  • Вы можете использовать специальные инструкции CPU, которые ваш компилятор не может оптимизировать с C. Например, на 80386 вы можете использовать инструкцию префикса "rep" + команду "movsb" для перемещения пронумерованных N байтов помещая N в регистр счета. Хорошие компиляторы просто сделают это для вас, но вы можете оказаться на платформе, которой не хватает хорошего компилятора. Обратите внимание, что этот пример имеет тенденцию быть плохой демонстрацией скорости, но в сочетании с инструкциями по выравниванию и большей единицей, он может быть быстрее, чем в основном все остальное на некоторых процессорах.

  • Loop unrolling - ветки могут быть довольно дорогими на некоторых процессорах, поэтому разворачивание циклов может уменьшить количество ветвей. Это также хороший способ комбинирования с SIMD-инструкциями и очень большими размерами.

Например, http://www.agner.org/optimize/#asmlib имеет реализацию memcpy, которая превосходит большинство из них (на очень маленькую сумму). Если вы прочтете исходный код, он будет заполнен множеством встроенного ассемблерного кода, который вытащит все три вышеупомянутых метода, выбирая, какой из этих методов основан на том, на каком процессоре вы работаете.

Заметьте, есть аналогичные оптимизации, которые можно сделать для поиска байтов в буфере. strchr() и друзья часто будут быстрее, чем ваш рулонный эквивалент. Это особенно справедливо для .NET и Java, Например, в .NET встроенный String.IndexOf() намного быстрее, чем даже поиск строки Boyer-Moore, поскольку он использует выше методы оптимизации.

Ответ 5

Короткий ответ:

  • заполнение кеша
  • перевод слов вместо байтов, где это возможно
  • SIMD magic

Ответ 6

Как и другие, memcpy копирует более 1 байт. Копирование фрагментов с размером слова намного быстрее. Однако большинство реализаций делают шаг вперед и запускают несколько инструкций MOV (word) перед циклом. Преимущество для копирования, например, 8 блоков слов за цикл состоит в том, что сам цикл является дорогостоящим. Этот метод уменьшает количество условных ветвей в 8 раз, оптимизируя копию для гигантских блоков.

Ответ 7

Я не знаю, действительно ли он используется в каких-либо реальных реализациях memcpy, но я думаю, Duff Device заслуживает упоминание здесь.

Из Wikipedia:

send(to, from, count)
register short *to, *from;
register count;
{
        register n = (count + 7) / 8;
        switch(count % 8) {
        case 0:      do {     *to = *from++;
        case 7:              *to = *from++;
        case 6:              *to = *from++;
        case 5:              *to = *from++;
        case 4:              *to = *from++;
        case 3:              *to = *from++;
        case 2:              *to = *from++;
        case 1:              *to = *from++;
                } while(--n > 0);
        }
}

Обратите внимание, что выше не является memcpy, поскольку он намеренно не увеличивает указатель to. Он выполняет несколько другую операцию: запись в регистр с отображением памяти. Подробнее см. Статью в Википедии.

Ответ 8

Ответы замечательные, но если вы все еще хотите реализовать быстрый memcpy самостоятельно, есть интересное сообщение в блоге о быстрой memcpy, Быстрая memcpy в C.

void *memcpy(void* dest, const void* src, size_t count)
{
    char* dst8 = (char*)dest;
    char* src8 = (char*)src;

    if (count & 1) {
        dst8[0] = src8[0];
        dst8 += 1;
        src8 += 1;
    }

    count /= 2;
    while (count--) {
        dst8[0] = src8[0];
        dst8[1] = src8[1];

        dst8 += 2;
        src8 += 2;
    }
    return dest;
}

Даже лучше оптимизировать доступ к памяти.

Ответ 9

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

Учитывая выбор, используйте библиотечные процедуры, а не сворачивайте свои собственные. Это вариация DRY, которую я называю DRO (Do not Repeat Others). Кроме того, библиотечные процедуры с меньшей вероятностью ошибочны, чем ваша собственная реализация.

Я видел, как контролеры доступа к памяти жалуются на то, что за пределами чтения читается память или строковые буферы, которые не были кратно размеру слова. Это результат использования оптимизации.