在C++中编写性能关键的C#代码

4

我目前正在编写一些性能关键代码,有一个特殊情况,我想用C#编写整个应用程序,但是由于性能原因,C++的速度比C#快得多。

我对两个不同实现的代码(一个是C#,另一个是C ++)进行了基准测试,计时显示C ++版本要快8倍,两个版本都是在发布模式下并启用了所有优化。(实际上,C#被编译为64位。我忘记在C++计时中启用这一点)

所以我想,我可以用C#编写大部分代码库(C#很容易编写),然后在性能关键的地方编写本机代码。我测试了C#和C ++中的特定代码段,这是处理时间超过95%的关键区域之一。

然而,如何编写本机代码呢?我从未编写过调用本机C ++的C#应用程序,所以我不知道该怎么做。我想尽可能以最小化调用本机代码的成本方式进行。

谢谢!

编辑:以下是我真正要处理的大部分代码。它用于n体模拟。 95-99%的CPU时间将在Body.Pairwise()中花费。

class Body
{
    public double Mass;
    public Vector Position;
    public Vector Velocity;
    public Vector Acceleration;

    // snip

    public void Pairwise(Body b)
    {
        Vector dr = b.Position - this.Position;
        double r2 = dr.LengthSq();
        double r3i = 1 / (r2 * Math.Sqrt(r2));

        Vector da = r3i * dr;
        this.Acceleration += (b.Mass * da);
        b.Acceleration -= (this.Mass * da);
    }

    public void Predict(double dt)
    {
        Velocity += (0.5 * dt) * Acceleration;
        Position += dt * Velocity;
    }

    public void Correct(double dt)
    {
        Velocity += (0.5 * dt) * Acceleration;
        Acceleration.Clear();
    }
}

我还有一个类专门驱动模拟器,具有以下方法:
    public static void Pairwise(Body[] b, int n)
    {
        for (int i = 0; i < n; i++)
            for (int j = i + 1; j < n; j++)
                b[i].Pairwise(b[j]);
    }

    public static void Predict(Body[] b, int n, double dt)
    {
        for (int i = 0; i < n; i++)
            b[i].Predict(dt);
    }

    public static void Correct(Body[] b, int n, double dt)
    {
        for (int i = 0; i < n; i++)
            b[i].Correct(dt);
    }

主循环看起来就像这样:
for (int s = 0; s < steps; s++)
{
    Predict(bodies, n, dt);
    Pairwise(bodies, n);
    Correct(bodies, n, dt);
}

以上只是我正在开发的较大应用程序中的最低要求。有一些其他的事情正在进行,但是最关键的性能问题出现在这三个函数中。我知道pairwise函数很慢(它是n²),而且我确实有其他更快的方法(比如Barnes-hutt,它是n log n),但这超出了我在这个问题中所询问的范围。

C ++代码几乎相同:

struct Body
{
public:
    double Mass;
    Vector Position;
    Vector Velocity;
    Vector Acceleration;

    void Pairwise(Body &b)
    {
        Vector dr = b.Position - this->Position;
        double r2 = dr.LengthSq();
        double r3i = 1 / (r2 * sqrt(r2));

        Vector da = r3i * dr;
        this->Acceleration += (b.Mass * da);
        b.Acceleration -= (this->Mass * da);
    }

    void Predict(double dt)
    {
        Velocity += (0.5 * dt) * Acceleration;
        Position += dt * Velocity;
    }

    void Correct(double dt)
    {
        Velocity += (0.5 * dt) * Acceleration;
        Acceleration.Clear();
    }
};

void Pairwise(Body *b, int n)
{
    for (int i = 0; i < n; i++)
        for (int j = i + 1; j < n; j++)
            b[i].Pairwise(b[j]);
}

void Predict(Body *b, int n, double dt)
{
    for (int i = 0; i < n; i++)
        b[i].Predict(dt);
}

void Correct(Body *b, int n, double dt)
{
    for (int i = 0; i < n; i++)
        b[i].Correct(dt);
}

主循环:

for (int s = 0; s < steps; s++)
{
    Predict(bodies, n, dt);
    Pairwise(bodies, n);
    Correct(bodies, n, dt);
}

还有一个 Vector 类,它的工作方式就像普通的数学向量,由于篇幅原因我不包括在内。


1
C# 不应该变慢。您可以使用未经检查的块来避免溢出检查和其他操作,从而实现更快的代码。 - Yochai Timmer
https://dev59.com/am435IYBdhLWcg3wlRMf - Yochai Timmer
@Yochai:我已经尝试过了,通过将所有算术操作都包装在不安全块中。这归结为一些函数,这些函数执行大量的浮点数运算,但是每秒钟我要进行很多计算。此外,我想知道如何从C#调用C++,因为我已经有一些现有的C++代码。我想编写的一些新代码与计算无关,更容易使用C#编写。 - Mike Bailey
@Yochai:我不确定未经检查的代码是否比经过检查的代码更快。我见过一些情况,其中未经检查的代码始终略慢,尽管我承认我无法解释为什么。 - user541686
@Mike Bantegui,这是一个老问题,但您应该查看C++ AMP以进行N体模拟。它可以通过让其在GPU硬件上运行来提供更好的性能提升。由于您已经调用了非托管代码,所以应该很简单。 - Kratz
7个回答

8
您需要与本地代码进行交互。您可以将其放入DLL并使用pinvoke调用。当您不经常转换且接口很薄时,这是可以的。最灵活和最快速的解决方案是使用C++/CLI语言编写ref类包装器。请参阅此杂志文章进行介绍。
最后,您真的应该对C#代码进行分析。8倍因素相当过度了。在至少有一半的想法为什么会如此缓慢之前,请不要开始此操作。您不希望在C++代码中重现原因,这会破坏一周的工作。
而且要注意错误的直觉。64位代码实际上不比x86代码快,它通常比x86代码慢一点。它具有一堆额外的寄存器,这非常好。但是所有指针的大小都加倍,您不会获得双倍的CPU缓存。

我重新计时,确保输入数据相同,但速度仍然慢了5倍。在C#中有哪些容易影响性能的问题需要注意? - Mike Bailey
仅通过重新计时就将性能几乎提高一倍,在我的看法中是一个红旗。优化C#代码没有通用的操作手册,只有好的分析工具可以显示循环所在的位置。明显的错误是使用调试生成或附加调试器进行分析。 - Hans Passant
我认为“快两倍”是因为我在Visual Studio中运行了。对于一个微小的计算,我可以始终获得500毫秒(C#)和2000毫秒(C++)的时间。我进行了分析并确认了所有周期都花费在同一方法上,这个方法在两个平台上都是相同的,并且具有相同的CPU时间百分比(99%)。 - Mike Bailey
@Mike Bantegui:汉斯是对的。你必须找出到底发生了什么。当我听到“周期去哪里了”和“所有周期都花在哪里了”时,我立刻就有所怀疑,因为最大的时间浪费往往是看似无害甚至是隐形的函数调用,它们通过在其他地方消耗时间来掩盖自己。我鼓励你深入了解 - 在指令级别上进行步进,或者做这个 - Mike Dunlavey

3
你有两个选择:P/Invoking 和 C++/CLI。 P/Invoking 通过使用 P/Invoke 或称平台调用,.NET(因此也包括 C#)可以调用非托管代码(你的 C++ 代码)。这可能有点令人不知所措,但绝对可以让你的 C# 代码调用性能关键的 C++ 代码。
以下是一些 MSDN 链接,可帮助你入门: 基本上,你将创建一个 C++ DLL,其中定义了你想从 C# 调用的所有非托管函数。然后,在 C# 中,你将使用 DllImportAttribute 将该函数导入到 C# 中。
例如,你有一个创建 Monkey.dll 的 C++ 项目,其中包含以下函数:
extern "C" __declspec(dllexport) void FastMonkey();

然后您将在C#中拥有以下定义:
class NativeMethods
{
    [DllImport("Monkey.dll", CallingConvention=CallingConvention.CDecl)]
    public static extern void FastMonkey();
}

您可以通过调用NativeMethods.FastMonkey在C#中调用C++函数。
一些常见的注意事项和说明:
  • 花时间学习Interop Marshaling。了解这个将极大地帮助创建正确的P/Invoking定义。
  • 默认的调用约定是StdCall,但C++将默认为CDecl。
  • 默认字符集是ANSI,所以如果要编组Unicode字符串,您需要更新您的DllImport定义(请参阅MSDN - DllImport.CharSet文档)。
  • http://www.pinvoke.net/是一个有用的资源,可以了解如何P/Invoke标准Windows函数调用。如果您知道类似的Windows函数调用,则可以使用该函数来了解如何编组某些内容。
C++/CLI C++/CLI 是由 Microsoft 创建的一系列扩展,用于使用 C++ 创建 .NET 程序集。C++/CLI 还允许您将非托管代码和托管代码混合到一个“混合”程序集中。您可以创建一个包含性能关键代码和任何 .NET 类包装器的 C++/CLI 程序集。

有关 C++/CLI 的更多信息,请参阅 MSDN - 针对 CLR 的语言特性MSDN - 本机和 .NET 互操作性

我建议您从 P/Invoke 路线开始。我发现在非托管代码和托管代码之间有清晰的分离有助于简化事情。


1
在C#中,向量(Vector)是类还是结构体?我怀疑它是一个类,Arthur Stankevich通过他的观察命中了要害,你可能正在分配许多这些。尝试将Vector定义为结构体,或者重复使用相同的Vector对象。

是的,我也在想同样的问题。我曾经看到过很多次,Java中实现了Vector类型,这导致了很多的分配。而在C++中,Vector的操作可以大部分内联。为了达到与C++相当的性能,Vector类型应该被实现为结构体,并且最好作为引用参数传递进行操作,而不是使用重载运算符,这样它可以更好地被Jitter内联。 - Dudu

0

看起来你的代码中有很多隐式的向量类分配:

Vector dr = b.Position - this.Position;
...
Vector da = r3i * dr;
this.Acceleration += (b.Mass * da);
b.Acceleration -= (this.Mass * da);

尝试重复使用已分配的内存。


0

最简单的方法是创建C++ ActiveX dlls。

然后在C#项目中引用它们,Visual Studio会创建包装ActiveX COM对象的互操作。

您可以像使用C#代码一样使用interop代码,无需额外的包装代码。

有关AciveX/C#的更多信息:

在.NET环境中创建和使用C++ ActiveX组件


这是最有效的方法吗?还是P/Invoke更有效?我不介意其中一种方式更难,只要能够尽可能地提高性能。 - Mike Bailey
这很容易做到。我认为在通信开销方面没有区别。 - Yochai Timmer

0
"我对某些代码的两种不同实现进行了基准测试(一种是C#,另一种是C++),计时结果显示C++版本快了8倍。"
"我在C#,C ++,Java和一点F#中进行了一些数值计算,C#和C ++之间最大的差异为3.5。"
"对您的C#版本进行分析并找出瓶颈(也许存在一些与IO相关的问题,不必要的分配)"

没有IO瓶颈(除了加载测试数据之外没有实际的IO),我重新检查了代码,可以确认有一个方法(在C#和C++上都是如此)只进行原始计算,它占用了99%的时间。 - Mike Bailey
当然,我可以发布已知的耗费大量计算资源的具体部分。这只是一个重力模拟,给我几分钟,我会更新我的主要帖子。 - Mike Bailey

0

P/Invoke对于简单情况肯定比COM Interop更容易。然而,如果您在C++中使用类模型的大块代码,您可能真的想考虑C++/CLI或COM Interop。

ATL让您很快就能创建一个类,一旦对象被实例化,调用开销基本上与P/Invoke一样小(除非您使用动态分派、IDispatch,但这应该是显而易见的)。

当然,C++/CLI是最好的选择,但并不是在任何地方都适用。P/Invoke可以在任何地方使用。COM interop在Mono上支持到某种程度


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接