Lors d'une discussion sur l'exportation de plusieurs millions d'enregistrements au format CSV, la question de la performance s'est posée. Pour y répondre, des tests comparatifs ont été réalisés avec deux millions de lignes de données sur différentes approches.
Structure des données de test
Un enregistrement simple contenant deux valeurs entières a été défini sous forme de structure pour des raisons de simplicité et de performance mémoire :
public struct Enregistrement
{
public int cleA;
public int cleB;
}
private Enregistrement[] jeuxDonnees;
Génération des données aléatoires :
jeuxDonnees = new Enregistrement[2_000_000];
var generateur = new Random(Environment.TickCount);
for (int idx = 0; idx < jeuxDonnees.Length; idx++)
{
jeuxDonnees[idx].cleA = generateur.Next();
jeuxDonnees[idx].cleB = generateur.Next();
}
Les tests ont été effectués en .NET Framework 4.8 (64 bits, mode Release).
Première approche : écriture directe
Cette méthode consiste à écrire chaque valeur individuellement dans le flux du fichier. Elle sert de référence de base. Résultat : 0,536 s.
const string chemin = "export_1.csv";
using (var flux = new FileStream(chemin, FileMode.Create))
{
var chrono = Stopwatch.StartNew();
var enTete = Encoding.ASCII.GetBytes("cleA,cleB");
flux.Write(enTete, 0, enTete.Length);
var separateur = Encoding.ASCII.GetBytes(",");
var finLigne = Encoding.ASCII.GetBytes("\r\n");
foreach (var enreg in jeuxDonnees)
{
flux.Write(finLigne, 0, finLigne.Length);
var octetsA = Encoding.ASCII.GetBytes(enreg.cleA.ToString());
flux.Write(octetsA, 0, octetsA.Length);
flux.Write(separateur, 0, separateur.Length);
var octetsB = Encoding.ASCII.GetBytes(enreg.cleB.ToString());
flux.Write(octetsB, 0, octetsB.Length);
}
chrono.Stop();
Console.WriteLine($"Export de {jeuxDonnees.Length} lignes : {chrono.Elapsed.TotalSeconds:F3} s");
}
Deuxième approche : tampon mémoire manuel
Ajout d'un tampon de 8 Mo pour réduire les appels d'écriture sur le flux. Sous C#, le gain est limité car FileStream dispose déjà d'un cache interne. Résultat : 0,503 s.
const string chemin = "export_2.csv";
const int tailleTampon = 8 * 1024 * 1024;
var tampon = new byte[tailleTampon + 1024];
int curseur = 0;
using (var flux = new FileStream(chemin, FileMode.Create))
{
var chrono = Stopwatch.StartNew();
var separateur = Encoding.ASCII.GetBytes(",");
var finLigne = Encoding.ASCII.GetBytes("\r\n");
void EcrireDansTampon(byte[] source)
{
Array.Copy(source, 0, tampon, curseur, source.Length);
curseur += source.Length;
}
EcrireDansTampon(Encoding.ASCII.GetBytes("cleA,cleB"));
foreach (var enreg in jeuxDonnees)
{
EcrireDansTampon(finLigne);
EcrireDansTampon(Encoding.ASCII.GetBytes(enreg.cleA.ToString()));
EcrireDansTampon(separateur);
EcrireDansTampon(Encoding.ASCII.GetBytes(enreg.cleB.ToString()));
if (curseur >= tailleTampon)
{
flux.Write(tampon, 0, curseur);
curseur = 0;
}
}
if (curseur > 0)
flux.Write(tampon, 0, curseur);
chrono.Stop();
Console.WriteLine($"Export de {jeuxDonnees.Length} lignes : {chrono.Elapsed.TotalSeconds:F3} s");
}
Analyse du goulot d'étranglement
L'opération dominante est la conversion d'entiers en chaînes de caractères. Avec deux millions de lignes et deux valeurs par ligne, cela représente quatre millions d'appels à ToString(). Un test isolé de ces conversions prend 0,265 s.
var chrono = Stopwatch.StartNew();
string temp = "";
foreach (var enreg in jeuxDonnees)
{
temp = enreg.cleA.ToString();
temp = enreg.cleB.ToString();
}
chrono.Stop();
Console.WriteLine($"{jeuxDonnees.Length * 2} conversions ToString : {chrono.Elapsed.TotalSeconds:F3} s");
Troisième approche : conversion directe en octets
L'idée consiste à contourner complètement l'allocation de chaînes en écrivant directement les caractères ASCII dans le tampon. Résultat : 0,176 s, soit plus rapide que les seuls appels à ToString() de la version précédente.
const string chemin = "export_3.csv";
const int tailleTampon = 8 * 1024 * 1024;
var tampon = new byte[tailleTampon + 1024];
int curseur = 0;
using (var flux = new FileStream(chemin, FileMode.Create))
{
var chrono = Stopwatch.StartNew();
var separateur = Encoding.ASCII.GetBytes(",");
var finLigne = Encoding.ASCII.GetBytes("\r\n");
void Copier(byte[] source)
{
Buffer.BlockCopy(source, 0, tampon, curseur, source.Length);
curseur += source.Length;
}
void ConvertirEntierVersAscii(int valeur)
{
int compteur = 0;
int positionTemp = curseur + 16;
do
{
tampon[positionTemp] = (byte)('0' + valeur % 10);
positionTemp++;
compteur++;
}
while ((valeur /= 10) != 0);
positionTemp--;
for (int k = 0; k < compteur; k++)
{
tampon[curseur] = tampon[positionTemp];
curseur++;
positionTemp--;
}
}
Copier(Encoding.ASCII.GetBytes("cleA,cleB"));
foreach (var enreg in jeuxDonnees)
{
Copier(finLigne);
ConvertirEntierVersAscii(enreg.cleA);
Copier(separateur);
ConvertirEntierVersAscii(enreg.cleB);
if (curseur >= tailleTampon)
{
flux.Write(tampon, 0, curseur);
curseur = 0;
}
}
if (curseur > 0)
flux.Write(tampon, 0, curseur);
chrono.Stop();
Console.WriteLine($"Export de {jeuxDonnees.Length} lignes : {chrono.Elapsed.TotalSeconds:F3} s");
}
La fonction ConvertirEntierVersAscii effectue une conversion chiffre par chiffre, en écrivant d'abord à l'envers puis en recopiant dans le bon ordre. Bien que son implémentation paraisse peu élégante, elle élimine complètement les allocations intermédiaires de chaînes.
Comparaison entre .NET Framework 4.8 et .NET 8
| Approche | .NET Framwork 4.8 | .NET 8 | Observation |
|---|---|---|---|
| Écriture directe | 0,538 s | 0,257 s | .NET 8 nettement plus rapide |
| Tampon manuel | 0,503 s | 0,190 s | .NET 8 nettement plus rapide |
| Conversion directe en octets | 0,176 s | 0,178 s | Performances équivalentes |
| ToString() isolé | 0,265 s | 0,075 s | .NET 8 nettement plus rapide |
.NET 8 optimise considérablement ToString() sur les types primitifs. La troisième approche, qui n'utilise pas ToString(), affiche des performances identiques sur les deux runtimes car elle ne dépend pas des optimisations internes du framework.
Note : les tests en mode Debug donnent des résultats très différants, notamment pour la troisième méthode qui perd son avantage. Le mode Release est indispensable pour évaluer correctement les performances.