in C#, Informatica, Programmazione

Benchmark in C# con BenchmarkDotNet

Il benchmarking è fondamentale per comprendere e ottimizzare le prestazioni del tuo codice C#. Lo strumento di riferimento nell’ecosistema .NET per questo scopo è BenchmarkDotNet. È una libreria potente e affidabile che ti aiuta a misurare le performance del tuo codice in modo accurato, eliminando il rumore da fattori esterni come la compilazione JIT, il garbage collection e la pianificazione del sistema operativo.

Ecco un esempio di analisi usando BenchmarkDotNet, con una spiegazione delle sue funzionalità e di come interpretare i risultati.

Perché BenchmarkDotNet?

  • Accuratezza: Esegue cicli di riscaldamento (warm-up), iterazioni multiple e analisi statistiche per fornire misurazioni estremamente affidabili.
  • Facilità d’uso: Si integra perfettamente con i tuoi progetti C# e utilizza attributi simili ai framework di unit testing.
  • Report completi: Genera report dettagliati in vari formati (console, Markdown, HTML, CSV) con metriche ricche.
  • Diagnosi: Offre diagnostici integrati (ad esempio, MemoryDiagnoser, HardwareCounters) per raccogliere ulteriori informazioni sulle prestazioni, oltre al solo tempo di esecuzione.

Esempio di Analisi Benchmark in C#

Confrontiamo le prestazioni di due modi comuni per concatenare stringhe in C#: usando l’operatore + e StringBuilder.

1. Setup del Progetto

Per prima cosa, creiamo una nuova applicazione console .NET:

Bash

dotnet new console -n BenchmarkDemo
cd BenchmarkDemo

Poi, installiamo il pacchetto NuGet BenchmarkDotNet:

Bash

dotnet add package BenchmarkDotNet

2. Creazione della Classe Benchmark

Creaiamo un nuovo file C# (ad esempio, StringConcatenationBenchmarks.cs) e aggiungi il seguente codice:

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
using System.Text; // Richiesto per StringBuilder

namespace BenchmarkDemo
{
    [MemoryDiagnoser] // Questo diagnostico mostrerà i dettagli di allocazione della memoria
    public class StringConcatenationBenchmarks
    {
        private const int N = 1000; // Numero di concatenazioni

        [Benchmark(Baseline = true)] // Contrassegna questo come riferimento per il confronto
        public string ConcatenateWithPlus()
        {
            string result = string.Empty;
            for (int i = 0; i < N; i++)
            {
                result += "hello";
            }
            return result;
        }

        [Benchmark]
        public string ConcatenateWithStringBuilder()
        {
            StringBuilder sb = new StringBuilder();
            for (int i = 0; i < N; i++)
            {
                sb.Append("hello");
            }
            return sb.ToString();
        }
    }
}
  • [MemoryDiagnoser]: Questo attributo dice a BenchmarkDotNet di raccogliere statistiche sull’allocazione della memoria, il che è molto utile per le operazioni sulle stringhe.
  • [Benchmark]: Questo attributo contrassegna i metodi che BenchmarkDotNet deve misurare.
  • [Benchmark(Baseline = true)]: Uno dei tuoi metodi benchmark dovrebbe essere designato come Baseline. Questo rende più facile confrontare le prestazioni degli altri metodi rispetto a uno standard noto.

3. Esecuzione dei Benchmark

Modifichiamo il file Program.cs per eseguire i benchmark:

C#

using BenchmarkDotNet.Running;
using BenchmarkDemo; // Assicurati di includere il tuo namespace benchmark

namespace BenchmarkDemo
{
    public class Program
    {
        public static void Main(string[] args)
        {
            // È cruciale eseguire i benchmark in modalità Release per risultati accurati.
            // Puoi farlo eseguendo: dotnet run -c Release
            var summary = BenchmarkRunner.Run<StringConcatenationBenchmarks>();
        }
    }
}

Importante: Eseguiamo sempre i tuoi benchmark in modalità Release e al di fuori del debugger. Il debug aggiunge un overhead che può falsare i risultati, e la modalità Release abilita le ottimizzazioni del compilatore che non sono presenti in modalità Debug.

Per eseguire, apriamo il terminale nella directory del progetto BenchmarkDemo ed esegui:

Bash

dotnet run -c Release

BenchmarkDotNet eseguirà diverse iterazioni (warm-up, pilot, misurazioni effettive) e poi stamperà un riepilogo dettagliato nella console. Genererà anche vari file di report nella cartella BenchmarkDotNet.Artifacts.

4. Analisi dei Risultati

Ecco un esempio di come potrebbe apparire l’output della console (i valori effettivi varieranno in base alla tua macchina):

BenchmarkDotNet v0.13.12, Windows 10 (10.0.19045.4412/22H2/2022Update), .NET SDK 8.0.300
Intel Core i7-10750H CPU 2.60GHz (Comet Lake), 1 CPU, 12 logical and 6 physical cores
.NET SDK 8.0.300
  [Host]     : .NET 8.0.5 (8.0.524.21711), X64 RyuJIT AVX2
  DefaultJob : .NET 8.0.5 (8.0.524.21711), X64 RyuJIT AVX2

| Method                          | Mean        | Error     | StdDev    | Ratio | Allocated |
|---------------------------------|-------------|-----------|-----------|-------|-----------|
| ConcatenateWithPlus             | 2,058.0 us  | 40.57 us  | 33.88 us  | 1.00  | 5600000 B |
| ConcatenateWithStringBuilder    | 12.00 us    | 0.08 us   | 0.07 us   | 0.00  | 3600 B    |

Analizziamo le colonne chiave nella tabella dei risultati:

  • Method (Metodo): Il nome del metodo benchmarked.
  • Mean (Media): Il tempo di esecuzione medio del metodo su tutte le misurazioni. Questa è solitamente la metrica più importante. Nel nostro esempio, ConcatenateWithStringBuilder ha una media significativamente inferiore (12.00 us) rispetto a ConcatenateWithPlus (2.058,0 us), indicando che è molto più veloce.
  • Error (Errore): Metà dell’intervallo di confidenza al 99,9%. Ti dà un’idea della precisione del valore Mean. Un errore minore indica misurazioni più consistenti.
  • StdDev (Deviazione Standard): Misura la dispersione dei tempi di esecuzione dalla Mean. Una deviazione standard minore indica prestazioni più consistenti.
  • Ratio (Rapporto): Il tempo di esecuzione medio del metodo corrente diviso per il tempo di esecuzione medio del metodo baseline. Per ConcatenateWithPlus (il nostro baseline), il rapporto è 1,00. Per ConcatenateWithStringBuilder, il rapporto è vicino a 0, confermando le sue prestazioni superiori.
  • Allocated (Allocato): La quantità di memoria gestita allocata da una singola invocazione del metodo. Questo è fornito dal MemoryDiagnoser. Qui, ConcatenateWithPlus alloca ben 5,6 MB a causa dell’immutabilità delle stringhe e delle ripetute riallocazioni, mentre ConcatenateWithStringBuilder alloca solo 3,6 KB. Questa è un’intuizione critica sul motivo per cui StringBuilder è preferito per le manipolazioni frequenti di stringhe.
  • Gen 0 / Gen 1 / Gen 2 (non mostrato in questo output semplificato, ma spesso presente con MemoryDiagnoser): Queste colonne indicano il numero di garbage collection in diverse generazioni (Gen 0, Gen 1, Gen 2) per 1000 operazioni. Un numero elevato di GC può anche influire sulle prestazioni.

Analisi dell’Output dell’Esempio:

I risultati del benchmark mostrano chiaramente che ConcatenateWithStringBuilder è di gran lunga superiore a ConcatenateWithPlus per le concatenazioni ripetute di stringhe.

  • Prestazioni: StringBuilder è ordini di grandezza più veloce (Media: 12.00 us contro 2.058,0 us).
  • Utilizzo della memoria: StringBuilder alloca significativamente meno memoria (3,6 KB contro 5,6 MB), il che è un enorme vantaggio per l’efficienza della memoria e la riduzione della pressione sul garbage collection.

Questo esempio evidenzia una comune insidia di performance in C# e dimostra come il benchmarking possa fornire dati concreti per convalidare le migliori pratiche.

Altre Funzionalità e Suggerimenti Utili di BenchmarkDotNet:

  • [Params] Attribute: da usare per testare i tuoi metodi con diverse dimensioni di input o configurazioni.C#[Params(100, 1000, 10000)] public int N; // Eseguirà il benchmark per N = 100, 1000 e 10000
  • [GlobalSetup] e [GlobalCleanup]: I metodi contrassegnati con questi attributi vengono eseguiti una volta prima di tutti i benchmark e una volta dopo tutti i benchmark, rispettivamente. Utili per inizializzare o pulire risorse condivise tra tutti i metodi benchmark.
  • [IterationSetup] e [IterationCleanup]: Eseguiti prima/dopo ogni iterazione di un benchmark. Utili per risorse necessarie per ogni esecuzione ma che non dovrebbero influenzare altri benchmark.
  • Custom Jobs (Lavori Personalizzati): definire diversi “lavori” per eseguire i tuoi benchmark su varie runtime .NET, JIT o piattaforme.
  • Exporters (Esportatori): BenchmarkDotNet può esportare i risultati in vari formati come HTML, CSV, Markdown, ecc. Puoi specificarli nel tuo Program.cs o usando argomenti da riga di comando. Ad esempio, per generare un report HTML:C#var config = DefaultConfig.Instance.AddExporter(BenchmarkDotNet.Exporters.HtmlExporter.Default); BenchmarkRunner.Run<StringConcatenationBenchmarks>(config);
  • Integrazione con Visual Studio: Visual Studio 2022 (versione 17.9+) offre strumenti integrati per visualizzare i dati di BenchmarkDotNet, inclusi i dati sull’utilizzo della CPU e sull’allocazione, se utilizzi il pacchetto NuGet Microsoft.VisualStudio.DiagnosticsHub.BenchmarkDotNetDiagnosers e i diagnostici pertinenti.

Eseguendo sistematicamente il benchmarking del tuo codice C# con strumenti come BenchmarkDotNet, puoi prendere decisioni basate sui dati per migliorare le prestazioni e l’utilizzo delle risorse della tua applicazione.