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

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

Я пишу целочисленный класс произвольной точности для использования в 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++, которая вычисляет 10000000 пар псевдослучайных 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++, но я не могу найти в Интернете ничего, что могло бы объяснить это. Я взаимодействую с ASM именно так, как Microsoft рекомендует.

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

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

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

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

И еще: почему MUL от 64 до 128 бит работает медленнее, чем четыре IMUL от 64 до 64 бит?!

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

1 ответ

Решение

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

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

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

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

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

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

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

Я не понимаю всех деталей этого "двойного" и "двойного", но, очевидно, это необходимо для того, чтобы неуправляемые функции можно было вызывать из управляемых функций. Тем не менее, можно отключить его для каждой функции, вставив его в #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, но пока я не нашел решения. Даже отдаленно.

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

Другие вопросы по тегам