Testing IOPS's BO search against random sampling using cached HPC results


IOPS can use Bayesian Optimization when the objective is to find the parameter combination that yields the maximum (or minimum) metric value in a search space. In this article, we decided to test the efficiency of IOPS’s BO search by comparing it against IOPS’s random search. The idea is simple: given a search space, run both IOPS BO and Random, then compare the results to see which one reaches the optimal first. And of course, it should not be a surprise at this point that we used an I/O benchmark to do it.

So the study was: find the best parameter combination that maximizes I/O bandwidth performance. We started writing a script to run these tests, but at some point we realized that this was just another PARAMETER EXPLORATION. We ran iops generate and started editing a YAML file to make IOPS run itself, like in the movie Inception.

In this inception-style study, we demonstrate the effectiveness of BO when running I/O benchmarks, but also showcase a bunch of interesting IOPS features. First, we use IOPS’s capability of running benchmarks entirely from cached results. Using the --cache-only flag, we replay thousands of HPC experiments offline, enabling rapid comparison without consuming compute resources.

This was possible because of the cache feature implemented in IOPS. The cache was generated during another execution campaign that will be published here soon (when the paper gets accepted). It contains 1,049 execution results (304 unique configurations × 3 repetitions) from IOR benchmarks run on the IRENE supercomputer (TGCC, France), covering a 5-dimensional parameter space:

ParameterValuesDescription
nodes1, 32, 64Compute nodes
processes_per_node1, 64, 128MPI ranks per node
ost_count1, 4, 8, 16, 24, 32, 40Lustre OST count
transfer_size_kb32, 1024, 8192, 32768, 65536I/O transfer size
volume_size_gb128Fixed data volume

After constraint filtering, the parameter space contains 315 valid configurations. Each optimization run explores only 20 of them, just 6.3% of the space.


Study Design

We compared three search methods across 25 random seeds. The idea was to study the variability of each method, since both random sampling and Bayesian optimization have stochastic components:

MethodSearch StrategyConfiguration
randomRandom sampling20 random configurations
bayesian_defaultBayesian (EI+RF)5 initial + 15 guided iterations
bayesian_tunedBayesian (EI+ET)Extra Trees estimator

Each execution spawns a nested IOPS run that queries the shared cache:

command:
  template: "iops run {{ inner_config }} --use-cache --cache-only"

The --cache-only flag ensures all results come from the pre-populated cache, so no actual benchmarks are executed. This let us run 75 complete optimization studies (3 methods × 25 seeds) in under 5 minutes on a laptop. Pretty cool, right?

How the Nested Execution Works

The script template generates YAML configurations dynamically using embedded Python, then invokes IOPS with that configuration. The parser analyzes each run’s CSV output to extract the best bandwidth found, number of iterations, and unique configurations explored (see the full configuration below for more details).


Results

Performance Comparison

The boxplot below shows the best bandwidth found by each method across 25 seeds:

Performance Boxplot

The optimal configuration achieves 72.4 GB/s. Bayesian optimization finds configurations within 10% of optimal in most runs, while random sampling shows higher variance and generally lower performance.

Search Effectiveness

Percentage of Optimal

Bayesian optimization achieves a 10 percentage point improvement over random sampling while exploring only 6% of the parameter space. The Extra Trees (ET) estimator provides a slight edge over Random Forest (RF) for this workload.

Convergence

Convergence Curves

The convergence plot shows how each method progresses over iterations. The shaded regions represent standard deviation across seeds. Bayesian methods start with 5 random points (the exploration phase), then use the surrogate model to guide the search. Both Bayesian variants converge faster and reach higher performance than random sampling.


Considerations

This study has a fundamental limitation: with only 315 valid configurations, the problem is relatively modest, which increases the probability of finding good configurations by chance.

More importantly, the nodes parameter dominates performance in this workload. Since it has only 3 values (1, 32, 64), random sampling has a 33% chance of selecting the optimal node count on each iteration. The figure below illustrates how each method selects node counts over iterations:

Node Selection Distribution

The y-axis shows the proportion of seeds (out of 25) that selected each node value at a given iteration. For example, if 20 out of 25 seeds selected nodes=64 at iteration 15, the green bar would show 0.8 (80%) at that point.

The Bayesian methods quickly learn that higher node counts yield better performance and concentrate their search accordingly. By iteration 10, nearly all seeds are selecting nodes=64. Random sampling, by contrast, distributes selections uniformly across all node values throughout the run.

Despite these limitations, the study shows that Bayesian optimization provides consistent improvements over random sampling, and that IOPS’s cache-only mode enables rapid algorithmic experimentation without HPC resources. A follow-up study with a larger parameter space would likely show an even bigger gap between the methods.


Full Configuration

Here’s the complete YAML configuration we used. One tricky part: since we’re generating a nested IOPS config inside a script template, we need to handle two levels of Jinja2 rendering. The outer variables (like {{ method }} and {{ seed }}) get rendered by the parent IOPS run, while the inner config variables (like {{ block_size_mb }}) are written as literal strings to the generated YAML file and only get rendered when the inner IOPS runs. Luckily, Jinja2 already has easy ways to do this by using {% raw %}...{% endraw %}, which prevents the outer level from handling the templates.

benchmark:
  name: "Bayesian vs Random Optimization Comparison"
  workdir: "./"
  executor: "local"
  search_method: "exhaustive"
  repetitions: 1
  track_executions: true

vars:
  method:
    type: str
    sweep:
      mode: list
      values: ["random", "bayesian_default", "bayesian_tuned"]

  seed:
    type: int
    sweep:
      mode: list
      values: [1992, 2023, 42, 123, 456, 789, 1000, 2000, 3000, 4000,
                5000, 6000, 7000, 8000, 9000, 10000, 11111, 22222, 33333, 44444,
                55555, 66666, 77777, 88888, 99999]

  actual_search_method:
    type: str
    expr: "{% if method == 'random' %}random{% else %}bayesian{% endif %}"

  base_estimator:
    type: str
    expr: "{% if method == 'bayesian_tuned' %}ET{% else %}RF{% endif %}"

  inner_workdir:
    type: str
    expr: "{{ execution_dir }}/inner_run"

  inner_config:
    type: str
    expr: "{{ execution_dir }}/inner_config.yaml"
  


constraints: 
  - name: "require_cache_env"                                                                                                          
    rule: "os_env.get('IOPS_CACHE', '') != ''"                                                                                         
    violation_policy: "error"                                                                                                          
    description: "IOPS_CACHE environment variable must be set" 


command:
  template: "~/.venvs/iops_env/bin/iops run {{ inner_config }} --use-cache --cache-only --log-level WARNING"

  labels:
    study_type: "optimization_comparison"


scripts:
  - name: "run_inner_iops"
    submit: "bash"
    script_template: |
      #!/bin/bash
      set -e      

  
      # Usings {% raw %}...{% endraw %} to prevent outer Jinja2 from evaluating inner templates
      ~/.venvs/iops_env/bin/python3 << 'PYTHON_EOF'
      import yaml
      import os
      from pathlib import Path

      cache_file = os.environ.get('IOPS_CACHE', './cache.db')

      config = {
          "benchmark": {
              "name": "{{ method }} seed {{ seed }}",
              "workdir": "{{ inner_workdir }}",
              "cache_file": cache_file,
              "repetitions": 3,
              "search_method": "{{ actual_search_method }}",
              "executor": "local",
              "random_seed": {{ seed }},
              "cache_exclude_vars": ["summary_file"],
              "track_executions": True,
          },
          "vars": {
              "nodes": {
                  "type": "int",
                  "sweep": {"mode": "list", "values": [1, 32, 64]}
              },
              "processes_per_node": {
                  "type": "int",
                  "sweep": {"mode": "list", "values": [1, 64, 128]}
              },
              "volume_size_gb": {
                  "type": "int",
                  "sweep": {"mode": "list", "values": [128]}
              },
              "ost_count": {
                  "type": "int",
                  "sweep": {"mode": "list", "values": [1, 4, 8, 16, 24, 32, 40]}
              },
              "transfer_size_kb": {
                  "type": "int",
                  "sweep": {"mode": "list", "values": [32, 1024, 8192, 32768, 65536]}
              },
              "ost_path": {
                  "type": "str",
                  "expr": {% raw %}"/ccc/scratch/cont003/gen15872/gouveial/ost_{{ ost_count }}"{% endraw %}
              },
              "ntasks": {
                  "type": "int",
                  "expr": {% raw %}"{{ nodes * processes_per_node }}"{% endraw %}
              },
              "block_size_mb": {
                  "type": "int",
                  "expr": "(volume_size_gb * 1024) // ntasks"
              },
              "cores_per_tasks": {
                  "type": "int",
                  "expr": {% raw %}"{{ 128 // processes_per_node }}"{% endraw %}
              },
              "summary_file": {
                  "type": "str",
                  "expr": {% raw %}"{{ execution_dir }}/summary_{{ execution_id }}_{{ repetition }}.json"{% endraw %}
              },
          },
          "constraints": [
              {
                  "name": "block_transfer_alignment",
                  "rule": "(block_size_mb * 1024) % transfer_size_kb == 0",
                  "violation_policy": "skip",
                  "description": "Block size must be a multiple of transfer size"
              },
              {
                  "name": "transfer_size_limit",
                  "rule": "transfer_size_kb <= (block_size_mb * 1024)",
                  "violation_policy": "skip"
              }
          ],
          "command": {
              "template": {% raw %}"ior -w -b {{ block_size_mb }}mb -t {{ transfer_size_kb }}kb -O summaryFile={{ summary_file }} -O summaryFormat=JSON -o {{ ost_path }}/output.ior"{% endraw %},
              "metadata": {
                  "operation": "write",
                  "filestrategy": "shared-file",
                  "spatiality": "contig"
              }
          },
          "scripts": [
              {
                  "name": "ior",
                  "submit": "bash",
                  "script_template": "#!/bin/bash\necho \"Placeholder - using cached results\"",
                  "parser": {
                      "file": {% raw %}"{{ summary_file }}"{% endraw %},
                      "metrics": [{"name": "bwMiB"}],
                      "parser_script": "def parse(file_path):\n    return {\"bwMiB\": 0}"
                  }
              }
          ],
          "output": {
              "sink": {
                  "type": "csv",
                  "path": {% raw %}"{{ workdir }}/results.csv"{% endraw %},
              }
          }
      }

      if "{{ actual_search_method }}" == "bayesian":
          config["benchmark"]["bayesian_config"] = {
              "objective_metric": "bwMiB",
              "objective": "maximize",
              "n_initial_points": 5,
              "n_iterations": 20,
              "acquisition_func": "EI",
              "base_estimator": "{{ base_estimator }}",
              "xi": 0.01,
              "fallback_to_exhaustive": False,
              "early_stop_on_convergence": False,
              "convergence_patience": 5,
              "xi_boost_factor": 5.0,
          }
      else:
          config["benchmark"]["random_config"] = {
              "n_samples": 20
          }

      config_path = Path("{{ inner_config }}")
      config_path.parent.mkdir(parents=True, exist_ok=True)
      with open(config_path, "w") as f:
          yaml.dump(config, f, default_flow_style=False, sort_keys=False)

      print(f"Generated config: {config_path}")
      PYTHON_EOF

      mkdir -p "{{ inner_workdir }}"

      echo "Running: {{ command.template }}"
      {{ command.template }} 2>&1 | tee "{{ execution_dir }}/inner_iops.log"
      inner_exit_code=${PIPESTATUS[0]}

      if [ $inner_exit_code -ne 0 ]; then
          echo "ERROR: Inner IOPS failed with exit code $inner_exit_code"
          echo "See {{ execution_dir }}/inner_iops.log for details"
          exit 1
      fi

      echo "Inner IOPS completed successfully"

    parser:
      file: "{{ inner_workdir }}/run_001/results.csv"
      metrics:
        - name: best_bw
        - name: iterations
        - name: unique_configs
        - name: final_bw
      parser_script: |
        import pandas as pd
        from pathlib import Path

        def parse(file_path):
            df = pd.read_csv(file_path)

            best_bw = df['metrics.bwMiB'].max()

            param_cols = ['vars.nodes', 'vars.processes_per_node', 'vars.ost_count', 'vars.transfer_size_kb']
            unique_configs = len(df.groupby(param_cols))

            config_groups = df.groupby(param_cols, sort=False)
            last_config = list(config_groups.groups.keys())[-1]
            final_bw = config_groups.get_group(last_config)['metrics.bwMiB'].max()

            iterations = unique_configs

            return {
                "best_bw": best_bw,
                "iterations": iterations,
                "unique_configs": unique_configs,
                "final_bw": final_bw
            }        


output:
  sink:
    type: csv
    path: "./results.csv"

  • Search Methods - Bayesian optimization, random sampling, and exhaustive search
  • Caching - Skip redundant tests with --use-cache and --cache-only
  • CLI Reference - All command-line options for iops run, iops generate, etc.
  • Templating and Context - Jinja2 syntax in scripts and configurations
  • Matrix Generation - How IOPS builds parameter combinations and applies constraints