x86 和 x64 中的非托管到托管互操作性能

Unmanaged to managed interop performance in x86 and x64

在我的测试中,我发现在为 x64 而不是 x86 编译时,非托管到托管互操作的性能成本翻了一番。是什么导致了这种放缓?

我正在调试器下测试发布版本而不是 运行。循环是 100,000,000 次迭代。

在 x86 中,我测得每个互操作调用的平均时间为 8ns,这似乎与我在其他地方看到的相符。 Unity 的 x86 互操作是 8.2ns。 Microsoft 的一篇文章和 Hans Passant 都提到了 7ns。 8ns 在我的机器上是 28 个时钟周期,这看起来至少是合理的,但我确实想知道是否有可能走得更快。

在 x64 中,我测得每个互操作调用的平均时间为 17ns。我找不到任何人提到 x86 和 x64 之间的区别,甚至找不到他们在给出时间时指的是什么。 Unity 的 x64 互操作时钟约为 5.9ns。

常规函数调用(包括对非托管 C++ DLL 的调用)平均花费 1.3ns。这在 x86 和 x64 之间没有显着变化。

下面是我用于测量这一点的最小 C++/CLI 代码,尽管我在我的实际项目中看到了相同的数字,该项目由调用 C++/CLI DLL 的托管端的本机 C++ 项目组成。

#pragma managed
void
ManagedUpdate()
{
}


#pragma unmanaged
#include <wtypes.h>
#include <cstdint>
#include <cwchar>

struct ProfileSample
{
    static uint64_t frequency;
    uint64_t startTick;
    wchar_t* name;
    int count;

    ProfileSample(wchar_t* name_, int count_)
    {
        name = name_;
        count = count_;

        LARGE_INTEGER win32_startTick;
        QueryPerformanceCounter(&win32_startTick);
        startTick = win32_startTick.QuadPart;
    }

    ~ProfileSample()
    {
        LARGE_INTEGER win32_endTick;
        QueryPerformanceCounter(&win32_endTick);
        uint64_t endTick = win32_endTick.QuadPart;

        uint64_t deltaTicks = endTick - startTick;
        double nanoseconds = (double) deltaTicks / (double) frequency * 1000000000.0 / count;

        wchar_t buffer[128];
        swprintf(buffer, _countof(buffer), L"%s - %.4f ns\n", name, nanoseconds);
        OutputDebugStringW(buffer);

        if (!IsDebuggerPresent())
            MessageBoxW(nullptr, buffer, nullptr, 0);
    }
};

uint64_t ProfileSample::frequency = 0;

int CALLBACK
WinMain(HINSTANCE, HINSTANCE, PSTR, INT)
{
    LARGE_INTEGER frequency;
    QueryPerformanceFrequency(&frequency);
    ProfileSample::frequency = frequency.QuadPart;

    //Warm stuff up
    for ( size_t i = 0; i < 100; i++ )
        ManagedUpdate();

    const int num = 100000000;
    {
        ProfileSample p(L"ManagedUpdate", num);

        for ( size_t i = 0; i < num; i++ )
            ManagedUpdate();
    }

    return 0;
}

1) 为什么 x64 互操作成本为 17ns 而 x86 互操作成本为 8ns

2) 8ns 是我可以合理预期的最快速度吗?

编辑 1

附加信息 CPU i7-4770k @ 3.5 GHz
测试用例是 VS2017 中的单个 C++/CLI 项目。
默认发布配置
全面优化/O2
我随机使用了一些设置,例如 Favor Size 或 Speed、Omit Frame Pointers、Enable C++ Exceptions 和 Security Check,none 似乎改变了 x86/x64 差异。

编辑 2

我已经完成了反汇编(此时我还不是很熟悉)。

在 x86 中似乎类似于

call    ManagedUpdate
jmp     ptr [__mep@?ManagedUpdate@@$$FYAXXZ]
jmp     _IJWNOADThunkJumpTarget@0

在 x64 中我看到了

call    ManagedUpdate
jmp     ptr [__mep@?ManagedUpdate@@$$FYAXXZ]
        //Some jumping around that quickly leads to IJWNOADThunk::MakeCall:
call    IJWNOADThunk::FindThunkTarget
        //MakeCall uses the result from FindThunkTarget to jump into UMThunkStub:

FindThunkTarget 相当繁重,看起来大部分时间都花在了那里。所以我的工作理论是,在 x86 中,thunk 目标是已知的,执行或多或少可以直接跳到它。但是在 x64 中,thunk 目标是未知的,并且在能够跳转到它之前会发生一个搜索过程来找到它。我想知道这是为什么?

我不记得曾经对这样的代码提供性能保证。 7 纳秒是您在 C++ Interop 代码(调用本机代码的托管代码)上可以预期的性能。这是反过来的,本机代码调用托管代码,又名 "reverse pinvoke".

您肯定会体会到这种互操作的缓慢味道。据我所知,IJWNOADThunk 中的 "No AD" 是令人讨厌的小细节。这段代码没有得到互操作存根中常见的微优化。它还高度特定于 C++/CLI 代码。讨厌,因为它不能假设托管代码需要在其中 运行 的 AppDomain 的任何内容。事实上,它甚至不能假定 CLR 已加载和初始化。

Is 8ns the fastest I can reasonably expect to go?

是的。实际上,您的测量值处于非常低的水平。你的硬件比我的强多了,我正在移动 Haswell 上测试这个。我看到 x86 大约需要 26 到 43 纳秒,x64 大约需要 40 到 46 纳秒。所以你得到了 x3 更好的时间,非常令人印象深刻。坦率地说,有点太令人印象深刻了,但您看到的代码与我看到的相同,所以我们必须测量相同的场景。

Why does x64 interop cost 17ns when x86 interop costs 8ns?

这不是最佳代码,Microsoft 程序员对他可以削减哪些角落非常悲观。我不知道这是否合理,UMThunkStub.asm 中的评论没有解释任何关于选择的内容。

reverse pinvoke 并没有什么特别之处。一直发生在处理 Windows 消息的 GUI 程序中。但这是非常不同的,这样的代码使用委托。这是取得成功并使其更快的方法。使用 Marshal::GetFunctionPointerForDelegate() 是关键。我试过这种方法:

using namespace System;
using namespace System::Runtime::InteropServices;


void* GetManagedUpdateFunctionPointer() {
    auto dlg = gcnew Action(&ManagedUpdate);
    auto tobereleased = GCHandle::Alloc(dlg);
    return Marshal::GetFunctionPointerForDelegate(dlg).ToPointer();
}

并在 WinMain() 函数中这样使用:

typedef void(__stdcall * testfuncPtr)();
testfuncPtr fptr = (testfuncPtr)GetManagedUpdateFunctionPointer();
//Warm stuff up
for (size_t i = 0; i < 100; i++) fptr();

    //...
    for ( size_t i = 0; i < num; i++ ) fptr();

这使得 x86 版本更快了一点。和 x64 版本一样快。

如果您打算使用这种方法,请记住,作为委托目标的实例方法比 x64 代码中的静态方法更快,调用存根需要做的重新排列函数参数的工作更少。请注意,我在 tobereleased 变量上使用了快捷方式,此处可能有内存管理细节,并且在插件场景中可能首选或需要 GCHandle::Free() 调用。