Ухудшение производительности матричного умножения одиночных и двойных прецизионных массивов на многоядерной машине

обновление

к сожалению, из-за моего недосмотра у меня была более старая версия MKL (11.1), связанная с numpy. Более новая версия MKL (11.3.1) дает такую же производительность в C и при вызове из python.

что скрывало вещи, даже если связывание скомпилированных общих библиотек явно с новым MKL и указание на них через переменные LD_*, а затем в python, выполняющем импорт numpy, каким-то образом заставляло python вызывать старые библиотеки MKL. Только заменив в папке Python lib все libmkl_*.таким образом, с новым MKL я смог сопоставить производительность в python и C-вызовах.

фон / библиотека информация.

умножение Матрицы было сделано через вызовы библиотеки MKL sgemm (single-precision) и dgemm (double-precision) Intel, через numpy.функция Dot. Фактический вызов функции библиотеки могут быть проверены, например, с опроф.

используя здесь 2X18 core CPU E5-2699 v3, следовательно, в общей сложности 36 физических сердце. KMP_AFFINITY=разброс. Работает на Linux.

TL; DR

1) Почему numpy.dot, хотя он вызывает те же функции библиотеки MKL, в лучшем случае в два раза медленнее по сравнению с скомпилированным кодом C?

2) Почему через numpy.dot вы получаете снижение производительности с увеличением количества ядер, тогда как тот же эффект не наблюдается в коде C (вызов тех же библиотечных функций).

проблема

Я наблюдал это делает умножение матрицы одиночной / двойной точности плавает в numpy.точка, а также вызов cblas_sgemm / dgemm непосредственно из скомпилированного C общая библиотека дают заметно худшую производительность по сравнению с вызовом тех же функций MKL cblas_sgemm/dgemm изнутри чистого кода C.

import numpy as np
import mkl
n = 10000
A = np.random.randn(n,n).astype('float32')
B = np.random.randn(n,n).astype('float32')
C = np.zeros((n,n)).astype('float32')

mkl.set_num_threads(3); %time np.dot(A, B, out=C)
11.5 seconds
mkl.set_num_threads(6); %time np.dot(A, B, out=C)
6 seconds
mkl.set_num_threads(12); %time np.dot(A, B, out=C)
3 seconds
mkl.set_num_threads(18); %time np.dot(A, B, out=C)
2.4 seconds
mkl.set_num_threads(24); %time np.dot(A, B, out=C)
3.6 seconds
mkl.set_num_threads(30); %time np.dot(A, B, out=C)
5 seconds
mkl.set_num_threads(36); %time np.dot(A, B, out=C)
5.5 seconds

делать точно так же, как выше, но с двойной точностью A, B и C, вы получаете: 3 ядра: 20s, 6 ядер: 10s, 12 ядер: 5s, 18 ядер: 4.3 s, 24 ядра: 3s, 30 ядер: 2.8 s, 36 ядер: 2.8 s.

пополнение скорости для одиночных прецизионных плавающих точек, похоже, связано с пропусками кэша. Для 28 core run, вот выход perf. Для одиночной точности:

perf stat -e task-clock,cycles,instructions,cache-references,cache-misses ./ptestf.py
631,301,854 cache-misses # 31.478 % of all cache refs

и двойная точность:

93,087,703 cache-misses # 5.164 % of all cache refs

C общая библиотека, скомпилированная с

/opt/intel/bin/icc -o comp_sgemm_mkl.so -openmp -mkl sgem_lib.c -lm -lirc -O3 -fPIC -shared -std=c99 -vec-report1 -xhost -I/opt/intel/composer/mkl/include

#include <stdio.h>
#include <stdlib.h>
#include "mkl.h"

void comp_sgemm_mkl(int m, int n, int k, float *A, float *B, float *C);

void comp_sgemm_mkl(int m, int n, int k, float *A, float *B, float *C)
{
    int i, j;
    float alpha, beta;
    alpha = 1.0; beta = 0.0;

    cblas_sgemm(CblasRowMajor, CblasNoTrans, CblasNoTrans,
                m, n, k, alpha, A, k, B, n, beta, C, n);
}

функция оболочки Python, вызывающая вышеуказанную скомпилированную библиотеку:

def comp_sgemm_mkl(A, B, out=None):
    lib = CDLL(omplib)
    lib.cblas_sgemm_mkl.argtypes = [c_int, c_int, c_int, 
                                 np.ctypeslib.ndpointer(dtype=np.float32, ndim=2), 
                                 np.ctypeslib.ndpointer(dtype=np.float32, ndim=2),
                                 np.ctypeslib.ndpointer(dtype=np.float32, ndim=2)]
    lib.comp_sgemm_mkl.restype = c_void_p
    m = A.shape[0]
    n = B.shape[0]
    k = B.shape[1]
    if np.isfortran(A):
        raise ValueError('Fortran array')
    if m != n:
        raise ValueError('Wrong matrix dimensions')
    if out is None:
        out = np.empty((m,k), np.float32)
    lib.comp_sgemm_mkl(m, n, k, A, B, out)

однако явные вызовы из C-скомпилированного двоичного вызова CBLAS_SGEMM MKL / cblas_dgemm, с массивами, выделенными через malloc в C, дает почти 2x лучшую производительность по сравнению с кодом python, т. е. numpy.точка вызова. Также эффект снижения производительности при увеличении количества ядер не наблюдается. самое лучшее представление было госпожой 900 для умножения матрицы одиночн-точности и был достигнут при использовании всех 36 физических ядер через mkl_set_num_cores и запуске кода C с numactl --interleave=all.

возможно любые причудливые инструменты или рекомендации по профилированию/проверке / пониманию этой ситуации? Любой материал для чтения также очень ценится.

обновление Следуя совету @Hristo Iliev, запустите numactl --interleave=all ./ipython не изменил тайминги (в пределах шума), но улучшает чистое двоичное время выполнения C.

1 ответов


Я подозреваю, что это связано с неудачным планированием потоков. Мне удалось воспроизвести эффект, похожий на ваш. Python работал на ~2.2 s, в то время как версия C показывала огромные вариации от 1.4-2.2 s.

применение: KMP_AFFINITY=scatter,granularity=thread Это гарантирует, что 28 потоков всегда работают на одном потоке процессора.

уменьшает оба времени выполнения до более стабильных ~1.24 s для C и ~1.26 s для python.

это на 28-ядерном двойном разъеме Xeon E5-2680 v3 система.

интересно, что на очень похожей системе 24 core dual socket Haswell как python, так и C выполняют почти идентичные даже без сродства потоков / закрепления.

Почему python влияет на планирование? Ну, я предполагаю, что вокруг него больше среды выполнения. Суть в том, что без закрепления ваших результатов будет недетерминированным.

Также вам нужно учитывать, что среда выполнения Intel OpenMP порождает дополнительный поток управления, который может запутать планировщик. Есть больше вариантов для закрепления, например KMP_AFFINITY=compact - но по какой-то причине это полностью испорчено в моей системе. Вы можете добавить ,verbose к переменной, чтобы увидеть, как среда выполнения закрепляет ваши потоки.

likwid-PIN-код - это полезная альтернатива обеспечивая более удобное управление.

вообще одиночная точность должна быть по крайней мере как быстро как двойная точность. Двойная точность может быть медленнее, потому что:

  • вы нужно больше пропускной способности памяти / кэша для двойной точности.
  • вы можете построить ALUs, которые имеют более высокий througput для одиночной точности, но это обычно не применяется к процессорам, а скорее графическим процессорам.

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

когда вы масштабируете количество потоков для MKL / * gemm, рассмотрите

  • пропускная способность памяти /общего кэша может стать узким местом, ограничение масштабируемости
  • режим Turbo эффектно уменьшит частоту сердечника увеличивая использование. Это применимо даже при работе на номинальной частоте: на процессорах Haswell-EP инструкции AVX будут вводить более низкую "базовую частоту AVX", но процессору разрешено превышать ее, когда используется меньше ядер / доступен тепловой запас и вообще еще больше в течение короткого времени. Если вы хотите получить совершенно нейтральные результаты, вам придется использовать базу AVX частота, которая 1.9 GHz для вас. Это документально здесь, и пояснил, в одна картинка.

Я не думаю, что есть действительно простой способ измерить, как ваше приложение зависит от плохого планирования. Вы можете разоблачить это с помощью perf trace -e sched:sched_switch и программное обеспечение визуализировать это, но это придет с высокой кривой обучения. И снова-для параллельного анализа производительности вы должны закрепить потоки в любом случае.