Richtig testen, oder: Was ist schneller… Foreach, LAMBDA Expressions, oder LINQ?
Einleitung
Der schweizer Kollege Daniel Schädler hat einen Blogpost mit dem Titel “Was ist schneller… Foreach, LAMBDA Expressions, oder LINQ?” veröffentlicht.
Nur schnell eine Korrektur zum Titel, die LINQ-Syntax wird – wie hier schon gezeigt – schlussendlich in die Erweiterungsmethoden-Syntax kompiliert.
Ich habe den Titel so übernommen, damit klar ist, dass das eine Antwort auf seinen Post darstellt.
Daniel hat sich die Frage gestellt, was wohl schneller ist: Listen per foreach, über LINQ oder die Erweiterungsmethoden Syntax für LINQ filtern.
Nun, ich wusste natürlich schon vorher, dass die Resultate nur ganz wenig auseinander liegen dürfen, weil:
- Wird die LINQ-Syntax in den Erweiterungsmethoden-Syntax übersetzt (Nahezu gleich).
- Arbeitet LINQ intern auch mit den Iteratoren, wie sie in foreach genutzt werden. Siehe Enumerable-Klasse im Reflector.
Abgesehen davon nutze ich diese Antwort in Form eines Blogposts einfach mal dazu, wie ich Geschwindigkeiten teste, auch wenn es in diesem Fall für mich theoretisch klar war, das alle in etwa gleich schnell sind.
Es braucht für einen Geschwindigkeitstest nicht unbedingt eine grafische Anwendung, auch wenn das natürlich nicht schlecht ist. Ich habe mir den Aufwand nicht gemacht und mich auf das Wesentliche konzentriert: Das Testen.
Verfälschte Tests und wie sie verhindert werden können
Der Test von Daniel liefert leider das falsche Resultat, sogar ein sehr verfälschtes.
Das liegt daran, das die Ergebnisse von LINQ bzw. den Erweiterungsmethoden als IEnumerable<T> zurückgegeben werden und diese zeitverzögert ausgeführt werden (deferred Execution).
Das heisst der ganze Code der die IEnumerable<T> Instanz liefert, wird erst ausgeführt, wenn das Ergebnis tatsächlich durchlaufen wird.
Dies kann bspw. mit <Menge>.Count(), <Menge>.ToList(), … angestossen werden.
Erst dann, und wirklich erst dann wird der Code überhaupt ausgeführt und braucht dann eben auch länger.
Auch wenn dies geschehen ist, bleibt das Ergebnis von Daniel noch verfälscht, denn eine Testiteration kann durch mehrere Faktoren verfälscht werden.
Sei das eine einmalige Kompilation einer Abfrage zur Laufzeit oder durch diversen Hintergrundprozessen die genau in dem Moment mehr Leistung benötigen als beim vorherigen Testkandidaten.
Es heisst also: Mehrere Iterationen einer Operation die getestet werden will, ausführen.
Darüber hinaus ist es noch so, dass das Gesamtergebnis auch noch bei mehreren Iterationen der zu testenden Operation verfälscht sein kann, weil eben die erste ausgeführte Operation verfälscht ist.
Um da einen Mittelwert zu bekommen, macht man über die ganzen Testläufe nochmals mehrere Testläufe.
Das hört sich jetzt womöglich ein bisschen verwirrend an, jedoch meine ich einfach gesagt nur folgendes:
- Eine Operation: Einträge aus einer Menge filtern
- Mehrere Iterationen einer Operation: Die Operationen mehrmals ausführen
- Das ganze für alle Testkandidaten mehrmals ausführen
Somit kann man sehr sicher sein, dass die Ergebnisse der verschiedenen Kandidaten realistisch miteinander vergleichbar sind, nicht aber dass die Werte 1:1 dem entsprechen, wie sie dann auch in der Produktivumgebung auftreten, denn dort kommen wieder andere Faktoren dazu.
Nur das keine Missverständisse auftreten: Natürlich ist dann ein solcher Test auch für die Praxis aussagekräftig, nur wird er nicht 100% gleich ausfallen, wenn man die Zeit stoppt.
Als Testhilfe würde ich nicht DateTime.Now, etc.. benutzen, da dies zu ungenau ist. Das meines Wissens genauste Instrument das ohne P/Invoke-Aufrufe in unmanaged Code verfügbar ist, heisst Stopwatch und ist im System.Diagnostics-Namespace zu finden.
Mittels der Eigenschaft <Stopwatch>.ElapsedMilliseconds lassen sich die Millisekunden auslesen, die seit dem Start- und Stopaufruf vergangen sind, das reicht uns für den Test und ist auch aussagekräftig und vorstellbar, was bei den von Daniel genutzten Ticks m.E. nicht der Fall ist.
Für die Tests nutze ich eine Methode namens SpeedTest die einen Action-Delegate entgegennimmt den sie n-Mal ausführt.
So kann das Testen zentralisiert werden und ist mit sehr kleinem Aufwand möglich den Test selber zu erweitern, sowie neue Kandidaten hinzuzufügen.
Wenn der Test ausgeführt wird, habe ich Abweichungen von 1 bis 4 Millisekunden und das bei sehr vielen Durchläufen. Diese Abweichungen sind für mich also – wie am Anfang vermutet – irrelevant.
Folgend den Code der ich für meinen Test verwendet habe:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace SpeedTest
{
public class Program
{
static void Main(string[] args)
{
int randomDataCount = 1000;
var persons = GetPersons(randomDataCount);
int iterations = 1000;
int runs = 10;
for (int runNumber = 1; runNumber <= runs; runNumber++)
{
Console.WriteLine("Run {0} ---------------", runNumber);
SpeedTest("LINQ", iterations,
() =>
{
var result = (from person in persons
where person.FirstName.Equals("Peter") && person.Age <= 25
select person).ToList();
});
SpeedTest("LINQ Extension Syntax", iterations,
() =>
{
var result = persons.Where(p => p.FirstName.Equals("Peter") && p.Age <= 25).ToList();
});
SpeedTest("Manual ForEach", iterations,
() =>
{
List<Person> result = new List<Person>();
foreach (Person person in persons)
{
if (person.FirstName.Equals("Peter") && person.Age <= 25)
{
result.Add(person);
}
}
});
Console.WriteLine("End run --------");
Console.WriteLine(String.Empty);
}
Console.Read();
}
private static void SpeedTest(string description, int iterations, Action speedTestAction)
{
Stopwatch watch = new Stopwatch();
watch.Start();
for (int i = 0; i < iterations; i++)
{
speedTestAction();
}
watch.Stop();
Console.WriteLine("{0}: {1}ms",
description,
watch.ElapsedMilliseconds);
Console.WriteLine(String.Empty);
}
private static IEnumerable<Person> GetPersons(int randomDataCount)
{
Random random = new Random();
string[] surnames = new[] { "Peter", "Daniel", "Johannes", "Stefanie", "Hans", "Klaus", "Benjamin" };
string[] lastnames = new[] { "Bucher", "Hurzeler", "Kübel", "Honegger", "Bock", "Wurst", "Grube" };
int dataLength = surnames.Length;
for (int i = 0; i < randomDataCount; i++)
{
yield return new Person
{
FirstName = surnames[random.Next(0, dataLength)],
LastName = lastnames[random.Next(0, dataLength)],
Age = random.Next(0, 100)
};
}
}
}
public class Person
{
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }
}
}