是什么优化让 .NET Core 性能飙升?

自闭症网瘾萝莉.ら 提交于 2020-04-29 10:17:28

 

本文参考文章:https://blogs.msdn.microsoft.com/dotnet/2017/06/07/performance-improvements-in-net-core/

转载请注明出自:葡萄城官网,葡萄城为开发者提供专业的开发工具、解决方案和服务,赋能开发者。

 

.NET Core(开放源代码,跨平台,x-copy可部署等)有许多令人兴奋的方面,其中最值得称赞的就是其性能了。

感谢所有社区开发人员对.NET Core做出的贡献,其中的许多改进也将在接下来的几个版本中引入.NET Framework。

本文主要介绍.NET Core中的一些性能改进,特别是.NET Core 2.0中的,重点介绍各个核心库的一些示例。

 

集合

集合是任何应用程序的基石,同时.NET库中也有大量集合。.NET库中的一些改进是为了消除开销,例如简化操作以便更好的实现内联,减少指令数量等。例如,下面的这个使用Q<T>的例子:

using System;
using System.Diagnostics;
using System.Collections.Generic;
public class Test
{
    public static void Main()
    {
        while (true)
        {
            var q = new Queue<int>();
            var sw = Stopwatch.StartNew();
            for (int i = 0; i < 100_000_000; i++)
            {
                q.Enqueue(i);
                q.Dequeue();
            }
            Console.WriteLine(sw.Elapsed);
        }
    }
}

PR dotnet/corefx #2515移除了这些操作中相对复杂的模数运算,在个人计算机,以上代码在.NET 4.7上产生如下输出:

000000.9392595 
000000.9390453 
000000.9455784 
000000.9508294 
000001.0107745

而使用.NET Core 2.0则会产生如下输出:

000000.5514887 
000000.5662477 
000000.5627481 
000000.5685286 
000000.5262378

由于这是挂钟时间所节省的,较小的值计算的更快,这也表明吞吐量增加了约2倍!

在其他情况下,通过更改操作算法的复杂性,可以更快地进行操作。编写软件时,最初编写的一个简单实现,虽然是正确的,但是这样实现往往不能表现出最佳的性能,直到特定的场景出现时,才考虑如何提高性能。例如,SortedSet <T>的ctor最初以相对简单的方式编写,由于使用O(N ^ 2)算法来处理重复项,因此不能很好地处理复杂性。该算法在PRnetnet / corefx#1955中的.NET Core中得到修复。以下简短的程序说明了修复的区别:

using System;
using System.Diagnostics;
using System.Collections.Generic;
using System.Linq;

public class Test
{
    public static void Main()
    {
        var sw = Stopwatch.StartNew();
        var ss = new SortedSet<int>(Enumerable.Repeat(42, 400_000));
        Console.WriteLine(sw.Elapsed);
    }
}

在个人电脑的.NET Framework上,这段代码需要大约7.7秒执行完成。在.NET Core 2.0上,减少到大约0.013s(改进改变了算法的复杂性,集合越大,节省的时间越多)。

或者在SortedSet <T>上考虑这个例子:

public class Test
{
    static int s_result;

    public static void Main()
    {
        while (true)
        {
            var s = new SortedSet<int>();
            for (int n = 0; n < 100_000; n++)
            {
                s.Add(n);
            }

            var sw = Stopwatch.StartNew();
            for (int i = 0; i < 10_000_000; i++)
            {
                s_result = s.Min;
            }
            Console.WriteLine(sw.Elapsed);
        }
    }
}

.NET 4.7中MinMax的实现遍布SortedSet <T>的整个树,但是只需要找到最小或最大值即可,因为实现可以只遍历相关的节点。PR dotnet / corefx#11968修复了.NET Core实现。在.NET 4.7中,此示例生成如下结果:

000001.1427246
000001.1295220 
000001.1350696 
000001.1502784 
000001.1677880

而在.NET Core 2.0中,我们得到如下结果:

000000.0861391 
000000.0861183 
000000.0866616 
000000.0848434 
000000.0860198

显示出相当大的时间下降和吞吐量的增加。

即使像List <T>这样的主工作核心也有改进的空间。考虑下面的例子:

using System;
using System.Diagnostics;
using System.Collections.Generic;
public class Test
{
    public static void Main()
    {
        while (true)
        {
            var l = new List<int>();
            var sw = Stopwatch.StartNew();
            for (int i = 0; i < 100_000_000; i++)
            {
                l.Add(i);
                l.RemoveAt(0);
            }
            Console.WriteLine(sw.Elapsed);
        }
    }
}

在.NET 4.7中,会得到的结果如下:

000000.4434135 
000000.4394329 
000000.4496867 
000000.4496383 
000000.4515505

和.NET Core 2.0,得到:

000000.3213094 
000000.3211772 
000000.3179631 
000000.3198449 
000000.3164009

可以肯定的是,在0.3秒内可以实现1亿次这样的添加并从列表中删除的操作,这表明操作开始并不慢。但是,通过执行一个应用程序,列表通常会添加到很多,同时也节省了总时间消耗。

这些类型的集合改进扩展不仅仅是System.Collections.Generic命名空间; System.Collections.Concurrent也有很多改进。事实上,.NET Core 2.0上的ConcurrentQueue <T>ConcurrentBag <T>完全重写了。下面看看一个基本的例子,使用ConcurrentQueue <T>但没有任何并发,例子中使用ConcurrentQueue <T>代替了Queue<T>

using System;
using System.Diagnostics;
using System.Collections.Concurrent;

public class Test
{
    public static void Main()
    {
        while (true)
        {
            var q = new ConcurrentQueue<int>();
            var sw = Stopwatch.StartNew();
            for (int i = 0; i < 100_000_000; i++)
            {
                q.Enqueue(i);
                q.TryDequeue(out int _);
            }
            Console.WriteLine(sw.Elapsed);
        }
    }
}

在个人电脑上,.NET 4.7产生的输出如下:

000002.6485174
000002.6144919 
000002.6699958 
000002.6441047 
000002.6255135

显然,.NET 4.7上的ConcurrentQueue <T>示例比.NET 4.7中的Queue <T>版本慢,因为ConcurrentQueue <T>需要采用同步来确保是否安全使用。但是,更有趣的比较是当在.NET Core 2.0上运行相同的代码时会发生什么:

000001.7700190 
000001.8324078 
000001.7552966 
000001.7518632 
000001.7560811

这表明当将.NET Core 2.0切换到30%时,ConcurrentQueue <T>的吞吐量没有任何并发​​性提高。但是实施中的变化提高了序列化的吞吐量,甚至更多地减少了使用队列的生产和消耗之间的同步,这可能对吞吐量有更明显的影响。请考虑以下代码:

using System;
using System.Diagnostics;
using System.Collections.Concurrent;
using System.Threading.Tasks;
public class Test
{
    public static void Main()
    {
        while (true)
        {
            const int Items = 100_000_000;
            var q = new ConcurrentQueue<int>();
            var sw = Stopwatch.StartNew();

            Task consumer = Task.Run(() =>
            {
                int total = 0;
                while (total < Items) if (q.TryDequeue(out int _)) total++;
            });
            for (int i = 0; i < Items; i++) q.Enqueue(i);
            consumer.Wait();

            Console.WriteLine(sw.Elapsed);
        }
    }
}

在.NET 4.7中,个人计算机输出如下结果:

000006.1366044
000005.7169339 
000006.3870274 
000005.5487718 
000006.6069291

而使用.NET Core 2.0,会得到以下结果:

000001.2052460 
000001.5269184 
000001.4638793 
000001.4963922 
000001.4927520

这是一个3.5倍的吞吐量的增长。不但CPU效率提高了, 而且内存分配也大大减少。下面的例子主要观察GC集合的数量,而不是挂钟时间:

using System;
using System.Diagnostics;
using System.Collections.Concurrent;
public class Test
{
    public static void Main()
    {
        while (true)
        {
            var q = new ConcurrentQueue<int>();
            int gen0 = GC.CollectionCount(0), gen1 = GC.CollectionCount(1), gen2 = GC.CollectionCount(2);
            for (int i = 0; i < 100_000_000; i++)
            {
                q.Enqueue(i);
                q.TryDequeue(out int _);
            }
            Console.WriteLine($"Gen0={GC.CollectionCount(0) - gen0} Gen1={GC.CollectionCount(1) - gen1} Gen2={GC.CollectionCount(2) - gen2}");
        }
    }
}

在.NET 4.7中,得到以下输出:

Gen0 = 162 Gen1 = 80 Gen2 = 0 
Gen0 = 162 Gen1 = 81 Gen2 = 0 
Gen0 = 162 Gen1 = 81 Gen2 = 0 
Gen0 = 162 Gen1 = 81 Gen2 = 0 
Gen0 = 162 Gen1 = 81 Gen2 = 0

而使用.NET Core 2.0,会得到如下输出:

Gen0 = 0 Gen1 = 0 Gen2 = 0 
Gen0 = 0 Gen1 = 0 Gen2 = 0 
Gen0 = 0 Gen1 = 0 Gen2 = 0 
Gen0 = 0 Gen1 = 0 Gen2 = 0 
Gen0 = 0 Gen1 = 0 Gen2 = 0

.NET 4.7中的实现使用了固定大小的数组链表,一旦固定数量的元素被添加到每个数组中,就会被丢弃, 这有助于简化实现,但也会导致生成大量垃圾。在.NET Core 2.0中,新的实现仍然使用链接在一起的链接列表,但是随着新的片段的添加,这些片段的大小会增加,更重要的是使用循环缓冲区,只有在前一个片段完全结束时,新片段才会增加。这种分配的减少可能对应用程序的整体性能产生相当大的影响。

ConcurrentBag <T>也有类似改进。ConcurrentBag <T>维护thread-local work-stealing队列,使得添加到的每个线程都有自己的队列。在.NET 4.7中,这些队列被实现为每个元素占据一个节点的链接列表,这意味着对该包的任何添加都会导致分配。在.NET Core 2.0中,这些队列是数组,这意味着除了增加阵列所涉及的均摊成本之外,增加的还是无需配置的。以下可以看出:

using System;
using System.Diagnostics;
using System.Collections.Concurrent;
public class Test
{
    public static void Main()
    {
        while (true)
        {
            var q = new ConcurrentBag<int>() { 1, 2 };
            var sw = new Stopwatch();

            int gen0 = GC.CollectionCount(0), gen1 = GC.CollectionCount(1), gen2 = GC.CollectionCount(2);
            sw.Start();

            for (int i = 0; i < 100_000_000; i++)
            {
                q.Add(i);
                q.TryTake(out int _);
            }

            sw.Stop();
            Console.WriteLine($"Elapsed={sw.Elapsed} Gen0={GC.CollectionCount(0) - gen0} Gen1={GC.CollectionCount(1) - gen1} Gen2={GC.CollectionCount(2) - gen2}");
        }
    }
}

在.NET 4.7中,个人计算机上产生以下输出:

Elapsed=00:00:06.5672723 Gen0=953 Gen1=0 Gen2=0
Elapsed=00:00:06.4829793 Gen0=954 Gen1=1 Gen2=0
Elapsed=00:00:06.9008532 Gen0=954 Gen1=0 Gen2=0
Elapsed=00:00:06.6485667 Gen0=953 Gen1=1 Gen2=0
Elapsed=00:00:06.4671746 Gen0=954 Gen1=1 Gen2=0

而使用.NET Core 2.0,会得到:

Elapsed=00:00:04.3377355 Gen0=0 Gen1=0 Gen2=0
Elapsed=00:00:04.2892791 Gen0=0 Gen1=0 Gen2=0
Elapsed=00:00:04.3101593 Gen0=0 Gen1=0 Gen2=0
Elapsed=00:00:04.2652497 Gen0=0 Gen1=0 Gen2=0
Elapsed=00:00:04.2808077 Gen0=0 Gen1=0 Gen2=0

吞吐量提高了约30%,并且分配和完成的垃圾收集量减少了。

 

LINQ

在应用程序代码中,集合通常与语言集成查询(LINQ)紧密相连,该查询已经有了更多的改进。LINQ中的许多运算符已经完全重写为.NET Core,以便减少分配的数量和大小,降低算法复杂度,并且消除不必要的工作。

例如,Enumerable.Concat方法用于创建一个单一的IEnumerable <T>,它首先产生first域可枚举的所有元素,然后再生成second域所有的元素。它在.NET 4.7中的实现是简单易懂的,下面的代码正好反映了这种行为表述:

static IEnumerable<TSource> ConcatIterator<TSource>(IEnumerable<TSource> first, IEnumerable<TSource> second) {
    foreach (TSource element in first) yield return element;
    foreach (TSource element in second) yield return element;
}

当两个序列是简单的枚举,如C#中的迭代器生成的,这种过程会执行的很好。但是如果应用程序代码具有如下代码呢?

first.Concat(second.Concat(third.Concat(fourth)));

每次我们从迭代器中退出时,则会返回到枚举器的MoveNext方法。这意味着如果你从另一个迭代器中枚举产生一个元素,则会返回两个MoveNext方法,并移动到下一个需要调用这两个MoveNext方法的元素。你调用的枚举器越多,操作所需的时间越长,特别是这些操作中的每一个都涉及多个接口调用(MoveNextCurrent)。这意味着连接多个枚举会以指数方式增长,而不是呈线性增长。PR dotnet / corefx#6131修正了这个问题,在下面的例子中,区别是显而易见的:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
public class Test
{
    public static void Main()
    {
        IEnumerable<int> zeroToTen = Enumerable.Range(0, 10);
        IEnumerable<int> result = zeroToTen;
        for (int i = 0; i < 10_000; i++)
        {
            result = result.Concat(zeroToTen);
        }

        var sw = Stopwatch.StartNew();
        foreach (int i in result) { }
        Console.WriteLine(sw.Elapsed);
    }
}

在个人计算机上,.NET 4.7需要大约4.12秒。但在.NET Core 2.0中,这只需要约0.14秒,提高了30倍。

通过消除多个运算器同时使用时的消耗,运算器也得到了大大的提升。例如下面的例子:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
public class Test
{
    public static void Main()
    {
        IEnumerable<int> tenMillionToZero = Enumerable.Range(0, 10_000_000).Reverse();
        while (true)
        {
            var sw = Stopwatch.StartNew();
            int fifth = tenMillionToZero.OrderBy(i => i).Skip(4).First();
            Console.WriteLine(sw.Elapsed);
        }
    }
}

在这里,我们创建一个可以从10,000,000下降到0的数字,然后再等待一会来排序它们上升,跳过排序结果中的前4个元素,并抓住第五个。在个人计算机上的NET 4.7中得到如下输出:

000001.3879042 
000001.3438509 
000001.4141820 
000001.4248908 
000001.3548279

而使用.NET Core 2.0,会得到如下输出:

000000.1776617 
000000.1787467 
000000.1754809 
000000.1765863 
000000.1735489

这是一个巨大的改进(〜8x),避免了大部分的开销。

类似地,来自justinvp的 PR dotnet / corefx#3429对常用的ToList方法添加了优化,为已知长度的源,提供了优化的路径,并且通过像Select这样的操作器来管理。在以下简单测试中,这种影响是显而易见的:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
public class Test
{
    public static void Main()
    {
        IEnumerable<int> tenMillionToZero = Enumerable.Range(0, 10_000_000).Reverse();
        while (true)
        {
            var sw = Stopwatch.StartNew();
            int fifth = tenMillionToZero.OrderBy(i => i).Skip(4).First();
            Console.WriteLine(sw.Elapsed);
        }
    }
}

在.NET 4.7中,会得到如下结果:

000000.1308687 
000000.1228546 
000000.1268445 
000000.1247647 
000000.1503511

而在.NET Core 2.0中,得到如下结果:

000000.0386857 
000000.0337234 
000000.0346344 
000000.0345419 
000000.0355355

显示吞吐量增加约4倍。

在其他情况下,性能优势来自于简化实施,以避免开销,例如减少分配,避免委托分配,避免接口调用,最小化字段读取和写入,避免拷贝等。例如,jamesqo为PR dotnet / corefx#11208做出的贡献,大大地减少了Enumerable.ToArray涉及的开销。请看下面的例子:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
public class Test
{
    public static void Main()
    {
        IEnumerable<int> zeroToTenMillion = Enumerable.Range(0, 10_000_000).ToArray();
        while (true)
        {
            var sw = Stopwatch.StartNew();
            zeroToTenMillion.Select(i => i).ToList();
            Console.WriteLine(sw.Elapsed);
        }
    }
}

在.NET 4.7中,会得到如下的结果:

Elapsed=00:00:01.0548794 Gen0=2 Gen1=2 Gen2=2
Elapsed=00:00:01.1147146 Gen0=2 Gen1=2 Gen2=2
Elapsed=00:00:01.0709146 Gen0=2 Gen1=2 Gen2=2
Elapsed=00:00:01.0706030 Gen0=2 Gen1=2 Gen2=2
Elapsed=00:00:01.0620943 Gen0=2 Gen1=2 Gen2=2

而.NET Core 2.0的结果如下:

Elapsed=00:00:00.1716550 Gen0=1 Gen1=1 Gen2=1
Elapsed=00:00:00.1720829 Gen0=1 Gen1=1 Gen2=1
Elapsed=00:00:00.1717145 Gen0=1 Gen1=1 Gen2=1
Elapsed=00:00:00.1713335 Gen0=1 Gen1=1 Gen2=1
Elapsed=00:00:00.1705285 Gen0=1 Gen1=1 Gen2=1

这个例子中提高了6倍,但是垃圾收集却只有一半。

LINQ有一百多个运算器,本文只提到了几个,其它的很多也都有所改进。

 

压缩

前面所展示的集合和LINQ的例子都是处理内存中的数据,当然还有许多其他形式的数据处理,包括大量CPU计算和逻辑判断,这些运算也在得到提升。

一个关键的例子是压缩,例如使用DeflateStream,性能方面也有一些重大的性能改进。例如,在.NET 4.7中,zlib(本地压缩库)用于压缩数据,但是相对未优化的托管实现了用于解压缩的数据; PR dotnet / corefx#2906添加了.NET Core支持,以便使用zlib进行解压缩。来自bjjones的 PR dotnet / corefx#5674使用英特尔生产的zlib这个更优化的版本。这些结合产生了非常棒的效果。下面的例子,创建一个大量的数据:

using System;
using System.IO;
using System.IO.Compression;
using System.Diagnostics;
public class Test
{
    public static void Main()
    {
        // Create some fairly compressible data
        byte[] raw = new byte[100 * 1024 * 1024];
        for (int i = 0; i < raw.Length; i++) raw[i] = (byte)i;
        var sw = Stopwatch.StartNew();

        // Compress it
        var compressed = new MemoryStream();
        using (DeflateStream ds = new DeflateStream(compressed, CompressionMode.Compress, true))
        {
            ds.Write(raw, 0, raw.Length);
        }
        compressed.Position = 0;

        // Decompress it
        var decompressed = new MemoryStream();
        using (DeflateStream ds = new DeflateStream(compressed, CompressionMode.Decompress))
        {
            ds.CopyTo(decompressed);
        }
        decompressed.Position = 0;

        Console.WriteLine(sw.Elapsed);
    }
}

在.NET 4.7中,这一个压缩/解压缩操作,会得到如下结果:

000000.7977190

而使用.NET Core 2.0,会得到如下结果:

000000.1926701

 

加密

.NET应用程序中另一个常见的计算源是使用加密操作,在这方面.NET Core也有改进。例如,在.NET 4.7中,SHA256.Create返回在管理代码中实现的SHA256类型,而管理代码可以运行得非常快,但是对于运算量非常大的计算,这仍然难以与原始吞吐量和编译器优化竞争。相反,对于.NET Core 2.0,SHA256.Create返回基于底层操作系统的实现,例如在Windows上使用CNG或在Unix上使用OpenSSL。从下面这个简单的例子可以看出,它散列着一个100MB的字节数组:

using System;
using System.Diagnostics;
using System.Security.Cryptography;

public class Test
{
    public static void Main()
    {
        byte[] raw = new byte[100 * 1024 * 1024];
        for (int i = 0; i < raw.Length; i++) raw[i] = (byte)i;

        using (var sha = SHA256.Create())
        {
            var sw = Stopwatch.StartNew();
            sha.ComputeHash(raw);
            Console.WriteLine(sw.Elapsed);
        }
    }
}

在.NET 4.7中,会得到:

000000.7576808

而使用.NET Core 2.0,会得到:

000000.4032290

零代码更改的一个很好提升。

 

数学运算

数学运算也是一个很大的计算量,特别是处理大量数据时。通过像dotnet / corefx#2182这样的PR ,axelheerBigInteger的各种操作做了一些实质的改进。请考虑以下示例:

using System;
using System.Diagnostics;
using System.Numerics;

public class Test
{
    public static void Main()
    {
        var rand = new Random(42);
        BigInteger a = Create(rand, 8192);
        BigInteger b = Create(rand, 8192);
        BigInteger c = Create(rand, 8192);

        var sw = Stopwatch.StartNew();
        BigInteger.ModPow(a, b, c);
        Console.WriteLine(sw.Elapsed);
    }

    private static BigInteger Create(Random rand, int bits)
    {
        var value = new byte[(bits + 7) / 8 + 1];
        rand.NextBytes(value);
        value[value.Length - 1] = 0;
        return new BigInteger(value);
    }
}

在.NET 4.7中,会得到以下输出结果:

000005.6024158

.NET Core 2.0上的相同代码会得到输出结果如下:

000001.2707089

这是开发人员只关注.NET的某个特定领域的一个很好的例子,开发人员使得这种改进更好的满足了自己的需求,同时也满足了可能会用到这方面功能的其他开发人员的需求。

一些核心的整型类型的数学运算也得到了改进。例如:

using System;
using System.Diagnostics;
public class Test
{
    private static long a = 99, b = 10, div, rem;

    public static void Main()
    {
        var sw = Stopwatch.StartNew();
        for (int i = 0; i < 100_000_000; i++)
        {
            div = Math.DivRem(a, b, out rem);
        }
        Console.WriteLine(sw.Elapsed);
    }
}

PR dotnet / coreclr#8125用更快的实现取代了DivRem,在.NET 4.7中会得到的如下结果:

000001.4143100

并在.NET Core 2.0上得到如下结果:

000000.7469733

吞吐量提高约2倍。

 

序列化

二进制序列化是.NET的另一个领域。BinaryFormatter最初并不是.NET Core中的一个组件,但是它包含在.NET Core 2.0中。该组件在性能方面有比较巧妙的修复。例如,PR dotnet / corefx#17949是一种单行修复,可以增加允许增长的最大大小的特定数组,但是这一变化可能对吞吐量产生重大影响,通过O(N)算法比以前的O(N ^ 2)算法要话费更长的操作时间。以下代码示例,明显的展示了这一点:

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
class Test
{
    static void Main()
    {
        var books = new List<Book>();
        for (int i = 0; i < 1_000_000; i++)
        {
            string id = i.ToString();
            books.Add(new Book { Name = id, Id = id });
        }

        var formatter = new BinaryFormatter();
        var mem = new MemoryStream();
        formatter.Serialize(mem, books);
        mem.Position = 0;

        var sw = Stopwatch.StartNew();
        formatter.Deserialize(mem);
        sw.Stop();

        Console.WriteLine(sw.Elapsed.TotalSeconds);
    }

    [Serializable]
    private class Book
    {
        public string Name;
        public string Id;
    }
}

在.NET 4.7中,代码输出如下结果:

76.677144

而在.NET Core 2.0中,会输出如下结果:

6.4044694

在这种情况下显示出了12倍的吞吐量提高。换句话说,它能够更有效地处理巨大的序列化输入。

 

文字处理

.NET应用程序中另一种很常见的计算形式就是处理文本,文字处理在堆栈的各个层次上都有大量的改进。

对于正则表达式,通常用于验证和解析输入文本中的数据。以下是使用Regex.IsMatch重复匹配电话号码的示例:

using System;
using System.Diagnostics;
using System.Text.RegularExpressions;
public class Test
{
    public static void Main()
    {
        var sw = new Stopwatch();
        int gen0 = GC.CollectionCount(0);
        sw.Start();

        for (int i = 0; i < 10_000_000; i++)
        {
            Regex.IsMatch("555-867-5309", @"^\d{3}-\d{3}-\d{4}$");
        }

        Console.WriteLine($"Elapsed={sw.Elapsed} Gen0={GC.CollectionCount(0) - gen0}");
    }
}

在个人计算机上,.NET 4.7会得到的如下结果:

Elapsed=00:00:05.4367262 Gen0=820 Gen1=0 Gen2=0

而使用.NET Core 2.0会得到如下结果:

Elapsed=00:00:04.0231373 Gen0=248

由于PR dotnet / corefx#231的变化很小,这些修改有助于缓存一部分数据,因此吞吐量提高了25%,分配/垃圾收集减少了70%。

文本处理的另一个例子是各种形式的编码和解码,例如通过WebUtility.UrlDecode进行URL解码。在这种解码方法中,通常情况下输入不需要任何解码,但是如果输入经过了解码器,则输入仍然可以通过。感谢来自hughbe的 PR dotnet / corefx#7671,这种情况已经被优化了。例如下面这段程序:

using System;
using System.Diagnostics;
using System.Net;
public class Test
{
    public static void Main()
    {
        var sw = new Stopwatch();
        int gen0 = GC.CollectionCount(0);
        sw.Start();

        for (int i = 0; i < 10_000_000; i++)
        {
            WebUtility.UrlDecode("abcdefghijklmnopqrstuvwxyz");
        }

        Console.WriteLine($"Elapsed={sw.Elapsed} Gen0={GC.CollectionCount(0) - gen0}");
    }
}

在.NET 4.7中,会得到以下输出:

Elapsed=00:00:01.6742583 Gen0=648

而在.NET Core 2.0中,输出如下:

Elapsed=00:00:01.2255288 Gen0=133

其他形式的编码和解码也得到了改进。例如,dotnet / coreclr#10124优化了使用一些内置Encoding -derived类型的循环。例如下面的示例:

using System;
using System.Diagnostics;
using System.Linq;
using System.Text;
public class Test
{
    public static void Main()
    {
        string s = new string(Enumerable.Range(0, 1024).Select(i => (char)('a' + i)).ToArray());
        while (true)
        {
            var sw = Stopwatch.StartNew();
            for (int i = 0; i < 1_000_000; i++)
            {
                byte[] data = Encoding.UTF8.GetBytes(s);
            }
            Console.WriteLine(sw.Elapsed);
        }
    }
}

在.NET 4.7中得到以下输出,如:

000002.4028829 
000002.3743152 
000002.3401392 
000002.4024785 
000002.3550876

而.NET Core 2.0等到如下输出:

000001.6133550 
000001.5915718 
000001.5759625 
000001.6070851 
000001.6070767

这些改进也适用于字符串和其它类型之间转换,例如.NET中生成Parse和ToString方法。使用枚举来表示各种状态是相当普遍的,例如使用Enum.Parse将字符串解析为相应的枚举。PR dotnet / coreclr#2933改善了这一点。请查看以下的代码:

using System;
using System.Diagnostics;
public class Test
{
    public static void Main()
    {
        while (true)
        {
            var sw = new Stopwatch();
            int gen0 = GC.CollectionCount(0);
            sw.Start();

            for (int i = 0; i < 2_000_000; i++)
            {
                Enum.Parse(typeof(Colors), "Red, Orange, Yellow, Green, Blue");
            }

            Console.WriteLine($"Elapsed={sw.Elapsed} Gen0={GC.CollectionCount(0) - gen0}");
        }
    }

    [Flags]
    private enum Colors
    {
        Red = 0x1,
        Orange = 0x2,
        Yellow = 0x4,
        Green = 0x8,
        Blue = 0x10
    }
}

在.NET 4.7中,会得到的以下结果:

Elapsed=00:00:00.9529354 Gen0=293
Elapsed=00:00:00.9422960 Gen0=294
Elapsed=00:00:00.9419024 Gen0=294
Elapsed=00:00:00.9417014 Gen0=294
Elapsed=00:00:00.9514724 Gen0=293

在.NET Core 2.0上,会得到以下结果:

Elapsed=00:00:00.6448327 Gen0=11
Elapsed=00:00:00.6438907 Gen0=11
Elapsed=00:00:00.6285656 Gen0=12
Elapsed=00:00:00.6286561 Gen0=11
Elapsed=00:00:00.6294286 Gen0=12

不但吞吐量提高了约33%,而且分配和相关垃圾收集也减少了约25倍。

当然,在.NET应用程序中需要进行大量的自定义文本处理,除了使用像Regex / Encoding这样的内置类型和Parse和ToString这样的内置操作之外,文本操作通常都是直接构建在字符串之上,并且大量的改进已经引入到了操作on String之上。

例如,String.IndexOf很擅长于查找字符串中的字符。IndexOfbnetyersmyth的dotnet / coreclr#5327中得到改进,他们为String实现了一系列的性能改进。正如下面的例子:

using System;
using System.Diagnostics;
public class Test
{
    public static void Main()
    {
        var dt = DateTime.Now;
        while (true)
        {
            var sw = new Stopwatch();
            int gen0 = GC.CollectionCount(0);
            sw.Start();

            for (int i = 0; i < 2_000_000; i++)
            {
                dt.ToString("o");
                dt.ToString("r");
            }

            Console.WriteLine($"Elapsed={sw.Elapsed} Gen0={GC.CollectionCount(0) - gen0}");
        }
    }
}

在.NET 4.7上,会得到如下结果:

000005.9718129 
000005.9199793 
000006.0203108 
000005.9458049 
000005.9622262

而在.NET Core 2.0中,会得到如下结果:

000003.1283763 
000003.0925150 
000002.9778923 
000003.0782851

吞吐量提高约2倍。

下面是比较字符串部分。这是一个使用String.StartsWith和序数比较的例子: 

using System;
using System.Diagnostics;
using System.Linq;
public class Test
{
    public static void Main()
    {
        string s = string.Concat(Enumerable.Repeat("a", 100)) + "b";
        while (true)
        {
            var sw = Stopwatch.StartNew();
            for (int i = 0; i < 100_000_000; i++)
            {
                s.IndexOf('b');
            }
            Console.WriteLine(sw.Elapsed);
        }
    }
}

在.NET 4.7上会得到如下结果:

000001.3097317 
000001.3072381 
000001.3045015 
000001.3068244 
000001.3210207

.NET Core 2.0会得到如下结果:

000000.6239002 
000000.6150021 
000000.6147173 
000000.6129136 
000000.6099822

String的改进,也让我们看到对于其它方面进行更多改进的可能性,这是非常有趣的。

 

文件系统

到目前为止,本文一直专注于内存中操纵数据的各种改进。但是.NET Core的许多更改都是​​关于I / O的。

下面从文件开始介绍。这是一个从文件中异步读取所有数据并将其写入另一个文件的示例:

using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
class Test
{
    static void Main() => MainAsync().GetAwaiter().GetResult();
    static async Task MainAsync()
    {
        string inputPath = Path.GetTempFileName(), outputPath = Path.GetTempFileName();
        byte[] data = new byte[50_000_000];
        new Random().NextBytes(data);
        File.WriteAllBytes(inputPath, data);

        var sw = new Stopwatch();
        int gen0 = GC.CollectionCount(0), gen1 = GC.CollectionCount(1), gen2 = GC.CollectionCount(2);
        sw.Start();

        for (int i = 0; i < 100; i++)
        {
            using (var input = new FileStream(inputPath, FileMode.Open, FileAccess.Read, FileShare.Read, 0x1000, useAsync: true))
            using (var output = new FileStream(outputPath, FileMode.Create, FileAccess.ReadWrite, FileShare.None, 0x1000, useAsync: true))
            {
                await input.CopyToAsync(output);
            }
        }

        Console.WriteLine($"Elapsed={sw.Elapsed} Gen0={GC.CollectionCount(0) - gen0} Gen1={GC.CollectionCount(1) - gen1} Gen2={GC.CollectionCount(2) - gen2}");
    }
}

 

FileStream中的开销也在进一步减少,例如DOTNET / corefx#11569增加了一个专门的CopyToAsync实现,dotnet/ corefx#2929也改进了异步写入的处理,.NET 4.7会得到如下结果:

Elapsed=00:00:09.4070345 Gen0=14 Gen1=7 Gen2=1

.NET Core 2.0会得到如下结果:

Elapsed=00:00:06.4286604 Gen0=4 Gen1=1 Gen2=1

 

网络

网络是值得关注的部分,这部分也将取得很大的改进。目前正在付出很大的努力来优化和调整低等级的网络堆栈,以便高效地构建更高级别的组件。

这种改变带来的一个很大的影响是PR dotnet / corefx#15141SocketAsyncEventArgsSocket上大量异步操作的核心,它支持同步完成模型,因此异步操作实际完成了同步操作,这样避免了异步操作的分配消耗。但是,.NET 4.7中的同步操作运算是失败的, PR修复了上述的实现问题,允许在socket上进行所有异步操作的同步完成。这样的提升在以下代码中变现的非常明显:

using System;
using System.Diagnostics;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
class Test
{
    static void Main()
    {
        using (Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
        using (Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
        {
            listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
            listener.Listen(1);

            Task connectTask = Task.Run(() => client.Connect(listener.LocalEndPoint));
            using (Socket server = listener.Accept())
            {
                connectTask.Wait();

                using (var clientAre = new AutoResetEvent(false))
                using (var clientSaea = new SocketAsyncEventArgs())
                using (var serverAre = new AutoResetEvent(false))
                using (var serverSaea = new SocketAsyncEventArgs())
                {
                    byte[] sendBuffer = new byte[1000];
                    clientSaea.SetBuffer(sendBuffer, 0, sendBuffer.Length);
                    clientSaea.Completed += delegate { clientAre.Set(); };

                    byte[] receiveBuffer = new byte[1000];
                    serverSaea.SetBuffer(receiveBuffer, 0, receiveBuffer.Length);
                    serverSaea.Completed += delegate { serverAre.Set(); };

                    var sw = new Stopwatch();
                    int gen0 = GC.CollectionCount(0), gen1 = GC.CollectionCount(1), gen2 = GC.CollectionCount(2);
                    sw.Start();

                    for (int i = 0; i < 1_000_000; i++)
                    {
                        if (client.SendAsync(clientSaea)) clientAre.WaitOne();
                        if (clientSaea.SocketError != SocketError.Success) throw new SocketException((int)clientSaea.SocketError);

                        if (server.ReceiveAsync(serverSaea)) serverAre.WaitOne();
                        if (serverSaea.SocketError != SocketError.Success) throw new SocketException((int)clientSaea.SocketError);
                    }

                    Console.WriteLine($"Elapsed={sw.Elapsed} Gen0={GC.CollectionCount(0) - gen0} Gen1={GC.CollectionCount(1) - gen1} Gen2={GC.CollectionCount(2) - gen2}");
                }
            }
        }
    }
}

该程序创建两个连接的socket,然后向socket写入1000次,并且在案例中使用异步方法接收,但绝大多数操作将同步完成。在.NET 4.7中会得到如下结果:

Elapsed=00:00:20.5272910 Gen0=42 Gen1=2 Gen2=0

在.NET Core 2.0中,大多数操作能够同步完成,得到如下结果:

Elapsed=00:00:05.6197060 Gen0=0 Gen1=0 Gen2=0

不仅仅是直接使用socket来实现组件的这种改进,而且还通过更高级别的组件来间接使用socket,其他PR的结果是更高级别组件(如NetworkStream)的额外性能提升。例如,PR dotnet / corefx#16502在SocketAsyncEventArgs上重新实现了基于Socket的SendAsync和ReceiveAsync操作,并且允许它们在NetworkStream中使用Read / WriteAsync和PR dotnet / corefx#12664添加了一个专门的CopyToAsync重写,以便更有效地从NetworkStream读取数据并将其复制到其他流中。这些变化对NetworkStream吞吐量和分配有非常大的影响。看看下面这个例子:

using System;
using System.Diagnostics;
using System.IO;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
class Test
{
    static void Main() => MainAsync().GetAwaiter().GetResult();
    static async Task MainAsync()
    {
        using (Socket listener = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
        using (Socket client = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp))
        {
            listener.Bind(new IPEndPoint(IPAddress.Loopback, 0));
            listener.Listen(1);

            Task connectTask = Task.Run(() => client.Connect(listener.LocalEndPoint));
            using (Socket server = listener.Accept())
            {
                await connectTask;

                using (var serverStream = new NetworkStream(server))
                using (var clientStream = new NetworkStream(client))
                {
                    Task serverCopyAll = serverStream.CopyToAsync(Stream.Null);

                    byte[] data = new byte[1024];
                    new Random().NextBytes(data);

                    var sw = new Stopwatch();
                    int gen0 = GC.CollectionCount(0), gen1 = GC.CollectionCount(1), gen2 = GC.CollectionCount(2);
                    sw.Start();

                    for (int i = 0; i < 1_000_000; i++)
                    {
                        await clientStream.WriteAsync(data, 0, data.Length);
                    }
                    client.Shutdown(SocketShutdown.Send);
                    serverCopyAll.Wait();
                    sw.Stop();

                    Console.WriteLine($"Elapsed={sw.Elapsed} Gen0={GC.CollectionCount(0) - gen0} Gen1={GC.CollectionCount(1) - gen1} Gen2={GC.CollectionCount(2) - gen2}");
                }
            }
        }
    }
}

与之前的Socket一样,下面我们创建两个连接的socket,然后把它们包含在NetworkStream中。在其中一个流中,我们将1K数据写入一百万次,而另一个流则通过CopyToAsync操作读出所有数据。在.NET 4.7中,会得到如下输出:

Elapsed = 000024.7827947 Gen0 = 220 Gen1 = 3 Gen2 = 0

而在.NET Core 2.0中,时间减少了5倍,垃圾回收有效地减少到零:

Elapsed=00:00:05.6456073 Gen0=74 Gen1=0 Gen2=0

其它网络相关组件也将得到进一步优化。例如SslStream通常将围绕在NetworkStream中,以便向连接中添加SSL。下面的示例将看到这种影响,这个示例将在NetworkStream之上添加SslStream的用法:

using System;
using System.Diagnostics;
using System.Threading;
class Test
{
    static void Main()
    {
        while (true)
        {
            int remaining = 20_000_000;
            var mres = new ManualResetEventSlim();
            WaitCallback wc = null;
            wc = delegate
            {
                if (Interlocked.Decrement(ref remaining) <= 0) mres.Set();
                else ThreadPool.QueueUserWorkItem(wc);
            };

            var sw = new Stopwatch();
            int gen0 = GC.CollectionCount(0), gen1 = GC.CollectionCount(1), gen2 = GC.CollectionCount(2);
            sw.Start();

            for (int i = 0; i < Environment.ProcessorCount; i++) ThreadPool.QueueUserWorkItem(wc);
            mres.Wait();

            Console.WriteLine($"Elapsed={sw.Elapsed} Gen0={GC.CollectionCount(0) - gen0} Gen1={GC.CollectionCount(1) - gen1} Gen2={GC.CollectionCount(2) - gen2}");
        }
    }
}

在.NET 4.7中,会得到如下结果:

Elapsed=00:00:21.1171962 Gen0=470 Gen1=3 Gen2=1

.NET Core 2.0包含了诸如dotnet / corefx#12935dotnet / corefx#13274等PR的改进,这两者都将大大减少了使用SslStream所涉及的分配。在.NET Core 2.0上运行相同的代码时,会得到如下结果:

Elapsed=00:00:05.6456073 Gen0=74 Gen1=0 Gen2=0

85%的垃圾收集已被删除!

 

并发

对于并发和并行性相关的原始化和基础部分,也得到了许多改进。

这里的一个关键点是ThreadPool,它是执行许多.NET应用程序的核心。例如,PR dotnet / coreclr#3157减少了QueueUserWorkItem中涉及的某些对象的大小,PR dotnet / coreclr#9234使用了ConcurrentQueue <T>重写来替换ThreadPool的全局队列,其中会用到较少的同步和分配。从以下的示例中,会看到最终结果: 

using System;
using System.Diagnostics;
using System.Threading;
class Test
{
    static void Main()
    {
        while (true)
        {
            int remaining = 20_000_000;
            var mres = new ManualResetEventSlim();
            WaitCallback wc = null;
            wc = delegate
            {
                if (Interlocked.Decrement(ref remaining) <= 0) mres.Set();
                else ThreadPool.QueueUserWorkItem(wc);
            };

            var sw = new Stopwatch();
            int gen0 = GC.CollectionCount(0), gen1 = GC.CollectionCount(1), gen2 = GC.CollectionCount(2);
            sw.Start();

            for (int i = 0; i < Environment.ProcessorCount; i++) ThreadPool.QueueUserWorkItem(wc);
            mres.Wait();

            Console.WriteLine($"Elapsed={sw.Elapsed} Gen0={GC.CollectionCount(0) - gen0} Gen1={GC.CollectionCount(1) - gen1} Gen2={GC.CollectionCount(2) - gen2}");
        }
    }
}

在.NET 4.7中,会等到如下结果:

Elapsed=00:00:03.6263995 Gen0=225 Gen1=51 Gen2=16
Elapsed=00:00:03.6304345 Gen0=231 Gen1=62 Gen2=17
Elapsed=00:00:03.6142323 Gen0=225 Gen1=53 Gen2=16
Elapsed=00:00:03.6565384 Gen0=232 Gen1=62 Gen2=16
Elapsed=00:00:03.5999892 Gen0=228 Gen1=62 Gen2=17

而在.NET Core 2.0中,会得到如下结果:

Elapsed=00:00:02.1797508 Gen0=153 Gen1=0 Gen2=0
Elapsed=00:00:02.1188833 Gen0=154 Gen1=0 Gen2=0
Elapsed=00:00:02.1000003 Gen0=153 Gen1=0 Gen2=0
Elapsed=00:00:02.1024852 Gen0=153 Gen1=0 Gen2=0
Elapsed=00:00:02.1044461 Gen0=154 Gen1=1 Gen2=0

这是一个巨大的吞吐量的改善,并且这样一个核心组件的垃圾量也将大幅减少。

同步原语也在.NET Core中得到提升。例如,低级并发代码通常使用SpinLock来尝试避免分配锁定对象或最小化竞争锁所花费的时间。PR dotnet / coreclr#6952改进了失败的快速路径,以下测试会得到显而易见的结果:

using System;
using System.Diagnostics;
using System.Threading;
class Test
{
    static void Main()
    {
        while (true)
        {
            bool taken = false;
            var sl = new SpinLock(false);
            sl.Enter(ref taken);

            var sw = Stopwatch.StartNew();
            for (int i = 0; i < 100_000_000; i++)
            {
                taken = false;
                sl.TryEnter(0, ref taken);
            }
            Console.WriteLine(sw.Elapsed);
        }
    }
}

在.NET 4.7中,会得到如下结果:

00:00:02.3276463
00:00:02.3174042
00:00:02.3022212
00:00:02.3015542
00:00:02.2974777

 

而在.NET Core 2.0中,会得到如下结果:

000000.3915327 
000000.3953084 
000000.3875121 
000000.3980009 
000000.3886977

吞吐量的这种差异可能会对运行这种锁的热路径产生很大的影响。

这只是众多例子中的一个。另一个例子围绕着Lazy<T>,它被PR dotnet / coreclr#8963manofstick重写,以便提高访问初始化过的Lazy <T>的效率。这样的提升效果从下面的示例中清晰可见:

using System;
using System.Diagnostics;
class Test
{
    static int s_result;

    static void Main()
    {
        while (true)
        {
            var lazy = new Lazy<int>(() => 42);
            s_result = lazy.Value;

            var sw = Stopwatch.StartNew();
            for (int i = 0; i < 1_000_000_000; i++)
            {
                s_result = lazy.Value;
            }
            Console.WriteLine(sw.Elapsed);
        }
    }
}

在.NET 4.7中,会得到的结果如下:

000002.6769712 
000002.6789140 
000002.6535493 
000002.6911146 
000002.7253927

而在.NET Core 2.0中,会得到的结果如下:

000000.5278348 
000000.5594950 
000000.5458245 
000000.5381743 
000000.5502970

吞吐量增加约5倍。

 

下一步是什么

本文只涉及了部分.NET Core的性能改进。在dotnet / corefxdotnet / coreclr repos 中的pull请求中搜索“perf”或“performance”,你会发现接近一千个合并的PR改进。其中一些是比较大的同时也很有影响力的改进,而另一些则主要减少了库和运行时的消耗,这些变化一起起作用,保证了能够在.NET Core上更快的运行应用程序。展望未来,性能将成为关注的重点,无论是以性能改进为目标的API还是现有库的性能的改进。

欢迎大家深入了解.NET Core代码库,以便找到影响自己的应用程序和库的瓶颈,并提交PR来修复它们。如果你的问题得到修复,也请将修复程序分享给所有需要的人。

 

相关阅读:

Visual Studio 2017正式版发布全纪录

从Visual Studio看微软20年技术变迁

Visual Studio 20周年,我和VS不得不说的故事

 

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!