blob: a778923e8fb18a0f599d4eede0f17b8332d7cfc8 [file] [edit]
// Copyright 2026 The Dawn & Tint Authors
//
// Redistribution and use in source and binary forms, with or without
// modification, are permitted provided that the following conditions are met:
//
// 1. Redistributions of source code must retain the above copyright notice, this
// list of conditions and the following disclaimer.
//
// 2. Redistributions in binary form must reproduce the above copyright notice,
// this list of conditions and the following disclaimer in the documentation
// and/or other materials provided with the distribution.
//
// 3. Neither the name of the copyright holder nor the names of its
// contributors may be used to endorse or promote products derived from
// this software without specific prior written permission.
//
// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
// AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
// IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
// DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
// FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
// DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
// SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
// CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
// OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
package main
import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"time"
"dawn.googlesource.com/dawn/tools/src/fileutils"
)
// Experiment mode path stems that get used multiple times
const (
kExperimentCorporaSubDir = "corpora"
kExperimentCorpusSubDir = "corpus"
kExperimentBinarySubDir = "bin"
kExperimentSettingsFile = "experiment.json"
kExperimentTaskStateFile = "state.json"
)
// CorpusDef defines the name and relative path of a fuzzer corpus.
type CorpusDef struct {
Name string `json:"name"`
Path string `json:"path"`
}
// DurationDef defines the configuration for a specific execution length.
// Seconds and Runs control the stopping criteria for a single fuzzer iteration and are mutually exclusive.
// Iterations provides an optional override for the experiment's default number of iterations.
type DurationDef struct {
Seconds *int `json:"seconds,omitempty"`
Runs *int `json:"runs,omitempty"`
Iterations *int `json:"iterations,omitempty"`
}
// ExperimentSettings represents the structure of the experiment.json settings file.
type ExperimentSettings struct {
Name string `json:"name"`
Hash string `json:"hash"`
Fuzzers []string `json:"fuzzers"`
Timeout *int `json:"timeout,omitempty"`
BenchmarkDuration *int `json:"benchmark_duration,omitempty"`
BurnInDuration *int `json:"burnin_duration,omitempty"`
BurnInEnabled *bool `json:"burnin_enabled,omitempty"`
WgslBenchmarkCorpus string `json:"wgsl_benchmark_corpus"`
IrBenchmarkCorpus string `json:"ir_benchmark_corpus"`
WgslCorpora []CorpusDef `json:"wgsl_corpora"`
IrCorpora []CorpusDef `json:"ir_corpora"`
DefaultIterations int `json:"default_iterations"`
Durations []DurationDef `json:"durations"`
}
type IterStateStatus string
const (
IterStatePending IterStateStatus = "pending"
IterStateRunning IterStateStatus = "running"
IterStateCompleted IterStateStatus = "completed"
)
func (s *IterStateStatus) UnmarshalJSON(b []byte) error {
var str string
if err := json.Unmarshal(b, &str); err != nil {
return err
}
switch IterStateStatus(str) {
case IterStatePending, IterStateRunning, IterStateCompleted:
*s = IterStateStatus(str)
return nil
default:
return fmt.Errorf("invalid status: %s", str)
}
}
type ExperimentLimitType string
const (
ExperimentLimitTypeSeconds ExperimentLimitType = "seconds"
ExperimentLimitTypeRuns ExperimentLimitType = "runs"
)
func (t *ExperimentLimitType) UnmarshalJSON(b []byte) error {
var str string
if err := json.Unmarshal(b, &str); err != nil {
return err
}
switch ExperimentLimitType(str) {
case ExperimentLimitTypeSeconds, ExperimentLimitTypeRuns:
*t = ExperimentLimitType(str)
return nil
default:
return fmt.Errorf("invalid limit_type: %s", str)
}
}
// IterState tracks the execution status and results of a single fuzzer iteration.
type IterState struct {
Status IterStateStatus `json:"status"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
PerfScore float64 `json:"perf_score"`
LimitType ExperimentLimitType `json:"limit_type"`
LimitValue int `json:"limit_value"`
ActualRuns int `json:"actual_runs"`
ActualSeconds float64 `json:"actual_seconds"`
}
// ExperimentTask represents a single unit of work to be executed by a worker.
type ExperimentTask struct {
FuzzerName string
CorpusName string
CorpusPath string
LimitType ExperimentLimitType
LimitValue int
Iteration int
TaskDir string
}
// FuzzerConfig contains the details of how a fuzzer should be built/run by the experimental framework
type FuzzerConfig struct {
mode FuzzMode
additionGnFlags map[string]string
}
var (
baseRequiredGnFlags = map[string]string{
"use_libfuzzer": "true",
"tint_build_wgsl_reader": "true",
"use_clang_coverage": "true",
"optimize_for_fuzzing": "false",
}
fuzzerConfigs = map[string]FuzzerConfig{
"tint_wgsl_fuzzer": {
mode: FuzzModeWgsl,
additionGnFlags: map[string]string{},
},
"tint_ir_fuzzer": {
mode: FuzzModeIr,
additionGnFlags: map[string]string{
"tint_build_ir_binary": "true",
"tint_has_protobuf": "true",
},
},
"tint_wgsl_mesa_fuzzer": {
mode: FuzzModeWgsl,
additionGnFlags: map[string]string{
"tint_build_mesa": "true",
"tint_build_fuzzer_vulkan_support": "true",
},
},
"tint_ir_mesa_fuzzer": {
mode: FuzzModeIr,
additionGnFlags: map[string]string{
"tint_build_ir_binary": "true",
"tint_has_protobuf": "true",
"tint_build_mesa": "true",
"tint_build_fuzzer_vulkan_support": "true",
},
},
}
)
// runExperiment is the entry point for the 'experiment' subcommand.
// It validates the environment, builds binaries if needed, and runs the task queue.
func runExperiment(t *taskConfig) error {
config, err := loadExperimentSettings(t, t.experimentPath)
if err != nil {
return err
}
binDir, err := buildExperimentBinariesIfNeeded(t, config)
if err != nil {
return err
}
machineResultsDir, err := generateResultsDirIfNeeded(t)
if err != nil {
return err
}
perfScores, needsBurnIn, err := runMicrobenchmarkIfNeeded(t, machineResultsDir, config)
if err != nil {
return err
}
tasks, err := calculateTasks(t, config, machineResultsDir)
if err != nil {
return err
}
pendingTasks := queueRemainingTasks(t, tasks)
if err = runPendingTasks(t, pendingTasks, config, perfScores, binDir, needsBurnIn); err != nil {
return err
}
fmt.Println("Experiment completed successfully!")
return nil
}
// loadExperimentSettings reads and parses the experiment.json file from the
// specified experiment root directory, performing both semantic validation
// and mechanical existence checks.
func loadExperimentSettings(t *taskConfig, experimentRoot string) (ExperimentSettings, error) {
if experimentRoot == "" {
return ExperimentSettings{}, fmt.Errorf("experiment root path is required")
}
configPath := filepath.Join(experimentRoot, kExperimentSettingsFile)
if !fileutils.IsFile(configPath, t.osWrapper) {
return ExperimentSettings{}, fmt.Errorf("experiment configuration file '%s' not found", configPath)
}
settingsBytes, err := t.osWrapper.ReadFile(configPath)
if err != nil {
return ExperimentSettings{}, fmt.Errorf("failed to read experiment config: %w", err)
}
var settings ExperimentSettings
if err := json.Unmarshal(settingsBytes, &settings); err != nil {
return ExperimentSettings{}, fmt.Errorf("failed to parse experiment config JSON: %w", err)
}
if len(settings.Fuzzers) < 1 {
return ExperimentSettings{}, fmt.Errorf("no fuzzers specified in 'fuzzers' list inside experiment.json")
}
activeModes := make(map[FuzzMode]bool)
for _, fuzzer := range settings.Fuzzers {
cfg, ok := fuzzerConfigs[fuzzer]
if !ok {
return ExperimentSettings{}, fmt.Errorf("unsupported fuzzer '%s'. Supported fuzzers are: %s", fuzzer, strings.Join(slices.Collect(maps.Keys(fuzzerConfigs)), ","))
}
activeModes[cfg.mode] = true
}
corporaDir := filepath.Join(experimentRoot, kExperimentCorporaSubDir)
for mode := range activeModes {
benchmarkCorpus := ""
var corpora []CorpusDef
switch mode {
case FuzzModeWgsl:
benchmarkCorpus = settings.WgslBenchmarkCorpus
corpora = settings.WgslCorpora
case FuzzModeIr:
benchmarkCorpus = settings.IrBenchmarkCorpus
corpora = settings.IrCorpora
}
if benchmarkCorpus == "" {
return ExperimentSettings{}, fmt.Errorf("%s_benchmark_corpus is required in experiment.json because %s fuzzers are specified", mode, strings.ToUpper(mode.String()))
}
if len(corpora) < 1 {
return ExperimentSettings{}, fmt.Errorf("at least one %s_corpora definition is required in experiment.json because %s fuzzers are specified", mode, strings.ToUpper(mode.String()))
}
bcPath := filepath.Join(corporaDir, benchmarkCorpus)
if !fileutils.IsDir(bcPath, t.osWrapper) {
return ExperimentSettings{}, fmt.Errorf("%s benchmark corpus directory '%s' not found under corpora root '%s'", mode, benchmarkCorpus, corporaDir)
}
for _, cDef := range corpora {
cPath := filepath.Join(corporaDir, cDef.Path)
if !fileutils.IsDir(cPath, t.osWrapper) {
return ExperimentSettings{}, fmt.Errorf("%s corpus directory '%s' not found under corpora root '%s'", mode, cDef.Path, corporaDir)
}
}
}
for i, dDef := range settings.Durations {
if dDef.Seconds != nil && dDef.Runs != nil {
return ExperimentSettings{}, fmt.Errorf("duration at index %d has both 'seconds' and 'runs' defined, which is not allowed", i)
}
if dDef.Seconds == nil && dDef.Runs == nil {
return ExperimentSettings{}, fmt.Errorf("duration at index %d must define either 'seconds' or 'runs'", i)
}
}
return settings, nil
}
// buildExperimentBinariesIfNeeded ensures that all required fuzzer binaries
// exist in the experiment's bin directory. If any are missing, it builds them.
// Before building, it confirms that the GN args are correct for building the
// fuzzer, and also warns the user if they have sanitizers enabled, since
// that can significantly skew results.
// Returns the binary directory as a string and nil if all binaries are present
// otherwise returns an error.
func buildExperimentBinariesIfNeeded(t *taskConfig, settings ExperimentSettings) (string, error) {
binDir := filepath.Join(t.experimentPath, kExperimentBinarySubDir)
missingBinaries := make([]string, 0, len(settings.Fuzzers))
for _, fuzzer := range settings.Fuzzers {
fPath := filepath.Join(binDir, fuzzer+fileutils.ExeExt)
if !fileutils.IsExe(fPath, t.osWrapper) {
missingBinaries = append(missingBinaries, fuzzer)
}
}
if len(missingBinaries) == 0 {
return binDir, nil
}
hasSanitizers, err := checkGnArgs(t, missingBinaries)
if err != nil {
return "", err
}
if hasSanitizers {
timeoutOverridden := settings.Timeout != nil && *settings.Timeout > 0
warnMsg := []string{
"",
"================================ WARNING ================================",
"Your build directory has compiler sanitizers (ASan, UBSan, TSan, or MSan) enabled.",
"Fuzzing with sanitizers will significantly increase the runtime per test case,",
"which will tend to dominate the results and possibly lead to false-positive timeouts.",
}
if !timeoutOverridden {
warnMsg = append(warnMsg,
"We highly recommend overriding the default 5s fuzzer timeout (by adding a \"timeout\"",
"field to your experiment.json) if you proceed with sanitizers.",
)
}
warnMsg = append(warnMsg,
"=========================================================================",
"",
)
fmt.Println(strings.Join(warnMsg, "\n"))
confirm, err := promptConfirm("Are you sure you want to proceed with sanitizers enabled?")
if err != nil {
return "", fmt.Errorf("failed to read sanitizer confirmation: %w", err)
}
if !confirm {
return "", fmt.Errorf("experiment execution aborted by user due to active sanitizers")
}
}
fmt.Println("It looks like we are running a new experiment and need to build the fuzzers.")
fmt.Println("Building will checkout commit ", settings.Hash, " and run gclient sync.")
confirm, err := promptConfirm("Do you want to proceed with the build?")
if err != nil {
return "", fmt.Errorf("failed to read build confirmation: %w", err)
}
if !confirm {
return "", fmt.Errorf("experiment execution aborted by user")
}
if err := prepareBinaries(t, &settings, binDir, missingBinaries); err != nil {
return "", err
}
return binDir, nil
}
// runMicrobenchmarkIfNeeded ensures that performance normalization scores (runs/sec)
// exist for all fuzzers in the experiment. It loads existing scores from
// perf_scores.json if they exist, and runs the microbenchmark for any missing fuzzers,
// saving the results back to the file.
// Returns the scores in a map and if whether burn-in is still needed, otherwise an error.
func runMicrobenchmarkIfNeeded(t *taskConfig, machineResultsDir string, settings ExperimentSettings) (map[string]float64, bool, error) {
perfScoresPath := filepath.Join(machineResultsDir, "perf_scores.json")
perfScores := make(map[string]float64)
if fileutils.IsFile(perfScoresPath, t.osWrapper) {
perfScoresBytes, err := t.osWrapper.ReadFile(perfScoresPath)
if err == nil {
_ = json.Unmarshal(perfScoresBytes, &perfScores)
}
}
burnInEnabled := true
if settings.BurnInEnabled != nil {
burnInEnabled = *settings.BurnInEnabled
}
needsBurnIn := burnInEnabled
for _, fuzzer := range settings.Fuzzers {
if _, ok := perfScores[fuzzer]; !ok {
if needsBurnIn {
if err := runBurnIn(t, &settings); err != nil {
return nil, false, err
}
needsBurnIn = false
}
fmt.Println("Running microbenchmark for", fuzzer, "...")
score, err := runMicrobenchmark(t, t.experimentPath, fuzzer, &settings)
if err != nil {
return nil, false, err
}
perfScores[fuzzer] = score
// Save immediately in case the top-level process gets halted
scoresBytes, _ := json.MarshalIndent(perfScores, "", " ")
_ = t.osWrapper.WriteFile(perfScoresPath, scoresBytes, 0644)
}
}
return perfScores, needsBurnIn, nil
}
// generateResultsDirIfNeeded ensures that the results directory for the current
// machine exists within the experiment path. It creates the directory structure
// 'results/<machine_name>' (defaulting to 'default' if no machine name is set)
// if doesn't already exist.
// Returns the path to the machine-specific result directory if successful,
// otherwise an error.
func generateResultsDirIfNeeded(t *taskConfig) (string, error) {
machine := t.machineName
if machine == "" {
machine = "default"
}
machineResultsDir := filepath.Join(t.experimentPath, "results", machine)
if err := t.osWrapper.MkdirAll(machineResultsDir, 0755); err != nil {
return "", fmt.Errorf("failed to create machine results directory '%s': %w", machineResultsDir, err)
}
return machineResultsDir, nil
}
// calculateTasks creates the full list of ExperimentTasks based on the
// experiment configuration. It iterates through all fuzzers and their
// corresponding corpora, producing tasks for each combination and
// duration.
// Returns a slice of ExperimentTask if successful, otherwise an error.
func calculateTasks(t *taskConfig, settings ExperimentSettings, machineResultsDir string) ([]ExperimentTask, error) {
corporaDir := filepath.Join(t.experimentPath, kExperimentCorporaSubDir)
var tasks []ExperimentTask
for _, fuzzer := range settings.Fuzzers {
var corpora []CorpusDef
cfg, _ := fuzzerConfigs[fuzzer]
switch cfg.mode {
case FuzzModeWgsl:
corpora = settings.WgslCorpora
case FuzzModeIr:
corpora = settings.IrCorpora
default:
return nil, fmt.Errorf("unknown fuzz mode %d", cfg.mode)
}
for _, corpus := range corpora {
fuzzerTasks, err := calculateTasksForFuzzer(fuzzer, corpus, corporaDir, machineResultsDir, &settings)
if err != nil {
return nil, err
}
tasks = append(tasks, fuzzerTasks...)
}
}
fmt.Println("Produced", len(tasks), "total tasks based on the experiment plan!")
return tasks, nil
}
// queueRemainingTasks filters the list of tasks to identify those that are
// pending or were interrupted, based on the presence and content of a
// state.json file in the task directory. It also ensures the task's corpus
// directory is prepared.
// Returns a slice of ExperimentTask containing only the remaining tasks to be run.
func queueRemainingTasks(t *taskConfig, tasks []ExperimentTask) []ExperimentTask {
// Queue up pending/interrupted tasks
var pendingTasks []ExperimentTask
for _, task := range tasks {
statePath := filepath.Join(task.TaskDir, kExperimentTaskStateFile)
if fileutils.IsFile(statePath, t.osWrapper) {
stateBytes, err := t.osWrapper.ReadFile(statePath)
if err == nil {
var state IterState
if err := json.Unmarshal(stateBytes, &state); err == nil && state.Status == IterStateCompleted {
continue // already completed, skip
}
}
}
// Reset/Prepare task folder
corpusDir := filepath.Join(task.TaskDir, kExperimentCorpusSubDir)
_ = t.osWrapper.RemoveAll(corpusDir)
_ = t.osWrapper.MkdirAll(corpusDir, 0755)
pendingTasks = append(pendingTasks, task)
}
fmt.Println("Found", len(pendingTasks), "pending/incomplete tasks!")
return pendingTasks
}
// runPendingTasks executes the provided list of experiment tasks using a parallel worker pool.
// It manages worker synchronization and context cancellation. It returns the first error
// encountered
func runPendingTasks(t *taskConfig, pendingTasks []ExperimentTask, settings ExperimentSettings, perfScores map[string]float64, binDir string, needsBurnIn bool) error {
if len(pendingTasks) == 0 {
return nil
}
if needsBurnIn {
if err := runBurnIn(t, &settings); err != nil {
return err
}
}
fmt.Println("Executing pending/incomplete tasks using", t.numProcesses, "parallel jobs...")
taskChan := make(chan ExperimentTask, len(pendingTasks))
for _, task := range pendingTasks {
taskChan <- task
}
close(taskChan)
var wg sync.WaitGroup
errChan := make(chan error, t.numProcesses)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
timeoutVal := 5
if settings.Timeout != nil && *settings.Timeout > 0 {
timeoutVal = *settings.Timeout
}
for i := 0; i < t.numProcesses; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for {
select {
case <-ctx.Done():
return
case task, ok := <-taskChan:
if !ok {
return
}
score := perfScores[task.FuzzerName]
if err := executeTask(ctx, t, binDir, task, score, timeoutVal); err != nil {
if !errors.Is(err, context.Canceled) && !errors.Is(err, context.DeadlineExceeded) {
errChan <- err
cancel()
}
return
}
}
}
}()
}
wg.Wait()
close(errChan)
if len(errChan) > 0 {
return <-errChan // return first error encountered
}
fmt.Println("Completed executing tasks!")
return nil
}
// GnValue holds the string value of a GN argument.
type GnValue struct {
Value string `json:"value"`
}
// GnArg represents a single GN argument as returned by 'gn args --json'.
type GnArg struct {
Name string `json:"name"`
Current *GnValue `json:"current,omitempty"`
Default *GnValue `json:"default,omitempty"`
}
// GetValue returns the effective value of a GN argument, stripping quotes.
func (g *GnArg) GetValue() string {
val := ""
if g.Current != nil {
val = g.Current.Value
} else if g.Default != nil {
val = g.Default.Value
}
if strings.HasPrefix(val, "\"") && strings.HasSuffix(val, "\"") {
val = strings.Trim(val, "\"")
}
return val
}
// promptConfirm displays a yes/no prompt to the user.
func promptConfirm(msg string) (bool, error) {
fmt.Printf("%s [y/N]: ", msg)
reader := bufio.NewReader(os.Stdin)
text, err := reader.ReadString('\n')
if err != nil {
return false, err
}
text = strings.TrimSpace(strings.ToLower(text))
return text == "y" || text == "yes", nil
}
// checkGnArgs verifies that the build directory has the required GN flags set for the fuzzers being tested.
// It also checks and returns true if any standard compiler sanitizers are enabled.
func checkGnArgs(t *taskConfig, fuzzers []string) (bool, error) {
out, err := t.runCmd("gn", "args", t.build, "--list", "--json")
if err != nil {
return false, fmt.Errorf("failed to query GN arguments for build directory '%s': %w", t.build, err)
}
var args []GnArg
if err := json.Unmarshal(out, &args); err != nil {
return false, fmt.Errorf("failed to parse GN arguments JSON: %w", err)
}
flagValues := make(map[string]string)
for _, arg := range args {
flagValues[arg.Name] = arg.GetValue()
}
requiredFlags := maps.Clone(baseRequiredGnFlags)
for _, fuzzer := range fuzzers {
cfg, _ := fuzzerConfigs[fuzzer]
maps.Copy(requiredFlags, cfg.additionGnFlags)
}
var incorrect []string
for flag, req := range requiredFlags {
val := flagValues[flag]
if val != req {
incorrect = append(incorrect, fmt.Sprintf("%s (expected: %s, actual: %s)", flag, req, val))
}
}
if len(incorrect) > 0 {
return false, fmt.Errorf("the build directory '%s' has incorrect/missing GN configuration flags:\n - %s\nPlease update your GN args and re-run", t.build, strings.Join(incorrect, "\n - "))
}
hasSanitizers := flagValues["is_asan"] == "true" ||
flagValues["is_ubsan"] == "true" ||
flagValues["is_tsan"] == "true" ||
flagValues["is_msan"] == "true"
return hasSanitizers, nil
}
// prepareBinaries checks out the specified git hash, syncs dependencies,
// builds the fuzzers, and copies them to the experiment's bin directory.
func prepareBinaries(t *taskConfig, settings *ExperimentSettings, binDir string, fuzzers []string) error {
return t.atGitHash(settings.Hash,
func() error {
if err := t.syncAndBuildTargets(fuzzers); err != nil {
return err
}
if err := t.osWrapper.MkdirAll(binDir, 0755); err != nil {
return fmt.Errorf("failed to create binary folder '%s': %w", binDir, err)
}
for _, fuzzer := range fuzzers {
srcPath := filepath.Join(t.build, fuzzer+fileutils.ExeExt)
dstPath := filepath.Join(binDir, fuzzer+fileutils.ExeExt)
if err := fileutils.CopyFile(dstPath, srcPath, t.osWrapper); err != nil {
return fmt.Errorf("failed to copy built binary %s to bin folder: %w", fuzzer, err)
}
}
files, err := t.osWrapper.ReadDir(t.build)
if err == nil {
for _, f := range files {
if !f.IsDir() {
ext := filepath.Ext(f.Name())
if ext == ".so" || ext == ".dylib" || ext == ".dll" {
srcLib := filepath.Join(t.build, f.Name())
dstLib := filepath.Join(binDir, f.Name())
_ = fileutils.CopyFile(dstLib, srcLib, t.osWrapper)
}
}
}
}
return nil
})
}
// runMicrobenchmark executes a short fuzzer run to determine the execution
// speed (runs/sec) on the current hardware for normalization of results.
func runMicrobenchmark(t *taskConfig, root string, fuzzer string, settings *ExperimentSettings) (float64, error) {
var corpusPath string
cfg, _ := fuzzerConfigs[fuzzer]
switch cfg.mode {
case FuzzModeWgsl:
corpusPath = filepath.Join(root, kExperimentCorporaSubDir, settings.WgslBenchmarkCorpus)
case FuzzModeIr:
corpusPath = filepath.Join(root, kExperimentCorporaSubDir, settings.IrBenchmarkCorpus)
default:
return 1.0, fmt.Errorf("unknown fuzz mode %d", cfg.mode)
}
binPath := filepath.Join(root, kExperimentBinarySubDir, fuzzer+fileutils.ExeExt)
timeoutVal := 5
if settings.Timeout != nil && *settings.Timeout > 0 {
timeoutVal = *settings.Timeout
}
benchmarkDuration := 60
if settings.BenchmarkDuration != nil && *settings.BenchmarkDuration > 0 {
benchmarkDuration = *settings.BenchmarkDuration
}
actualRuns, elapsed, err := runBenchmarkCmd(t, binPath, corpusPath, benchmarkDuration, timeoutVal)
if err != nil {
return 1.0, err
}
score := float64(actualRuns) / elapsed
if score <= 0 {
score = 1.0
}
fmt.Println(fmt.Sprintf("Microbenchmark result for %s: %d runs in %.2fs (%.2f runs/sec)", fuzzer, actualRuns, elapsed, score))
return score, nil
}
// runBenchmarkCmd runs a single fuzzer benchmark execution for the given duration (in seconds).
// It returns the number of runs executed, the actual elapsed time, and any error.
func runBenchmarkCmd(t *taskConfig, binPath string, corpusPath string, duration int, timeoutVal int) (int, float64, error) {
tmpOut, err := t.osWrapper.MkdirTemp("", "perf_out")
if err != nil {
return 0, 0, err
}
defer t.osWrapper.RemoveAll(tmpOut)
args := []string{tmpOut, corpusPath, fmt.Sprintf("-timeout=%d", timeoutVal), fmt.Sprintf("-max_total_time=%d", duration)}
start := time.Now()
out, err := t.runCmd(binPath, args...)
elapsed := time.Since(start).Seconds()
if err != nil {
// The fuzzer may exit with non-zero if interrupted by max_total_time or if it hit a crash report, which is
// fine, unless it exited really fast, since that means it is finding issues quickly, which means that the whole
// experimental apparatus is not going to perform as expected
if elapsed < 1.0 {
return 0, elapsed, fmt.Errorf("microbenchmark terminated too quickly: %w", err)
}
}
actualRuns, err := parseActualRuns(out)
if err != nil {
return 0, elapsed, err
}
return actualRuns, elapsed, nil
}
// runBurnIn runs the burn-in phase.
// It executes the micro benchmarks for each of the configured fuzzers in parallel matching t.numProcesses to attempt to
// get the physical hardware to a thermal steady state to avoid unexpected throttling mid-experiment.
func runBurnIn(t *taskConfig, settings *ExperimentSettings) error {
burnInDuration := 300
if settings.BurnInDuration != nil {
burnInDuration = *settings.BurnInDuration
}
fmt.Println("Starting burn-in phase for", burnInDuration, "seconds using", t.numProcesses, "parallel jobs...")
timeoutVal := 5
if settings.Timeout != nil && *settings.Timeout > 0 {
timeoutVal = *settings.Timeout
}
var wg sync.WaitGroup
errChan := make(chan error, t.numProcesses)
for i := 0; i < t.numProcesses; i++ {
fuzzer := settings.Fuzzers[i%len(settings.Fuzzers)]
var corpusPath string
cfg, _ := fuzzerConfigs[fuzzer]
switch cfg.mode {
case FuzzModeWgsl:
corpusPath = filepath.Join(t.experimentPath, kExperimentCorporaSubDir, settings.WgslBenchmarkCorpus)
case FuzzModeIr:
corpusPath = filepath.Join(t.experimentPath, kExperimentCorporaSubDir, settings.IrBenchmarkCorpus)
default:
return fmt.Errorf("unknown fuzz mode %d for fuzzer %s", cfg.mode, fuzzer)
}
binPath := filepath.Join(t.experimentPath, kExperimentBinarySubDir, fuzzer+fileutils.ExeExt)
wg.Add(1)
go func(workerID int, f string, cp string, bp string) {
defer wg.Done()
_, _, err := runBenchmarkCmd(t, bp, cp, burnInDuration, timeoutVal)
if err != nil {
errChan <- fmt.Errorf("burn-in worker %d failed: %w", workerID, err)
}
}(i, fuzzer, corpusPath, binPath)
}
wg.Wait()
close(errChan)
if len(errChan) > 0 {
return <-errChan
}
fmt.Println("Burn-in phase completed successfully!")
return nil
}
// calculateTasksForFuzzer creates the list of ExperimentTasks for a specific
// fuzzer and corpus combination based on the experiment durations.
func calculateTasksForFuzzer(fuzzer string, corpus CorpusDef, corporaDir string, machineDir string, settings *ExperimentSettings) ([]ExperimentTask, error) {
var tasks []ExperimentTask
cPath := filepath.Join(corporaDir, corpus.Path)
for _, dDef := range settings.Durations {
var limitType ExperimentLimitType
limitValue := 0
if dDef.Seconds != nil {
limitType = ExperimentLimitTypeSeconds
limitValue = *dDef.Seconds
} else {
limitType = ExperimentLimitTypeRuns
limitValue = *dDef.Runs
}
iterations := settings.DefaultIterations
if dDef.Iterations != nil {
iterations = *dDef.Iterations
}
limitStr := fmt.Sprintf("%s_%d", limitType, limitValue)
for i := 1; i <= iterations; i++ {
taskDir := filepath.Join(machineDir, fuzzer, corpus.Name, limitStr, fmt.Sprintf("iter_%d", i))
tasks = append(tasks, ExperimentTask{
FuzzerName: fuzzer,
CorpusName: corpus.Name,
CorpusPath: cPath,
LimitType: limitType,
LimitValue: limitValue,
Iteration: i,
TaskDir: taskDir,
})
}
}
return tasks, nil
}
// executeTask runs a single fuzzer iteration, manages its state file,
// and captures the output logs and performance data.
func executeTask(ctx context.Context, t *taskConfig, binDir string, task ExperimentTask, score float64, timeoutVal int) error {
statePath := filepath.Join(task.TaskDir, kExperimentTaskStateFile)
// Update state to running
state := IterState{
Status: IterStateRunning,
StartTime: time.Now().Format(time.RFC3339),
PerfScore: score,
LimitType: task.LimitType,
LimitValue: task.LimitValue,
}
stateBytes, _ := json.MarshalIndent(state, "", " ")
_ = t.osWrapper.WriteFile(statePath, stateBytes, 0644)
corpusOut := filepath.Join(task.TaskDir, kExperimentCorpusSubDir)
_ = t.osWrapper.MkdirAll(corpusOut, 0755)
absCorpusOut, err := filepath.Abs(corpusOut)
if err != nil {
return fmt.Errorf("failed to get absolute path for corpus out: %w", err)
}
absCorpusPath, err := filepath.Abs(task.CorpusPath)
if err != nil {
return fmt.Errorf("failed to get absolute path for corpus input: %w", err)
}
args := []string{absCorpusOut, absCorpusPath, fmt.Sprintf("-timeout=%d", timeoutVal)}
switch task.LimitType {
case ExperimentLimitTypeSeconds:
args = append(args, fmt.Sprintf("-max_total_time=%d", task.LimitValue))
case ExperimentLimitTypeRuns:
args = append(args, fmt.Sprintf("-runs=%d", task.LimitValue))
default:
return fmt.Errorf("unknown limit type %s", task.LimitType)
}
cfg, _ := fuzzerConfigs[task.FuzzerName]
// Add dictionary if running WGSL fuzzer
if cfg.mode == FuzzModeWgsl {
dictPath := filepath.Join(filepath.Join(fileutils.DawnRoot(t.osWrapper), "src", "tint", "cmd", "fuzz", "wgsl"), "dictionary.txt")
if fileutils.IsFile(dictPath, t.osWrapper) {
absDictPath, err := filepath.Abs(dictPath)
if err != nil {
return fmt.Errorf("failed to get absolute path for dictionary: %w", err)
}
args = append(args, "-dict="+absDictPath)
}
}
fmt.Println(fmt.Sprintf("[%s] Starting: %s on %s (%s=%d, iter %d)",
time.Now().Format("15:04:05"), task.FuzzerName, task.CorpusName, task.LimitType, task.LimitValue, task.Iteration))
start := time.Now()
// Run the binary directly from binDir, setting working directory to TaskDir so profraw files are not combined
// between tasks
binPath := filepath.Join(binDir, task.FuzzerName+fileutils.ExeExt)
absTaskBin, err := filepath.Abs(binPath)
if err != nil {
return fmt.Errorf("failed to get absolute path of fuzzer binary: %w", err)
}
var out []byte
for retry := 0; retry < 5; retry++ {
cmd := t.execWrapper.CommandContext(ctx, absTaskBin, args...).WithDir(task.TaskDir)
out, err = cmd.RunWithCombinedOutput()
if err == nil {
break
}
if strings.Contains(err.Error(), "text file busy") {
time.Sleep(100 * time.Millisecond)
continue
}
break
}
duration := time.Since(start)
if err != nil {
if ctx.Err() != nil {
return ctx.Err()
}
if t.verbose {
fmt.Println("Fuzzer process returned error/exit status:", err)
}
}
logPath := filepath.Join(task.TaskDir, "fuzzer.log")
_ = t.osWrapper.WriteFile(logPath, out, 0644)
actualRuns, err := parseActualRuns(out)
if err != nil {
return err
}
state.Status = IterStateCompleted
state.EndTime = time.Now().Format(time.RFC3339)
state.ActualRuns = actualRuns
state.ActualSeconds = duration.Seconds()
fmt.Println(fmt.Sprintf("[%s] Completed: %s on %s (iter %d) in %.2fs, %d runs",
time.Now().Format("15:04:05"), task.FuzzerName, task.CorpusName, task.Iteration, duration.Seconds(), actualRuns))
stateBytes, _ = json.MarshalIndent(state, "", " ")
_ = t.osWrapper.WriteFile(statePath, stateBytes, 0644)
return nil
}
// parseActualRuns extracts the number of fuzzer executions from the fuzzer output.
func parseActualRuns(output []byte) (int, error) {
// First, try to parse the 'stat::number_of_executed_units' line.
re := regexp.MustCompile(`stat::number_of_executed_units:\s*(\d+)`)
if match := re.FindStringSubmatch(string(output)); len(match) > 1 {
if val, err := strconv.Atoi(match[1]); err == nil {
return val, nil
}
}
// Fallback to parsing pulse lines like: #1234 PULSE
rePulse := regexp.MustCompile(`#(\d+)\s+`)
matches := rePulse.FindAllStringSubmatch(string(output), -1)
if len(matches) > 0 {
lastMatch := matches[len(matches)-1]
if val, err := strconv.Atoi(lastMatch[1]); err == nil {
return val, nil
}
}
return 0, fmt.Errorf("failed to parse actual runs from fuzzer output")
}