【问题标题】:Help with optimizing C# function via C and/or Assembly通过 C 和/或汇编帮助优化 C# 函数
【发布时间】:2010-05-30 13:15:13
【问题描述】:

我有这个 C# 方法,我正在尝试优化:

// assume arrays are same dimensions
private void DoSomething(int[] bigArray1, int[] bigArray2)
{
    int data1;
    byte A1, B1, C1, D1;
    int data2;
    byte A2, B2, C2, D2;
    for (int i = 0; i < bigArray1.Length; i++)
    {
        data1 = bigArray1[i];
        data2 = bigArray2[i];

        A1 = (byte)(data1 >> 0);
        B1 = (byte)(data1 >> 8);
        C1 = (byte)(data1 >> 16);
        D1 = (byte)(data1 >> 24);

        A2 = (byte)(data2 >> 0);
        B2 = (byte)(data2 >> 8);
        C2 = (byte)(data2 >> 16);
        D2 = (byte)(data2 >> 24);

        A1 = A1 > A2 ? A1 : A2;
        B1 = B1 > B2 ? B1 : B2;
        C1 = C1 > C2 ? C1 : C2;
        D1 = D1 > D2 ? D1 : D2;

        bigArray1[i] = (A1 << 0) | (B1 << 8) | (C1 << 16) | (D1 << 24); 
    }
}

该函数基本上比较两个int 数组。对于每对匹配元素,该方法比较每个单独的字节值并取两者中的较大者。然后为第一个数组中的元素分配一个新的int 值,该值由 4 个最大字节值构成(与来源无关)。

认为我已经在 C# 中尽可能地优化了这个方法(当然,我可能没有 - 也欢迎关于这个分数的建议)。我的问题是,将这个方法移动到非托管 C DLL 对我来说值得吗? 考虑到编组托管 @ 的开销,生成的方法会执行得更快(以及快多少) 987654324@ 数组,以便可以将它们传递给方法?

如果这样做能让我的速度提高 10%,那么肯定不值得我花时间。如果它快 2 或 3 倍,那么我可能不得不这样做。

注意:请不要“过早优化”cmets,在此先感谢。这只是简单的“优化”。

更新:我意识到我的代码示例没有捕捉到我在这个函数中尝试做的所有事情,所以这里是一个更新版本:

private void DoSomethingElse(int[] dest, int[] src, double pos, 
    double srcMultiplier)
{
    int rdr;
    byte destA, destB, destC, destD;
    double rem = pos - Math.Floor(pos);
    double recipRem = 1.0 - rem;
    byte srcA1, srcA2, srcB1, srcB2, srcC1, srcC2, srcD1, srcD2;
    for (int i = 0; i < src.Length; i++)
    {
        // get destination values
        rdr = dest[(int)pos + i];
        destA = (byte)(rdr >> 0);
        destB = (byte)(rdr >> 8);
        destC = (byte)(rdr >> 16);
        destD = (byte)(rdr >> 24);
        // get bracketing source values
        rdr = src[i];
        srcA1 = (byte)(rdr >> 0);
        srcB1 = (byte)(rdr >> 8);
        srcC1 = (byte)(rdr >> 16);
        srcD1 = (byte)(rdr >> 24);
        rdr = src[i + 1];
        srcA2 = (byte)(rdr >> 0);
        srcB2 = (byte)(rdr >> 8);
        srcC2 = (byte)(rdr >> 16);
        srcD2 = (byte)(rdr >> 24);
        // interpolate (simple linear) and multiply
        srcA1 = (byte)(((double)srcA1 * recipRem) + 
            ((double)srcA2 * rem) * srcMultiplier);
        srcB1 = (byte)(((double)srcB1 * recipRem) +
            ((double)srcB2 * rem) * srcMultiplier);
        srcC1 = (byte)(((double)srcC1 * recipRem) +
            ((double)srcC2 * rem) * srcMultiplier);
        srcD1 = (byte)(((double)srcD1 * recipRem) +
            ((double)srcD2 * rem) * srcMultiplier);
        // bytewise best-of
        destA = srcA1 > destA ? srcA1 : destA;
        destB = srcB1 > destB ? srcB1 : destB;
        destC = srcC1 > destC ? srcC1 : destC;
        destD = srcD1 > destD ? srcD1 : destD;
        // convert bytes back to int
        dest[i] = (destA << 0) | (destB << 8) |
            (destC << 16) | (destD << 24);
    }
}

本质上,这与第一种方法的作用相同,除了在这个方法中,第二个数组 (src) 总是小于第一个 (dest),并且第二个数组的位置相对于第一个 (这意味着它可以定位在 10.682791),而不是相对于 dest 的 10)。

为了实现这一点,我必须在源中的两个括号值之间进行插值(例如,上例中的 10 和 11,对于第一个元素),然后将插值字节与目标字节进行比较。

我在这里怀疑这个函数中涉及的乘法比字节比较的成本要高得多,所以这部分可能是一个红鲱鱼(对不起)。此外,即使比较相对于乘法来说仍然有些昂贵,我仍然有这个系统实际上可以是多维的问题,这意味着不是比较一维数组,而是数组可以是 2-、5- 或任何维度,因此最终计算插值所花费的时间将使最终按字节比较 4 个字节所花费的时间相形见绌(我假设是这种情况)。

相对于位移位,这里的乘法有多昂贵,这种操作是否可以通过卸载到 C DLL(甚至是程序集 DLL,尽管我不得不雇人)来加速为我创建)?

【问题讨论】:

  • 顺便说一句,我很好奇您使用此算法的目的。愿意启发我们吗?
  • 这是遗传算法的事情。每个数组代表一个伪染色体,这个合并过程需要任一染色体中最大的字节来产生表型输出。
  • 在您的编辑中小题大做:您不能用rem = pos%1; 替换rem = pos - Math.Floor(pos); 吗?它不会快一个数量级,但如果你足够频繁地调用该函数,它可能会有所不同。
  • @Drew:数组通常有大约 10,000 个元素(src)和至少 75,000 个(dest),所以这将是一个很小的改进,除非 Math 非常慢(我从不使用 @ 987654331@ 无论如何都在一个昂贵的循环中)。

标签: c# c optimization assembly


【解决方案1】:

是的,_mm_max_epu8() 内在函数可以满足您的需求。一次浏览 16 个字节。痛点是数组。 SSE2 指令要求它们的参数在 16 字节地址上对齐。你不能从垃圾收集堆中得到它,它只承诺 4 字节对齐。即使你通过计算数组中 16 字节对齐的偏移量来欺骗它,当垃圾收集器启动并移动数组时,你也会失败。

您必须使用 __declspec(align(#)) 声明符在 C/C++ 代码中声明数组。现在您需要将托管阵列复制到那些非托管阵列中。结果回来了。您是否仍然领先取决于您的问题中不容易看到的细节。

【讨论】:

  • 固定阵列会解决 GC 移动它们的问题吗?
  • +1 -- 很好的答案。通过fixed 固定数组将阻止 GC 移动它们,但我认为您无法影响 .NET 中的 16 字节对齐,即使使用显式结构布局也是如此。
  • 堆栈上的固定数组或 stackalloc() 可以工作。将它们过度分配 12 个字节,以便您可以将 byte* 提升到 16 字节对齐。只是要让堆栈帧保持足够长的时间。
  • 这对我来说可能值得一试。我可以忍受必须在 C 代码中声明数组。抱歉,我没有在问题中包含足够的细节,但在比较之前我还需要对字节值进行一些数学运算(如乘法)。鉴于我对该方法进行的调用次数,整个操作需要 15-20 秒,我希望将其减少到 5 秒或更短。这可以通过切换到 C DLL 来实现吗?我这样看是不是快了 3-4 倍?
  • 它应该比 x4 快很多。一条指令 vs ~126。
【解决方案2】:

下面的函数使用不安全的代码将整数数组视为字节数组,这样就不需要位旋转了。

    private static void DoOtherThing(int[] bigArray1, int[] bigArray2)
    {
        unsafe
        {
            fixed (int* p1 = bigArray1, p2=bigArray2)
            {
                byte* b1 = (byte*)p1;
                byte* b2 = (byte*)p2;
                byte* bend = (byte*)(&p1[bigArray1.Length]);
                while (b1 < bend)
                {
                    if (*b1 < *b2)
                    {
                        *b1 = *b2;
                    }
                    ++b1;
                    ++b2;
                }
            }
        }
    }

在我的机器上以 Release 模式在调试器下针对 2500 万个整数数组运行,此代码比您的原始代码快 29%。但是,独立运行,运行时几乎没有区别。有时您的原始代码更快,有时新代码更快。

大概数字:

          Debugger  Standalone
Original  1,400 ms    700 ms
My code     975 ms    700 ms

而且,是的,我确实比较了结果以确保函数执行相同的操作。

我不知道为什么我的代码没有更快,因为它的工作量大大减少。

鉴于这些结果,我怀疑您是否可以通过使用本机代码来改进事情。正如您所说,编组阵列的开销可能会耗尽您在处理过程中可能实现的任何节省。

但是,对原始代码的以下修改速度提高了 10% 到 20%。

    private static void DoSomething(int[] bigArray1, int[] bigArray2)
    {
        for (int i = 0; i < bigArray1.Length; i++)
        {
            var data1 = (uint)bigArray1[i];
            var data2 = (uint)bigArray2[i];

            var A1 = data1 & 0xff;
            var B1 = data1 & 0xff00;
            var C1 = data1 & 0xff0000;
            var D1 = data1 & 0xff000000;

            var A2 = data2 & 0xff;
            var B2 = data2 & 0xff00;
            var C2 = data2 & 0xff0000;
            var D2 = data2 & 0xff000000;

            if (A2 > A1) A1 = A2;
            if (B2 > B1) B1 = B2;
            if (C2 > C1) C1 = C2;
            if (D2 > D1) D1 = D2;

            bigArray1[i] = (int)(A1 | B1 | C1 | D1);
        }
    }

【讨论】:

  • +1 击败了我 :) 使用与上述代码几乎相同的版本和 50M 个整数,我看到独立发行版的速度提高了 4 倍。我想知道我们在做什么不同。您是否不小心测量了数组的创建和比较?我使用一个可执行文件来背靠背测试两段代码,所以可能某种形式的缓存与我的结果有关。
  • 如果我在调用第一个函数后将修改后的 bigArray1 传递给第二个函数,我将获得 4 倍的加速。但是如果我使用数组的副本,时间是一样的。而且,不,我没有测量数组的创建和比较。
  • 我发现了一个非常愚蠢的错误(当我重新创建 array1 时,我使用了之前测试中的较小长度),现在我的数字与你的一样。在我的机器/设置上,不安全版本的运行速度实际上比安全版本稍慢。谢谢吉姆!
  • 没问题,特里。我仍在试图弄清楚为什么不安全的版本更慢。我认为这是由于写入时的运行时内存检查。
  • 我认为我们没有看到加速,因为我们循环的是字节指针而不是 int 指针。因此,对于输入数组中的每一项,我们必须进行 4 次比较和 8 次增量,以管理 while 循环。循环 int 指针将这些数字分别设为 1 和 2。与原始海报代码相比,此更改使我的速度提高了 2 倍(独立、发布、依赖数据,因为数据决定了写入次数)......除非出现任何愚蠢的错误。
【解决方案3】:

这个怎么样?

    private void DoSomething(int[] bigArray1, int[] bigArray2)
    {
        for (int i = 0; i < bigArray1.Length; i++)
        {
            var data1 = (uint)bigArray1[i];
            var data2 = (uint)bigArray2[i];

            bigArray1[i] = (int)(
                Math.Max(data1 & 0x000000FF, data2 & 0x000000FF) |
                Math.Max(data1 & 0x0000FF00, data2 & 0x0000FF00) |
                Math.Max(data1 & 0x00FF0000, data2 & 0x00FF0000) |
                Math.Max(data1 & 0xFF000000, data2 & 0xFF000000));
        }
    }

它的位移要少得多。如果您对其进行分析,您可能会发现对 Math.Max 的调用没有内联。在这种情况下,您只需让方法更冗长。

我没有测试此代码,因为我没有 IDE。我认为它可以满足您的需求。

如果这仍然没有按您的预期执行,您可以尝试在不安全的块中使用指针算术,但我严重怀疑您会看到收益。从我读过的所有内容来看,如果您使用它,这样的代码不太可能更快。但不要相信我的话。测量,测量,测量。

祝你好运。

【讨论】:

  • 我的真实代码实际上在循环中做了一些额外的数学运算(我把它们排除在“清晰”之外 - :)),所以我的问题是一个更普遍的问题,即数组处理速度有多快方法将作为 C DLL。如果不实际编写它,我无法真正衡量它的速度有多快,但我在这里询问是否值得付出努力。
  • 我认为您需要将掩码加倍(0x000F 应该是 0x000000FF)。此外,由于int 是带符号的 32 位整数,因此最终比较(使用掩码 0xFF000000)可能会导致问题;用uint 替换int 可能是前进的方向。但原则是合理的:-)
  • @psmears:会比位移更快吗?
  • @MusiGenesis -- 我想您必须查看英特尔(或其他芯片制造商)图表并查看 AND/OR/SHIFT 操作的周期数。您也可以使用 VS 显示优化的 JITted 汇编代码,尽管它需要一些设置。在 codeproject.com 的某个地方有一篇很好的文章。
  • 我尝试了上面的运行,对我来说,在.NET 4下,1亿个项目的时间是:原始(x86)1.05秒,原始(x64)0.80秒,0.75秒以上(x86),以上(x64)为0.63。所以上面在x64下运行的版本明显比原来的要快。对 Math.Max 的调用对我来说肯定是内联的。原始函数的直接移植到 VC++ 2010 的执行方式与 C# 相同。
【解决方案4】:

我看不出有任何方法可以通过巧妙的技巧来加速这段代码。

如果您真的希望此代码更快,那么在我看到的 x86 平台上显着(> 2 倍左右)加速它的唯一方法是使用汇编程序/内在函数实现。 SSE 有指令PCMPGTB

“对目标操作数(第一个操作数)和源操作数(第二个操作数)中压缩字节、字或双字的较大值执行 SIMD 比较。如果目标操作数中的数据元素是大于源操作数中对应的日期元素,则目标操作数中对应的数据元素设置为全1;否则,设置为全0。”

XMM 寄存器将适合四个 32 位整数,您可以遍历数组读取值,获取掩码,然后将第一个输入与掩码与第二个输入与反转掩码进行与运算。

另一方面,也许你可以重新设计你的算法,这样你就不需要选择更大的字节,但也许例如取操作数的 AND ?只是一个想法,很难在没有看到实际算法的情况下看到它是否可以工作。

【讨论】:

    【解决方案5】:

    如果您能够运行 Mono,您的另一个选择是使用 Mono.Simd 包。这提供了从 .NET 中对 SIMD 指令集的访问。不幸的是,您不能只获取程序集并在 MS 的 CLR 上运行它,因为 Mono 运行时处理在 JIT 时间以一种特殊的方式。实际程序集包含 SIMD 操作的常规 IL(非 SIMD)“模拟”作为备用,以防硬件不支持 SIMD 指令。

    据我所知,您还需要能够使用 API 使用的类型来表达您的问题。

    Here is the blog post Miguel de Icaza 早在 2008 年 11 月就在其中宣布了该功能。非常酷的东西。希望它将被添加到 ECMA 标准中,并且 MS 可以将其添加到他们的 CLR。​​

    【讨论】:

      【解决方案6】:

      您可能想查看 BitConverter 类 - 不记得它是否是您尝试执行的特定转换的正确字节序,但无论如何值得了解。

      【讨论】:

      • 在这种情况下,调用 BitConverter 函数的函数调用开销几乎肯定会掩盖您从中看到的任何收益。
      • 实际上,我的代码最初为此使用了 BitConverter,但它比上述示例中的位移慢得多。我认为(但不知道)这主要是因为 BitConverter.GetBytes() 必须分配一个新的字节数组并在每次调用时返回它。
      猜你喜欢
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 1970-01-01
      • 2012-11-04
      • 2011-08-07
      • 1970-01-01
      • 2010-12-04
      • 1970-01-01
      相关资源
      最近更新 更多