دروس السلسلة: C# 14 و.NET 10 · EF Core 10 · Worker Services .NET 10 · gRPC ASP.NET Core 10
قياس أداء قطعة كود .NET بدون BenchmarkDotNet مثل محاولة توقيت Usain Bolt بساعة شمسية. المكتبة، مشروع رسمي لـ .NET Foundation متبنّى من أكثر من 22,000 مستودع GitHub (بما فيها runtime .NET نفسه)، تكشف API تُحوّل method إلى قياس صارم، صالح إحصائيًا، تُقارن runtimes، GC modes، بيئات x86/ARM/AOT، وتُقدّم allocations الذاكرة، latencies percentiles، وحتى الكود assembleur المُولَّد. الإصدار 0.15.0 من فبراير 2026 يُضيف دعم .NET 10، vectorisation AVX-512، والتصوّر بـ box plot.
المتطلبات
- .NET 10 LTS مُثبَّت
- أساسيات C# الحديث
- IDE بدعم .NET (Visual Studio، Rider، أو VS Code + C# Dev Kit)
- الوقت المُقدَّر: 75 دقيقة
الخطوة 1 — إنشاء مشروع benchmarks مخصص
benchmark لا يجب أن يعيش في نفس assembly مع كود الإنتاج: يجب أن يعمل في وضع Release بدون debug symbols. الاصطلاح: مشروع console منفصل نُطلقه بـ dotnet run -c Release.
dotnet new console -n MonApp.Benchmarks -o benchmarks
cd benchmarks
dotnet add package BenchmarkDotNet --version 0.15.0
dotnet add reference ../src/MonApp.Core/MonApp.Core.csproj
# Program.cs minimal
cat > Program.cs <<'EOF'
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<StringConcatBenchmark>();
EOF
المشروع يجب أن يستهدف runtime(s) المُراد قياسها. <TargetFrameworks>net10.0;net8.0</TargetFrameworks> يُتيح مقارنة جنبًا إلى جنب نسختين من .NET على نفس الكود.
الخطوة 2 — أول benchmark: 3 تنفيذات لـ concaténation
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Order;
using System.Text;
[MemoryDiagnoser]
[Orderer(SummaryOrderPolicy.FastestToSlowest)]
public class StringConcatBenchmark
{
private readonly string[] _mots = Enumerable.Range(0, 100)
.Select(i => \$"mot-{i}").ToArray();
[Benchmark(Baseline = true)]
public string ConcatPlus()
{
var resultat = "";
foreach (var m in _mots) resultat += m + " ";
return resultat;
}
[Benchmark]
public string ConcatStringBuilder()
{
var sb = new StringBuilder();
foreach (var m in _mots) sb.Append(m).Append(' ');
return sb.ToString();
}
[Benchmark]
public string ConcatStringJoin() => string.Join(' ', _mots) + ' ';
[Benchmark]
public string ConcatStringConcat() => string.Concat(_mots.Select(m => m + " "));
}
ثلاثة attributes حاسمة. [MemoryDiagnoser] يُضيف أعمدة Gen0/Gen1/Gen2 وAllocated — غالبًا أكثر إيضاحًا من المدة وحدها. [Orderer(FastestToSlowest)] يُرتّب التقرير. Baseline = true على نسخة « ساذجة » يخدم كمرجع للنسب.
الخطوة 3 — الإطلاق وتفسير التقرير
cd benchmarks
dotnet run -c Release
# Exemple de sortie :
# | Method | Mean | Error | StdDev | Ratio | Allocated |
# |-------------------- |-----------:|---------:|---------:|------:|----------:|
# | ConcatStringJoin | 1.234 μs | 0.012 μs | 0.011 μs | 0.05 | 968 B |
# | ConcatStringBuilder | 1.567 μs | 0.015 μs | 0.014 μs | 0.06 | 1.45 KB |
# | ConcatStringConcat | 2.890 μs | 0.024 μs | 0.022 μs | 0.12 | 2.78 KB |
# | ConcatPlus | 24.567 μs | 0.198 μs | 0.176 μs | 1.00 | 41.23 KB |
ثلاث قراءات مفتاحية. Mean المتوسط. Error الخطأ المعياري 99.9% — إذا تجاوز 5% من mean، أعد البنشمارك في ظروف أكثر استقرارًا. Ratio يُعطي فورًا الكلفة النسبية مقارنةً بالـ baseline.
الخطوة 4 — تمعير بـ [Params]
[MemoryDiagnoser]
public class HashBenchmark
{
[Params(10, 100, 1_000, 10_000, 100_000)]
public int N { get; set; }
private byte[] _data = [];
[GlobalSetup]
public void Setup() => _data = new byte[N].Tap(d => Random.Shared.NextBytes(d));
[Benchmark(Baseline = true)]
public byte[] Sha256() => SHA256.HashData(_data);
[Benchmark]
public byte[] Sha512() => SHA512.HashData(_data);
[Benchmark]
public byte[] Md5() => MD5.HashData(_data);
}
الـ [GlobalSetup] يُهيّئ البيانات مرة واحدة لكل (method، N) قبل مرحلة القياس — وقته غير مُضمَّن. SHA-256 نموذجيًا أسرع 2× من SHA-512 على إدخالات صغيرة، لكن الفرق يقلّ على الكبيرة (أثر vectorisation).
الخطوة 5 — متعدد runtime بـ [SimpleJob]
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;
[SimpleJob(RuntimeMoniker.Net80, baseline: true)]
[SimpleJob(RuntimeMoniker.Net90)]
[SimpleJob(RuntimeMoniker.Net100)]
[SimpleJob(RuntimeMoniker.NativeAot10)]
[MemoryDiagnoser]
public class JsonBenchmark
{
private readonly Article _article = new("Hello", 42, DateTime.UtcNow);
[Benchmark]
public string Serialize() => JsonSerializer.Serialize(_article);
}
public record Article(string Titre, int Vues, DateTime CreeLe);
التقرير يُضيف عمود Runtime ويُتيح تصوّر مكاسب الترحيل. نموذجيًا، تسلسل عبر System.Text.Json أسرع 20-40% على .NET 10 من .NET 8. الـ AOT يبدأ فوريًا لكن يثبت عند -10/-20% من throughput المستدام مقارنةً بـ JIT.
الخطوة 6 — DisassemblyDiagnoser: رؤية assembleur المُولَّد
[DisassemblyDiagnoser(printSource: true, maxDepth: 2)]
[MemoryDiagnoser]
public class SumBenchmark
{
private int[] _data = new int[1024];
[GlobalSetup]
public void Setup() => Random.Shared.NextBytes(MemoryMarshal.AsBytes(_data.AsSpan()));
[Benchmark]
public int SumNaive()
{
int s = 0;
foreach (var v in _data) s += v;
return s;
}
[Benchmark]
public int SumVectorized() =>
System.Numerics.Tensors.TensorPrimitives.Sum(_data.AsSpan());
}
التقرير يحوي، لكل method، الكود x86-64 أو ARM64 المُولَّد. نرى instructions VPADDD/VMOVDQU (AVX-512)، الحلقات المُفَكَّكة، فحوصات الحدود المُلغاة. هكذا نُؤكّد أن تغيير كود يُنتج حقًا التأثير المُنتظَر على جانب الآلة.
الخطوة 7 — مقارنة GC والبيئات
[Config(typeof(GcConfig))]
[MemoryDiagnoser]
public class GcBenchmark
{
[Benchmark]
public List<byte[]> AllouerBeaucoup()
{
var list = new List<byte[]>();
for (int i = 0; i < 1000; i++)
list.Add(new byte[1024]);
return list;
}
}
public class GcConfig : ManualConfig
{
public GcConfig()
{
AddJob(Job.Default.WithGcServer(true).WithGcConcurrent(true).WithId("ServerConcurrent"));
AddJob(Job.Default.WithGcServer(true).WithGcConcurrent(false).WithId("ServerNonConcurrent"));
AddJob(Job.Default.WithGcServer(false).WithGcConcurrent(true).WithId("WorkstationConcurrent"));
}
}
على حمل يُخصّص 1 MB لكل استدعاء، ServerGC + Concurrent يُعطي نموذجيًا 30-50% throughput إضافي مقارنةً بـ Workstation + Concurrent.
الخطوة 8 — تكامل CI وتاريخ
# .github/workflows/bench.yml
name: Benchmarks
on:
pull_request:
schedule:
- cron: "0 2 * * 1" # lundi 02h00 UTC
jobs:
benchmark:
runs-on: self-hosted # runner dédié avec CPU stable
steps:
- uses: actions/checkout@v5
- uses: actions/setup-dotnet@v5
with:
dotnet-version: '10.0.x'
- run: dotnet run -c Release --project benchmarks -- --exporters json --filter '*'
- uses: actions/upload-artifact@v5
with:
name: bench-results
path: benchmarks/BenchmarkDotNet.Artifacts/results/
ثلاث ممارسات لـ benchmarks CI مفيدة. Runner مخصص: VMs المشتركة لـ GitHub Actions لها perfs متغيّرة جدًا. لا منافسة مع jobs أخرى. فلاتر مُستهدفة: لا تُطلق كل السويت في CI.
أخطاء شائعة
| العَرَض | السبب | الحل |
|---|---|---|
| خطأ « Benchmarks not found » | ليس في Release أو methods غير public | dotnet run -c Release وmethods public |
| خطأ عالٍ (5%+) | ضوضاء نظام (Chrome، antivirus، throttling حراري) | أغلق التطبيقات، اوصل الشاحن، وضع أداء OS |
| Mémoire مُخصَّصة 0 B | Method تُرجع قيمة cached | تحقّق أن كل استدعاء يُخصّص فعلًا |
| JIT artefacts عند أول استدعاء | مرحلة Warmup غير كافية | BenchmarkDotNet يُدير تلقائيًا، زِد WarmupCount |
| Disassembly فارغ | method قصيرة جدًا، inlinée | أضف [MethodImpl(MethodImplOptions.NoInlining)] |
| NativeAOT crash | Réflexion غير محفوظة | أضف rd.xml أو [DynamicallyAccessedMembers] |
الأسئلة الشائعة
BenchmarkDotNet أم Stopwatch؟
Stopwatch للإنتاج (قياس في runtime). BenchmarkDotNet للتطوير (مقارنة تنفيذات). الاثنان لا يستبدلان بعضهما.
كم iteration لكل benchmark؟
BenchmarkDotNet يُقرّر تلقائيًا (Pilot stage). للـ methods السريعة (< 1 μs)، ~10-100 مليون. للبطيئة (> 100 ms)، 5-20.
مقارنة مع node.js / Java؟
لمقارنات cross-language، استخدم benchmark تركيبي (TechEmpower) أو اكتب نفس الخوارزمية في كل، قِس في بيئة متطابقة.
كيف نقيس async؟
BenchmarkDotNet يدعم أصلًا async Task. ينتظر method ويقيس المدة الإجمالية.
BenchmarkDotNet في الإنتاج؟
لا. الإنتاج = télémétrie (App Insights، OpenTelemetry، Prometheus). BenchmarkDotNet حصرًا للتطوير.