CLR 在调用 c++ 函数时如何避免重击?

Posted

技术标签:

【中文标题】CLR 在调用 c++ 函数时如何避免重击?【英文标题】:How does the CLR avoid thunking when calling c++ functions? 【发布时间】:2018-08-12 08:47:29 【问题描述】:

MSDN states:

无论使用何种互操作技术,每次托管函数调用非托管函数时都需要特殊的转换序列(称为 thunk),反之亦然。这些 thunk 由 Visual C++ 编译器自动插入,但重要的是要记住,这些转换累积起来会在性能方面付出高昂的代价。

当然,CLR 始终调用 C++ 和 Win32 函数。为了处理文件/网络/窗口和几乎任何其他东西,必须调用非托管代码。它是如何摆脱分块惩罚的?

这是一个用 C++/CLI 编写的实验,可能有助于描述我的问题:

#define REPS 10000000

#pragma unmanaged
void go1() 
    for (int i = 0; i < REPS; i++)
        pow(i, 3);

#pragma managed
void go2() 
    for (int i = 0; i < REPS; i++)
        pow(i, 3);

void go3() 
    for (int i = 0; i < REPS; i++)
        Math::Pow(i, 3);


public ref class C1 
public:
    static void Go() 
        auto sw = Stopwatch::StartNew();
        go1();
        Console::WriteLine(sw->ElapsedMilliseconds);
        sw->Restart();
        go2();
        Console::WriteLine(sw->ElapsedMilliseconds);
        sw->Restart();
        go3();
        Console::WriteLine(sw->ElapsedMilliseconds);
    
;

//Go is called from a C# app

结果是(一致的):

405 (go1 - pure C++)
818 (go2 - managed code calling C++)
289 (go3 - pure managed)

为什么 go3 比 go1 快有点神秘,但这不是我的问题。我的问题是,我们从 go1 和 go2 中看到,thunking 惩罚增加了 400 毫秒。 go3如何摆脱这个惩罚,since it calls C++进行实际计算?

即使这个实验由于某种原因无效,我的问题仍然存在 - CLR 真的每次调用 C++/Win32 时都会受到重击惩罚吗?

【问题讨论】:

实验确实无效。所有时间测量都应该在 Release 配置中完成,Release 中的函数 go1、go2 和 go3 可能会被删除,因为它们没有副作用。 @AlexF 你说得对,我是在发布模式下完成的,并且汇总并输出总数,因此不会被优化掉,结果是269,306,330。所以我猜 Math.Pow 毕竟确实受到了分块的影响。请写下这个作为答案。 托管代码的全部目的是防止蓝屏。因此,为了确保您不会出现蓝屏,每个函数调用都必须受到异常处理程序的保护,或者必须彻底验证代码不会发生异常。因此,thunk 是执行堆栈中的包装器,它添加异常处理程序以捕获微处理器异常。 【参考方案1】:

基准测试是一门黑色艺术,您在这里得到了一些误导性的结果。运行发布版本非常重要,如果你这样做了,那么你现在会注意到 go1() 不再需要任何时间。本机代码优化器对它有特殊的了解,如果你不使用它的结果,它就会完全消除它。

您必须更改代码才能获得可靠的结果。首先在 Go() 测试主体周围放置一个循环,重复至少 20 次。这消除了抖动和缓存开销,并有助于查看较大的标准偏差。将 REPS 设为 0,因此您不必等待太久。喜欢工具 > 选项 > 调试 > 常规,未勾选“抑制 JIT 优化”。改代码,我推荐:

__declspec(noinline)
double go1() 
    double sum = 0;
    for (int i = 0; i < REPS; i++)
        sum += pow(i, 3);
    return sum;

注意 sum 变量如何强制优化器保持调用,使用 __declspec 防止整个函数被删除并避免污染 Go() 主体。对 go2 和 go3 执行相同操作,使用 [MethodImpl(MethodImplOptions::NoInlining)]。

我在笔记本电脑上看到的结果:x64:75、84、84、x86:73、89、89 +5/-3 毫秒。

三种不同的机制在起作用:

go1() 代码生成正如您在本机代码中所期望的那样,在 x64 模式下直接调用 __libm_sse2_pow_precise() CRT 函数。除了在发布版本中删除它的风险之外,这里没有什么了不起的。 go2() 使用您询问的 thunk。文档对 thunking 有点过于恐慌,只需要代码在堆栈上写入一个 cookie,以防止垃圾收集器在查找对象根时误入非托管堆栈帧。当它还必须转换函数参数和/或返回值时,它可能更昂贵,但这里不是这种情况。抖动优化器无法消除 pow() 调用,它没有 CRT 函数的特殊知识。 go3() 使用了一种非常不同的机制,尽管测量值相似。 Math::Pow() 在 CLR 中是特例,它使用所谓的FCall mechanism。没有 thunking,直接从托管代码到编译的 C++ 机器代码。这种微优化在 CLR/BCL 中很常见。有点必要,有额外的开销,因为它对可能引发异常的参数执行检查。这也是抖动优化器没有消除调用的基本原因,它通常会避免使异常消失的优化。

【讨论】:

谢谢,我对 thunking 感到困惑,这一切都解决了。 PInvoke 开销(如在go2() 中)也有点难以衡量,因为每个调用方法部分和每个调用站点部分。请参阅dotnet/coreclr#2373 进行一些讨论。 请注意链接的错误报告与此问题无关。 go2() 不使用 pinvoke 编组器,并且 [DllImport] 不在游戏中。这是工作中的 C++ 互操作,thunk 特定于 C++/CLI 代码,在其他语言中没有等效项。

以上是关于CLR 在调用 c++ 函数时如何避免重击?的主要内容,如果未能解决你的问题,请参考以下文章

在 C# 库和 C++ (CLR) 之间传递对象

如何避免在 C++ 中调用祖父构造函数?

C,如何将多维数组传递给 CLR/类库项目中的函数

PCB MS SQL CLR聚合函数(函数作用,调用顺序,调用次数) CLR说明

调用非托管代码时,CLR 如何封送仅包含单个字段的结构?

如何重新导出 CLR c++ 静态库