Avatar ·

Почему поэлементное сложение в двух отдельных циклах работает быстрее, чем в одном объединённом?

Всем привет! Пишу на C++ уже пару месяцев, и столкнулся с очень странным поведением производительности. Есть два массива: `a` и `b` размером 200000000 элементов (около 800 МБ каждый). Я хочу поэлементно сложить их и сохранить результат в массив `c`, а затем умножить каждый элемент `c` на 2 и сохранить в `d`. Я написал два варианта кода: **Вариант 1 (два отдельных цикла):** ```cpp for (int i = 0; i < n; ++i) { c[i] = a[i] + b[i]; } for (int i = 0; i < n; ++i) { d[i] = c[i] * 2; } ``` **Вариант 2 (один объединённый цикл):** ```cpp for (int i = 0; i < n; ++i) { c[i] = a[i] + b[i]; d[i] = c[i] * 2; } ``` Я ожидал, что второй вариант будет быстрее, так как он делает всё за один проход. Но на практике (компилятор MSVC, флаги /O2, архитектура x64) первый вариант работает **почти в два раза быстрее**! Почему так? Ведь во втором варианте меньше операций с индексами и меньше проверок границ. Объясните, пожалуйста, что происходит под капотом. Это связано с кэшем процессора? С конвейером команд? Или компилятор просто лучше оптимизирует отдельные циклы? <<>>

Проблема: эффект кэш-промахов и ограничение пропускной способности памяти

Ваше наблюдение абсолютно верно: на современных x86-процессорах (Intel/AMD) два отдельных цикла могут работать быстрее одного объединённого. Причина кроется не в логике программы, а в том, как процессор взаимодействует с памятью через кэш.

Давайте разберёмся по шагам. Размер ваших массивов — 800 МБ каждый. Общий объём данных, с которыми работает объединённый цикл (a, b, c, d) — 3.2 ГБ. Кэш L3 современного процессора обычно составляет 8–30 МБ. Это значит, что все массивы целиком в кэш не помещаются.

Что происходит в объединённом цикле?

for (int i = 0; i < n; ++i) {
    c[i] = a[i] + b[i];   // Чтение a[i], b[i] и запись c[i]
    d[i] = c[i] * 2;      // Чтение c[i] (только что записанного) и запись d[i]
}

За одну итерацию вы выполняете:

  • 2 чтения из памяти (a[i], b[i])
  • 2 записи в память (c[i], d[i])

Итого — 4 операции с памятью на итерацию. Но это не всё. Самое важное: вы читаете c[i] сразу после его записи. Процессор вынужден ждать, пока запись в кэш завершится, прежде чем считать это же значение. Это называется зависимостью по данным через память (store-to-load forwarding delay).

Кроме того, когда вы идёте по массивам последовательно, процессор пытается предзагружать данные в кэш (prefetch). Но в объединённом цикле prefetch-у нужно обрабатывать 4 потока данных одновременно (a, b, c, d). Это создаёт конкуренцию за шину памяти и кэш-линии.

Что происходит в двух отдельных циклах?

// Первый цикл
for (int i = 0; i < n; ++i) {
    c[i] = a[i] + b[i];   // 2 чтения + 1 запись
}
// Второй цикл
for (int i = 0; i < n; ++i) {
    d[i] = c[i] * 2;      // 1 чтение + 1 запись
}

В первом цикле — 3 операции с памятью на итерацию. Во втором — 2 операции. Но главное преимущество: в каждом цикле prefetch работает только с двумя потоками данных. Это позволяет процессору более эффективно загружать данные в кэш.

Более того, после завершения первого цикла массив c уже полностью лежит в кэше (если размер кэша позволяет). Второй цикл читает c[i] из кэша L1/L2, что очень быстро. В объединённом же цикле c[i] может быть вытеснен из кэша до того, как вы к нему вернётесь на следующей итерации.

Техническое объяснение: пропускная способность памяти

Современные процессоры имеют ограниченную пропускную способность работы с памятью. Например, для Intel Core i7-12700K она составляет около 50 ГБ/с. Каждая операция чтения/записи занимает «слот» в шине памяти. В объединённом цикле вы используете 4 слота на итерацию, что быстрее упирается в лимит. В отдельных циклах вы используете 3 и 2 слота соответственно, что даёт больше времени для обработки данных в кэше.

Как это проверить?

Попробуйте уменьшить размер массива до 100 000 элементов (чтобы он поместился в кэш L2). Вы увидите, что объединённый цикл станет быстрее или, как минимум, не медленнее. Это подтвердит, что проблема именно в кэш-промахах.

Вывод

Не всегда меньшее количество циклов означает большую скорость. В задачах с большими объёмами данных (big data) часто выгоднее делать несколько проходов, чтобы минимизировать количество одновременных потоков к памяти и дать процессору эффективно работать с кэшем. Это называется cache-aware programming.

<<>>

Альтернативный взгляд: роль векторизации и конвейера команд

Коллега правильно указал на кэш, но есть и ещё один важный аспект — векторизация. Современные компиляторы (GCC, Clang, MSVC) при включённой оптимизации (-O2, -O3) автоматически генерируют SIMD-инструкции (AVX, SSE).

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

// Первый цикл векторизуется как:
// for i = 0 to n step 4:
//   _mm256_loadu_si256(a+i), _mm256_loadu_si256(b+i)
//   _mm256_add_epi32 -> запись в c

// Второй цикл:
//   _mm256_loadu_si256(c+i)
//   _mm256_mul_epi32 -> запись в d

В объединённом цикле компилятору сложнее применить векторизацию из-за зависимости между c[i] и d[i] на одной и той же итерации. Он может попытаться развернуть цикл, но это не всегда эффективно.

Кроме того, в объединённом цикле конвейер команд процессора чаще останавливается из-за того, что результат сложения (c[i]) нужен немедленно для умножения. В отдельных циклах конвейер работает непрерывно, так как нет такой зависимости между итерациями.

Совет: Если вам критична производительность, рассмотрите использование библиотек вроде std::transform с параллельной политикой исполнения (C++17) или ручную векторизацию через интринсики. Но помните: сначала профилируйте, потом оптимизируйте!