使用dotTrace进行性能调优

欢迎阅读8月号的U渣月报(此处应有狗头)。

最近在搓FreeMote(开源)工具的时候,发现我写的辣鸡代码对文件处理非常慢。具体地说,此工具读入一种格式(PSB)的文件并进行分析,将文件内容重新组织为json形式导出。然而,对于一个几MB的PSB文件,整个处理过程竟然需要110秒。于是我决定稍微优化一下。

JetBrains dotTrace和dotMemory是JB家出品的.NET工具系列,在安装R#的时候可以顺便安装。之前我有篇文提到过,同属一个系列的dotPeek是最差的C井反编译器之一,所以说JB虽好但也不能迷信。本文由于是性能调优,主要关注哪个方法用了多少时间,因此使用dotTrace。而dotMemory则可用于内存调优,如果你有这方面的需求可以了解一下,以后有机会再作介绍。

首先我们打开dotTrace,如图所示,dotTrace支持测量各种桌面平台的.NET程序。这里选择测量独立程序,找到刚编译的debug版本的目标程序(同目录下有pdb文件,因此dotTrace可以读取并显示源码),启动。

经过漫长的等待,程序结束后,就会显示本次测量的结果:

这里直观地显示了程序内的方法调用关系,以及每步的总耗时。由于此辣鸡程序的处理是存在递归调用的,因此仅从调用关系很难看出啥端倪。

再切换到Hot Spots视图看看:

Hot Spots视图显示了时间占用的“大头”方法。前几个方法要么是程序顶层的方法、要么是递归调用,因此占用大部分的时间是正常的,但是可以看到ReadStringZeroTrim方法仅仅作为一个从文件中读字符串的方法,却占用了22.41%的时间,可见它可能会是我们需要开刀的对象。那么看看这个方法的实现

        public static string ReadStringZeroTrim(this BinaryReader br)
        {
            StringBuilder sb = new StringBuilder();
            while (br.PeekChar() != 0)
            {
                sb.Append(br.ReadChar());
            }
            return sb.ToString();
        }

这个方法是要在文件流中读取以\0作为结尾的字符串。由于无法得知字符串的长度,这里采取的策略是检查下一个字符是否是0,若不是就继续读入这个字符,否则就结束。明眼人一看就知道(没错我瞎了),对于文件流来说,PeekChar可能要反复移动文件指针,很可能会是性能瓶颈;StringBuilder.Append对于较长的字符串而言,反复调用与扩容也可能制约效率。

那么更好的处理方式是什么呢?常识考虑,以下实现可以规避上述两点:

            string ReadDetectAndSwallow(BinaryReader br)
            {
                var pos = br.BaseStream.Position;
                var length = 0;
                while (br.ReadByte() > 0)
                {
                    length++;
                }
                br.BaseStream.Position = pos;
                var str = Encoding.UTF8.GetString(br.ReadBytes(length));
                br.ReadByte(); //skip \0 - fail if end without \0
                return str;
            }

首先先一路读下去,直到撞到0,记下走过的长度,然后回到出发点重新一次性将此字符串的所有字节读入,并使用编码器一次性将字节数组编码为字符串。乍一看可能觉得,这是将同样的内容读了两次,难道不会更慢吗?其实不需要测试也知道,连续读取较长的字节是很快的。有人可能还会考虑在第一次读时就把每个字节存下来,这对于长度已知的情况当然是最好的,而长度未知时,无论使用List还是MemoryStream都会存在扩容(新申请一块更大的内存->将现有内容复制到新内存)的性能损耗,除非提前申请一大块内存,而这又是对内存的无谓占用,总的来说并不值得这么去做。

接下来就来测试一下两种方法的性能差距:(请查看代码了解细节)

由图可知,对于同样长度/数量的字符串,新方法(ReadDetectAndSwallow)比原方法(ReadPeekAndAppend)快了7~10倍。而PeekChar确实是导致原方法慢的主要性能瓶颈。

应用新方法后,程序对同一个输入的执行时间减少了30秒,而这30秒确实是从此方法上节省出来的(对比前图可知)。

同时,从Subsystems视图中我们还可以看到,在读取字符串时,尽管已经进行了优化,从文件中读取所有字符串所需的时间还是长达24秒。(此时我想到我的输入是在U盘上的,挪到固态硬盘又减了一半的时间。)这是由于字符串不是连续读取,仍然需要来回移动文件指针,这仍然可以说是性能瓶颈。若要继续优化,可以考虑将文件中包含所有字符串的整个段一次性读入到MemoryStream,毕竟从内存中移动指针就要快得多了。不过,这也是一个典型的空间换时间的策略,滥用可能导致OutOfMemoryException。

 

后记:

随后,在dotTrace和Alive的帮助下,通过多种优化(包括上面提到的读入MemoryStream),FreeMote v1.3把读取时间降低到了前一个版本的1/10左右。

添加评论

Loading