Những Cạm Bẫy Hiệu Suất Trong C# / .NET – List Contains

Ngày hôm qua, người dùng reddit zigs đã hỏi trên subreddit csharp:

Phương pháp trợ giúp có nỗ lực thấp nhất, tác động cao nhất mà bạn từng viết là gì?

Đây là một bài đăng rất phổ biến, và một trong những câu trả lời phổ biến hơn từ người dùng _mattmc3_ là:

Tôi đã viết rất nhiều SQL trong những năm làm lập trình viên, vì vậy foo IN(1, 2, 3) là cách diễn đạt trực quan hơn cho tôi so với foo == 1 || foo == 2 || foo == 3 hoặc thậm chí new int[] {1,2,3}.Contains(foo). Việc foo đứng đầu có vẻ hợp lý hơn, vì vậy tôi có một phương thức mở rộng IsIn() tiện lợi để tôi có thể viết foo.IsIn(1, 2, 3):

public static bool IsIn<T>(this T obj, params T[] values) {
        foreach (T val in values) {
            if (val.Equals(obj)) return true;
        }
        return false;
    }

    public static bool IsIn<T>(this T obj, IComparer comparer, params T[] values) {
        foreach (T val in values) {
            if (comparer.Compare(obj, val) == 0) return true;
        }
        return false;
    }

Có một số thảo luận về điều này, chỉ ra rằng các phiên bản mới hơn của C# hỗ trợ `params ReadOnlySpan` để tránh phân bổ bộ nhớ.

Tôi quyết định thực hiện benchmark một kịch bản đơn giản mà bạn có thể sử dụng điều này.

Benchmark

Hãy nói rằng chúng ta có một danh sách 10.000 số ngẫu nhiên từ 0 đến 63 (bao gồm cả hai số). Chúng ta muốn đếm có bao nhiêu trong số này là 1, 3 hoặc 7. Nghe có vẻ trừu tượng, nhưng đây là một mẫu thực tế khá phổ biến. Mặc dù trong thực tế, điều này có thể thực sự đang đếm một danh sách các loại trái cây chuối, dâu tây và xoài. Nói chung, kịch bản này xuất hiện khi bạn có một tập hợp các mục thuộc tính của chúng thường được biết tại thời điểm biên dịch, nhưng miền của nó mở rộng cho tương lai nên không phù hợp vào một enum cờ.

Cấu hình của chúng ta vì vậy rất đơn giản, chúng ta tạo danh sách các số:

[GlobalSetup]
    public void Setup()
    {
        for (int i = 0; i < 10_000; i++)
        {
            keys[i] = Random.Shared.Next(0, 63);
        }
    }

Làm điểm chuẩn, chúng ta sẽ so sánh các phương pháp có thể là điều tự nhiên nhất để làm: tạo một danh sách và sử dụng `Contains`:

[Benchmark(Baseline = true)]
    public int Contains()
    {
        return keys.Count(key => new int[] { 1, 3, 7 }.Contains(key));
    }

Chúng ta muốn so sánh điều này với phương thức trợ giúp được đề xuất, cũng như phiên bản `ReadOnlySpan` được đề xuất:

[Benchmark]
    public int IsIn()
    {
        return keys.Count(key => key.IsIn(1, 3, 7));
    }

    [Benchmark]
    public int IsInReadOnly()
    {
        return keys.Count(key => key.IsInReadOnly(1, 3, 7));
    }

Chúng ta cũng nên so sánh nó với phương pháp đơn giản nhất, `if` hoặc `switch case`:

[Benchmark]
    public int WithIf()
    {
        return keys.Count(key => key == 1 || key == 3 || key == 7);
    }

    [Benchmark]
    public int WithSwitch()
    {
        return keys.Count(key =>
        {
            switch (key)
            {
                case 1:
                case 3:
                case 7:
                    return true;
            }
            return false;
        });
    }

Kết Quả

Method Job Runtime Mean Error StdDev Median Ratio RatioSD Gen0 Allocated Alloc Ratio
IsIn .NET 8.0 .NET 8.0 192.533 us 3.8374 us 6.8209 us 193.961 us 1.87 0.08 132.3242 1107792 B 2.769
IsIn .NET 10.0 .NET 10.0 126.626 us 2.5270 us 4.4259 us 126.874 us 1.23 0.05 132.5684 1109488 B 2.773
IsInReadOnly .NET 8.0 .NET 8.0 146.813 us 7.2935 us 21.5051 us 158.514 us 1.43 0.21 84.7168 708872 B 1.772
IsInReadOnly .NET 10.0 .NET 10.0 105.307 us 5.0440 us 14.8725 us 100.645 us 1.02 0.15 84.7168 708696 B 1.772
Contains .NET 8.0 .NET 8.0 103.059 us 2.0413 us 2.8616 us 101.683 us 1.00 0.04 47.7295 400032 B 1.000
Contains .NET 10.0 .NET 10.0 73.218 us 1.4635 us 1.6267 us 72.464 us 0.71 0.02 47.7295 400000 B 1.000
WithIf .NET 8.0 .NET 8.0 23.081 us 0.1456 us 0.1362 us 23.095 us 0.22 0.01 32 B 0.000
WithIf .NET 10.0 .NET 10.0 6.826 us 0.0714 us 0.0633 us 6.792 us 0.07 0.00 0.000
WithSwitch .NET 8.0 .NET 8.0 22.848 us 0.4243 us 0.3969 us 22.757 us 0.22 0.01 32 B 0.000
WithSwitch .NET 10.0 .NET 10.0 9.770 us 0.1027 us 0.0960 us 9.756 us 0.09 0.00 0.000

Biểu đồ kết quả benchmark

.NET Framework

Tôi cũng đã chạy một so sánh trên .NET Framework:

Method Job Runtime Mean Error StdDev Median Ratio RatioSD Gen0 Allocated Alloc Ratio
IsIn .NET Framework 4.8.1 .NET Framework 4.8.1 186.539 us 1.9634 us 1.5329 us 186.937 us 1.75 0.07 176.5137 1111075 B 2.777
IsInReadOnly .NET Framework 4.8.1 .NET Framework 4.8.1 241.478 us 4.1324 us 3.6633 us 242.215 us 2.27 0.09 112.7930 709897 B 1.775
Contains .NET Framework 4.8.1 .NET Framework 4.8.1 560.785 us 9.9977 us 9.3518 us 565.321 us 5.26 0.21 63.4766 401204 B 1.003
WithIf .NET Framework 4.8.1 .NET Framework 4.8.1 59.772 us 0.7011 us 0.6558 us 59.798 us 0.56 0.02 31 B 0.000
WithSwitch .NET Framework 4.8.1 .NET Framework 4.8.1 64.200 us 1.1971 us 1.2294 us 64.313 us 0.60 0.03 31 B 0.000

Không có hỗ trợ `ReadOnlySpan` gốc, phiên bản đó chậm hơn nhiều so với `params T[]`, mặc dù ít phân bổ hơn.

Việc thiếu LINQ optimization trong .NET Framework, `Contains` là phiên bản có hiệu suất tồi tệ nhất ở đây.

Kết Luận

Điều gây bất ngờ lớn nhất cho tôi ở đây thực sự là sự khác biệt về hiệu suất giữa .NET 8 và .NET 10 cho phương thức `if` đơn giản. Sự cải thiện đáng kể giữa .NET 8 và .NET 10 cho một bộ các câu lệnh `if` đơn giản cho thấy các cải tiến JIT đã tạo ra sự khác biệt lớn.

Phương pháp `ReadOnlySpan` đã tránh một số phân bổ bộ nhớ, nhưng nó vẫn phân bổ nhiều hơn phương pháp `Contains`, vốn vẫn nhiều hơn phiên bản `switch / case`.

Đối với một phương thức trợ giúp phổ biến và hấp dẫn như vậy, đây thực sự là một cái bẫy hiệu suất. Tổng thể, phiên bản có hiệu suất tồi tệ nhất chậm hơn 28 lần so với các câu lệnh `if` được mã hóa cứng.

Như với bất kỳ micro-benchmark nào, điều quan trọng cần nhấn mạnh là chỉ quan trọng nếu profiling trường hợp sử dụng thực tế của bạn cho thấy nó.

Luôn profiling ứng dụng thực tế của bạn và để nó định hướng bất kỳ tối ưu nào bạn thực hiện. Tôi đã thấy mẫu ảnh hưởng đến hiệu suất trong một ứng dụng thực tế, vì vậy hãy ghi nhớ điều đó.

Luôn profiling trong ngữ cảnh ứng dụng của bạn. Việc ngẫu nhiên loại bỏ phương thức trợ giúp và thay thế bằng `Contains` sẽ là một thảm họa nếu bạn vẫn đang sử dụng .NET Framework.

Mã Nguồn

Mã nguồn để tạo ra các kết quả này có sẵn tại https://github.com/richardcocks/IsInList.

Pull requests luôn được chào đón.

Chỉ mục