65.9K
CodeProject 正在变化。 阅读更多。
Home

优化 .NET 应用程序中的内存分配

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (18投票s)

2015年12月24日

CPOL

5分钟阅读

viewsIcon

28277

downloadIcon

179

本文介绍了一组可用于查找 .NET 代码中内存分配问题并显著提升应用程序性能的工具和技术。

引言

不久前,我偶然遇到了一个用于验证 ISBN-10 号码的 Web 代码片段(如果您不熟悉 ISBN 是什么,请参阅 wiki 页面)。这段代码运行良好。我编写了几项单元测试来验证算法的正确性,并且它们都通过了。但当我开始深入研究细节时,我发现性能优化方面有很大的改进空间。我使用了 Visual Studio 分析器和 WinDbg 来找出瓶颈的根本原因。我还使用了 BenchmarkDotNet 库(https://github.com/PerfDotNet/BenchmarkDotNet)来进行基准测试。下面,我将描述为显著提升算法性能所采取的所有步骤。

算法

该算法试图解决的问题是:给定一个 string 输入(例如 0-201-53082-1),判断它是否是一个有效的 ISBN-10 号码。

根据 wiki ISBN-10,验证算法可以表示如下:

引用

十位国际标准书号 (ISBN) 的最后一位是校验位,通过将每位数字乘以其在数字中的位置(从右侧开始计数)并取这些乘积的总和的模 11 来计算,结果应为 0。最右边的数字(乘以 1 的数字)是校验位,用于使总和正确。它可能需要值为 10,用字母 X 表示。例如,以 ISBN 0-201-53082-1 为例:乘积之和为 0×10 + 2×9 + 0×8 + 1×7 + 5×6 + 3×5 + 0×4 + 8×3 + 2×2 + 1×1 = 99 ≡ 0 (mod 11)。因此 ISBN 是有效的。请注意,也可以从左侧计算位置,在这种情况下,校验位乘以 10,以检查有效性:0×1 + 2×2 + 0×3 + 1×4 + 5×5 + 3×6 + 0×7 + 8×8 + 2×9 + 1×10 = 143 ≡ 0 (mod 11)。

虽然这可能比第一个方案更复杂,但可以通过将所有乘积相加然后除以 11 来简单地验证。可以通过初始化两个变量 tsum 为 0,并重复执行 t = t + digit; sum = sum + t;(这在 C 中可以表示为 sum += t += digit;)来计算总和,而无需进行任何乘法。如果最终的 sum 是 11 的倍数,则 ISBN 有效。

很简单,不是吗?

初始解决方案

初始代码如下所示:

public static class Isbn
{
    // ********************************************************************
    // * ISBN Reference:                                                  *
    // * http://en.wikipedia.org/wiki/International_Standard_Book_Number  *
    // ********************************************************************
 
    /// <summary>
    /// This method will validate a ISBN 10
    /// or ISBN 13 code.
    /// </summary>
    /// <param name="isbn">code to validate</param>
    /// <returns>true, if valid, otherwise false</returns>
    public static bool IsValid(string isbn)
    {
        bool result = false;
 
        if (!string.IsNullOrEmpty(isbn))
        {
            if (isbn.Contains("-")) isbn = isbn.Replace("-", "");

                long j;

                // Length must be 10 and only the last character could be a char('X') or a numeric value,
                // otherwise it's not valid.
                if (isbn.Length != 10 || !long.TryParse(isbn.Substring(0, isbn.Length - 1), out j))
                    return false;

                char lastChar = isbn[isbn.Length - 1];

                // Using the alternative way of calculation
                int sum = 0;
                for (int i = 0; i < 9; i++)
                    sum += int.Parse(isbn[i].ToString()) * (i + 1);

                // Getting the remainder or the checkdigit
                int remainder = sum % 11;

                // If the last character is 'X', then we should check if the checkdigit is equal to 10
                if (lastChar == 'X')
                {
                    result = (remainder == 10);
                }
                // Otherwise check if the lastChar is numeric
                // Note: I'm passing sum to the TryParse method to not create a new variable again
                else if (int.TryParse(lastChar.ToString(), out sum))
                {
                    // lastChar is numeric, so let's compare it to remainder
                    result = (remainder == int.Parse(lastChar.ToString()));
                }

            return result;
        }

        return false;
    }
}

我还编写了一个调用 Isbn.IsValid 方法的控制台应用程序。

namespace Console
{
    class Program
    {
        static void Main(string[] args)
        {
            Isbn.IsValid("99921-58-10-7");
        }
    }
}

我决定在 Visual Studio 内存分析器下运行此代码,并查看算法产生的内存分配。结果如下:

天哪!一个简单的函数就有 195 次内存分配。显然,我们应该想办法解决这个问题。

同样的问题也可以通过一个硬核工具——WinDbg——来找到。要做到这一点:

  • WinDbg 下运行应用程序
  • 执行 sxe ld clrjit 命令,等待 CLR 加载到进程地址空间
  • 运行 g 命令继续执行程序
  • 加载 sos 扩展。要做到这一点,只需执行 .loadby sos clr
  • 加载 sosex 扩展。要做到这一点,只需执行 .loadby sosex clr
  • Main 方法的开头设置一个断点。命令如下:!bpmd Console Console.Program.Main
  • 运行 g 命令。程序执行将在 Main 方法的开头中断。
  • 运行 !dumpheap -stat -type System.String。它将打印出堆上分配的 string 对象数量。在我的机器上,输出如下:
  • Statistics:
          MT    Count    TotalSize Class Name
    60baab9c        2          104 System.Object[]
    60bfacc4       38         2366 System.String
    Total 40 objects
  • Main 方法开始时,有 38 个 System.String 类型的对象。
  • 在调用 Isbn.IsValid 方法之后的源代码行上设置断点。我们可以使用 sosex 扩展及其 !mbp 命令,如下所示:!mbp Program.cs 34
  • 再次运行 g 命令。程序执行将在 Main 方法的末尾中断。
  • 再次运行 !dumpheap -stat -type System.String。在我的情况下,输出是:
  • Statistics:
          MT    Count    TotalSize Class Name
    60bfd6ac        1           12 System.Collections.Generic.GenericEqualityComparer`1[[System.String, mscorlib]]
    60812348        1           48 System.Collections.Generic.Dictionary`2
                                [[System.String, mscorlib],[System.Globalization.CultureData, mscorlib]]
    60bfd7b8        1           60 System.Collections.Generic.Dictionary`2+Entry
                                [[System.String, mscorlib],[System.Globalization.CultureData, mscorlib]][]
    60baab9c       19          736 System.Object[]
    60bfacc4      185         5822 System.String
    Total 207 object
     

堆上分配了 185 个 System.String 类型的对象!非常低效。很明显,ISBN 验证算法理想情况下不应该分配任何 System.String 对象。

我决定重写算法以最大限度地减少内存分配的数量。

优化解决方案

我创建了一个名为 IsbnEx 的类,其中包含算法的优化版本。

using System.Text;
using Utils;

public static class IsbnEx
{
    unsafe public static bool IsValid(object value)
    {
        if (value == null)
        {
            return true;
        }

        string val = (string)value;

        if (!string.IsNullOrEmpty(val))
        {
            long length = 0;

            char[] array = val.ToCharArray();

            fixed (char* left = array)
            {
                char* right = left;
                char* mLeft = left;

                while (*right != '\0')
                {
                    if (*right == '-')
                    {
                        right++;
                    }
                    else
                    {
                        *mLeft = *right;
                        mLeft++;
                        right++;
                        length++;
                    }
                }
                *mLeft = '\0';
            }

            if (length == 10)
            {
                return IsValidIsbn10(array);
            }
         
            return false;
        }

        return false;
    }

    private static bool IsValidIsbn10(char[] isbn)
    {
        int sum = 0;
        int val;

        for (int i = 0; i < 9; ++i)
        {
            char c = isbn[i];
            if (c.TryParse(out val))
            {
                sum += (i + 1) * val;
            }
            else
            {
                return false;
            }
        }

        int remainder = sum % 11;
        char lastCharacter = isbn[9];

        if (lastCharacter == 'X')
        {
            return remainder == 10;
        }

        if (lastCharacter.TryParse(out val))
        {
            return remainder == val;
        }

        return false;
    }
}

我将字符解析算法提取到一个单独的 Utils 类中。

namespace Utils
{
    public static class Utils
    {
        public static bool TryParse(this char c, out int val)
        {
            if (c < '0' || c > '9')
            {
                val = 0;
                return false;
            }
            val = (c - '0');
            return true;
        }
    }
}

我还重写了我的控制台应用程序以调用 IsbnEx.IsValid 方法。

namespace Console
{
    class Program
    {
        static void Main(string[] args)
        {
            IsbnEx.IsValid("99921-58-10-7");
        }
    }
}

让我们看看是否获得了性能提升。

结果

这是 Visual Studio 内存分析的结果:

只有一个内存分配。它由下面的代码行引起:

char[] array = val.ToCharArray();

以及 WinDbg 输出:

在调用 Isbn.IsValid 之前

Statistics:
      MT    Count    TotalSize Class Name
60baab9c        2          104 System.Object[]
60bfacc4       38         2366 System.String
Total 40 objects

在调用 Isbn.IsValid 之后

Statistics:
      MT    Count    TotalSize Class Name
60baab9c        2          104 System.Object[]
60bfacc4       38         2366 System.String
Total 40 objects

因此,Isbn.IsValid 方法根本没有分配 System.String 对象!太棒了!

现在,是时候测量和比较两种方法的执行时间了。我创建了一个基准测试(感谢 Andrey Akinshin 的出色 BenchmarkDotNet 库)。

using BenchmarkDotNet;
using BenchmarkDotNet.Tasks;

[BenchmarkTask(platform: BenchmarkPlatform.X86, jitVersion: BenchmarkJitVersion.LegacyJit)]
public class IsbnBenchmark
{
    [Benchmark]
    public void IsbnTest()
    {
        Isbn.IsValid("99921-58-10-7");
    }

    [Benchmark]
    public void IsbnExTest()
    {
        IsbnEx.IsValid("99921-58-10-7");
    }
}

namespace Console
{
    class Program
    {
        static void Main(string[] args)
        {
            new BenchmarkRunner().Run<IsbnBenchmark>();
        }
    }
}

输出:

180.9 毫秒对 1719.9 毫秒。还不错。

进一步优化

过了一会儿,我意识到可以做得更好,并对算法进行一点调整。显而易见的改进方法是完全消除任何内存分配。因此,我决定删除以下代码行并相应地修改算法。

char[] array = val.ToCharArray();

除此之外,我还研究了 JIT 生成的汇编代码,并发现 Utils.TryParse 方法没有被内联。我不知道原因——JIT 决定是否内联方法的决策是一门神秘的学问。我假设这是由于 out 参数。所以我将 Utils.TryParse 方法拆分成了 2 个独立的方法——一个用于检查字符是否为数字,另一个用于将字符转换为整数。然后,在 JIT 编译过程中,这两个方法都被内联了。我的最终代码如下所示:

IsbnEx

using Utils;

public static class IsbnEx
{
    public static bool IsValid(string val)
    {
        if (!string.IsNullOrEmpty(val))
        {
            int length = 0;

            for (int i = 0; i < val.Length; ++i)
            {
                if (val[i] == '-')
                {
                    continue;
                }
                length++;
            }

            if (length == 10)
            {
                return IsValidIsbn10(val);
            }

            return false;
        }

        return false;
    }

    private unsafe static bool IsValidIsbn10(string isbn)
    {
        int i = 0, sum = 0, val;
        char lastCharacter = '\0';

        fixed (char* left = isbn)
        {
            char* right = left;

            while (*right != '\0')
            {
                if (*right == '-')
                {
                    right++;
                    continue;
                }

                if (i < 9)
                {
                    char c = *right;
                    if (c.TryParse())
                    {
                        val = c.Parse();
                        sum += (i + 1) * val;
                        ++i;
                    }
                    else
                    {
                        return false;
                    }
                }
                else
                {
                    lastCharacter = *right;
                }

                right++;
            }
        }

        int remainder = (sum) % 11;

        if (lastCharacter == 'X')
        {
            return remainder == 10;
        }

        if (lastCharacter.TryParse())
        {
            val = lastCharacter.Parse();
            return remainder == val;
        }

        return false;
    }
}

Utils

namespace Utils
{
    public static class Utils
    {
        public static bool TryParse(this char c)
        {
            if (c < '0' || c > '9')
            {
                return false;
            }
            return true;
        }

        public static int Parse(this char c)
        {
            return (c - '0');
        }
    }
}

最后是基准输出:

76.6 纳秒。更好!

源代码可在此处获取 下载 Isbn.zip

历史

  • 版本 1 - 2015 年 12 月
  • 版本 2 - 2015 年 12 月 - 添加了进一步优化部分
© . All rights reserved.