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-graph beantwortet die WCET-Vorfragen: Gibt es Rekursion? Ist etwas unerreichbar (also toter Code im Nachweis)?
  • –static-analysis nennt den Stapelbedarf je Funktion — die Grundlage für @stack_limit(N).
  • –mcdc zeigt, 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) + 1 liefert 42.

Bis 1.1.3O verlor der Aufruf einer so annotierten Funktion innerhalb eines Ausdrucks den Rückgabetyp und rechnete als f64 — heraus kam ein Bitmuster. Wer mit einem älteren Compiler arbeitet, weist das Ergebnis erst einer Variablen zu (var v: int64 := F(20);). @stack_limit, @dal und @flight_crit waren nie betroffen.

Messen, das etwas taugt

  1. Erst messen, dann ändern. Die teure Stelle ist selten die, die man vermutet.
  2. Genug Wiederholungen, damit die Uhr etwas zu messen hat — Millisekunden statt Mikrosekunden.
  3. Mehrere Läufe, und die Streuung ansehen.
  4. Gleiche Bedingungen: dasselbe Binary, dieselbe Maschine, möglichst ohne Last daneben.
  5. Ergebnis mitprüfen, damit die schnellere Fassung noch dasselbe rechnet.
  6. -O0 zum 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 alloc im 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.