【问题标题】:Unmanaged to managed interop performance in x86 and x64x86 和 x64 中的非托管到托管互操作性能
【发布时间】:2017-03-26 08:54:40
【问题描述】:

在我的测试中,我看到在为 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) 为什么 x86 互操作成本为 8ns 而 x64 互操作成本为 17ns

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

编辑 1

附加信息 CPU i7-4770k @ 3.5 GHz
测试用例是 VS2017 中的单个 C++/CLI 项目。
默认发布配置
全面优化/O2
我随机使用了偏好大小或速度、省略帧指针、启用 C++ 异常和安全检查等设置,但似乎都没有改变 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 目标是未知的,并且在跳转到它之前会进行搜索过程以找到它。我想知道这是为什么?

【问题讨论】:

  • 了解这条性能测试规则:始终启用优化,始终包含您使用的编译设置。在 .NET 世界中,JIT 编译发生在运行时,所以我很高兴您提到您没有使用附加的调试器运行,因为这会抑制 JIT 优化。但是你的编译设置对于本机代码方面也很重要。
  • 当您处理绝对数字时,了解硬件很重要。 Xeon 将比 Atom 好得多。
  • 我测试了一系列优化设置,但没有什么比这些数字的差异超过 10%。开箱即用的 Visual Studio 2017 Release 配置具有代表性。我会在帖子中添加更多信息。
  • 我知道绝对数字会因机器而异,这也是我包含常规 DLL 调用时间的部分原因。如果互操作是 DLL 调用成本的 4 倍是错误的比率,那么有人可能会指出这一点。或者,如果它偏离了一个数量级,那几乎可以肯定是从我所包含的绝对数字中可以观察到的一个问题。问题集中在为什么 x64 的成本是两倍,所以绝对数字远不如它们的相对大小重要。

标签: performance c++-cli 64-bit interop


【解决方案1】:

我不记得曾经对这样的代码提供过性能保证。 7 纳秒是您在 C++ 互操作代码(调用本机代码的托管代码)上可以期待的那种性能。反过来,本机代码调用托管代码,也就是“反向 pinvoke”。

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

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

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

为什么 x86 互操作成本为 8ns,而 x64 互操作成本为 17ns?

这不是最佳代码,微软程序员对他可以走什么弯路非常悲观。我不知道这是否有必要,UMThunkStub.asm 中的 cmets 没有解释任何关于选择的内容。

反向 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()。

【讨论】:

  • 谢谢,这正是我正在寻找的信息。今晚我将尝试委托方法并进行比较。
  • 抱歉,我并不是要暗示您做出任何性能保证。我指的是这篇文章stackoverflow.com/a/4007893/986007,您在其中提到 7ns 对于托管到非托管 C++ 互操作是合理的。我知道这有点像苹果对橙子,但我认为相反的方向会有类似的表现。这主要是一个数量级的健全性检查。真正好奇的是 x86 和 x64 之间的区别。
  • 这似乎是一个很好的解决方案。通过直接调用,我测量了 7.9ns (x86) 和 17.0ns (x64)。通过一个静态函数的委托,我测量了 5.2ns (x86) 和 8.5ns (x64)。通过一个成员方法的委托,我测量了 4.7ns (x86) 和 7.8ns (x64)。因此,成员代表最终会花费 59% (x86) 和 46% (x64) 的简单直接调用时间。转向 x64 的命中率从 215% 上升到 166%。拆卸看起来更加理智,基本上只是一个重击和进出的一点重新排列。不再搜索。
猜你喜欢
  • 2012-01-03
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2010-10-22
  • 1970-01-01
  • 1970-01-01
  • 2015-03-14
  • 1970-01-01
相关资源
最近更新 更多