Почему std::fill(0) медленнее, чем std:: fill(1)?
я наблюдал в системе, что std::fill
большой std::vector<int>
был значительно и последовательно медленнее при установке постоянного значения 0
по сравнению с постоянным значением 1
или динамическое значение:
5.8 гиб / с против 7.5 гиб / с
однако результаты различны для меньших размеров данных, где fill(0)
быстрее:
С больше чем одним потоком, на размере данных 4 гиб, fill(1)
показывает более высокий наклон, но достигает гораздо более низкого пика, чем fill(0)
(51 гиб / с против 90 гиб / с):
это поднимает вторичный вопрос, почему пиковая пропускная способность fill(1)
гораздо ниже.
тестовой системой для этого был двойной разъем Intel Xeon CPU E5-2680 v3, установленный на 2,5 ГГц (через /sys/cpufreq
) С 8x16 GiB DDR4-2133. Я тестировал с GCC 6.1.0 (-O3
) и компилятор Intel 17.0.1 (-fast
), оба получают одинаковые результаты. GOMP_CPU_AFFINITY=0,12,1,13,2,14,3,15,4,16,5,17,6,18,7,19,8,20,9,21,10,22,11,23
был установлен. Strem/add/24 threads получает 85 GiB / s в системе.
я смог воспроизвести этот эффект в другой системе сервера с двумя сокетами Haswell, но не в любой другой архитектуре. Например, на Sandy Bridge EP производительность памяти идентична, а в кэше fill(0)
гораздо быстрее.
вот код для воспроизведения:
#include <algorithm>
#include <cstdlib>
#include <iostream>
#include <omp.h>
#include <vector>
using value = int;
using vector = std::vector<value>;
constexpr size_t write_size = 8ll * 1024 * 1024 * 1024;
constexpr size_t max_data_size = 4ll * 1024 * 1024 * 1024;
void __attribute__((noinline)) fill0(vector& v) {
std::fill(v.begin(), v.end(), 0);
}
void __attribute__((noinline)) fill1(vector& v) {
std::fill(v.begin(), v.end(), 1);
}
void bench(size_t data_size, int nthreads) {
#pragma omp parallel num_threads(nthreads)
{
vector v(data_size / (sizeof(value) * nthreads));
auto repeat = write_size / data_size;
#pragma omp barrier
auto t0 = omp_get_wtime();
for (auto r = 0; r < repeat; r++)
fill0(v);
#pragma omp barrier
auto t1 = omp_get_wtime();
for (auto r = 0; r < repeat; r++)
fill1(v);
#pragma omp barrier
auto t2 = omp_get_wtime();
#pragma omp master
std::cout << data_size << ", " << nthreads << ", " << write_size / (t1 - t0) << ", "
<< write_size / (t2 - t1) << "n";
}
}
int main(int argc, const char* argv[]) {
std::cout << "size,nthreads,fill0,fill1n";
for (size_t bytes = 1024; bytes <= max_data_size; bytes *= 2) {
bench(bytes, 1);
}
for (size_t bytes = 1024; bytes <= max_data_size; bytes *= 2) {
bench(bytes, omp_get_max_threads());
}
for (int nthreads = 1; nthreads <= omp_get_max_threads(); nthreads++) {
bench(max_data_size, nthreads);
}
}
представленные результаты скомпилированы с g++ fillbench.cpp -O3 -o fillbench_gcc -fopenmp
.
2 ответов
из вашего вопроса + сгенерированный компилятором asm из вашего ответа:
-
fill(0)
Это ERMSBrep stosb
которая будет использовать 256Б магазинах в оптимизированном контуре платформах. (Лучше всего работает, если буфер выровнен, вероятно, по крайней мере до 32B или, возможно, 64B). -
fill(1)
это простой 128-битmovaps
векторная петля-магазине. Только один магазин может выполнить в такт сердечника независимо от ширины, до 256B AVX. Поэтому магазины 128b может только заполнить половина пропускной способности записи кэша L1D Haswell. вот почемуfill(0)
примерно в 2 раза быстрее для буферов до ~32kiB. Скомпилировать с помощью-march=haswell
или-march=native
исправить.Haswell может едва поспевать за накладными расходами цикла, но он все еще может запускать 1 магазин за часы, даже если он вообще не развернут. Но с 4 плавлеными доменами uops за часы, это много наполнителя, занимающего место в окне вне порядка. Некоторые разворачивания, возможно, позволят TLB промахам начать разрешать дальше от того, где магазины происходят, так как для uops адреса хранилища больше пропускной способности, чем для данных хранилища. Развертывание может помочь компенсировать остальную разницу между ERMSB и этим векторным циклом для буферов, которые вписываются в L1D. (Комментарий к вопросу говорит, что
-march=native
помог толькоfill(1)
для L1.)
отметим, что rep movsd
(который может быть использован для реализации fill(1)
на int
элементы) вероятно, выполняют те же как rep stosb
на Haswell.
Хотя только официальная документация только гарантирует, что ERMSB дает fast rep stosb
(но не rep stosd
), фактические процессоры, поддерживающие ERMSB, используют аналогичный эффективный микрокод для rep stosd
. Есть некоторые сомнения насчет Айвибриджа, где, может быть, только b
быстро. Смотрите @ BeeOnRope отлично ERMSB ответ для обновления на этой.
gcc имеет некоторые параметры настройки x86 для string ops (как -mstringop-strategy=
alg и -mmemset-strategy=strategy
), но IDK, если любой из них получит его на самом деле испускать rep movsd
на fill(1)
. Вероятно, нет, так как я предполагаю, что код начинается как цикл, а не memset
.
С более чем одним потоком, при размере данных 4 GiB, fill (1) показывает более высокий наклон, но достигает гораздо более низкого пика, чем fill(0) (51 GiB/S vs 90 GiB/s):
нормальный movaps
хранить в холодной строке кэша триггеры Читать На Праве Собственности (RFO). Много реальной пропускной способности DRAM тратится на чтение строк кэша из памяти, когда movaps
записывает первые 16 байт. Магазины ERMSB используют протокол no-RFO для своих магазинов, поэтому контроллеры памяти только пишут. (За исключением разных чтений, таких как таблицы страниц, если какие-либо прогулки по страницам пропускаются даже в кэше L3, и, возможно, некоторые пропуски загрузки в обработчиках прерываний или что-то еще).
@BeeOnRope объясняет в комментарии что разница между обычными Магазины RFO и протокол, избегающий RFO, используемый ERMSB, имеют недостатки для некоторых диапазонов размеров буферов на серверных процессорах, где есть высокая задержка в кэше uncore/L3. см. также связанный ответ ERMSB для больше о RFO против non-RFO, и высокая латентность uncore (L3/memory) в многоядерных процессорах Intel является проблемой для одноядерной пропускной способности.
movntps
(_mm_stream_ps()
) магазинов слабо упорядочены, поэтому они могут обойти кэш и перейти прямо в память всю кэш-строку за раз, никогда не читая строку кэша в L1D. movntps
избегает RFOs, как rep stos
делает. (rep stos
магазины могут переупорядочивать друг друга, но не за пределами инструкции.)
код movntps
результаты в вашем обновленном ответе удивительны.
для одного потока с большими буферами ваши результаты movnt
> > обычный RFO > ERMSB. Так что это действительно странно, что два метода non-RFO включены противоположные стороны равнины старые магазины, и что ERMSB так далеко от оптимального. В настоящее время у меня нет объяснения этому. (редактирование приветствуется с объяснением + хорошими доказательствами).
как мы и ожидали, movnt
позволяет нескольким потокам достигать высокой пропускной способности агрегатного магазина, например ERMSB. movnt
всегда идет прямо в буферы заполнения строк, а затем в память, поэтому он намного медленнее для размеров буферов, которые вписываются в кэш. Один вектор 128b в часы достаточно легко насытить a одноядерная полоса пропускания no-RFO для DRAM. Вероятно vmovntps ymm
(256b) только измеримое преимущество над vmovntps xmm
(128b) при хранении результатов вычислений AVX 256b с привязкой к процессору (т. е. только тогда, когда это экономит проблемы распаковки до 128b).
movnti
пропускная способность низкая, потому что хранение в узких местах 4B на 1 магазине uop за часы добавление данных в буферы заполнения строки, а не при отправке этих буферов полной строки в DRAM (пока у вас не будет достаточно потоков для насыщения памяти пропускная способность.)
@osgx posted некоторые интересные ссылки в комментариях:
- руководство по оптимизации asm Agner Fog, таблицы инструкций и руководство по микроархиву:http://agner.org/optimize/
руководство по оптимизации Intel: http://www.intel.com/content/dam/www/public/us/en/documents/manuals/64-ia-32-architectures-optimization-manual.pdf.
NUMA шпионаж: http://frankdenneman.nl/2016/07/11/numa-deep-dive-part-3-cache-coherency/
- https://software.intel.com/en-us/articles/intelr-memory-latency-checker
- протокол когерентности кэша и памяти Производительность архитектуры Intel Haswell-EP
Смотрите также другие вещи в x86 тег wiki.
я поделюсь своим предварительные выводы в надежде поощрять более подробные ответы. Я просто чувствовал, что это будет слишком большой частью самого вопроса.
компилятор оптимизация fill(0)
внутренней memset
. Он не может сделать то же самое для fill(1)
С memset
работает только на байт.
в частности, оба glibcs __memset_avx2
и __intel_avx_rep_memset
реализованы с помощью одного горячего инструкция:
rep stos %al,%es:(%rdi)
где ручной цикл компилируется до фактической 128-битной инструкции:
add x1,%rax
add x10,%rdx
movaps %xmm0,-0x10(%rdx)
cmp %rax,%r8
ja 400f41
интересно, пока есть оптимизация шаблона / заголовка для реализации std::fill
via memset
для типов байтов, но в этом случае это оптимизация компилятора для преобразования фактического цикла.
Странно, Для а std::vector<char>
, gcc также начинает оптимизировать fill(1)
. Компилятор Intel не делает, несмотря на memset
спецификация шаблона.
С этого момента происходит только тогда, когда код фактически работает в памяти, а не в кэше, что делает его похожим на архитектуру Haswell-EP, которая не может эффективно консолидировать однобайтовые записи.
я бы признателен за любую дополнительную информацию в проблему и связанные с ней детали микро-архитектуры. В частности мне непонятно, почему этот ведет себя так по-разному для четырех или более потоков, и почему memset
это намного быстрее кэш.
обновление:
вот результат по сравнению с
- fill (1), который использует
-march=native
(поддержкой AVX2vmovdq %ymm0
) - он работает лучше в L1, но похож наmovaps %xmm0
версия для других уровней памяти. - варианты 32, 128 и 256-битных нестационарных хранилищ. Они выполняют последовательно с таким же представлением независимо от размера данных. Все они превосходят другие варианты в памяти, особенно для небольшого количества потоков. 128-битный и 256 бит выполняют точно такие же, для низкого количества потоков 32 бит выполняет значительно хуже.
для vmovnt
имеет преимущество 2x над rep stos
при работе в памяти.
однопоточная полоса пропускания:
совокупная пропускная способность в памяти:
вот код, используемый для дополнительных тестов с их соответствующие горячие петли:
void __attribute__ ((noinline)) fill1(vector& v) {
std::fill(v.begin(), v.end(), 1);
}
┌─→add x1,%rax
│ vmovdq %ymm0,(%rdx)
│ add x20,%rdx
│ cmp %rdi,%rax
└──jb e0
void __attribute__ ((noinline)) fill1_nt_si32(vector& v) {
for (auto& elem : v) {
_mm_stream_si32(&elem, 1);
}
}
┌─→movnti %ecx,(%rax)
│ add x4,%rax
│ cmp %rdx,%rax
└──jne 18
void __attribute__ ((noinline)) fill1_nt_si128(vector& v) {
assert((long)v.data() % 32 == 0); // alignment
const __m128i buf = _mm_set1_epi32(1);
size_t i;
int* data;
int* end4 = &v[v.size() - (v.size() % 4)];
int* end = &v[v.size()];
for (data = v.data(); data < end4; data += 4) {
_mm_stream_si128((__m128i*)data, buf);
}
for (; data < end; data++) {
*data = 1;
}
}
┌─→vmovnt %xmm0,(%rdx)
│ add x10,%rdx
│ cmp %rcx,%rdx
└──jb 40
void __attribute__ ((noinline)) fill1_nt_si256(vector& v) {
assert((long)v.data() % 32 == 0); // alignment
const __m256i buf = _mm256_set1_epi32(1);
size_t i;
int* data;
int* end8 = &v[v.size() - (v.size() % 8)];
int* end = &v[v.size()];
for (data = v.data(); data < end8; data += 8) {
_mm256_stream_si256((__m256i*)data, buf);
}
for (; data < end; data++) {
*data = 1;
}
}
┌─→vmovnt %ymm0,(%rdx)
│ add x20,%rdx
│ cmp %rcx,%rdx
└──jb 40
Примечание: мне пришлось сделать ручной расчет указателя, чтобы получить петли настолько компактными. В противном случае он будет делать векторную индексацию в цикле, вероятно, из-за внутренней путаницы оптимизатора.