Chuyển tới nội dung chính

19.3 — 2. BenchmarkDotNet

Tóm tắt

Đo một đoạn code chạy trong micro giây bằng Stopwatch không cho kết quả đáng tin, và lý do sâu hơn người ta nghĩ: JIT có thể xoá hẳn code bạn đang đo nếu kết quả không được dùng, tiering compilation khiến vài nghìn lần chạy đầu chạy bằng phiên bản chưa tối ưu, và GC có thể chen vào đúng lúc đo. BenchmarkDotNet xử lý toàn bộ những thứ đó: làm nóng, chạy nhiều vòng, phân tích thống kê, phát hiện nhiễu. Nhưng phải nhớ giới hạn của nó: micro-benchmark trả lời "hàm A hay hàm B nhanh hơn", không trả lời "endpoint của tôi chậm ở đâu". Nhầm hai câu hỏi này dẫn tới việc tối ưu một hàm nhanh gấp đôi nhưng chỉ chiếm 0,1% thời gian request — công sức bỏ ra cho một cải thiện không đo được.

Mục tiêu bài học​

Sau bài này bạn có thể:

  • Giải thích vì sao Stopwatch không đủ cho micro-benchmark.
  • Viết benchmark đúng với Baseline và MemoryDiagnoser.
  • Tránh dead code elimination làm sai kết quả.
  • Đọc bảng kết quả gồm Mean, Error, StdDev, Allocated.
  • Biết khi nào micro-benchmark là công cụ sai.

Nội dung bài học​

19.3.1 — Vì sao Stopwatch nói dối​

// SAI — bốn vấn đề cùng lúc
var sw = Stopwatch.StartNew();
for (int i = 0; i < 1_000_000; i++)
_ = ComputeHash(input); // kết quả không dùng -> JIT có thể XOÁ
Console.WriteLine(sw.ElapsedMilliseconds);
Vấn đềHậu quả
Dead code eliminationJIT thấy kết quả không dùng → xoá lời gọi → đo được 0ms
Tiering compilationVài nghìn lần đầu chạy bằng phiên bản tier-0 chưa tối ưu
GC chen vàoMột lần thu gom gen-2 trong lúc đo làm sai lệch hoàn toàn
Không có thống kêMột con số không cho biết độ biến thiên

Về dead code elimination: .NET JIT là trình biên dịch tối ưu hoá, và nó được phép loại bỏ tính toán không có tác dụng quan sát được. Kết quả là bạn đo được "0,3 nano giây" cho một hàm băm — con số vô lý nhưng dễ tin nếu không biết chuyện gì đang xảy ra.

19.3.2 — Benchmark đúng cách​

[MemoryDiagnoser]                       // đo cả cấp phát bộ nhớ
[SimpleJob(RuntimeMoniker.Net90)]
public class StringConcatenationBenchmark
{
private string[] _parts = null!;

[Params(10, 100, 1000)] // chay voi ca ba kich thuoc
public int Count { get; set; }

[GlobalSetup]
public void Setup() =>
_parts = Enumerable.Range(0, Count).Select(i => $"phan-{i}").ToArray();

[Benchmark(Baseline = true)]
public string PlusOperator()
{
var result = "";
foreach (var part in _parts) result += part; // O(n^2)
return result;
}

[Benchmark]
public string StringBuilder()
{
var sb = new StringBuilder();
foreach (var part in _parts) sb.Append(part);
return sb.ToString();
}

[Benchmark]
public string StringJoin() => string.Join("", _parts);
}

Bốn chi tiết quan trọng:

  • Trả về kết quả thay vì gán vào biến bỏ đi. BenchmarkDotNet tiêu thụ giá trị trả về nên JIT không xoá được code.
  • [Baseline = true] cho cột Ratio so sánh tương đối — dễ đọc hơn con số tuyệt đối.
  • [Params] chạy cùng benchmark với nhiều kích thước, phơi bày độ phức tạp thuật toán.
  • [GlobalSetup] chuẩn bị dữ liệu ngoài phần được đo.
dotnet run -c Release                   # BẮT BUỘC Release, không bao giờ Debug

Chạy ở Debug làm sai kết quả hoàn toàn — BenchmarkDotNet sẽ cảnh báo và từ chối.

19.3.3 — Đọc kết quả​

| Method         | Count | Mean         | Error      | StdDev     | Ratio | Allocated |
|--------------- |------ |-------------:|-----------:|-----------:|------:|----------:|
| PlusOperator | 10 | 412.3 ns | 2.15 ns | 1.90 ns | 1.00 | 1264 B |
| StringBuilder | 10 | 238.7 ns | 1.42 ns | 1.33 ns | 0.58 | 456 B |
| StringJoin | 10 | 156.2 ns | 0.89 ns | 0.83 ns | 0.38 | 240 B |
| PlusOperator | 1000 | 187,432.1 ns | 1,205.3 ns | 1,127.4 ns | 1.00 | 2534216 B |
| StringBuilder | 1000 | 14,203.5 ns | 89.2 ns | 83.4 ns | 0.08 | 41320 B |
| StringJoin | 1000 | 9,871.3 ns | 52.1 ns | 48.7 ns | 0.05 | 23848 B |
CộtÝ nghĩa
MeanTrung bình các lần chạy
ErrorNửa khoảng tin cậy 99,9%
StdDevĐộ lệch chuẩn — cao là kết quả không ổn định
RatioSo với baseline
AllocatedBộ nhớ cấp phát mỗi lần gọi

Điều đáng chú ý nhất: PlusOperator chậm hơn 1,5 lần với 10 phần tử nhưng 20 lần với 1.000. Đó là chữ ký của độ phức tạp O(n²) — và nó chỉ lộ ra nhờ [Params]. Đo một kích thước duy nhất sẽ bỏ sót hoàn toàn.

Cột Allocated thường quan trọng hơn Mean trên server: cấp phát nhiều nghĩa là GC chạy thường xuyên hơn, và GC gây độ trễ cho mọi request đang chạy, không riêng request này.

StdDev cao (so với Mean) nghĩa là kết quả không đáng tin — thường do máy đang chạy việc khác. Đóng mọi thứ và đo lại.

19.3.4 — Giới hạn: khi micro-benchmark là công cụ sai​

// Tối ưu này nhanh gấp ĐÔI... và vô nghĩa trong thực tế
[Benchmark] public int OldMapping() => ...; // 120 ns
[Benchmark] public int NewMapping() => ...; // 60 ns

Nếu endpoint mất 200ms và phần mapping chiếm 120 nano giây, cải thiện 60ns là 0,00003% tổng thời gian. Không đo được, không ai cảm nhận được.

Dùng BenchmarkDotNet khiDùng profiler khi
So sánh hai cách cài đặt cụ thểTìm điểm nghẽn trong endpoint
Đo chi phí cấp phát của một hàmXem toàn cảnh một request
Kiểm chứng giả thuyết về thuật toánChưa biết vấn đề nằm ở đâu
Code chạy hàng triệu lầnCode chạy vài lần mỗi request

Thứ tự đúng: profiler tìm ra chỗ tốn thời gian (bài 19.4) → BenchmarkDotNet so sánh các cách sửa chỗ đó. Dùng ngược lại là tối ưu ngẫu nhiên.

19.3.5 — Ba cái bẫy khi viết benchmark​

Bẫy 1 — Đo cả phần chuẩn bị dữ liệu.

// SAI — đo cả việc tạo mảng
[Benchmark]
public int SumArray()
{
var data = Enumerable.Range(0, 10_000).ToArray(); // phần lớn thời gian ở đây!
return data.Sum();
}

// DUNG — chuan bi trong GlobalSetup
private int[] _data = null!;
[GlobalSetup] public void Setup() => _data = Enumerable.Range(0, 10_000).ToArray();
[Benchmark] public int SumArray() => _data.Sum();

Bẫy 2 — Benchmark code bất đồng bộ sai cách.

// SAI — .Result gây block, đo cả chi phí đồng bộ hoá
[Benchmark] public int GetData() => _service.GetDataAsync().Result;

// DUNG — BenchmarkDotNet ho tro async truc tiep
[Benchmark] public async Task<int> GetData() => await _service.GetDataAsync();

Bẫy 3 — Kết luận từ một môi trường. Kết quả phụ thuộc CPU, phiên bản .NET và hệ điều hành. Một tối ưu thắng trên x64 có thể thua trên ARM. Nếu production chạy ARM, hãy đo trên ARM.

19.3.6 — Rà lại code của bạn​

Danh sách rà soát micro-benchmark

  • •Chạy ở cấu hình Release, không bao giờ Debug.
  • •Benchmark trả về kết quả để JIT không xoá code.
  • •Dữ liệu được chuẩn bị trong GlobalSetup, không trong phần đo.
  • •Có Baseline để đọc tỷ lệ thay vì số tuyệt đối.
  • •Dùng Params với nhiều kích thước để lộ độ phức tạp thuật toán.
  • •Bật MemoryDiagnoser và có xem cột Allocated.
  • •StdDev thấp so với Mean, nếu không thì đo lại.
  • •Code bất đồng bộ được benchmark bằng async, không dùng .Result.
  • •Đã xác nhận phần được benchmark thật sự chiếm tỷ trọng đáng kể.

Bài tập áp dụng​

Ba bài dưới đây đều có kết quả đo thật, chạy trên cùng một máy: Intel Xeon Platinum 8272CL 2,60 GHz, 4 nhân, .NET SDK 9.0.203 (runtime 9.0.4), X64 RyuJIT AVX-512, GC Concurrent Workstation. Cấu hình BenchmarkDotNet: warmupCount: 3, iterationCount: 5 — đủ ổn định cho các chênh lệch lớn ở đây, nhưng Error sẽ rộng hơn so với cấu hình mặc định.

Bài 1 — Tái hiện dead code elimination​

Viết benchmark có kết quả bị bỏ đi và một bản trả về kết quả. So sánh hai con số.

Tiêu chí hoàn thành: bạn đo được sự khác biệt, và biết trường hợp nào JIT xoá code — cũng như trường hợp nào nó không xoá, dù lý thuyết nói là có thể.

Gợi ý và lời giải — Bài 1

Gợi ý. Thử cả hai công cụ: Stopwatch thô và BenchmarkDotNet. Kết quả không giống nhau, và chỗ không giống nhau mới là phần đáng học.

Lời giải — bắt đầu bằng Stopwatch thô, đúng như cách sai trong mục 19.3.1:

const int N = 50_000_000;
int x = Environment.TickCount & 127; // JIT không biết trước giá trị

[MethodImpl(MethodImplOptions.AggressiveInlining)]
static int Bam(int v) => (v * 31 + 17) ^ (v >> 3);

for (int i = 0; i < 1_000_000; i++) { _ = Bam(x + i); } // làm nóng, vượt tier-0

var sw = Stopwatch.StartNew();
for (int i = 0; i < N; i++) _ = Bam(x + i); // kết quả BỊ BỎ ĐI
sw.Stop();
var boDi = sw.Elapsed.TotalMilliseconds;

int tong = 0;
sw.Restart();
for (int i = 0; i < N; i++) tong += Bam(x + i); // kết quả ĐƯỢC DÙNG
sw.Stop();
var duocDung = sw.Elapsed.TotalMilliseconds;

Console.WriteLine($"Bỏ kết quả đi : {boDi,8:F2} ms ({boDi * 1e6 / N:F3} ns/lần)");
Console.WriteLine($"Dùng kết quả : {duocDung,8:F2} ms ({duocDung * 1e6 / N:F3} ns/lần)");
Console.WriteLine($"(tổng = {tong})");
Bỏ kết quả đi :    18,67 ms  (0,373 ns/lần)
Dùng kết quả : 55,78 ms (1,116 ns/lần)
Tỷ lệ : 3,0x

Con số 0,373 ns nói chính xác chuyện gì đã xảy ra:

CPU 2,60 GHz -> một chu kỳ ≈ 0,385 ns

0,373 ns/lần ≈ ĐÚNG MỘT chu kỳ cho mỗi vòng lặp
-> phần còn lại của vòng lặp: tăng biến đếm và so sánh
-> phần tính toán trong Bam() đã BIẾN MẤT

JIT không xoá cả vòng lặp — nó xoá phần tính toán bên trong, vì kết quả không có tác dụng quan sát được. Điều còn lại chỉ là cái khung của vòng lặp.

Đây là dạng nguy hiểm nhất của dead code elimination: không phải 0 ms, mà là một con số trông hợp lý — và bạn sẽ báo cáo rằng hàm băm của mình chạy trong 0,37 nano giây.

Nhưng với BenchmarkDotNet, kết quả lại KHÁC — và đây là phần bất ngờ:

[SimpleJob(RunStrategy.Throughput, warmupCount: 3, iterationCount: 5)]
public class DeadCodeBenchmark
{
private int[] _data = null!;

[GlobalSetup]
public void Setup() => _data = Enumerable.Range(1, 10_000).ToArray();

private int TinhTong()
{
int t = 0;
for (int i = 0; i < _data.Length; i++) t += _data[i] * 3 + 1;
return t;
}

[Benchmark(Baseline = true, Description = "Bỏ kết quả đi")]
public void BoKetQua() => _ = TinhTong();

[Benchmark(Description = "Trả về kết quả")]
public int TraVe() => TinhTong();
}
| Method           | Mean     | Error    | StdDev   | Ratio | Allocated |
|----------------- |---------:|---------:|---------:|------:|----------:|
| 'Bỏ kết quả đi' | 10.80 us | 2.063 us | 0.536 us | 1.00 | - |
| 'Trả về kết quả' | 10.35 us | 2.582 us | 0.400 us | 0.96 | - |

Hai con số gần như bằng nhau — không có dead code elimination.

Thử thêm với một hàm nhỏ, thuần tuý, được đánh dấu nội dòng:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
private int BamNho(int v) => (v * 31 + 17) ^ (v >> 3);

private int BamLon(int v)
{
int t = v;
for (int i = 0; i < 64; i++) t = (t * 31 + i) ^ (t >> 3);
return t;
}

[Benchmark] public void NhoBo() { _ = BamNho(_x); }
[Benchmark] public int NhoTraVe() => BamNho(_x);
[Benchmark] public void LonBo() { _ = BamLon(_x); }
[Benchmark] public int LonTraVe() => BamLon(_x);
Hàm nhỏ - bỏ kết quả :  0,155 ns
Hàm nhỏ - trả về : 0,241 ns
Hàm lớn - bỏ kết quả : 79,585 ns
Hàm lớn - trả về : 79,278 ns

Ba kết luận từ bốn con số này:

1. Với hàm đủ lớn để không nội dòng được, bỏ hay trả về kết quả không khác nhau (79,6 so với 79,3 ns). Cấu trúc mà BenchmarkDotNet sinh ra giữ cho lời gọi không bị xoá.

2. Với hàm nhỏ, cả hai bản đều về dưới 0,25 ns — nghĩa là cả hai đều bị gập lại, kể cả bản có trả về. Con số dưới một chu kỳ CPU là tín hiệu code đã biến mất, không phải tín hiệu code nhanh.

BenchmarkDotNet tự cảnh báo điều này:

// * Warnings *
ZeroMeasurement
DeadCode2.NhoBo: Default -> The method duration is indistinguishable from the empty method duration

3. Và kết luận thực dụng nhất: đừng dựa vào việc "trả về kết quả" như một sự bảo đảm.

Quy tắc trong mục 19.3.2 — "trả về kết quả thay vì gán vào biến bỏ đi" — vẫn đúng
và vẫn nên theo. Nhưng nó là một lớp phòng thủ, không phải một sự bảo đảm.

Sự bảo đảm thật nằm ở chỗ khác: ĐỌC con số và hỏi nó có hợp lý không.

Cách kiểm tra một con số có đáng tin không — ba câu hỏi:

Câu hỏiNếu trả lời là "có"
Kết quả dưới 1 ns?Gần như chắc chắn code đã bị xoá hoặc gập
BenchmarkDotNet có cảnh báo ZeroMeasurement?Nó đang nói thẳng rằng phép đo vô nghĩa
Thời gian có tỷ lệ thuận với [Params] không?Nếu không, có thể phần tính toán không thật sự chạy

Câu cuối là phép thử mạnh nhất và rẻ nhất:

[Params(1000, 10_000, 100_000)]
public int Count { get; set; }
Nếu Mean gần như không đổi khi Count tăng 100 lần
-> code không chạy, hoặc bạn đang đo thứ khác

Khi thật sự cần chặn JIT xoá code, BenchmarkDotNet có công cụ riêng:

private Consumer _consumer = new();

[Benchmark]
public void XuLyDanhSach() => _danhSach.Select(Chuyen).Consume(_consumer);

Consumer tồn tại đúng cho trường hợp benchmark không có giá trị trả về duy nhất — ví dụ một chuỗi LINQ lười. Không có nó, Select không bao giờ được thực thi và bạn đo thời gian tạo ra một iterator.

Và một lưu ý về Error trong bảng kết quả ở trên:

| 'Bỏ kết quả đi'  | 10.80 us | Error 2.063 us |

Error ≈ 19% của Mean — rộng.
Nguyên nhân: iterationCount = 5 để chạy nhanh.

Với chênh lệch 3 lần hay 675 lần (bài 2), sai số này không đổi kết luận.
Với chênh lệch 4% như ở đây, nó có nghĩa là KHÔNG kết luận được gì
-> và "không kết luận được" chính là kết luận đúng cho bài này.

Bài 2 — Phát hiện O(n²)​

Benchmark nối chuỗi với [Params(10, 100, 1000, 10000)] và vẽ đồ thị Mean theo Count.

Tiêu chí hoàn thành: bạn nhận ra chữ ký O(n²) từ bảng số, không cần vẽ đồ thị — và nhận ra cột Allocated kể một câu chuyện còn rõ hơn cột Mean.

Gợi ý và lời giải — Bài 2

Gợi ý. Với O(n), tăng n gấp 10 thì thời gian tăng gấp 10. Với O(n²), nó tăng gấp 100.

Lời giải:

[MemoryDiagnoser]
[SimpleJob(RunStrategy.Throughput, warmupCount: 3, iterationCount: 5)]
public class NoiChuoiBenchmark
{
private string[] _parts = null!;

[Params(10, 100, 1000, 10000)]
public int Count { get; set; }

[GlobalSetup]
public void Setup() => _parts = Enumerable.Range(0, Count).Select(i => $"phan-{i}").ToArray();

[Benchmark(Baseline = true, Description = "Toán tử +=")]
public string ToanTuCong()
{
var kq = "";
foreach (var p in _parts) kq += p;
return kq;
}

[Benchmark(Description = "StringBuilder")]
public string XaiStringBuilder()
{
var sb = new StringBuilder();
foreach (var p in _parts) sb.Append(p);
return sb.ToString();
}

[Benchmark(Description = "string.Join")]
public string XaiStringJoin() => string.Join("", _parts);
}
| Method        | Count |             Mean |  Ratio |      Allocated | Alloc Ratio |
|-------------- |------ |-----------------:|-------:|---------------:|------------:|
| 'Toán tử +=' | 10 | 397.5 ns | 1.000 | 880 B | 1.000 |
| StringBuilder | 10 | 333.4 ns | 0.840 | 488 B | 0.550 |
| string.Join | 10 | 178.8 ns | 0.450 | 144 B | 0.160 |
| | | | | | |
| 'Toán tử +=' | 100 | 22,147.5 ns | 1.000 | 71,264 B | 1.000 |
| StringBuilder | 100 | 1,976.1 ns | 0.090 | 3,960 B | 0.060 |
| string.Join | 100 | 1,690.7 ns | 0.080 | 1,408 B | 0.020 |
| | | | | | |
| 'Toán tử +=' | 1000 | 1,855,237.1 ns | 1.000 | 7,825,665 B | 1.000 |
| StringBuilder | 1000 | 15,124.8 ns | 0.008 | 32,912 B | 0.004 |
| string.Join | 1000 | 16,977.6 ns | 0.009 | 15,808 B | 0.002 |
| | | | | | |
| 'Toán tử +=' | 10000 | 236,970,691.5 ns | 1.000 | 879,292,357 B | 1.000 |
| StringBuilder | 10000 | 350,998.7 ns | 0.001 | 371,741 B | 0.000 |
| string.Join | 10000 | 262,655.8 ns | 0.001 | 177,845 B | 0.000 |

Chữ ký O(n²) đọc thẳng từ bảng, không cần đồ thị — lập tỷ lệ giữa các bậc:

Count tăng+= tăngStringBuilder tăngKết luận
10 → 10055,7×5,9×+= tăng nhanh hơn n
100 → 100083,8×7,7×gần 100× — chữ ký O(n²)
1000 → 10000127,7×23,2×vượt cả 100×
n tăng 10 lần:
O(n) -> thời gian tăng ~10 lần (StringBuilder: 5,9 / 7,7 / 23,2)
O(n²) -> thời gian tăng ~100 lần (+= : 55,7 / 83,8 / 127,7)

Vì sao tỷ lệ của += vượt 100 ở bậc cuối (127,7×): ở Count = 10000 nó cấp phát 879 MB cho một lần gọi, nên chi phí GC cộng thêm vào chi phí thuật toán. Cột Gen2 trong kết quả đầy đủ xác nhận điều đó — 210.666 lần thu gom gen-2 trên 1.000 lần gọi.

Và đây là phần quan trọng hơn cả cột Mean:

Count = 10000, toán tử += cấp phát 879.292.357 B ≈ 879 MB

Cho MỘT lần gọi. Nối 10.000 chuỗi ngắn.

Lý do nằm ở chỗ chuỗi trong .NET là bất biến: mỗi lần kq += p tạo ra một chuỗi mới chứa toàn bộ nội dung cũ cộng phần mới. Chuỗi thứ i có độ dài xấp xỉ i × 8 ký tự, và tổng bộ nhớ đi qua là tổng của cấp số cộng — chính là O(n²).

Vì sao cột Allocated quan trọng hơn Mean trên server:

Mean = thời gian của CHÍNH request này
Allocated = áp lực GC mà request này đặt lên TOÀN BỘ tiến trình

879 MB × 10 request/giây = 8,79 GB/giây đi qua GC
-> gen-2 chạy liên tục
-> MỌI request khác trong tiến trình đều bị dừng theo

Một endpoint chậm chỉ làm chậm chính nó. Một endpoint cấp phát nhiều làm chậm mọi thứ — và nó không xuất hiện trong biểu đồ độ trễ của chính endpoint đó, nên rất khó lần ra nếu không nhìn cột này.

Chi tiết đáng chú ý: ở Count = 1000, StringBuilder NHANH HƠN string.Join (15.125 ns so với 16.978 ns), nhưng ở ba kích thước còn lại thì ngược lại.

Chênh lệch ~11%, trong khi Error của string.Join là 5.732 ns (34% của Mean)
-> KHÔNG kết luận được gì từ cặp số này

Còn Allocated thì khác: 32.912 B so với 15.808 B — hơn hai lần.
Đây là con số ổn định, đếm được, không phụ thuộc nhiễu của máy.

Quy tắc rút ra: khi Mean của hai phương án nằm trong khoảng sai số của nhau, hãy quyết định bằng Allocated. Nó không có nhiễu.

Khi nào += vẫn chấp nhận được:

Count = 10:  397,5 ns so với 178,8 ns — chênh 2,2 lần, cả hai đều dưới nửa micro giây
-> nối 3–5 chuỗi trong một hàm: không đáng để thay

Vấn đề chỉ xuất hiện khi việc nối nằm TRONG VÒNG LẶP có số lần lớn.

Và khi số phần tử biết trước, có cách rẻ hơn cả hai:

// Không qua bộ đệm trung gian nào
var kq = string.Create(tongDoDai, _parts, (span, parts) =>
{
var viTri = 0;
foreach (var p in parts) { p.AsSpan().CopyTo(span[viTri..]); viTri += p.Length; }
});

Cách đưa phát hiện này vào CI, để nó không quay lại:

[Fact]
public void Noi_chuoi_khong_duoc_cap_phat_qua_nguong()
{
var ketQua = BenchmarkRunner.Run<NoiChuoiBenchmark>();

var choPhep = ketQua.Reports
.Where(r => r.BenchmarkCase.Descriptor.WorkloadMethod.Name == nameof(NoiChuoiBenchmark.XaiStringBuilder))
.Select(r => r.GcStats.GetBytesAllocatedPerOperation(r.BenchmarkCase));

choPhep.Should().OnlyContain(b => b < 500_000,
"cấp phát vượt ngưỡng nghĩa là có ai đó đổi sang nối chuỗi O(n²)");
}

Ngưỡng theo Allocated ổn định hơn nhiều so với ngưỡng theo thời gian, nên nó chạy được trên máy CI dùng chung — cùng lý do đã nêu ở bài 19.1.


Bài 3 — So sánh cấp phát​

Benchmark một hàm dùng string.Split và một hàm dùng ReadOnlySpan<char>. So sánh cột Allocated.

Tiêu chí hoàn thành: bạn đạt 0 byte cấp phát ở bản dùng span, và nói được khi nào việc đổi sang span là đáng, khi nào không.

Gợi ý và lời giải — Bài 3

Gợi ý. string.Split cấp phát một mảng và một chuỗi mới cho mỗi phần. ReadOnlySpan<char> chỉ ghi nhớ vị trí bắt đầu và độ dài.

Lời giải — phân tích một dòng CSV:

[MemoryDiagnoser]
[SimpleJob(RunStrategy.Throughput, warmupCount: 3, iterationCount: 5)]
public class TachChuoiBenchmark
{
private string _dong = null!;

[GlobalSetup]
public void Setup() =>
_dong = "LEAD-8842;Nguyen Van An;an@congty.vn;0901234567;DangXuLy;2026-09-25;12500000";

[Benchmark(Baseline = true, Description = "string.Split")]
public int XaiSplit()
{
var phan = _dong.Split(';');
return phan[0].Length + phan[4].Length + int.Parse(phan[6]) % 7;
}

[Benchmark(Description = "ReadOnlySpan")]
public int XaiSpan()
{
ReadOnlySpan<char> s = _dong;
int tong = 0, chiSo = 0;
foreach (var range in s.Split(';')) // MemoryExtensions.Split — .NET 8 trở lên
{
var truong = s[range];
if (chiSo == 0 || chiSo == 4) tong += truong.Length;
else if (chiSo == 6) tong += int.Parse(truong) % 7;
chiSo++;
}
return tong;
}
}
| Method       |      Mean |    Error |   StdDev | Ratio |   Gen0 | Allocated | Alloc Ratio |
|------------- |----------:|---------:|---------:|------:|-------:|----------:|------------:|
| string.Split | 284.69 ns | 67.24 ns | 17.46 ns | 1.00 | 0.0205 | 392 B | 1.00 |
| ReadOnlySpan | 92.10 ns | 27.35 ns | 7.10 ns | 0.32 | - | 0 B | 0.00 |

Hai con số, hai ý nghĩa khác nhau:

Mean:       284,69 -> 92,10 ns   = nhanh hơn 3,1 lần
Allocated: 392 B -> 0 B = KHÔNG cấp phát gì cả

392 byte cho một dòng đến từ đâu:

1 mảng string[7]                     : 24 + 7 × 8 = 80 B
7 chuỗi con, mỗi chuỗi 22 + 2×dài B : ≈ 312 B
----------
392 B

Cột Gen0 bằng - ở bản span là bằng chứng mạnh hơn cả con số 0: nó nghĩa là qua toàn bộ quá trình đo, không có một lần thu gom gen-0 nào do hàm này gây ra.

Quy đổi ra quy mô thật:

Nhập một tệp CSV 500.000 dòng:
string.Split : 500.000 × 392 B = 196 MB đi qua GC
ReadOnlySpan : 0 B

Thời gian:
string.Split : 500.000 × 284,69 ns = 142 ms
ReadOnlySpan : 500.000 × 92,10 ns = 46 ms

96 ms tiết kiệm được là đáng kể, nhưng 196 MB áp lực GC mới là phần quan trọng hơn — nhất là khi việc nhập tệp chạy song song với lưu lượng bình thường của API.

Khi nào đổi sang span là ĐÁNG:

Tình huốngĐáng?Vì sao
Phân tích tệp lớn, hàng trăm nghìn dòngCóÁp lực GC tích luỹ thành vấn đề thật
Phân tích trong vòng lặp nóng, mỗi requestCóCấp phát nhân với số request
Đọc cấu hình lúc khởi độngKhôngChạy một lần, tiết kiệm 96 ms trên cả vòng đời
Tách một chuỗi trong một handlerKhông392 B so với vài KB của chính request
Code cần nhiều người đọc và sửaCân nhắcXem phần đánh đổi bên dưới

Cái giá của span — ba thứ bạn đánh đổi:

1. Không dùng được trong phương thức async.

// KHÔNG biên dịch được: ref struct không sống qua điểm await
public async Task XuLyAsync(string dong)
{
ReadOnlySpan<char> s = dong; // lỗi CS4012
await _db.SaveChangesAsync();
}

Cách vòng: tách phần đồng bộ ra một hàm riêng.

public async Task XuLyAsync(string dong)
{
var ketQua = PhanTich(dong); // hàm đồng bộ, dùng span bên trong
await _luuTru.GhiAsync(ketQua);
}

private static KetQuaPhanTich PhanTich(ReadOnlySpan<char> s) { /* ... */ }

2. Không lưu được vào trường, không đưa vào collection.

private ReadOnlySpan<char> _truong;           // lỗi — ref struct không làm trường được
List<ReadOnlySpan<char>> ds = new(); // lỗi

Span chỉ là một cửa sổ nhìn vào bộ nhớ của người khác. Nếu cần giữ lại, phải sao chép — và lúc đó bạn cấp phát, đúng thứ đang cố tránh.

3. Code khó đọc hơn.

string.Split : ai cũng đọc được ngay
span : cần biết ref struct, Range, và vì sao không await được

Với code được sửa thường xuyên bởi nhiều người, đây là cái giá thật — và nó chỉ đáng trả ở những chỗ đã đo được rằng có vấn đề.

Mức trung gian, rẻ hơn về mặt phức tạp:

// Giới hạn số phần tách + bỏ phần rỗng -> ít cấp phát hơn, vẫn dễ đọc
var phan = _dong.Split(';', 7, StringSplitOptions.RemoveEmptyEntries);
// Hoặc dùng IndexOf khi chỉ cần một vài trường
var viTri = _dong.IndexOf(';');
var ma = _dong.AsSpan(0, viTri); // không cấp phát, vẫn dễ hiểu

Và một cảnh báo về Error trong bảng đo này:

string.Split : Mean 284,69 ns, Error 67,24 ns  -> 23,6% của Mean
ReadOnlySpan : Mean 92,10 ns, Error 27,35 ns -> 29,7% của Mean

Sai số rộng vì iterationCount: 5. Nhưng hai khoảng tin cậy không chồng lên nhau (284,69 ± 67,24 so với 92,10 ± 27,35), nên kết luận "span nhanh hơn" vẫn đứng vững. Nếu chúng chồng nhau, cách đúng là tăng iterationCount chứ không phải chọn con số mình thích.

Và dù sao, con số quyết định ở bài này không phải Mean mà là Allocated: 392 B so với 0 B không có sai số.

Nối lại ba bài: bài 1 dạy cách nghi ngờ một con số, bài 2 dạy cách đọc độ phức tạp từ nhiều con số, bài 3 dạy cách chọn đúng cột để quyết định. Cả ba đều dẫn về một điểm ở mục 19.3.4 — micro-benchmark chỉ có ích sau khi profiler đã chỉ ra chỗ đáng tối ưu.

Tự kiểm tra​

Câu hỏi thường gặp

Vì sao Stopwatch không đủ để đo code chạy trong micro giây?

Vì JIT có thể xoá hẳn code nếu kết quả không được dùng, tiering compilation khiến vài nghìn lần chạy đầu dùng phiên bản chưa tối ưu, GC có thể chen vào giữa lúc đo, và một con số đơn lẻ không cho biết độ biến thiên.

Dead code elimination ảnh hưởng thế nào tới benchmark?

JIT được phép loại bỏ tính toán không có tác dụng quan sát được, nên nếu kết quả bị gán vào biến bỏ đi, lời gọi có thể bị xoá và bạn đo được gần như 0. Cách tránh là trả về kết quả để BenchmarkDotNet tiêu thụ nó.

Params dùng để làm gì?

Chạy cùng một benchmark với nhiều kích thước dữ liệu, nhờ đó lộ ra độ phức tạp thuật toán. Một hàm chậm hơn 1,5 lần ở 10 phần tử nhưng 20 lần ở 1000 phần tử là chữ ký của O bình phương, và đo một kích thước duy nhất sẽ bỏ sót điều đó.

Vì sao cột Allocated thường quan trọng hơn Mean trên server?

Vì cấp phát nhiều làm GC chạy thường xuyên hơn, và GC gây độ trễ cho mọi request đang chạy chứ không riêng request này. Một hàm nhanh nhưng cấp phát nhiều có thể làm cả hệ thống chậm đi.

Khi nào micro-benchmark là công cụ sai?

Khi bạn chưa biết điểm nghẽn ở đâu. Tối ưu một hàm từ 120 xuống 60 nano giây trong một endpoint mất 200 mili giây là cải thiện không đo được. Thứ tự đúng là profiler tìm chỗ tốn thời gian rồi mới dùng benchmark so sánh các cách sửa.

StdDev cao nghĩa là gì?

Kết quả không ổn định và không đáng tin, thường do máy đang chạy việc khác trong lúc đo. Cần đóng các tiến trình khác và đo lại trước khi rút ra kết luận nào.

Kết luận​

Ba điều đáng nhớ nhất:

  1. Stopwatch không đủ — JIT, tiering và GC đều có thể làm sai kết quả.
  2. [Params] lộ độ phức tạp thuật toán, thứ một phép đo đơn lẻ luôn bỏ sót.
  3. Profiler trước, benchmark sau. Ngược lại là tối ưu ngẫu nhiên.

Tham khảo​

Điều hướng​