// Package benchmark misst Durchsatz und Ressourcenverbrauch der Anlage // (SYNCOVA_IMPLEMENTATION_PLAN.md §22). // // Die Regel dieses Pakets steht in einem Satz: // // Eine Zahl ohne Messbedingungen ist wertlos — und wird trotzdem zitiert. // // Deshalb trägt jede Messung die Maschine, die Datenmenge, die Beschaffenheit // der Daten und die Einstellungen bei sich. „420 MiB/s" ohne den Zusatz // „inkompressible Daten, lokale SSD, acht Kerne" ist eine Zahl, die in einer // Präsentation landet und dort etwas anderes behauptet, als gemessen wurde. // // Das Projekt hat diesen Fehler bereits einmal gemacht: In Phase 4 zeigte ein // Lauf eine 1021-fache Kompression, weil die Testdaten periodisch waren. Seither // gilt: Durchsatzmessungen ausschließlich mit inkompressiblen Daten. package benchmark import ( "fmt" "runtime" "time" ) // MachineProfile beschreibt die Maschine, auf der gemessen wurde. // // Ohne diese Angaben lässt sich eine Messung weder einordnen noch wiederholen. type MachineProfile struct { // OperatingSystem ist das Betriebssystem. OperatingSystem string `json:"operating_system"` // Architecture ist die Prozessorarchitektur. Architecture string `json:"architecture"` // LogicalCPUs ist die Zahl nutzbarer Kerne. LogicalCPUs int `json:"logical_cpus"` // GoMaxProcs ist die tatsächlich genutzte Zahl paralleler Abläufe. // // Sie steht neben der Kernzahl, weil ein Messlauf sie absichtlich // begrenzen kann — genau das ist das Szenario „wenig CPU". GoMaxProcs int `json:"go_max_procs"` // GoVersion ist die Fassung der Laufzeitumgebung. GoVersion string `json:"go_version"` // MemoryLimitBytes ist eine gesetzte Speichergrenze; 0 bedeutet keine. MemoryLimitBytes int64 `json:"memory_limit_bytes,omitempty"` } // CurrentMachine erfasst die Maschine des laufenden Prozesses. func CurrentMachine() MachineProfile { return MachineProfile{ OperatingSystem: runtime.GOOS, Architecture: runtime.GOARCH, LogicalCPUs: runtime.NumCPU(), GoMaxProcs: runtime.GOMAXPROCS(0), GoVersion: runtime.Version(), } } // DataShape beschreibt die Beschaffenheit der gemessenen Daten. // // Der wichtigste Wert ist Compressible: Bei komprimierbaren Daten misst man die // Kompression, nicht den Durchsatz. Beide Zahlen sind gültig — sie beantworten // nur verschiedene Fragen, und wer sie verwechselt, veröffentlicht eine // Leistung, die es nie gab. type DataShape struct { // Description benennt die Datenart in Worten. Description string `json:"description"` // TotalBytes ist die Gesamtmenge der Ursprungsdaten. TotalBytes int64 `json:"total_bytes"` // FileCount ist die Zahl der Dateien. FileCount int `json:"file_count"` // Compressible meldet komprimierbare Daten. Compressible bool `json:"compressible"` } // ResourceUsage sind die um einen Lauf herum erfassten Verbrauchswerte. type ResourceUsage struct { // WallClockSeconds ist die vergangene Zeit. WallClockSeconds float64 `json:"wall_clock_seconds"` // UserCPUSeconds ist die im Programm verbrachte Rechenzeit. UserCPUSeconds float64 `json:"user_cpu_seconds"` // SystemCPUSeconds ist die im Betriebssystem verbrachte Rechenzeit. SystemCPUSeconds float64 `json:"system_cpu_seconds"` // PeakMemoryBytes ist der höchste beobachtete Speicherbedarf. // // Gemessen als maximale Belegung des Go-Haufens, nicht als RSS des // Prozesses: Der RSS enthält auch den Dateizwischenspeicher des // Betriebssystems und stiege bei einem Sicherungslauf allein durch das // Lesen der Quelle — eine Zahl, die mehr über den Kernel aussagt als über // diese Anlage. PeakMemoryBytes int64 `json:"peak_memory_bytes"` // AllocatedBytes ist die insgesamt angeforderte Speichermenge. // // Sie liegt weit über dem Höchststand, weil kurzlebige Puffer mitzählen. // Interessant ist sie als Maß für den Druck auf die Speicherbereinigung. AllocatedBytes int64 `json:"allocated_bytes"` // GarbageCollections ist die Zahl der Speicherbereinigungen. GarbageCollections int `json:"garbage_collections"` } // CPUEfficiency liefert die verbrauchte Rechenzeit je Sekunde Laufzeit. // // Ein Wert um 1,0 bedeutet: ein Kern war ausgelastet. Bei 8,0 laufen acht Kerne // voll. Deutlich unter 1,0 heißt, dass der Lauf auf etwas gewartet hat — meist // auf die Platte. func (usage ResourceUsage) CPUEfficiency() float64 { if usage.WallClockSeconds <= 0 { return 0 } return (usage.UserCPUSeconds + usage.SystemCPUSeconds) / usage.WallClockSeconds } // Result ist das Ergebnis eines Messlaufs. type Result struct { // ScenarioName benennt das Szenario. ScenarioName string `json:"scenario"` // Description erklärt, welche Frage der Lauf beantwortet. Description string `json:"description"` // Machine ist die Maschine, auf der gemessen wurde. Machine MachineProfile `json:"machine"` // Data beschreibt die gemessenen Daten. Data DataShape `json:"data"` // Usage sind die Verbrauchswerte. Usage ResourceUsage `json:"usage"` // BytesProcessed ist die verarbeitete Datenmenge. BytesProcessed int64 `json:"bytes_processed"` // BytesStored ist die tatsächlich abgelegte Datenmenge. BytesStored int64 `json:"bytes_stored"` // FilesProcessed ist die Zahl verarbeiteter Objekte. FilesProcessed int `json:"files_processed"` // Notes benennen Einschränkungen dieser Messung. // // Sie stehen **im Ergebnis**, nicht in einer Fußnote: Wer die Zahl // weiterreicht, reicht die Einschränkung mit. Notes []string `json:"notes,omitempty"` // MeasuredAt ist der Zeitpunkt der Messung in UTC. MeasuredAt time.Time `json:"measured_at"` } // ThroughputBytesPerSecond liefert den Durchsatz. // // Er ist nur dann eine Aussage, wenn die Daten inkompressibel waren. Bei // komprimierbaren Daten misst er, wie gut zstd arbeitet — eine andere Frage. func (result *Result) ThroughputBytesPerSecond() float64 { if result.Usage.WallClockSeconds <= 0 { return 0 } return float64(result.BytesProcessed) / result.Usage.WallClockSeconds } // FilesPerSecond liefert die Zahl verarbeiteter Objekte je Sekunde. // // Bei vielen kleinen Dateien ist das die aussagekräftigere Zahl: Dort begrenzt // nicht der Durchsatz, sondern der Aufwand je Datei. func (result *Result) FilesPerSecond() float64 { if result.Usage.WallClockSeconds <= 0 { return 0 } return float64(result.FilesProcessed) / result.Usage.WallClockSeconds } // AddNote ergänzt eine Einschränkung. func (result *Result) AddNote(noteFormat string, formatArguments ...any) { result.Notes = append(result.Notes, fmt.Sprintf(noteFormat, formatArguments...)) } // Validate prüft ein Messergebnis auf Aussagekraft. // // Sie verhindert die beiden Zahlen, die am häufigsten falsch zitiert werden: // einen Durchsatz aus komprimierbaren Daten und einen aus einem Lauf, der zu // kurz war, um mehr zu messen als die eigene Startzeit. func (result *Result) Validate() error { if result.Data.Compressible && result.BytesProcessed > 0 { return fmt.Errorf("der lauf %q misst komprimierbare daten; ein durchsatz daraus "+ "beschreibt die kompression, nicht die leistung", result.ScenarioName) } if result.Usage.WallClockSeconds < minimumMeaningfulSeconds { return fmt.Errorf("der lauf %q dauerte nur %.3f s; darunter bestimmt die "+ "messungenauigkeit das ergebnis", result.ScenarioName, result.Usage.WallClockSeconds) } return nil } // minimumMeaningfulSeconds ist die kürzeste noch aussagekräftige Laufzeit. // // Eine halbe Sekunde: Darunter schlagen der Start der Arbeiter, das Öffnen des // Repositorys und die erste Speicherbereinigung stärker durch als die eigentliche // Arbeit. Dieselbe Überlegung wie bei den Durchsatzdiagrammen (Phase 13), wo // Läufe unter einer Sekunde herausfallen. const minimumMeaningfulSeconds = 0.5