Как оптимизировать этот продукт из трех матриц в C++ для x86?

у меня есть ключевой алгоритм, в котором большая часть его времени выполнения тратится на вычисление плотного матричного продукта:

A*A'*Y, where: A is an m-by-n matrix, 
               A' is its conjugate transpose,
               Y is an m-by-k matrix

Typical characteristics:
    - k is much smaller than both m or n (k is typically < 10)
    - m in the range [500, 2000]
    - n in the range [100, 1000]

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

мое приложение написано на C++ для платформы x86_64. Я использую Eigen библиотека линейной алгебры, с библиотека ядра математики Intel в качестве бэкэнда. Eigen может использовать интерфейс BLAS IMKL для выполнения умножения, и импульс от перехода к родной реализации SSE2 Eigen к оптимизированной, основанной на AVX реализации Intel на моей машине Sandy Bridge также является значительным.

, выражение A * (A.adjoint() * Y) (написано на собственном языке) разлагается на два общих матрично-матричных произведения (вызовы xGEMM BLAS routine), с временной матрицей, созданной между ними. Мне интересно, могу ли я, перейдя к специализированной реализации для оценки всего выражения сразу, прийти к реализации, которая быстрее, чем общая, которая у меня есть сейчас. Несколько замечаний, которые заставляют меня поверить в это:
  • используя типичные размеры, описанные выше, входная матрица A обычно не помещается в кэш. Таким образом, конкретный шаблон доступа к памяти, используемый для вычисления трехматричного продукта, будет ключевым. Очевидно, что было бы также выгодно избежать создания временной матрицы для частичного продукта.

  • A и его сопряженная транспонирование, очевидно, имеют очень связанную структуру, которая может быть использована для улучшения шаблона доступа к памяти для общего выражение.

существуют ли какие-либо стандартные методы для реализации такого рода выражений удобным для кэша способом? Большинство методов оптимизации, которые я нашел для умножения матрицы для стандартных A*B case, не более крупные выражения. Мне комфортно с аспектами микро-оптимизации проблемы, такими как перевод в соответствующие наборы инструкций SIMD, но я ищу любые ссылки для разбиения этой структуры в самый дружественный к памяти способ.

Edit: основываясь на ответах, которые пришли до сих пор, я думаю, что я был немного неясен выше. Тот факт, что я использую C++/Eigen, - это просто деталь реализации с моей точки зрения на эту проблему. Eigen отлично справляется с реализацией шаблонов выражений, но оценка этого типа проблемы как простого выражения просто не поддерживается (поддерживаются только продукты 2 общих плотных матриц).

на более высоком уровень, чем то, как выражения будут оцениваться компилятором, я ищу более эффективный математическое нервное расстройство составной деятельности умножения, с согнутым к во избежание ненужные резервные обращения к памяти должные к общей структуре A и его сопряженного транспонирования. Результат, вероятно, будет трудно эффективно реализовать в чистом Eigen, поэтому я, скорее всего, просто внедрю его в специализированную процедуру с SIMD intrinsics.

4 ответов


это не полный ответ (пока - и я не уверен, что она станет одной).

давайте сначала немного подумаем о математике. Поскольку умножение матрицы ассоциативно, мы можем либо (A * A')или(A' * Y).

операции с плавающей запятой для (A * A') * Y

2*m*n*m + 2*m*m*k //the twos come from addition and multiplication

операции с плавающей запятой для A*(A' * Y)

2*m*n*k + 2*m*n*k = 4*m*n*k

поскольку k намного меньше m и n, понятно, почему второй случай намного быстрее.

а симметрия мы могли бы в принципе уменьшить количество вычислений для A * A 'на два (хотя это может быть нелегко сделать с SIMD), чтобы мы могли уменьшить количество операций с плавающей запятой (A*A') * Y до

m*n*m + 2*m*m*k.

мы знаем, что m и n больше, чем k. Давайте выберем новую переменную для m и n под названием z и выяснить, где случай один и два равны:

z*z*z + 2*z*z*k = 4*z*z*k  //now simplify
z = 2*k.

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

С точки зрения эффективного кода. Если код оптимизирован для эффективного использования кэша (как MKL и Eigen), то большое плотное умножение матрицы связано с вычислениями, а не с памятью, поэтому вам не нужно беспокоиться о кэше. MKL быстрее, чем Eigen, так как MKL использует AVX (и, возможно, FMA3 сейчас?).

Я не думаю, что вы будете возможность сделать это более эффективно, чем вы уже делаете, используя второй случай и MKL (через Eigen). Включите OpenMP для получения максимальных провалов.

вы должны рассчитать эффективность, сравнивая флопы с пиковыми флопами вашего процессора. Предполагая, что у вас есть процессор Sandy Bridge/Ivy Bridge. Пик SP проваливается

frequency * number of physical cores * 8 (8-wide AVX SP) * 2 (addition + multiplication)

для двойной прецессии разделить на два. Если у вас есть Haswell и MKL использует FMA, то удвоьте пиковые флопы. Чтобы получить правильную частоту, вы должны используйте значения turbo boost для всех ядер (это ниже, чем для одного ядра). Вы можете посмотреть это, если вы не разогнали свою систему или не используете CPU-Z В Windows или Powertop в Linux, если у вас разогнанная система.


используйте временную матрицу для вычисления A' * Y, но убедитесь, что вы говорите eigen, что нет сглаживания:temp.noalias() = A.adjoint()*Y. Затем вычислите свой результат, еще раз сообщив eigen, что объекты не имеют псевдонимов: result.noalias() = A*temp.


были бы избыточные вычисления, только если бы вы выполнили (A*A')*Y Так как в этом случае (A*A') является симметричным, и требуется только половина вычисления. Однако, как вы заметили, это все еще намного быстрее выполнить A*(A'*Y) в этом случае нет избыточных вычислений. Я подтверждаю, что стоимость временного создания здесь совершенно ничтожна.


Я думаю, что выполнить следующее

result = A * (A.adjoint() * Y)

будет то же самое, что сделать это

temp = A.adjoint() * Y
result = A * temp;

если ваша матрица Y вписывается в кэш, вы, вероятно, можете воспользоваться этим, как это

result = A * (Y.adjoint() * A).adjoint()

или, если предыдущая нотация не разрешена, вот так

temp = Y.adjoint() * A
result = A * temp.adjoint();

тогда вам не нужно делать сопряженную матрицу A и хранить временную сопряженную матрицу для A, что будет намного дороже, чем для Я.

если ваша матрица Y помещается в кэш, она должна быть намного быстрее, выполняя цикл, Бегущий по колумам A для первого умножения, а затем по строкам A для второго умножения (имея Y. adjoint() в кэше для первого умножения и temp.adjoint () для второго), но я думаю, что внутренне eigen уже заботится об этом.