Code Performance với BenchmarkDotNet cho .NET

10 min read

BenchmarkDotNet

1. Introduction

Tối ưu hóa hiệu năng là một khía cạnh quan trọng trong phát triển phần mềm, bất kể kinh nghiệm của lập trình viên hay loại ứng dụng đang được phát triển. Việc đo lường và hiểu rõ hiệu năng của source code là điều cần thiết để xác định các vấn đề trong code và thực hiện tối ưu hóa một cách hiệu quả. Bài viết này sẽ hướng dẫn bạn cách sử dụng thư viện BenchmarkDotNet trong .NET để đánh giá hiệu năng (benchmark) và đo lường hiệu suất của code của bạn.

2. Giới thiệu về BenchmarkDotNet?

BenchmarkDotNet là một thư viện mã nguồn mở mạnh mẽ dùng để đánh giá hiệu năng code .NET. Nó cho phép bạn đo lường chính xác thời gian thực thi của code, giúp bạn xác định các điểm nghẽn và đưa ra các cải tiến hiệu suất một cách có căn cứ. Hãy cùng tìm hiểu cách sử dụng nó.

Bạn có thể cài đặt nó thông qua NuGet Package Manager trong Visual Studio, sau đó chỉ việc search “BenchmarkDotNet” và chọn version phù hợp với project. Bên cạnh đó có thể sử dụng lệnh ở consle để cài đặt như sau:

dotnet add package BenchmarkDotNet

Structure Benchmark Code

Việc triển khai code benchmark một cách hợp lý là rất quan trọng để đảm bảo kết quả đánh giá chính xác và hiệu quả. Hãy tuân theo các hướng dẫn sau:

  1. Tạo một class riêng cho benchmark: Bắt đầu bằng việc tạo một class chuyên dụng cho các benchmark của bạn. Điều này giúp giữ code benchmark có tổ chức và tách biệt với phần còn lại của code ứng dụng.
  2. Áp dụng thuộc tính [XXXRunJob] phù hợp: Chọn loại benchmark job phù hợp mà bạn muốn chạy bằng cách đánh dấu class benchmark của bạn với một trong các thuộc tính sau: [ShortRunJob], [MediumRunJob], [LongRunJob], hoặc [VeryLongRunJob].
  3. Áp dụng thuộc tính [MemoryDiagnoser]: Để cho phép đo lường bộ nhớ trong quá trình benchmark, hãy áp dụng thuộc tính [MemoryDiagnoser] cho class benchmark của bạn. Thuộc tính này cho phép bạn thu thập thông tin liên quan đến bộ nhớ cùng với thời gian thực thi. Nếu bạn chỉ quan tâm đến thời gian chạy mà không quan tâm đến bộ nhớ, bạn có thể bỏ qua thuộc tính này.

Note: class của bạn không thể là sealed. Tôi khuyến nghị chỉ nên sử dụng định nghĩa public class tiêu chuẩn với các thuộc tính phù hợp như đã đề cập ở trên. Tuy nhiên, bạn có thể cho class benchmark kế thừa từ một class khác, vì vậy nếu bạn thấy có lợi ích khi sử dụng kế thừa để tái sử dụng code, đó có thể là một lựa chọn khả thi.

Benchmark Methods

Các Benchmark Methods là nơi bạn định nghĩa code mà bạn muốn đo lường. Dưới đây là một số tips để viết các phương thức benchmark sử dụng BenchmarkDotNet:

  1. Áp dụng thuộc tính [Benchmark] attribute: Mỗi benchmark method cần có thuộc tính [Benchmark]. Thuộc tính này cho BenchmarkDotNet biết rằng phương thức này nên được xử lý như một benchmark.
  2. Tránh chi phí setup trong các phương thức benchmark: Các benchmark method nên tập trung vào việc đo lường hiệu năng của chính code đó, không phải chi phí của việc khởi tạo kịch bản benchmark.
  3. Tránh allocation và lạm dụng memory: Các benchmark method không nên bận tâm đến overhead gây ra bởi memory allocation. Hãy giảm thiểu allocation và giảm việc sử dụng memory trong các phương thức benchmark để có được các phép đo hiệu năng chính xác.

Note: BenchmarkDotNet sẽ xử lý tất cả quá trình warmup cho bạn – Bạn không cần phải cố gắng tự code thêm các vòng lặp trước để đưa mọi thứ vào trạng thái ổn định trước khi benchmark code C#.

Ví dụ:

[Benchmark]
public void SimpleMethodBenchmark()
{
    for (int i = 0; i < 1000; i++)
    {
        // Execute the code to be measured
        MyClass.SimpleMethod();
    }
}

Trong ví dụ này, thuộc tính [Benchmark] được áp dụng cho method SimpleMethodBenchmark, cho biết rằng nó sẽ được xử lý như một benchmark. Giả sử chúng ta đang sử dụng một instance method thay vì static method như đã minh họa. Trong trường hợp đó, chúng ta sẽ muốn khởi tạo class BÊN NGOÀI benchmark method — đặc biệt là nếu chúng ta cần tạo, cấu hình và truyền vào các dependencies. Hãy giảm thiểu (hay nói cách khác là loại bỏ) lượng công việc được thực hiện trong method mà không phải là thứ bạn đang cố gắng benchmark.

Hãy nhớ rằng, nếu bạn quan tâm đến memory bên cạnh các đặc điểm runtime, hãy đảm bảo áp dụng thuộc tính [MemoryDiagnoser] cho benchmark class — không phải cho method đang được benchmark.

3. BenchmarkDotNet

BenchmarkRunner

BenchmarkRunner class cung cấp một cơ chế rất đơn giản để chạy tất cả các Benchmark mà bạn cung cấp cho nó từ một type, danh sách các type, hoặc một assembly. Lợi ích của điều này nằm ở tính đơn giản vì bạn chỉ cần chạy executable và nó sẽ ngay lập tức chạy tất cả các benchmark mà bạn đã cấu hình code để chạy.

using BenchmarkDotNet.Running;

var assembly = typeof(Benchmarking.BenchmarkDotNet.BenchmarkBaseClass.Benchmarks).Assembly;

BenchmarkRunner.Run(
    assembly,
    args: args);

BenchmarkSwitcher

using BenchmarkDotNet.Running;

var assembly = typeof(Benchmarking.BenchmarkDotNet.BenchmarkBaseClass.Benchmarks).Assembly;


BenchmarkSwitcher
    // used to load all benchmarks from an assembly
    .FromAssembly(assembly)
    // OR... if there are multiple assemblies,
    // you can use this instead
    //.FromAssemblies
    // OR... if you'd rather specify the benchmark
    // types directly, you can use this
    ///.FromTypes(new[]
    ///{
    ///    typeof(MyBenchmark1),
    ///    typeof(MyBenchmark2),
    ///})
    .Run(args);

4. Customizing Benchmark Execution

BenchmarkDotNet cung cấp nhiều tùy chọn để tùy chỉnh việc thực thi các benchmark của bạn, cho phép bạn tinh chỉnh quá trình benchmark theo nhu cầu của mình. Hai tùy chọn quan trọng cần xem xét là số lần lặp lại và số lần lặp warm-up.

Cấu hình các tham số cho Benchmarks

Nếu chúng ta muốn có sự đa dạng trong các lần chạy benchmark, chúng ta có thể sử dụng thuộc tính [Params] trên một trường public. Điều này tương tự như việc sử dụng xUnit Theory cho các test có tham số, nếu bạn đã quen với nó. Đối với mỗi trường bạn đánh dấu bằng thuộc tính này, về cơ bản bạn sẽ xây dựng một ma trận các kịch bản benchmark để chạy.

Hãy xem xét code ví dụ sau:

[MemoryDiagnoser]
[ShortRunJob]
public class OurBenchmarks
{
    List<int>? _list;

    [Params(1_000, 10_000, 100_000, 1_000_000)]
    public int ListSize;

    [GlobalSetup]
    public void Setup()
    {
        _list = new List<int>();
        for (int i = 0; i < ListSize; i++)
        {
            _list.Add(i);
        }
    }

    [Benchmark]
    public void OurBenchmark()
    {
        _list!.Sort();
    }
}

Trong đoạn code trên, chúng ta có một trường ListSize được đánh dấu với [Params]. Điều này có nghĩa là trong phương thức [GlobalSetup] của chúng ta, chúng ta có thể nhận được một giá trị mới cho mỗi biến thể của ma trận benchmark mà chúng ta muốn chạy. Trong trường hợp này, vì chỉ có một tham số, sẽ chỉ có một benchmark cho mỗi giá trị của ListSize — vì vậy sẽ có 4 benchmark khác nhau dựa trên 4 giá trị khác nhau được chỉ định.

Điều chỉnh số lần lặp lại cho mỗi Benchmark

BenchmarkDotNet cho phép bạn kiểm soát số lần lặp lại cho mỗi phương thức benchmark. Theo mặc định, mỗi benchmark được thực thi một số lần hợp lý để có được các phép đo đáng tin cậy. Tuy nhiên, bạn có thể điều chỉnh số lần lặp bằng cách áp dụng thuộc tính [IterationCount] cho từng phương thức benchmark riêng lẻ, chỉ định số lần lặp mong muốn.

[Benchmark]
[IterationCount(10)] // Custom iteration count
public void MyBenchmarkMethod()
{
    // Benchmark code here
}

5. Tối ưu hoá code C# với BenchmarkDotNet

Benchmark là một phần quan trọng của quá trình phát triển phần mềm khi nói đến việc tối ưu hóa hiệu năng. Sau khi chạy benchmark sử dụng BenchmarkDotNet, việc có thể phân tích và diễn giải kết quả một cách chính xác là rất quan trọng. Trong phần này, tôi sẽ hướng dẫn bạn qua quá trình phân tích kết quả benchmark và xác định các cải tiến hiệu năng tiềm năng.

Xác định các nút thắt về hiệu suất

Một trong những bước đầu tiên trong việc tối ưu hóa code C# là xác định các điểm nghẽn về hiệu năng. BenchmarkDotNet cho phép chúng ta đo lường thời gian thực thi của code và so sánh các cách triển khai khác nhau. Bằng cách phân tích kết quả benchmark, chúng ta có thể xác định chính xác các khu vực chiếm nhiều thời gian nhất.

Hãy xem xét một ví dụ trong đó chúng ta có một vòng lặp thực hiện tính toán trên một mảng lớn. Chúng ta có thể sử dụng BenchmarkDotNet để đo lường thời gian thực thi của các cách triển khai khác nhau của vòng lặp này và xác định bất kỳ điểm nghẽn tiềm năng nào.

[ShortRunJob]
public class ArrayComputation
{
    private readonly int[] array = new int[1000000];

    [GlobalSetup]
    public void Setup()
    {
        // TODO: decide how you want to fill the array :)
    }

    [Benchmark]
    public void LoopWithMultipleOperations()
    {
        for (int i = 0; i < array.Length; i++)
        {
            array[i] += 1;
            array[i] *= 2;
            array[i] -= 1;
        }
    }

    [Benchmark]
    public void LoopWithSingleOperation()
    {
        for (int i = 0; i < array.Length; i++)
        {
            array[i] = (array[i] + 1) * 2 - 1;
        }
    }
}

Trong ví dụ này, chúng ta có hai phương thức benchmark LoopWithMultipleOperationsLoopWithSingleOperation. Phương thức đầu tiên thực hiện nhiều phép toán trên mỗi phần tử của mảng, trong khi phương thức thứ hai kết hợp các phép toán thành một phép tính duy nhất. Bằng cách so sánh thời gian thực thi của hai phương thức này sử dụng BenchmarkDotNet, chúng ta có thể xác định phương pháp nào hiệu quả hơn.

Tối ưu hoá vòng lặp và giảm tải bộ nhớ

Các vòng lặp thường là khu vực mà chúng ta có thể tối ưu hóa code để có hiệu năng tốt hơn. Các vòng lặp không hiệu quả có thể dẫn đến việc cấp phát bộ nhớ không cần thiết hoặc các phép tính dư thừa. BenchmarkDotNet có thể giúp chúng ta xác định những vấn đề như vậy và hướng dẫn chúng ta trong việc tối ưu hóa code.

Hãy xem xét ví dụ sau đây, trong đó chúng ta có một vòng lặp nối các chuỗi, và chúng ta cũng đảm bảo sử dụng [MemoryDiagnoser]:

[MemoryDiagnoser]
[ShortRunJob]
public class StringConcatenation
{
    private readonly string[] strings = new string[1000];

    [GlobalSetup]
    public void Setup()
    {
        // TODO: decide how you want to fill the array :)
    }

    [Benchmark]
    public string ConcatenateStrings()
    {
        string result = "";
        for (int i = 0; i < strings.Length; i++)
        {
            result += strings[i];
        }

        return result;
    }

    [Benchmark]
    public string StringBuilderConcatenation()
    {
        StringBuilder stringBuilder = new StringBuilder();
        for (int i = 0; i < strings.Length; i++)
        {
            stringBuilder.Append(strings[i]);
        }

        return stringBuilder.ToString();
    }
}

Trong ví dụ này, chúng ta có hai phương thức benchmark: ConcatenateStringsStringBuilderConcatenation. Phương thức đầu tiên sử dụng phép nối chuỗi bên trong vòng lặp, điều này có thể dẫn đến việc cấp phát bộ nhớ thường xuyên và hiệu năng kém. Phương thức thứ hai sử dụng StringBuilder để nối các chuỗi một cách hiệu quả. Bằng cách so sánh thời gian thực thi của hai phương thức này sử dụng BenchmarkDotNet, chúng ta có thể quan sát sự khác biệt về hiệu năng và xác nhận tính hiệu quả của việc sử dụng StringBuilder cho việc nối chuỗi.

6. Kết

BenchmarkDotNet là một công cụ có giá trị để benchmark code C# một cách chính xác và hiệu quả. Xuyên suốt bài viết này, chúng ta đã khám phá các mẹo để sử dụng BenchmarkDotNet một cách hiệu quả. Bằng cách tận dụng những điều này, bạn có thể đo lường và tối ưu hóa hiệu năng code C# của mình một cách chính xác. Hiệu năng được cải thiện có thể dẫn đến các ứng dụng hiệu quả hơn, trải nghiệm người dùng tốt hơn, và tổng thể nâng cao chất lượng phần mềm!

Avatar photo

Leave a Reply

Your email address will not be published. Required fields are marked *