C# 标准性能测试高级用法(Benchmark)

·

7 min read

C# 标准性能测试 已经告诉大家如何使用 BenchmarkDotNet 测试性能,本文会告诉大家高级的用法。

建议是创建一个控制台项目用来做性能测试,这个项目要求是 dotnet framework 4.6 以上,建议是 dotnet 7 的版本。使用这个项目引用需要测试的项目,然后在里面写测试的代码。

例如被测试项目有一个类 Foo 里面有一个叫 Lindexidb 的方法,接下来的任务是需要测试这个 Lindexidb 方法的性能

最简单的测试的代码

public class FooPerf
{
    [Benchmark]
    public void Lindexidb()
    {
        new Foo().Lindexidb();
    }
}

在 Main 函数使用下面代码,即可执行基准性能测试

  var boKar = BenchmarkRunner.Run<Foo>();

以上代码即可完成最基础的测试,但如上面代码所示,其实不够标准。其原因是咱的需求是测试这个 Lindexidb 方法的性能,但是在测试方法里面,却还需要构造出 Foo 对象,构造出对象的耗时将会影响测量的结果。此时就需要用到标准性能测试的更高级的用法了,如传入参数

传入参数

如果需要测试的方法需要传入不同的参数,且在使用不同的参数的性能也是不相同,就需要使用传入参数特性的辅助

例如有底层的项目的代码如下

    public class Foo
    {
        public void Lindexidb()
        {

        }
    }

推荐是创建另一个项目测试这个项目的性能,需要注意不要在自己的库安装 BenchmarkDotNet ,因为安装之后会让启动速度慢很多。用一个专门的项目用来执行性能测试才是推荐的方法

在测试性能的另一个项目,安装 BenchmarkDotNet 引用库进行测试,所有的用来测试性能的代码如下

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

    public class FooPerf
    {
        [Benchmark]
        public void Lindexidb()
        {
            var foo = new Foo();
            foo.Lindexidb();
        }
    }

需要知道的是,必须设置 FooPerf 的访问是 public 权限。如没有设置,将会出现异常

现在例如修改了 Lindexidb 方法,让这个方法需要传入参数,修改之后的代码如下

    public class Foo
    {
        public void Lindexidb(int a, int b)
        {
            var foo = a + b;
            if (Arguments>a)
            {
                return;
            }
            if (foo == 2)
            {
                Arguments = foo;
            }
        }

        public int Arguments { get; set; }
    }

现在需要修改性能项目用来传入参数,修改的代码如下

    public class FooPerf
    {
        [Benchmark(Description = "这里可以写这个方法是什么")]
        public void Lindexidb()
        {
            var foo = new Foo();
            foo.Lindexidb(2, 3);
        }
    }

可以看到上面写法很难写出测试很多参数,代码里写固定了参数了。一个好的方法是通过 ArgumentsAttribute 特性辅助传入参数,修改后的代码如下

    public class FooPerf
    {
        [Benchmark]
        [Arguments(100, 10)]
        [Arguments(2, 10)]
        [Arguments(2, 3)]
        [Arguments(10, 3)]
        [Arguments(21, 3)]
        public void Lindexidb(int a, int b)
        {
            var foo = new Foo();
            foo.Lindexidb(a, b);
        }
    }

通过 Arguments 可以传入不同的参数,使用这个方法可以防止初始化参数需要的时间计算为算法的时间,让测量更加准确

运行程序可以看到下面输出

//   FooPerf.Lindexidb: DefaultJob [a=2, b=3]
//   FooPerf.Lindexidb: DefaultJob [a=2, b=10]
//   FooPerf.Lindexidb: DefaultJob [a=10, b=3]
//   FooPerf.Lindexidb: DefaultJob [a=21, b=3]
//   FooPerf.Lindexidb: DefaultJob [a=100, b=10]

在使用不同的参数可以看到不同的速度

MethodabMeanErrorStdDev
Lindexidb232.037 ns0.0749 ns0.0833 ns
Lindexidb2103.263 ns0.0992 ns0.2682 ns
Lindexidb1032.333 ns0.0798 ns0.1038 ns
Lindexidb2132.278 ns0.0776 ns0.0863 ns
Lindexidb100102.364 ns0.0809 ns0.2242 ns

可以传入不同的参数,传入的参数可以自动转换为输出结果的描述,如此可以看到在不同的参数下的性能

如果传入的参数不对,就会提示,如下面代码

        [Benchmark]
        [Arguments("123", "123")]
        [Arguments(2, 10)]
        [Arguments(2, 3)]
        [Arguments(10, 3)]
        [Arguments(21, 3)]
        public void Lindexidb(int a,int b)
        {
            var foo = new Foo();
            foo.Lindexidb(a, b);
        }

本来是使用 int 但是参数写 string 类型,用错类型会出现下面提示

// Build Exception: The build has failed!
CS0029: Cannot implicitly convert type 'string' to 'int'
CS0029: Cannot implicitly convert type 'string' to 'int'

如果需要参数的数量 一个,如代码,但传入的参数是两个,那么会出现异常

    public class FooPerf
    {
        [Benchmark]
        [Arguments(1, 2)]
        [Arguments(2, 10)]
        [Arguments(2, 3)]
        [Arguments(10, 3)]
        [Arguments(21, 3)]
        public void Lindexidb(int a)
        {
            var foo = new Foo();
            var b = Arguments;
            foo.Lindexidb(a, b);
        }

        public int Arguments { get; set; }
    }

要求特性里传入的参数无论是数量和类型都和方法需要的参数相同才可以

属性

属性和字段都可以修改,但是修改字段需要修改公开字段,不推荐修改字段。修改的方法如下

        [Params(10, 2, 3)]
        public int Arguments { get; set; }

可以设置属性的值为 10,2,3 在下面代码会组合属性和传入参数

        [Benchmark(Description = "这里可以写这个方法是什么")]
        [Arguments(1)]
        [Arguments(2)]
        [Arguments(2)]
        [Arguments(10)]
        [Arguments(21)]
        public void Lindexidb(int a)
        {
            var foo = new Foo();
            var b = Arguments;
            foo.Lindexidb(a, b);
        }

        [Params(10, 2, 3)]
        public int Arguments { get; set; }

运行看到有 15 个测试

//   FooPerf.Lindexidb: DefaultJob [Arguments=2, a=1]
//   FooPerf.Lindexidb: DefaultJob [Arguments=2, a=2]
//   FooPerf.Lindexidb: DefaultJob [Arguments=2, a=2]
//   FooPerf.Lindexidb: DefaultJob [Arguments=2, a=10]
//   FooPerf.Lindexidb: DefaultJob [Arguments=2, a=21]
//   FooPerf.Lindexidb: DefaultJob [Arguments=3, a=1]
//   FooPerf.Lindexidb: DefaultJob [Arguments=3, a=2]
//   FooPerf.Lindexidb: DefaultJob [Arguments=3, a=2]
//   FooPerf.Lindexidb: DefaultJob [Arguments=3, a=10]
//   FooPerf.Lindexidb: DefaultJob [Arguments=3, a=21]
//   FooPerf.Lindexidb: DefaultJob [Arguments=10, a=1]
//   FooPerf.Lindexidb: DefaultJob [Arguments=10, a=2]
//   FooPerf.Lindexidb: DefaultJob [Arguments=10, a=2]
//   FooPerf.Lindexidb: DefaultJob [Arguments=10, a=10]
//   FooPerf.Lindexidb: DefaultJob [Arguments=10, a=21]

传入多个值

可以看到在特性写参数是比较多的,如果需要很多参数就需要写很多代码。有时候需要做对比测试,例如新旧的代码的性能差异,如果还使用以上的方法传入参数,将会发现存在大量的重复代码。 可以使用数组的方式把很多的代码作为数组,方便多个不同的性能测试方法使用相同的参数

请看代码

        [Benchmark(Description = "这里可以写这个方法是什么")]
        [ArgumentsSource(nameof(LeesikeasowSearjeeball))]
        public void Lindexidb(int a, int b)
        {
            var foo = new Foo();
            foo.Lindexidb(a, b);
        }

        public IEnumerable<object[]> LeesikeasowSearjeeball()
        {
            yield return new object[] {2, 3};
            yield return new object[] {10, 2};
            yield return new object[] {5, 2};
            yield return new object[] {100, 5};
            yield return new object[] {3, 100};
        }

上面使用 LeesikeasowSearjeeball 作为输入的参数,注意需要返回一个数组,这个数组里就是参数的列表。上面使用的参数有两个,所以数组就是包含两个参数

//   FooPerf.Lindexidb: DefaultJob [Arguments=2, a=2, b=3]
//   FooPerf.Lindexidb: DefaultJob [Arguments=2, a=3, b=100]
//   FooPerf.Lindexidb: DefaultJob [Arguments=2, a=5, b=2]
//   FooPerf.Lindexidb: DefaultJob [Arguments=2, a=10, b=2]
//   FooPerf.Lindexidb: DefaultJob [Arguments=2, a=100, b=5]
//   FooPerf.Lindexidb: DefaultJob [Arguments=3, a=2, b=3]
//   FooPerf.Lindexidb: DefaultJob [Arguments=3, a=3, b=100]
//   FooPerf.Lindexidb: DefaultJob [Arguments=3, a=5, b=2]
//   FooPerf.Lindexidb: DefaultJob [Arguments=3, a=10, b=2]
//   FooPerf.Lindexidb: DefaultJob [Arguments=3, a=100, b=5]
//   FooPerf.Lindexidb: DefaultJob [Arguments=10, a=2, b=3]
//   FooPerf.Lindexidb: DefaultJob [Arguments=10, a=3, b=100]
//   FooPerf.Lindexidb: DefaultJob [Arguments=10, a=5, b=2]
//   FooPerf.Lindexidb: DefaultJob [Arguments=10, a=10, b=2]
//   FooPerf.Lindexidb: DefaultJob [Arguments=10, a=100, b=5]

例子放在 github 欢迎小伙伴访问

除了可以设置方法传入,还可以设置属性,如以下代码

        [Benchmark]
        public void Foo()
        {
            for (int i = 0; i < Arguments; i++)
            {

            }
        }

        [ParamsSource(nameof(PememasiDismikasu))]
        public int Arguments { get; set; }

        public IEnumerable<int> PememasiDismikasu => new[] { 100, 200 };

通过 ParamsSource 可以告诉测试使用的从哪个拿到

基线

基线可以用在三个不同的地方,最简单的是方法,另外可以用在分类和不同环境。

因为测试的时间在不同的设备的时间都不相同,如何判断一个方法优化之后是比原来好?方法就是把原来的方法作为基线,这样可以对比不同的方法的速度

如有三个不同的方法,选一个作为基线

        [Benchmark]
        public void Time50() => Thread.Sleep(50);

        [Benchmark(Baseline = true)]
        public void Time100() => Thread.Sleep(100);

        [Benchmark]
        public void Time150() => Thread.Sleep(150);

设置基线的方法是添加 Baseline = true ,建议在原来的方法添加,然后使用不同的方法看哪个方法的速度比较快

在输出会添加一列 Scaled 用于表示这个方法对比基线的速度,他的时间是基线的多少。如上面代码的运行会输出

MethodMeanErrorStdDevScaled
Time5050.46 ms0.0779 ms0.0729 ms0.50
Time100100.39 ms0.0762 ms0.0713 ms1.00
Time150150.48 ms0.0986 ms0.0922 ms1.50

这里的 Scaled 就是对比基线方法的时间

如果在不同的分类下需要做不同的标准,就可以在 BenchmarkCategory 添加 Baseline 告诉在哪个分类使用哪个方法作为标准。如下面的代码,设置 Fast 类和 Slow 类使用不同的标准

    [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
    [CategoriesColumn]
    public class IntroCategoryBaseline
    {
        [BenchmarkCategory("Fast"), Benchmark(Baseline = true)]
        public void Time50() => Thread.Sleep(50);

        [BenchmarkCategory("Fast"), Benchmark]
        public void Time100() => Thread.Sleep(100);

        [BenchmarkCategory("Slow"), Benchmark(Baseline = true)]
        public void Time550() => Thread.Sleep(550);

        [BenchmarkCategory("Slow"), Benchmark]
        public void Time600() => Thread.Sleep(600);
    }

运行的输出,可以看到对于不同的分类用的是不同的方法

MethodCategoriesMeanErrorStdDevScaled
Time50Fast50.46 ms0.0745 ms0.0697 ms1.00
Time100Fast100.47 ms0.0955 ms0.0893 ms1.99
Time550Slow550.48 ms0.0525 ms0.0492 ms1.00
Time600Slow600.45 ms0.0396 ms0.0331 ms1.09

基线除了可以测试方法的基线,还可以测试环境。如我的代码需要在 Clr Mono Core 三个不同环境运行,这时我想知道对比 Clr 环境,其他两个环境的性能。可以使用 JobBaseline 的方式。

    [ClrJob(baseline: true)]
    [MonoJob]
    [CoreJob]
    public class IntroJobBaseline
    {
        [Benchmark]
        public int SplitJoin() 
            => string.Join(",", new string[1000]).Split(',').Length;
    }

这时输出可以看到 Clr 运行的是标准,在 Core 运行时间是在 Clr 运行的 0.67 通过这个方法就知道在不同的环境相同的方法的测试

MethodRuntimeMeanErrorStdDevScaledScaledSD
SplitJoinClr19.42 us0.2447 us0.1910 us1.000.00
SplitJoinCore13.00 us0.2183 us0.1935 us0.670.01
SplitJoinMono39.14 us0.7763 us1.3596 us2.020.07

更多关于基线请看 Benchmark and Job Baselines

分类

如果在一个类的测试方法有不同的类型,而只需要测试某几个类型的就需要使用本文的方法

    [DryJob]
    [CategoriesColumn]
    [BenchmarkCategory("分类")]
    [AnyCategoriesFilter("A", "1")]
    public class FooPerf
    {
        [Benchmark]
        [BenchmarkCategory("A", "1")]
        public void A1() => Thread.Sleep(10); // Will be benchmarked

        [Benchmark]
        [BenchmarkCategory("A", "2")]
        public void A2() => Thread.Sleep(10); // Will be benchmarked

        [Benchmark]
        [BenchmarkCategory("B", "1")]
        public void B1() => Thread.Sleep(10); // Will be benchmarked

        [Benchmark]
        [BenchmarkCategory("B", "2")]
        public void B2() => Thread.Sleep(10);
    }

在方法表示方法属于的类型,可以标记一个方法属于多个类型,如 A1 方法属于 A 1 两个类型,在类标记 AnyCategoriesFilter 表示测试某些类型,这里标记了 A 和 1 也就是所有包含 A 或 1 类型的方法会被测试,所以 A1 A2 B1 都会被运行。

包含名字

如果在一个类有很多方法,只需要名字满足某些条件的方法才可以执行,就需要进行包含名字判断。

    [Config(typeof(Config))]
    public class FooPerf
    {
        private class Config : ManualConfig
        {
            // 只有在名字满足包含 "A" 或 "1" 和名字长度小于 3 才执行
            public Config()
            {
                Add(new DisjunctionFilter
                (
                    // 这里的是或关系
                    // 只要名字包含 "A" 或 "1" 就执行
                    new NameFilter(name => name.Contains("A")),
                    new NameFilter(name => name.Contains("1"))
                ));

                // 这里和上面是 And 关系,也就是必须要同时满足名字长度小于 3 才可以执行
                Add(new NameFilter(name => name.Length < 3));
            }
        }

        [Benchmark]
        public void A1() => Thread.Sleep(10); // Will be benchmarked

        [Benchmark]
        public void A2() => Thread.Sleep(10); // Will be benchmarked

        [Benchmark]
        public void A3() => Thread.Sleep(10); // Will be benchmarked

        [Benchmark]
        public void B1() => Thread.Sleep(10); // Will be benchmarked

        [Benchmark]
        public void B2() => Thread.Sleep(10);

        [Benchmark]
        public void B3() => Thread.Sleep(10);

        [Benchmark]
        public void C1() => Thread.Sleep(10); // Will be benchmarked

        [Benchmark]
        public void C2() => Thread.Sleep(10);

        [Benchmark]
        public void C3() => Thread.Sleep(10);

        [Benchmark]
        public void Aaa() => Thread.Sleep(10);
    }

在类添加特性,告诉这个类需要使用哪个配置,在配置的构造函数使用了两次的 Add 函数,在多个 Add 之间是 And 关系,也就是必须所有 Add 的条件都满足才可以执行。在一个Add使用的 DisjunctionFilter 可以使用或关系多个条件。

上面的函数使用的满足名字带有 A 或 1 而且名字的长度小于 3 才可以执行。

除了使用名字作为条件,还可以使用 AnyCategoriesFilter 表示存在任意的类型符合,AllCategoriesFilter 要求所有的类型都符合。

运行多个类

一个需要测试的类需要使用下面代码

            BenchmarkRunner.Run<FooPerf>();

只能测试一个类,如果有很多类就需要写很多代码,下面告诉大家如何找到所有方法

 BenchmarkSwitcher.FromAssembly(typeof(Program).GetTypeInfo().Assembly).Run(args);

在运行的时候就可以选运行哪个

加上内存分配

如果需要测试内存分配,可以加上 MemoryDiagnoser 特性,如以下代码

    [MemoryDiagnoser] // 加上这个特性
    [DryJob]
    [CategoriesColumn]
    [BenchmarkCategory("分类")]
    [AnyCategoriesFilter("A", "1")]
    public class FooPerf
    {
        [Benchmark]
        [BenchmarkCategory("A", "1")]
        public void A1() => Thread.Sleep(10); // Will be benchmarked

        [Benchmark]
        [BenchmarkCategory("A", "2")]
        public void A2() => Thread.Sleep(10); // Will be benchmarked

        [Benchmark]
        [BenchmarkCategory("B", "1")]
        public void B1() => Thread.Sleep(10); // Will be benchmarked

        [Benchmark]
        [BenchmarkCategory("B", "2")]
        public void B2() => Thread.Sleep(10);
    }

加上之后将会自动加上 Allocated 这一列,如下面代码

MethodMeanGen 0Gen 1Gen 2Allocated
F114.17 ns0.0153--64 B
F240.84 ns----

这里的 Allocated 指的是内存申请了多少,不等于运行方法结束之后,还占用多少内存哦