Чому memcpy () і memmove () швидші за збільшення покажчика?


92

Я копіюю N байт з pSrcдо pDest. Це можна зробити за один цикл:

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

Чому це повільніше ніж memcpyабо memmove? Які трюки вони використовують, щоб пришвидшити це?


2
Ваш цикл копіює лише одне місце. Я думаю, ти якось хотів збільшити покажчики.
Містичний

13
Або ви можете просто виправити це для них, як я це зробив. І, до речі, чи не так C програміст коли - або відліки від 1до N, це завжди від 0до N-1:-)
paxdiablo

6
@paxdiablo: Якщо ви переглядаєте масиви, звичайно. Але є багато випадків, коли циклічне від 1 до N цілком добре. Залежить від того, що ви робите з даними - якщо ви показуєте нумерований список, починаючи з 1, наприклад, користувачеві, тоді, починаючи з 1, можливо, має більше сенсу. У будь-якому випадку він ігнорує більшу проблему, яка використовується intяк лічильник, коли size_tзамість цього слід використовувати непідписаний тип типу .
Billy ONeal

2
@paxdiablo Ви також можете рахувати від N до 1. На деяких процесорах, які усунуть одну інструкцію порівняння, оскільки декремент встановить відповідний біт для інструкції гілки, коли вона досягне нуля.
onemasse

6
Я думаю, що передумова запитання хибна. Сучасні компілятори перетворять це на memcpyабо memmove(залежно від того, чи зможуть вони визначити, чи можуть вказівники мати псевдонім).
Девід Шварц,

Відповіді:


120

Оскільки memcpy використовує вказівники на слова замість байтових вказівників, також реалізації memcpy часто пишуться з інструкціями SIMD, що дозволяє перетасовувати 128 бітів за раз.

Інструкції SIMD - це інструкція по збірці, яка може виконувати одну і ту ж операцію над кожним елементом у векторі довжиною до 16 байт. Сюди входять інструкції щодо завантаження та зберігання.


15
Коли ви повернете GCC до -O3, він використовуватиме SIMD для циклу, принаймні, якщо він знає pDestі pSrcне псевдоніми.
Дітріх Епп,

Зараз я працюю над Xeon Phi із 64 байтами (512 біт) SIMD, тому цей вміст "до 16 байт" викликає у мене посмішку. Крім того, ви повинні вказати, на який процесор, на який ви орієнтуєтесь для активації SIMD, наприклад, з -march = native.
yakoudbz

Можливо, мені слід переглянути свою відповідь. :)
onemasse

Це дуже застаріло навіть на час розміщення. Вектори AVX на x86 (поставляються в 2011 році) мають 32 байти, а AVX-512 - 64 байти. Є деякі архітектури з 1024-бітними або 2048-бітними векторами або навіть змінною шириною вектора, як ARM SVE
phuclv

@phuclv, хоча інструкції тоді вже були доступними, чи є у вас докази того, що memcpy їх використовує? Зазвичай бібліотекам потрібно трохи часу, щоб наздогнати, і найновіші, які я можу знайти, використовують SSSE3 і набагато новіші, ніж 2011 рік.
Піт Кіркхем,

81

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

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 я отримав кращу продуктивність, вирівнюючи цільовий покажчик, а не вихідний.

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

На цьому етапі код закінчується написанням у збірці, а не на C (або C ++), оскільки вам потрібно вручну розмістити навантаження та зберегти інструкції, щоб отримати максимальну вигоду від приховування та пропускної здатності затримки.

Як правило, цілий рядок даних кешу повинен бути скопійований за одну ітерацію розкрученого циклу.

Що підводить мене до наступного вдосконалення, додавання попереднього вибору. Це спеціальні вказівки, які повідомляють кеш-систему процесора завантажувати певні частини пам'яті в свій кеш. Оскільки між видачею інструкції та заповненням рядка кеш-пам’яті є затримка, інструкції потрібно розміщувати таким чином, щоб дані були доступні тоді, коли їх потрібно скопіювати, а не раніше / пізніше.

Це означає розміщувати вказівки щодо попереднього вибору на початку функції, а також всередині основного циклу копіювання. За допомогою інструкцій попередньої вибірки в середині циклу копіювання отримують дані, які будуть скопійовані за кілька ітерацій.

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

Фактори

Основними факторами, що впливають на швидкість копіювання пам'яті, є:

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

Отже, якщо ви хочете написати ефективний і швидкий режим роботи з пам'яттю, вам доведеться знати досить багато про процесор та архітектуру, для якої ви пишете. Досить сказати, що якщо ви не пишете на якійсь вбудованій платформі, було б набагато простіше просто використовувати вбудовану процедуру копіювання пам’яті.


Сучасні центральні процесори визначають лінійний шаблон доступу до пам'яті та починають попереднє завантаження самостійно. Я сподіваюся, що вказівки попередньої вибірки через це не матимуть великої різниці.
maxy

@maxy Щодо тих небагатьох архітектур, де я впровадив процедури копіювання пам’яті, додавання попередньої вибірки помітно допомогло. Хоча може бути правдою, що чіпи сучасного покоління Intel / AMD виконують попередню підготовку досить далеко вперед, є багато старих чіпів та інших архітектур, які цього не роблять.
Daemin

хто-небудь може пояснити "(b_src & 0x3)! = 0"? Я не можу це зрозуміти, а також - він не компілюється (видає помилку: недійсний оператор до двійкового &: unsigned char та int);
Девід Рефаелі,

"(b_src & 0x3)! = 0" перевіряє, чи не найнижчі 2 біти не 0. Так, якщо вказівник джерела вирівнюється до кратного 4 байтів чи ні. Помилка компіляції трапляється тому, що вона розглядає 0x3 як байт, а не in, ви можете виправити це, використовуючи 0x00000003 або 0x3i (я думаю).
Daemin

b_src & 0x3не буде компілювати, оскільки вам не дозволено робити порозрядну арифметику на типах покажчиків. Ви повинні кинути його (u)intptr_tпершим
phuclv

18

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

З одного прикладу реалізації :

    00026 * Для швидкого копіювання оптимізуйте загальний випадок, коли обидва вказівники
    00027 * і довжина вирівняні за словом, а замість цього скопіюйте слово за раз
    00028 * байт за раз. В іншому випадку скопіюйте в байти.

8
На 386 (для одного прикладу), який не мав вбудованого кешу, це мало величезну різницю. У більшості сучасних процесорів читання і запис відбуватиметься по одному кеш-рядку за один раз, а шина пам'яті, як правило, буде вузьким місцем, тому очікуйте покращення на кілька відсотків, а не десь поруч із чотирикратним.
Джеррі Труну

2
Я думаю, ви повинні бути трохи більш чіткими, коли ви говорите "з джерела". Звичайно, це "джерело" для деяких архітектур, але це, звичайно, не на, скажімо, BSD або машині Windows. (І пекло, навіть між системами GNU часто є велика різниця в цій функції)
Billy ONeal

@Billy ONeal: +1 абсолютно вірно ... Існує більше ніж один спосіб зібрати кішку. Це був лише один приклад. Виправлено! Дякую за конструктивний коментар.
Марк Байєрс

7

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

  1. Використовуйте великі одиниці, наприклад, 32-бітні слова замість байтів. Ви також можете (або, можливо, доведеться) вирішувати вирівнювання і тут. Ви не можете читати / писати 32-бітне слово в непарне місце для пам'яті, наприклад, на деяких платформах, а на інших платформах ви сплачуєте величезний штраф за продуктивність. Щоб це виправити, адреса повинна бути одиницею, що ділиться на 4. Ви можете взяти це до 64-біт для 64-бітних процесорів або навіть вище, використовуючи інструкції SIMD (одна інструкція, кілька даних) ( MMX , SSE тощо)

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

  3. Розгортання циклу - гілки можуть бути досить дорогими для деяких процесорів, тому розгортання циклів може зменшити кількість гілок. Це також хороша методика поєднання із інструкціями SIMD та дуже великими розмірами одиниць.

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

Зверніть увагу, що існують подібні оптимізації для пошуку байтів у буфері. strchr()а друзі часто швидше, ніж ваш еквівалент рукою. Особливо це стосується .NET та Java . Наприклад, у .NET вбудований String.IndexOf()набагато швидше, ніж навіть пошук рядків Боєра – Мура , оскільки він використовує вищезазначені методи оптимізації.


1
Той самий Agner Fog, до якого ви посилаєтесь, також теоретизує, що розгортання циклу є контрпродуктивним для сучасних процесорів .

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

5

Коротка відповідь:

  • кеш-заливка
  • де можливо, переклади слів замість байтів
  • SIMD магія

4

Я не знаю, чи він насправді використовується в реальних реалізаціях memcpy, але я думаю , що пристрій Даффа заслуговує на згадування тут.

З Вікіпедії :

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покажчик. Він реалізує дещо іншу операцію: запис у регістр, нанесений на пам'ять. Детальніше дивіться у статті Вікіпедії.


Пристрій Даффа, або просто початковий механізм стрибка, є корисним використанням для копіювання перших байтів 1..3 (або 1..7), щоб покажчики вирівнювались до приємнішої межі, де можна використовувати більші інструкції щодо переміщення пам'яті.
Daemin

@MarkByers: Код ілюструє дещо іншу операцію ( *toвідноситься до відображеного в пам'яті реєстру і навмисно не збільшується - див. Статтю, пов’язану з посиланням). Як я вважав, що я зрозумів, моя відповідь не намагається забезпечити ефективну memcpy, вона просто згадує досить цікаву техніку.
NPE

@Daemin погодився, як ви вже сказали, що можете пропустити do {}, поки (), і перемикач буде переведений компілятором у таблицю переходів. Дуже корисно, коли ви хочете подбати про інші дані. Слід зазначити попередження про пристрій Даффа, очевидно, що в нових архітектурах (новіша x86) передбачення гілок настільки ефективно, що пристрій Даффа насправді працює повільніше, ніж простий цикл.
onemasse

1
О ні .. не пристрій Даффа. Не використовуйте пристрій Даффа. Будь ласка. Використовуйте PGO, і дозвольте компілятору робити циклічну розгортання для вас, де це має сенс.
Billy ONeal

Ні, пристрій Даффа, безумовно, не використовується в жодній сучасній реалізації.
gnasher729

3

Як кажуть інші, мемпі-копії розміром більше 1-байтних фрагментів. Копіювання фрагментами розміру слова відбувається набагато швидше. Однак більшість реалізацій роблять це на крок далі і виконують кілька інструкцій MOV (word) перед циклом. Перевага копіювання у скажімо, 8 словоблоків на цикл полягає в тому, що цикл сам по собі дорогий. Ця методика зменшує кількість умовних гілок в 8 разів, оптимізуючи копію для гігантських блоків.


1
Я не думаю, що це правда. Ви можете розкрутити цикл, але ви не можете скопіювати в одній інструкції більше даних, ніж адресованих одночасно в цільовій архітектурі. Плюс, є також накладні
розкручування

@Billy ONeal: Я не думаю, що це означало VoidStar. Маючи кілька послідовних інструкцій щодо переміщення, накладні витрати на підрахунок кількості одиниць зменшуються.
wallyk

@Billy ONeal: Ви втратили суть. Одночасне слово входить як MOV, JMP, MOV, JMP тощо. Де можна зробити MOV MOV MOV MOV JMP. Я писав пам’ятку раніше, і я відзначив багато способів це зробити;)
VoidStar

@wallyk: Можливо. Але він каже "скопіювати ще більші шматки" - що насправді неможливо. Якщо він має на увазі розмотування циклу, то він повинен сказати, що «більшість реалізацій зробить його крок далі і розгорніть цикл». Відповідь, написана в кращому випадку, оманлива, в гіршому - неправильна.
Billy ONeal

@VoidStar: Згоден --- зараз краще. +1.
Billy ONeal

2

Відповіді великов, але якщо ви все ще хочете здійснити швидкі memcpyсебе, є цікавий блог про швидке тетсре, Fast тетсра в 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;
}

Навіть це може бути краще за рахунок оптимізації доступу до пам'яті.


1

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

Зважаючи на вибір, скористайтеся бібліотечними процедурами, а не прокручуйте власні. Це варіація СУХОГО, яку я називаю DRO (Не повторюйте інші). Крім того, бібліотечні процедури рідше помилкові, ніж ваша власна реалізація.

Я бачив, як перевіряючі доступ до пам'яті скаржаться на позачергові зчитування в буферах пам'яті або рядків, які не кратні розміру слова. Це результат оптимізації, яка використовується.


0

Ви можете подивитися реалізацію memset, memcpy та memmove в MacOS.

Під час завантаження ОС визначає, на якому процесорі він працює. Він вбудував спеціально оптимізований код для кожного підтримуваного процесора і під час завантаження зберігає інструкцію jmp до потрібного коду у фіксованому місці / лише для читання.

Реалізації мемсету C, memcpy та memmove - це лише стрибок до цього фіксованого місця.

Реалізації використовують різний код в залежності від вирівнювання джерела та місця призначення для memcpy та memmove. Вони, очевидно, використовують усі наявні векторні можливості. Вони також використовують варіанти, що не кешуються, коли ви копіюєте великі обсяги даних, і мають інструкції, щоб мінімізувати очікування таблиць сторінок. Це не просто асемблерний код, це асемблерний код, написаний кимось, хто надзвичайно добре знає архітектуру кожного процесора.

Intel також додала інструкції асемблера, які можуть робити швидкі операції швидше. Наприклад, з інструкцією для підтримки strstr, яка виконує порівняння 256 байт за один цикл.


Версія Apple з відкритим кодом memset / memcpy / memmove - це просто загальна версія, яка буде набагато повільніше, ніж реальна версія за допомогою SIMD
phuclv
Використовуючи наш веб-сайт, ви визнаєте, що прочитали та зрозуміли наші Політику щодо файлів cookie та Політику конфіденційності.
Licensed under cc by-sa 3.0 with attribution required.