Laufzeit messen: Performance und WCET
Vermutungen über Laufzeit sind fast immer falsch. Lyx macht das Messen einfach: eine monotone Uhr in der Standardbibliothek, statische Analysen im Compiler, und Binaries ohne Laufzeitsystem, das die Messung verfälscht.
→ Fehlersuche · Attribute & Pragmas · DO-178C
Die richtige Uhr
var t0: int64 := GetTimeNs(CLOCK_MONOTONIC);
… Arbeit …
var t1: int64 := GetTimeNs(CLOCK_MONOTONIC);
| Uhr | Wofür |
|---|---|
CLOCK_MONOTONIC | Zeitspannen — springt nicht, wenn die Systemzeit gestellt wird |
CLOCK_MONOTONIC_RAW | dasselbe, ohne NTP-Feinregelung |
CLOCK_PROCESS_CPUTIME_ID | Rechenzeit des Prozesses statt Wanduhr |
CLOCK_THREAD_CPUTIME_ID | Rechenzeit eines Threads |
CLOCK_REALTIME | Zeitpunkte, nicht Zeitspannen |
Für Zeitstempel im Alltag gibt es NowMs() und NowUs() aus std.time; zum Messen ist die monotone Uhr die richtige Wahl.
Beispiel: zwei Fassungen vergleichen
import std.io;
import std.alloc;
import std.time;
// Zwei Fassungen derselben Aufgabe: Summe eines Feldes
fn SummeEinfach(a: int64, n: int64): int64 {
var s: int64 := 0;
var i: int64 := 0;
while (i < n) { s := s + peek64(a + i * 8); i := i + 1; }
return s;
}
// Vierfach abgerollt — dieselbe Rechnung, weniger Schleifenkopf
fn SummeAbgerollt(a: int64, n: int64): int64 {
var s: int64 := 0;
var i: int64 := 0;
var grenze: int64 := n - (n - (n / 4) * 4);
while (i < grenze) {
s := s + peek64(a + i * 8)
+ peek64(a + (i + 1) * 8)
+ peek64(a + (i + 2) * 8)
+ peek64(a + (i + 3) * 8);
i := i + 4;
}
while (i < n) { s := s + peek64(a + i * 8); i := i + 1; }
return s;
}
fn Messen(name: pchar, wert: int64, ns: int64): void {
PrintLn(StrConcat(StrConcat(name, ": Summe="), StrConcat(IntToStr(wert),
StrConcat(", ", StrConcat(IntToStr(ns / 1000000), " ms")))));
}
fn main(): int64 {
var n: int64 := 2000000;
var a: int64 := allocZeroed(n * 8);
var i: int64 := 0;
while (i < n) { poke64(a + i * 8, i); i := i + 1; }
// Monotone Uhr: sie springt nicht, wenn die Systemzeit gestellt wird
var t0: int64 := GetTimeNs(CLOCK_MONOTONIC);
var s1: int64 := SummeEinfach(a, n);
var t1: int64 := GetTimeNs(CLOCK_MONOTONIC);
var s2: int64 := SummeAbgerollt(a, n);
var t2: int64 := GetTimeNs(CLOCK_MONOTONIC);
Messen("einfach ", s1, t1 - t0);
Messen("abgerollt", s2, t2 - t1);
free(a, n * 8);
return 0;
}
Zwei Läufe hintereinander, gemessen mit lyxc 1.1.3I:
einfach : Summe=1999999000000, 24 ms
abgerollt: Summe=1999999000000, 21 ms
einfach : Summe=1999999000000, 22 ms
abgerollt: Summe=1999999000000, 15 ms
Zwei Dinge sind daran wichtiger als die Zahlen:
- Beide Fassungen liefern dasselbe Ergebnis. Eine schnellere Fassung, die etwas anderes rechnet, ist keine schnellere Fassung.
- Die Werte streuen — 24/21 gegen 22/15 ms. Ein einzelner Lauf beweist nichts; erst mehrere Läufe zeigen, ob ein Unterschied echt ist.
Was der Compiler ohne Ausführung sagt
lyxc prog.lyx --static-analysis --call-graph -o prog
=== Aufrufgraph ===
Funktionen: 2, Aufrufkanten: 1
keine Rekursion
von main nicht erreichbar: 0
=== Statische Analyse: Stapelbedarf ===
Regel: 0 lokale Variablen, ca. 0 Byte Rahmen
main: 1 lokale Variablen, ca. 8 Byte Rahmen
groesster Rahmen: ca. 8 Byte
–call-graphbeantwortet die WCET-Vorfragen: Gibt es Rekursion? Ist etwas unerreichbar (also toter Code im Nachweis)?–static-analysisnennt den Stapelbedarf je Funktion — die Grundlage für@stack_limit(N).–mcdczeigt, welche Bedingungen strukturell nicht abgedeckt sind.
Vorhersagbarkeit statt Geschwindigkeit
Für sicherheitskritischen Code zählt nicht der Mittelwert, sondern die obere Schranke. Die Sprachmittel dafür:
| Mittel | Zusage |
|---|---|
@wcet(N) | Diese Funktion soll in N Zeiteinheiten fertig sein |
@stack_limit(N) | Ihr Rahmen bleibt unter N Byte; Rekursion ohne diese Angabe ist verboten |
@flight_crit | Kein new/alloc, keine lokalen Felder, FPU-Traps für NaN/Inf |
limit(N) an Schleifen | Obergrenze der Durchläufe steht im Quelltext |
@wcet(N)ist seit lyxc 1.1.4A wieder unauffällig (#1704, nachgemessen):F(20) + 1liefert42.
Bis 1.1.3O verlor der Aufruf einer so annotierten Funktion innerhalb eines Ausdrucks den Rückgabetyp und rechnete alsf64— heraus kam ein Bitmuster. Wer mit einem älteren Compiler arbeitet, weist das Ergebnis erst einer Variablen zu (var v: int64 := F(20);).@stack_limit,@dalund@flight_critwaren nie betroffen.
Messen, das etwas taugt
- Erst messen, dann ändern. Die teure Stelle ist selten die, die man vermutet.
- Genug Wiederholungen, damit die Uhr etwas zu messen hat — Millisekunden statt Mikrosekunden.
- Mehrere Läufe, und die Streuung ansehen.
- Gleiche Bedingungen: dasselbe Binary, dieselbe Maschine, möglichst ohne Last daneben.
- Ergebnis mitprüfen, damit die schnellere Fassung noch dasselbe rechnet.
-O0zum Gegenprüfen: Verschwindet der Unterschied, lag er an der Faltung, nicht am Code.
Und der Hinweis aus dem Bestand: Eine Änderung, die 3 ms bringt, aber den Code unlesbar macht, ist ein schlechtes Geschäft — es sei denn, die 3 ms stehen in einer Anforderung.
Wenn es wirklich langsam ist
Die Reihenfolge, in der sich das Suchen lohnt:
- Algorithmus — quadratisch statt linear schlägt jede Mikrooptimierung.
- Speicherzugriffe — jedes
allocim Innersten einer Schleife ist ein Kandidat (Speicher in der Praxis). - Syscalls — I/O in der Schleife statt gepuffert.
- Erst danach Schleifenkörper, Abrollen, Zugriffsmuster.
Letzte Aktualisierung: 2026-08-20 — Messungen mit lyxc 1.1.3I durchgeführt; der dabei gefundene @wcet-Defekt #1704 ist mit lyxc 1.1.4A nachgemessen behoben.
