Почему в массиве 2048x2048 против 2047x2047 массив умножается?

Я делаю некоторый бенчмаркинг матричного умножения, как ранее упоминалось в Почему MATLAB так быстро работает в матричном умножении?

Теперь у меня есть еще одна проблема: при умножении двух матриц 2048x2048 существует большая разница между С# и другими. Когда я пытаюсь умножить только матрицы 2047x2047, это кажется нормальным. Добавлены некоторые другие для сравнения.

1024x1024 - 10 секунд.

1027x1027 - 10 секунд.

2047x2047 - 90 секунд.

2048x2048 - 300 секунд.

2049x2049 - 91 секунд. (Обновление)

2500x2500 - 166 секунд

Это разница в три с половиной минуты для случая 2k на 2k.

с использованием массивов 2dim

//Array init like this
int rozmer = 2048;
float[,] matice = new float[rozmer, rozmer];

//Main multiply code
for(int j = 0; j < rozmer; j++)
{
   for (int k = 0; k < rozmer; k++)
   {
     float temp = 0;
     for (int m = 0; m < rozmer; m++)
     {
       temp = temp + matice1[j,m] * matice2[m,k];
     }
     matice3[j, k] = temp;
   }
 }

Ответ 1

Вероятно, это связано с конфликтами в вашем кэше L2.

Недостатки кэша в matice1 не являются проблемой, потому что к ним обращаются последовательно. Однако для matic2, если полный столбец соответствует L2 (т.е. Когда вы получаете доступ к matice2 [0, 0], matice2 [1, 0], matice2 [2, 0]... и т.д., Ничего не высылается), чем нет проблем с cache пропускает с matice2.

Теперь, чтобы глубже работать с кешами, если байт-адрес вашей переменной X, то для строки кэша для нее будет (X → 6) и (L-1). Где L - общее количество строк кэша в вашем кеше. L всегда имеет значение 2. Шесть исходит из факта, что 2 ^ 6 == 64 байта - это стандартный размер строки кэша.

Что это значит? Ну, это означает, что если у меня есть адрес X и адрес Y и (X → 6) - (Y → 6) делится на L (т.е. Какая-то большая степень 2), они будут сохранены в одной и той же строке.

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

когда 2048 - ваш размер:

если вы возьмете & matice2 [x, k] и & matice2 [y, k] разность (& matice2 [x, k] → 6) - (& matice2 [y, k] → 6) будет делиться на 2048 * 4 (размер поплавка). Таким образом, большая мощность 2.

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

Когда размер равен 2049, разница составляет 2049 * 4, которая не равна 2, поэтому у вас будет меньше конфликтов, и ваш столбец будет безопасно вписываться в ваш кеш.

Теперь, чтобы проверить эту теорию, вы можете сделать пару вещей:

Выделите массив массива matice2, как этот matice2 [razmor, 4096], и запустите с razmor = 1024, 1025 или любого размера, и вы увидите очень плохую производительность по сравнению с тем, что было раньше. Это связано с тем, что вы принудительно выровняете все столбцы друг с другом.

Затем попробуйте matice2 [razmor, 4097] и запустите его с любым размером, и вы должны увидеть гораздо лучшую производительность.

Ответ 2

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

Если вы разберете другие пары (2 ^ n-1,2 ^ n), я ожидаю, что вы увидите похожие эффекты.

Чтобы более полно объяснить, во внутреннем цикле, где вы обращаетесь к matice2 [m, k], вероятно, что matice2 [m, k] и matice2 [m + 1, k] смещены друг от друга на 2048 * sizeof (float) и, таким образом, сопоставить один и тот же индекс в кеше L1. С N-образным ассоциативным кешем у вас обычно будет 1-8 мест кэша для всех этих. Таким образом, почти все эти обращения вызовут выселение кеша L1 и выборку данных из более медленного кеша или основной памяти.

Ответ 3

Это может иметь отношение к размеру вашего кэша процессора. Если 2 строки матричной матрицы не подходят, тогда вы потеряете время, заменяя элементы из ОЗУ. Дополнительные 4095 элементов могут быть просто достаточными для предотвращения установки строк.

В вашем случае 2 строки для 2047 матриц 2d попадают в 16 Кбайт памяти (при условии 32 бит). Например, если у вас есть кеш L1 (ближайший к процессору на шине) 64 КБ, то вы можете поместить не менее 4 строк (2047 * 32) в кеш одновременно. С более длинными строками, если требуется любое дополнение, которое подталкивает пары строк за пределами 16 КБ, тогда все начинает становиться беспорядочным. Кроме того, каждый раз, когда вы пропускаете кеш, замена данных из другого кеша или основной памяти задерживает события.

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

Ответ 4

Луи Бренди написал два блога, анализируя именно эту проблему:

Больше кэш-сумасшествия и Вычислительная производительность - пример для начинающих с некоторой интересной статистикой и попытками объяснить поведение более подробно, оно действительно сводится к ограничениям размера кэша.

Ответ 5

Учитывая, что время падает при больших размерах, не будет ли более вероятным конфликты кэша, особенно с полномочиями 2 для проблемных размеров матрицы? Я не эксперт по вопросам кэширования, но отличная информация о проблемах производительности, связанных с кешем здесь.

Ответ 6

Когда вы обращаетесь к массиву matice2 по вертикали, он будет меняться в и из кэша намного больше. Если вы зеркалируете массив по диагонали, чтобы вы могли получить к нему доступ, используя [k,m] вместо [m,k], код будет работать намного быстрее.

Я тестировал это для матриц 1024x1024, и это примерно в два раза быстрее. Для матриц 2048x2048 это примерно в десять раз быстрее.

Ответ 7

Сглаживание кэша

Или сбой в кэше, если я могу использовать термин.

Кэши работают путем индексирования с младшими битами и тегами с битами верхнего порядка.

Отображение того, что ваш кеш имеет 4 слова, а ваша матрица - 4 x 4. Когда к столбцу обращаются, а строка имеет две длины в длину, каждый элемент столбца в памяти будет отображаться в один и тот же элемент кэша.

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

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

Поскольку кеш намного быстрее, чем DRAM (в основном из-за того, что он находится на кристалле), скорость атаки - это все.

Ответ 8

Похоже, вы попали в ограничение размера кеша или, возможно, имели некоторые проблемы с повторяемостью ваших таймингов.

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

Ответ 9

Эффективное использование иерархии кеша очень важно. Вы должны убедиться, что многомерные массивы имеют данные в удобном расположении, что может быть достигнуто с помощью tiling. Для этого вам нужно будет хранить 2D-массив как 1D-массив вместе с механизмом индексирования. Проблема с традиционным методом состоит в том, что хотя два соседних элемента массива, которые находятся в одной строке, находятся рядом друг с другом в памяти, два соседних элемента в одном столбце будут разделены элементами W в памяти, где W - количество столбцов, Плитка может достигать разницы в производительности в десять раз.

Ответ 10

Я подозреваю, что это результат чего-то, называемого Последовательное наводнение. Это то, что вы пытаетесь перебрать список объектов, который немного больше размера кэша, поэтому каждый отдельный запрос к списку (массиву) должен быть выполнен из ram, и вы не получите один кеш удар.

В вашем случае вы перебираете 2049 индексов 2048 индексов, но у вас есть только пространство для 2047 (возможно, из-за некоторых издержек из структуры массива), поэтому каждый раз, когда вы получаете массив pos, ему нужно получить этот массив pos из ram. Затем он сохраняется в кеше, но перед его повторным использованием он сбрасывается. Таким образом, кэш практически бесполезен, что приводит к значительному времени выполнения.