blob: a9d51a3c4d26809418a940817084239577e3d819 [file] [log] [blame]
// Copyright 2024 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
package driver
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"github.com/google/pprof/profile"
"golang.org/x/benchmarks/sweet/common/diagnostics"
sprofile "golang.org/x/benchmarks/sweet/common/profile"
)
// There are three ways of gathering diagnostic profiles, in order of
// preference:
//
// - In-process: The process coordinating the benchmark and running the
// benchmarked code are the same, and driver.B takes care of diagnostic
// collection.
//
// - Subprocess self collection: The benchmarked code is running in a subprocess
// that has the ability to collect diagnostics using a command line flag. For
// this, we use [Diagnostic.NewFile] and pass the name of the file to the
// subprocess.
//
// - Subprocess HTTP collection: The benchmarked code is an HTTP server with
// net/http/pprof endpoints. We use [Diagnostic] in conjunction with
// [server.FetchDiagnostic].
//
// TODO: Can we better consolidate the last case into Diagnostics?
type Diagnostics struct {
name string
once sync.Once
tmpDir string
tmpDirErr error
}
func NewDiagnostics(name string) *Diagnostics {
return &Diagnostics{name: name}
}
// MarshalText marshals this Diagnostics configuration into text that can be
// passed to another process (e.g., via a flag) and unmarshaled into a new
// Diagnostics with UnmarshalText. That other process may call Create* on the
// result and use the resulting [DiagnosticsFile]s as usual, but it must not
// call [Diagnostics.Commit].
func (d *Diagnostics) MarshalText() (text []byte, err error) {
tmpDir, err := d.getTmpDir()
return []byte(tmpDir), err
}
func (d *Diagnostics) UnmarshalText(text []byte) error {
// Clear the tmpDir once.
first := false
d.once.Do(func() { first = true })
if !first {
return fmt.Errorf("Diagnostics.UnmarshalText requires an unused Diagnostics")
}
d.tmpDir = string(text)
d.tmpDirErr = nil
return nil
}
func safeFileName(name string) string {
// The following characters are disallowed by either VFAT, NTFS, APFS, or
// most Unix file systems:
//
// 0x00–0x1F 0x7F " * / : < > ? \ |
//
// We use % for escaping, so we also escape it.
const bad = (1<<0x20 - 1) | 1<<'"' | 1<<'*' | 1<<'/' | 1<<':' | 1<<'<' | 1<<'>' | 1<<'?' | 1<<'\\' | 1<<'|' | 1<<'%'
const badLo uint64 = bad & 0xFFFFFFFFFFFFFFFF
const badHi uint64 = bad >> 64
var buf strings.Builder
for i := 0; i < len(name); i++ {
ch := name[i]
if ch >= 0x7F || (badLo>>ch)&1 != 0 || (ch >= 64 && (badHi>>(ch-64))&1 != 0) {
fmt.Fprintf(&buf, "%%%02x", ch)
} else {
buf.WriteByte(ch)
}
}
return buf.String()
}
// Commit combines all individually committed diagnostic files into the final
// output files. If there are multiple diagnostic files with the same type and
// name, it merges them into a single file. If b != nil, it adds metrics for
// diagnostic file sizes to b.
func (d *Diagnostics) Commit(b *B) error {
// Commit is usually used in a defer, so log the error.
err := d.commit1(b)
if err != nil {
fmt.Fprintln(os.Stderr, err)
}
return err
}
func (d *Diagnostics) commit1(b *B) error {
if d.tmpDir == "" {
// No diagnostics were created.
return nil
}
allEntries, err := os.ReadDir(d.tmpDir)
if err != nil {
return err
}
// Bucket the file names.
type mergeKey struct {
typ diagnostics.Type
name string
}
toMerge := make(map[mergeKey][]string)
var toDelete []string
for _, entry := range allEntries {
fileName := entry.Name()
path := filepath.Join(d.tmpDir, fileName)
typ, name, committed := parseDiagnosticPath(fileName)
if !committed {
// Uncommitted. Delete this one.
toDelete = append(toDelete, path)
continue
}
info, err := entry.Info()
if err != nil {
return err
} else if info.Size() == 0 {
// Skip zero-sized files, otherwise the pprof package
// will call it a parsing error.
toDelete = append(toDelete, path)
continue
}
// Add to the merge list.
k := mergeKey{typ, name}
toMerge[k] = append(toMerge[k], path)
}
// Process each merge list.
var errs []error
anyTrace := false
var traceBytes int64
for k, paths := range toMerge {
if err, outPath, deleteInputs := d.merge(k.typ, k.name, paths); err != nil {
errs = append(errs, err)
} else {
if deleteInputs {
toDelete = append(toDelete, paths...)
}
if k.typ == diagnostics.Trace {
anyTrace = true
if st, err := os.Stat(outPath); err == nil {
traceBytes = st.Size()
}
}
}
}
if b != nil && anyTrace {
// Report metric for diagnostic size.
b.Report("trace-bytes", uint64(traceBytes))
}
// Delete all of the temporary files.
for _, path := range toDelete {
errs = append(errs, os.Remove(path))
}
errs = append(errs, os.Remove(d.tmpDir))
return errors.Join(errs...)
}
func (d *Diagnostics) merge(typ diagnostics.Type, subName string, paths []string) (err error, outPath string, deleteInputs bool) {
if len(paths) > 1 && !typ.CanMerge() {
return fmt.Errorf("found %d > 1 %s files, but this diagnostic cannot be merged", len(paths), typ), "", false
}
// Create the output file.
name := d.name
if subName != "" {
name += "-" + subName
}
outFile, err := os.CreateTemp(diag.ResultsDir, safeFileName(name)+"-*-"+typ.FileName())
if err != nil {
return err, "", false
}
outPath = outFile.Name()
if len(paths) == 1 {
// Simply rename it to the final path.
outFile.Close()
if err := os.Rename(paths[0], outPath); err != nil {
return err, "", false
}
} else if len(paths) > 1 {
defer outFile.Close()
// Otherwise, merge the profiles.
var profiles []*profile.Profile
for _, path := range paths {
p, err := sprofile.ReadPprof(path)
if err != nil {
return err, "", false
}
profiles = append(profiles, p)
}
p, err := profile.Merge(profiles)
if err != nil {
return fmt.Errorf("error merging profiles: %w", err), "", false
}
err = p.Write(outFile)
if err == nil {
err = outFile.Close()
}
if err != nil {
return fmt.Errorf("error writing profile %s: %s", outPath, err), "", false
}
// Now we can delete all of the input files.
deleteInputs = true
}
return nil, outPath, deleteInputs
}
type DiagnosticFile struct {
*os.File
}
// getTmpDir returns the directory for storing uncommitted diagnostics files.
func (d *Diagnostics) getTmpDir() (string, error) {
d.once.Do(func() {
// Create the uncommitted results directory.
d.tmpDir, d.tmpDirErr = os.MkdirTemp(diag.ResultsDir, safeFileName(d.name)+"-*.tmp")
})
return d.tmpDir, d.tmpDirErr
}
// Create is shorthand for CreateNamed(typ, "").
func (d *Diagnostics) Create(typ diagnostics.Type) (*DiagnosticFile, error) {
return d.CreateNamed(typ, "")
}
// CreateNamed returns a new file that a diagnostic can be written to. If this
// type of diagnostic can be merged, this can be called multiple times with the
// same type and name and Commit will merge all of the files. The caller must
// close this file. Diagnostic files are temporary until the caller calls
// [DiagnosticFile.Commit] to indicate they are ready for merging into the final
// output.
func (d *Diagnostics) CreateNamed(typ diagnostics.Type, name string) (*DiagnosticFile, error) {
if !DiagnosticEnabled(typ) {
return nil, nil
}
tmpDir, err := d.getTmpDir()
if err != nil {
return nil, err
}
// Construct diagnostic file name. This path must be parsable by
// parseDiagnosticPath.
if strings.Contains(string(typ), "-") {
// To later parse the file name, we assume there's no "-".
panic("diagnostic type contains '-'")
}
pattern := string(typ) + "-*"
if name != "" {
pattern += "-" + safeFileName(name)
}
// Mark this as uncommitted.
pattern += ".tmp"
// Create file.
f, err := os.CreateTemp(tmpDir, pattern)
if err != nil {
return nil, err
}
return &DiagnosticFile{f}, nil
}
func parseDiagnosticPath(fileName string) (typ diagnostics.Type, name string, committed bool) {
// Check whether its committed.
committed = !strings.HasSuffix(fileName, ".tmp")
fileName = strings.TrimSuffix(fileName, ".tmp")
// Get the type.
typString, rest, _ := strings.Cut(fileName, "-")
typ = diagnostics.Type(typString)
// Drop the CreateTemp junk, leaving only the name. If there's no "-", then
// there's no name, so we let this set name to "".
_, name, _ = strings.Cut(rest, "-")
return
}
// Commit indicates that diagnostic file f is ready to be merged into the final
// output. For a diagnostic that cannot be truncated, this should only be called
// when the file has been fully written.
func (f *DiagnosticFile) Commit() {
path := f.Name()
if !strings.HasSuffix(path, ".tmp") {
panic("temporary diagnostic file does not end in .tmp: " + path)
}
newPath := strings.TrimSuffix(path, ".tmp")
if err := os.Rename(path, newPath); err != nil {
// If rename fails, something is *horribly* wrong.
panic(fmt.Sprintf("failed to rename %q to %q: %s", path, newPath, err))
}
}