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






4.91/5 (18投票s)
本文介绍了一组可用于查找 .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 来简单地验证。可以通过初始化两个变量
t
和sum
为 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 月 - 添加了进一步优化部分