Skip to article frontmatterSkip to article content
Site not loading correctly?

This may be due to an incorrect BASE_URL configuration. See the MyST Documentation for reference.

Benchmarking (Julia)

Maintained by the JuliaQUBO organization
SECQUOIA  ·  PSR Energy

Open In Colab

Setup

Google Colab

Click the badge above to open this notebook in Colab. The notebook installs or activates dependencies in the setup cells below.

Local installation

Run the following from the repository root before opening this notebook locally:

julia --project=notebooks_jl -e 'using Pkg; Pkg.instantiate()'

Run the command from the repository root before opening the Julia notebook locally.

Colab Instructions

If not in a Colab notebook, continue to the next section.

  1. Work on a copy of this notebook: File > Save a copy in Drive.

  2. Make sure the runtime is set to Julia. If Colab opens a Python runtime, use Runtime > Change runtime type and select Julia.

  3. Execute the following setup cell to clone the repository when needed, activate the shared notebook project, and install dependencies. The first Colab run can take several minutes.

Notebook Cell

Activate Environment

Notebook Cell

Learning objectives

By the end of this notebook you will be able to:

  1. Define solver, instance, schedule, and hyperparameter ensembles for benchmarking experiments.

  2. Compute and interpret time-to-solution, success probability, and performance-ratio summaries.

  3. Use bootstrap summaries to compare solver settings across repeated samples and instance ensembles.

  4. Explain the difference between default, tuned, ensemble-selected, and virtual-best parameter choices.

Prerequisites

Mathematical background: QUBO/Ising models, probability, medians, and confidence intervals.
Prior notebooks: Notebooks 2-4, especially QUBO construction and simulated/quantum annealing outputs.
Accounts required: None for the bundled cache; regenerating hardware data may require solver access.
Julia version: Julia 1.10+ with the notebook project and benchmarking dependencies instantiated.

About this notebook

A solver is the combination of hardware, algorithm, software, parameter settings, and resource limits used to solve an optimization problem. No configuration is best for every problem family or computational budget.

Tuning every parameter on the target instance consumes the same time, memory, or energy budget that the final solve is meant to conserve, and it can overfit one instance. A more realistic workflow tunes configurations offline on a representative family of training instances, then evaluates the selected configuration on held-out or previously unseen instances.

This notebook develops metrics and experiments for comparing solver configurations across repeated samples and related problem instances.

Benchmarking example

For illustration purposes, we will use an example that you are already familiar with, which is an Ising model. As a solver, we will use the DWave SA simulated annealing code.

Ising model

An Ising model represents a collection of binary spin variables si∈{−1,+1}s_i \in \{-1, +1\} with pairwise couplings and optional local fields. The objective, or energy, assigns lower values to spin configurations that better satisfy those couplings, so solving the model means finding a minimum-energy spin assignment. These models are a common benchmark for simulated annealing because the same problem family can be generated at many sizes and with different coupling distributions. We use JuMP and QUBO.jl to define Ising models and solve them with simulated annealing.

Problem statement

We pose the Ising problem as the following optimization problem:

min⁡s∈{±1}nH(s)=min⁡s∈{±1}n∑(i,j)∈E(G)Ji,jsisj+∑i∈V(G)hisi+β\min_{s \in \{ \pm 1 \}^n} H(s) = \min_{s \in \{ \pm 1 \}^n} \sum_{(i, j) \in E(G)} J_{i,j}s_is_j + \sum_{i \in V(G)} h_is_i + \beta

where we optimize over spins s∈{±1}ns \in \{ \pm 1 \}^n, on a constrained graph G(V,E)G(V,E), where the quadratic coefficients are Ji,jJ_{i,j} and the linear coefficients are hih_i. We also include an arbitrary offset of the Ising model β\beta.

Example 1

Suppose we have an Ising model defined from

h=[145.0122.0122.0266.0266.0266.0242.5266.0386.5387.0386.5],J=[00024242424242424240002402424242424240000240242424242400002448242448484800000242448484848000000242448484800000002448484800000000484848000000000727200000000007200000000000] and β=1319.5h = \begin{bmatrix} 145.0 \\ 122.0 \\ 122.0 \\ 266.0 \\ 266.0 \\ 266.0 \\ 242.5 \\ 266.0 \\ 386.5 \\ 387.0 \\ 386.5 \end{bmatrix}, J = \begin{bmatrix} 0 & 0 & 0 & 24 & 24 & 24 & 24 & 24 & 24 & 24 & 24\\ 0 & 0 & 0 & 24 & 0 & 24 & 24 & 24 & 24 & 24 & 24\\ 0 & 0 & 0 & 0 & 24 & 0 & 24 & 24 & 24 & 24 & 24\\ 0 & 0 & 0 & 0 & 24 & 48 & 24 & 24 & 48 & 48 & 48\\ 0 & 0 & 0 & 0 & 0 & 24 & 24 & 48 & 48 & 48 & 48\\ 0 & 0 & 0 & 0 & 0 & 0 & 24 & 24 & 48 & 48 & 48\\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 24 & 48 & 48 & 48\\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 48 & 48 & 48\\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 72 & 72\\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 72\\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0\\ \end{bmatrix} \text{ and } \beta = 1319.5
Min 24 s_var[4]*s_var[1] + 24 s_var[4]*s_var[2] + 24 s_var[5]*s_var[1] + 24 s_var[5]*s_var[3] + 24 s_var[5]*s_var[4] + 24 s_var[6]*s_var[1] + 24 s_var[6]*s_var[2] + 48 s_var[6]*s_var[4] + 24 s_var[6]*s_var[5] + 24 s_var[7]*s_var[2] + 24 s_var[7]*s_var[3] + 24 s_var[7]*s_var[4] + 24 s_var[7]*s_var[5] + 24 s_var[7]*s_var[6] + 24 s_var[8]*s_var[1] + 24 s_var[8]*s_var[3] + 24 s_var[8]*s_var[4] + 48 s_var[8]*s_var[5] + 24 s_var[8]*s_var[6] + 24 s_var[8]*s_var[7] + 24 s_var[9]*s_var[1] + 24 s_var[9]*s_var[2] + 24 s_var[9]*s_var[3] + 48 s_var[9]*s_var[4] + 48 s_var[9]*s_var[5] + 48 s_var[9]*s_var[6] + 48 s_var[9]*s_var[7] + 48 s_var[9]*s_var[8] + 24 s_var[10]*s_var[1] + 24 s_var[10]*s_var[2] + 24 s_var[10]*s_var[3] + 48 s_var[10]*s_var[4] + 48 s_var[10]*s_var[5] + 48 s_var[10]*s_var[6] + 48 s_var[10]*s_var[7] + 48 s_var[10]*s_var[8] + 72 s_var[10]*s_var[9] + 24 s_var[11]*s_var[1] + 24 s_var[11]*s_var[2] + 24 s_var[11]*s_var[3] + 48 s_var[11]*s_var[4] + 48 s_var[11]*s_var[5] + 48 s_var[11]*s_var[6] + 48 s_var[11]*s_var[7] + 48 s_var[11]*s_var[8] + 72 s_var[11]*s_var[9] + 72 s_var[11]*s_var[10] + 145 s_var[1] + 122 s_var[2] + 122 s_var[3] + 266 s_var[4] + 266 s_var[5] + 266 s_var[6] + 242.5 s_var[7] + 266 s_var[8] + 386.5 s_var[9] + 387 s_var[10] + 386.5 s_var[11] + 1319.5
Subject to
 
s_var[1] spin
 s_var[2] spin
 s_var[3] spin
 s_var[4] spin
 s_var[5] spin
 s_var[6] spin
 s_var[7] spin
 s_var[8] spin
 s_var[9] spin
 s_var[10] spin
 s_var[11] spin

We can visualize the graph that defines this instance using the J\mathbf{J} matrix as the adjacency matrix of a graph.

Image produced in Jupyter

Since the problem is relatively small (11 variables, 211=20482^{11} = 2048 combinations), we can afford to enumerate all the solutions.

Enumeration took 0.414555883 seconds
=== ENUMERATION RESULTS ===
Minimum energy: 5.0

Total number of unique solutions: 2048
Total number of solutions (with multiplicity): 2048
Expected: 2048 solutions for 2^11 combinations
Minimum energy: 5.0
Image produced in Jupyter
Minimum energy: 5.0
Image produced in Jupyter

Simulated Annealing (SA)

SA is an optimization algorithm that works by navigating the input space and evaluating the objective function (energy) similarly to hill climbing. The main difference is that SA implements a strategy to avoid getting stuck in local optima.

The general idea is to allow moves to a worse position, but in a structured way. When a candidate solution is selected, the probability of actually moving to the new position is determined by the change in the objective function and by a metric called temperature. When the temperature is high, the chance of performing a jump to a worse state increases, and the opposite happens when the temperature is low. By continuously decreasing the temperature during the execution of the algorithm, it can escape local optima during the first stages (high temperature), but it settles into a position towards the end of the execution (low temperature).

Probability of Making a Jump

Case 1:

ΔE=Ecurrent−Enext>0\Delta E = E_{current} - E_{next} > 0

It is always advantageous to move to a state with a better objective function. In this case, the probability is P=1P = 1.

Case 2:

ΔE=Ecurrent−Enext<0\Delta E = E_{current} - E_{next} < 0

In this case, the probability of taking the step is determined by:

P=e−∣ΔE∣/TP = e^{-|\Delta E| / T}

Note: This formulation applies to a minimization problem. To solve a maximization problem, the only change is that ΔE=Enext−Ecurrent\Delta E = E_{next} - E_{current}.

Temperature Decrease

The way the temperature decreases during the algorithm’s execution varies between implementations. This is referred to as the temperature schedule, which is a function of time or the number of iterations.

Note: β=1/T\beta = 1 / T is also commonly used in the implementation of the algorithm.

solution_summary(; result = 1, verbose = false)
├ solver_name          : D-Wave Neal Simulated Annealing Sampler
├ Termination
│ ├ termination_status : LOCALLY_SOLVED
│ ├ result_count       : 12
│ └ raw_status         : locally_solved
├ Solution (result = 1)
│ ├ primal_status        : FEASIBLE_POINT
│ ├ dual_status          : NO_SOLUTION
│ ├ objective_value      : 5.00000e+00
│ └ dual_objective_value : 1.31950e+03
└ Work counters
  └ solve_time (sec)   : 6.43423e-01
* s = [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, 1]
=== SIMULATED ANNEALING RESULTS ===
Minimum energy: 5.0
Total number of unique solutions: 12
Total number of solutions (with multiplicity): 1000
Expected: ~1000 reads from SA, but likely fewer unique solutions than enumeration
Minimum energy: 5.0
Image produced in Jupyter
Minimum energy: 5.0
Image produced in Jupyter

We are going to use the default limits of temperature given by the simulating annealing code. These are defined using the minimum and maximum nonzero coefficients in the Ising model. Then the range for beta is defined as

β∈[log⁡(2)max⁡{ΔE},log⁡(100)min⁡{ΔE}]\beta \in \left[ \frac{\log(2)}{\max \{ \Delta E \} },\frac{\log(100)}{\min \{ \Delta E \} } \right]

where

ΔE=min⁡i{hi}+∑jJij+Jji\Delta E = \min_{i} \{h_i \} + \sum_j J_{ij}+J_{ji}

Hot temperature: We want to scale hot_beta so that for the most unlikely qubit flip, we get at least 50% chance of flipping. (This means all other qubits will have > 50% chance of flipping initially). Most unlikely flip is when we go from a very low energy state to a high energy state, thus we calculate hot_beta based on max_delta_energy.

0.50=exp⁡(−β‾∗max⁡{ΔE})0.50 = \exp(-\overline{\beta} * \max \{ \Delta E \})

Cold temperature: Towards the end of the annealing schedule, we want to minimize the chance of flipping. Don’t want to be stuck between small energy tweaks. Hence, set cold_beta so that at minimum energy change, the chance of flipping is set to 1%.

0.01=exp⁡(−β‾∗min⁡{ΔE})0.01 = \exp(-\underline{\beta} * \min \{ \Delta E \})

By default, the schedule also follows a geometric series.

geomspace (generic function with 1 method)
(2.0, 100.0)
Image produced in Jupyter
Dict{Symbol, Any} with 3 entries: :p => Dict{String, Any}("geometric"=>[0.626, 0.558, 0.424, 0.394, 0.375, 0.… :ttt => Dict{String, Any}("geometric"=>[0.0358034, 0.0481117, 0.0650856, 0.07… :t => Dict{String, Any}("geometric"=>[0.00764632, 0.00852967, 0.00779652, 0…
Image produced in Jupyter

These plots represent often contradictory metrics: on one hand you would like to obtain a large probability of finding a right solution (the definition of right comes from what you define as success). On the other hand, the time it takes to solve these cases should be as small as possible. In this notebook, time means QUBOTools.total_time(sol): the full user-observed solution gathering time. QUBOTools.effective_time(sol) is useful for algorithm-only comparisons that exclude access, precompilation, and other overhead, but it is not the timing convention used below.

This is why we are interested in a metric that combines both, and that is why we settle on the Time To Solution (TTS) which is defined as

TTS=tlog⁡1−slog⁡1−pTTS = t\frac{\log{1-s}}{\log{1-p}}

where tt is the total user-observed time, ss is a success factor, usually taken as s=99%s = 99\%, and pp is the success probability, usually accounted as the observed success probability.

One usually reads this as the time to solution within 99%99\% probability.

Image produced in Jupyter

As you can notice, the default parameters given by D-Wave (number of sweeps = 1000 and a geometric update of β\beta) are not optimal for our tiny example in terms of expected total runtime. This is certainly a function of the problem, for such a small instance having two sweeps are more than enough and more sweeps are an overkill. This parameters choice might not generalize to any other problem, as seen below.

Benchmarking example 2

Let’s define a larger model, with 100 variables and random weights, to see how this performance changes.

Assume that we are interested in an instance with random weights hi,Ji,j∼U[−1,+1]h_{i}, J_{i, j} \sim U[-1, +1].

Image produced in Jupyter

For a problem of this size we cannot do a complete enumeration (2100≈1.2e302^{100} \approx 1.2e30) but we can randomly sample the distribution of energies to have a baseline for our later comparisons.

A JuMP Model ├ solver: none ├ objective_sense: MIN_SENSE │ └ objective_function_type: QuadExpr ├ num_variables: 100 ├ num_constraints: 100 │ └ VariableRef in QUBOTools_MOI.Spin: 100 └ Names registered in the model └ :s
Average random energy = -0.7062486495361374
Minimum energy: -133.4415555415882
Image produced in Jupyter
Simulated Annealing best energy = -408.47197451530656
Minimum energy: -408.47197451530656
Image produced in Jupyter
Minimum energy: -408.47197451530656
Image produced in Jupyter

Notice that the minimum energy coming from the random sampling and the one from the simulated annealing are very different. Moreover, the distributions that both lead to are extremely different too.

Image produced in Jupyter

We can solve this problem using IP such that we have guarantees that it is solved to optimality (this might be a great quiz for future lectures), but in this case let us define the “success” as getting an objective certain percentage of the best found solution in all cases (which we see it might not be even found with the default parameters). To get a scaled version of this success equivalent for all instances, we will define this success with respect to the metric:

found−randomminimum−random\frac{\textrm{found} - \textrm{random}}{\textrm{minimum} - \textrm{random}}

Where found\textrm{found} corresponds to the best found solution within our sampling, random\textrm{random} is the mean of the random sampling shown above, and minimum\textrm{minimum} corresponds to the best found solution to our problem during the exploration. Consider that this minimum might not be the global minimum. The best possible performance ratio is 1, attained at the best-known value; a negative ratio means the method performs worse than random sampling. Success is now counted as being within a specified threshold of this value of 1. We will refer to this quality of solution metric as Performance Ratio.

Before figuring out if we have the right optimal parameters, we want to save some effort by loading previously computed results. If you do not want to load the results that we are providing, feel free to change the overwrite_pickles variable, at the expense that it will take some time (around 3 minutes per instance) to run. If you do not want to wait, drop the results.zip file in the folder that is about to be created.

┌ Warning: Results directory '<notebook-directory>/results' does not exist. We will create it.
└ @ Main In[36]:4
"<notebook-directory>/results"

Put the file in there and we will decompress it for you.

[ Info: Results zip file has been extracted to '<notebook-directory>/results'

Now either we have the pickled file or not, let us compute the statistics we are looking for.

After gathering all the results, we would like to see the progress of the Performance Ratio with respect to the increasing number of sweeps. To account for the stochasticity of this method, we are bootstrapping all of our results with different values of the bootstrapping sample, and each confidence interval corresponds to a standard deviation away from the mean.

Now, besides looking at the sweeps, which are our parameter, we want to see how the performance changes with respect to the number of shots, which in this case are proportional to the computational time/effort that it takes to solve the problem.

Image produced in Jupyter
Image produced in Jupyter

Here see how using the optimal number of sweeps is better than using other values (including the default recommended by the solver) in terms of solving this problem. Obviously, we only know this after running the experiments and verifying it ourselves. This is not the usual case, so we want to see how well can we do if we solve similar (but no the same instances). Here we will generate 20 random instances from the same distribution and size but different random seed.

Starting multi-instance analysis...
Loading multi-instance results from <notebook-directory>/results/all_results.json...
Multi-instance analysis script finished.
tsplotboot!

Now we bootstrap our solutions across the full set of instances, or ensemble, and use the median because it is less sensitive to outliers than the mean.

Image produced in Jupyter

Notice how much performance would we be losing if we had used the default value for all these instances, and how much we could eventually win if we knew the best for each.

This example shows that for the benchmark instance, using the mean of the best accross the ensemble is better than using the default, but not as good as if we knew from scratch what would have made the best case.

After figuring out what would be the best parameter for our instance of interest, it would be nice to see what the ensemble performance is. We have several choices, either going with the (arbitrary) default values, or using the mean of the best performance we have found up to that point. There is an unachievable goal, which would be the case where we knew the best solution of each case, which we call the virtual best. This helps us understand how much is at stake with the choice of parameters we make.

Processing instance 0...
Processing instance 1...
Processing instance 2...
Processing instance 3...
Processing instance 4...
Processing instance 5...
Processing instance 6...
Processing instance 7...
Processing instance 8...
Processing instance 9...
Processing instance 10...
Processing instance 11...
Processing instance 12...
Processing instance 13...
Processing instance 14...
Processing instance 15...
Processing instance 16...
Processing instance 17...
Processing instance 18...
Processing instance 19...
Plotting ensemble data...
Image produced in Jupyter

As you can see, there is a gap between the case with the best mean performance and the virtual best. This difference is arguably small, but you can imagine that with a larger number of parameters, this difference can become larger and larger, making the search of good parameters more worthy and complicated. We are actively working on such parameter setting strategies, and expect to make progress in this area (keep tuned!).

Practice checkpoints

Use these checkpoints during the workshop to test the main ideas before moving on.

Notebook Cell
solver_results = new_sampler: best_energy=-12.6, time=2.1; baseline_sa: best_energy=-12.4, time=1.8
Notebook Cell
target_confidence = 0.99; tts_reads = 2900
Notebook Cell
performance_ratio = 0.75

Summary

In this notebook we:

  • Treated a solver as a combination of hardware, algorithm, software, and hyperparameters to make comparisons reproducible.

  • Loaded or generated benchmark samples and converted raw sampling results into success probabilities and time-to-solution metrics.

  • Used bootstrap summaries and performance ratios to compare schedules, sweeps, default choices, and virtual-best behavior.

  • Interpreted how tuning on one instance or ensemble can improve results while still leaving a gap to per-instance best choices.

Learning objectives met: You practiced setting up benchmark ensembles, computing TTS and performance ratios, using bootstrap summaries, and interpreting scaling and tuning tradeoffs.

Next steps: Proceed to Python Notebook 6: QCi if you want to compare these benchmarking ideas with a QCi solver workflow.

Further reading:

Acknowledgments

This notebook was developed by:

References
  1. Rønnow, T. F., Wang, Z., Job, J., Boixo, S., Isakov, S. V., Wecker, D., Martinis, J. M., Lidar, D. A., & Troyer, M. (2014). Defining and detecting quantum speedup. Science, 345(6195), 420–424. 10.1126/science.1252319