Вызов MASM PROC из C ++ / CLI в режиме x64 приводит к неожиданным проблемам с производительностью - PullRequest
0 голосов
/ 20 марта 2019

Это мой первый вопрос здесь ...

Я пишу целочисленный класс произвольной точности для использования в C # (64-бит).В настоящее время я работаю над процедурой умножения, использующей рекурсивный алгоритм «разделяй и властвуй», чтобы разбить многобитовое умножение на серию примитивных умножений от 64 до 128 битов, результаты которых затем объединяются простымдополнение.Чтобы значительно повысить производительность, я пишу код на родном x64 C ++, встроенный в оболочку C ++ / CLI, чтобы его можно было вызывать из кода C #.

Пока все прекрасно работает, что касаетсяалгоритмы.Однако моя проблема заключается в оптимизации скорости.Поскольку умножение с 64 на 128 бит является настоящим узким местом, я попытался оптимизировать свой код прямо здесь.Моим первым простым подходом была функция C ++, которая реализует это умножение, выполняя четыре 32-разрядных умножения и объединяя результаты с парой сдвигов и добавлений.Это исходный код:

// 64-bit to 128-bit multiplication, using the following decomposition:
// (a*2^32 + i) (b*2^32 + i) = ab*2^64 + (aj + bi)*2^32 + ij

public: static void Mul (UINT64  u8Factor1,
                         UINT64  u8Factor2,
                         UINT64& u8ProductL,
                         UINT64& u8ProductH)
    {
    UINT64 u8Result1, u8Result2;
    UINT64 u8Factor1L = u8Factor1 & 0xFFFFFFFFULL;
    UINT64 u8Factor2L = u8Factor2 & 0xFFFFFFFFULL;
    UINT64 u8Factor1H = u8Factor1 >> 32;
    UINT64 u8Factor2H = u8Factor2 >> 32;

    u8ProductL = u8Factor1L * u8Factor2L;
    u8ProductH = u8Factor1H * u8Factor2H;
    u8Result1  = u8Factor1L * u8Factor2H;
    u8Result2  = u8Factor1H * u8Factor2L;

    if (u8Result1 > MAX_UINT64 - u8Result2)
        {
        u8Result1 +=  u8Result2;
        u8Result2  = (u8Result1 >> 32) | 0x100000000ULL; // add carry
        }
    else
        {
        u8Result1 +=  u8Result2;
        u8Result2  = (u8Result1 >> 32);
        }
    if (u8ProductL > MAX_UINT64 - (u8Result1 <<= 32))
        {
        u8Result2++;
        }
    u8ProductL += u8Result1;
    u8ProductH += u8Result2;
    return;
    }

Эта функция ожидает два 64-битных значения и возвращает 128-битный результат в виде двух 64-битных величин, переданных в качестве ссылки.Это отлично работает.На следующем шаге я попытался заменить вызов этой функции кодом ASM, который вызывает инструкцию MUL процессора.Поскольку встроенного ASM в режиме x64 больше нет, код должен быть помещен в отдельный файл .asm.Это реализация:

_TEXT segment

; =============================================================================
; multiplication
; -----------------------------------------------------------------------------
; 64-bit to 128-bit multiplication, using the x64 MUL instruction

AsmMul1 proc ; ?AsmMul1@@$$FYAX_K0AEA_K1@Z

; ecx  : Factor1
; edx  : Factor2
; [r8] : ProductL
; [r9] : ProductH

mov  rax, rcx            ; rax = Factor1
mul  rdx                 ; rdx:rax = Factor1 * Factor2
mov  qword ptr [r8], rax ; [r8] = ProductL
mov  qword ptr [r9], rdx ; [r9] = ProductH
ret

AsmMul1 endp

; =============================================================================

_TEXT ends
end

Это очень просто и понятно.На функцию ссылаются из кода C ++ с использованием прямого определения extern "C":

extern "C"
    {
    void AsmMul1 (UINT64, UINT64, UINT64&, UINT64&);
    }

К моему удивлению, оно оказалось значительно медленнее, чем функция C ++.Чтобы правильно оценить производительность, я написал функцию C ++, которая вычисляет 10 000 000 пар псевдослучайных 64-разрядных значений без знака и выполняет умножения в узком цикле, используя эти реализации одну за другой, с точно такими же значениями.Код скомпилирован в режиме Release с включенными оптимизациями.Время, проведенное в цикле, составляет 515 мсек для версии ASM, по сравнению с 125 мсек (!) Для версии C ++.

Это довольно странно.Поэтому я открыл окно разборки в отладчике и скопировал код ASM, сгенерированный компилятором.Вот что я нашел там, слегка отредактированный для удобства чтения и для использования с MASM:

AsmMul3 proc ; ?AsmMul3@@$$FYAX_K0AEA_K1@Z

; ecx  : Factor1
; edx  : Factor2
; [r8] : ProductL
; [r9] : ProductH

mov  eax,  0FFFFFFFFh
and  rax,  rcx

; UINT64 u8Factor2L = u8Factor2 & 0xFFFFFFFFULL;
mov  r10d, 0FFFFFFFFh
and  r10,  rdx

; UINT64 u8Factor1H = u8Factor1 >> 32;
shr  rcx,  20h

; UINT64 u8Factor2H = u8Factor2 >> 32;
shr  rdx,  20h

; u8ProductL = u8Factor1L * u8Factor2L;
mov  r11,  r10
imul r11,  rax
mov  qword ptr [r8], r11

; u8ProductH = u8Factor1H * u8Factor2H;
mov  r11,  rdx
imul r11,  rcx
mov  qword ptr [r9], r11

; u8Result1 = u8Factor1L * u8Factor2H;
imul rax,  rdx

; u8Result2 = u8Factor1H * u8Factor2L;
mov  rdx,  rcx
imul rdx,  r10

; if (u8Result1 > MAX_UINT64 - u8Result2)
mov  rcx,  rdx
neg  rcx
dec  rcx
cmp  rcx,  rax
jae  label1

; u8Result1 += u8Result2;
add  rax,  rdx

; u8Result2 = (u8Result1 >> 32) | 0x100000000ULL; // add carry
mov  rdx,  rax
shr  rdx,  20h
mov  rcx,  100000000h
or   rcx,  rdx
jmp  label2

; u8Result1 += u8Result2;
label1:
add  rax,  rdx

; u8Result2 = (u8Result1 >> 32);
mov  rcx,  rax
shr  rcx,  20h

; if (u8ProductL > MAX_UINT64 - (u8Result1 <<= 32))
label2:
shl  rax,  20h
mov  rdx,  qword ptr [r8]
mov  r10,  rax
neg  r10
dec  r10
cmp  r10,  rdx
jae  label3

; u8Result2++;
inc  rcx

; u8ProductL += u8Result1;
label3:
add  rdx,  rax
mov  qword ptr [r8], rdx

; u8ProductH += u8Result2;
add  qword ptr [r9], rcx
ret

AsmMul3 endp

Копирование этого кода в мой исходный файл MASM и вызов его из моей подпрограммы тестирования привело к тому, что в цикле было затрачено 547 мс.Это немного медленнее, чем функция ASM, и значительно медленнее, чем функция C ++.Это даже странно, поскольку последние должны выполнять точно такой же машинный код.

Поэтому я попробовал другой вариант, на этот раз с использованием оптимизированного вручную кода ASM, который выполняет точно такие же четыре 32-разрядных 64-разрядныхумножения, но более простым способом.Код должен избегать скачков и непосредственных значений, использовать флаги ЦП для оценки переноса и использовать чередование инструкций, чтобы избежать сбоев регистра.Вот что я придумал:

; 64-bit to 128-bit multiplication, using the following decomposition:
; (a*2^32 + i) (b*2^32 + j) = ab*2^64 + (aj + bi)*2^32 + ij

AsmMul2 proc ; ?AsmMul2@@$$FYAX_K0AEA_K1@Z

; ecx  : Factor1
; edx  : Factor2
; [r8] : ProductL
; [r9] : ProductH

mov  rax,  rcx           ; rax = Factor1
mov  r11,  rdx           ; r11 = Factor2
shr  rax,  32            ; rax = Factor1H
shr  r11,  32            ; r11 = Factor2H
and  ecx,  ecx           ; rcx = Factor1L
mov  r10d, eax           ; r10 = Factor1H
and  edx,  edx           ; rdx = Factor2L

imul rax,  r11           ; rax = ab = Factor1H * Factor2H
imul r10,  rdx           ; r10 = aj = Factor1H * Factor2L
imul r11,  rcx           ; r11 = bi = Factor1L * Factor2H
imul rdx,  rcx           ; rdx = ij = Factor1L * Factor2L

xor  ecx,  ecx           ; rcx = 0
add  r10,  r11           ; r10 = aj + bi
adc  ecx,  ecx           ; rcx = carry (aj + bi)
mov  r11,  r10           ; r11 = aj + bi
shl  rcx,  32            ; rcx = carry (aj + bi) << 32
shl  r10,  32            ; r10 = lower (aj + bi) << 32
shr  r11,  32            ; r11 = upper (aj + bi) >> 32

add  rdx,  r10           ; rdx = ij + (lower (aj + bi) << 32)
adc  rax,  r11           ; rax = ab + (upper (aj + bi) >> 32)
mov  qword ptr [r8], rdx ; save ProductL
add  rax,  rcx           ; add carry (aj + bi) << 32
mov  qword ptr [r9], rax ; save ProductH
ret

AsmMul2 endp

Тест производительности дал 500 мсек, так что, похоже, это самая быстрая версия этих трех реализаций ASM.Однако различия в производительности довольно незначительны - но все они примерно в четыре раза медленнее, чем наивный подход C ++!

Так что же здесь происходит?Мне кажется, что есть некоторые общие потери производительности для вызова кода ASM из C ++, но я не могу найти в Интернете ничего, что могло бы объяснить это.Microsoft взаимодействует с ASM именно так, как рекомендует Microsoft.

Но теперь остерегайтесь еще более странной вещи!Ну, есть встроенные компиляторы, не так ли?Предполагается, что встроенная функция _umul128 должна делать именно то, что делает моя функция AsmMul1, то есть вызывать инструкцию MUL 64-битного процессора.Поэтому я заменил вызов AsmMul1 на соответствующий вызов _umul128.Теперь посмотрим, какие значения производительности я получаю взамен (опять же, я запускаю все четыре теста последовательно в одной функции):

_umul128: 109 msec
AsmMul2: 94 msec (hand-optimized ASM)
AsmMul3: 125 msec (compiler-generated ASM)
C++ function: 828 msec

Теперь версии ASM невероятно быстрые, с примерно такими же относительными различиями, как и раньше.Тем не менее, функция C ++ теперь очень ленива!Каким-то образом использование встроенного переворачивает все значения производительности с ног на голову.Страшно ...

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

И еще одна вещь: почему 64-разрядный MUL медленнее, чем четыре 64-разрядных IMUL?!

Заранее спасибо!

1 Ответ

0 голосов
/ 21 марта 2019

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

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

Я нашел вопрос о стековом потоке, который, похоже, решает аналогичную проблему: Как наилучшим образом избежать двойного преобразования в нативных типах C ++ / CLI В комментариях найдена следующая дополнительная информация:

Один из моих собственных классов в нашей базовой библиотеке, который использует MFC, называется около миллиона раз. Мы наблюдаем массовые спорадические проблемы с производительностью, а также Профилировщик Я вижу Thunk прямо в нижней части этой цепочки. Тот thunk занимает больше времени, чем вызов метода.

Именно это я и наблюдаю - «что-то» на пути вызова функции занимает примерно в четыре раза больше, чем мой код. О некоторых функциях объясняется в некоторой степени в документации к модификатору __ clrcall и в статье о Double Thunking . В первом случае есть подсказка для побочного эффекта использования встроенных функций:

Вы можете напрямую вызывать функции __clrcall из существующего кода C ++, который был скомпилирован с использованием / clr, если эта функция имеет MSIL реализация. Функции __clrcall нельзя вызывать напрямую из функции, которые имеют встроенный asm и вызывают специфичные для CPU intrinisics, для Например, даже если эти функции скомпилированы с /clr.

Итак, насколько я понимаю, функция, которая содержит встроенные функции, теряет свой модификатор __clrcall, который добавляется автоматически при указании переключателя компилятора / clr, что обычно происходит, если функции C ++ должны быть скомпилированы в native код.

Я не получаю все детали этого "двойного" и "двойного", но, очевидно, требуется, чтобы неуправляемые функции могли вызываться из управляемых функций. Тем не менее, можно отключить его для каждой функции, встраивая его в пару #pragma managed(push, off) / #pragma managed(pop). К сожалению, эта #pragma не работает внутри блоков пространства имен, поэтому может потребоваться некоторое редактирование, чтобы разместить ее везде, где она должна происходить.

Я попробовал этот трюк, поместив весь свой собственный код с высокой точностью в эту #pragma, и получил следующие результаты теста:

AsmMul1: 78 msec (64-to-128-bit CPU MUL)
AsmMul2: 94 msec (hand-optimized ASM, 4 x IMUL)
AsmMul3: 125 msec (compiler-generated ASM, 4 x IMUL)
C++ function: 109 msec

Теперь это выглядит разумно, наконец! Теперь все версии имеют примерно одинаковое время выполнения, чего я и ожидал от оптимизированной программы C ++. Увы, счастливого конца еще нет ... Размещение победителя AsmMul1 в моем множителе с мульти-точностью дало вдвое больше времени исполнения версии с функцией C ++ без #pragma. По моему мнению, это объясняется тем, что этот код вызывает неуправляемые функции в других классах, которые находятся за пределами #pragma и, следовательно, имеют модификатор __clrcall. Похоже, это снова создает значительные накладные расходы.

Честно говоря, я устал от дальнейшего изучения этой проблемы. Хотя ASM PROC с единственной инструкцией MUL, кажется, побеждает все другие попытки, выигрыш не такой большой, как ожидалось, и устранение путаницы приводит к стольким изменениям в моем коде, что я не думаю, что это стоит того хлопот. Итак, я продолжу с функцией C ++, которую я написал в самом начале, изначально предназначенной для того, чтобы быть просто заменителем чего-то лучшего ...

Мне кажется, что интерфейс ASM в C ++ / CLI не очень хорошо поддерживается, или, может быть, я все еще здесь упускаю что-то базовое.Может быть, есть способ избавиться от этой функции только для функций ASM, но пока я не нашел решения.Даже отдаленно.

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

...