| // 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") | |
| } |