User guide
Throughout this guide, \(N\) is the number of binary variables of the instance (equivalently, the number of Ising spins), so an exhaustive search enumerates all \(2^N\) configurations, and \(k\) is the number of variables the distributed sampler freezes to split the work.
Installation
The package is distributed as a source archive only: there are no wheels, because the CUDA extension is compiled against the toolkit present on the target machine. A working CUDA installation is therefore required at install time.
# Make the build deterministic on machines with several toolchains
export CUDAHOME=/usr/local/cuda
# Single-GPU sampler only
pip install omnisolver-bruteforce
# Include ray, required by the distributed sampler
pip install "omnisolver-bruteforce[distributed]"
If CUDAHOME is not set, the build tries to deduce it from the location of nvcc. That
heuristic is not reliable on systems carrying both a vendor HPC SDK and a system CUDA, so
set the variable explicitly. The build also adds -allow-unsupported-compiler through
NVCC_PREPEND_FLAGS, which relaxes the host-compiler version check.
Supported versions: Python 3.10 and 3.11; CUDA Toolkit 12.4 and 12.5 are exercised in continuous integration.
Single-GPU usage
import numpy as np
from dimod.serialization import coo
from omnisolver.bruteforce.gpu import BruteforceGPUSampler
with open("instance.txt") as fd:
bqm = coo.load(fd, vartype="SPIN")
result = BruteforceGPUSampler().sample(
bqm,
num_states=1,
suffix_size=23,
grid_size=8192,
block_size=1024,
num_steps_per_kernel=8192,
partial_diff_buffer_depth=10,
dtype=np.float32,
)
print(result.first.energy, result.info["solve_time_in_seconds"])
The same sampler is reachable from the command line, where Omnisolver registers it as
bruteforce-gpu:
omnisolver bruteforce-gpu --vartype SPIN --num_states 1 --suffix_size 23 instance.txt
Multi-GPU usage
Start Ray on the head and worker nodes, telling each how many GPUs it owns:
# Head node
ray stop --force
ray start --head --port=6379 --num-gpus=4
# Every worker node
ray stop --force
ray start --address='<HEAD_NODE_IP>:6379' --num-gpus=4
Then use the distributed sampler, whose only additional parameter is num_fixed_vars:
from omnisolver.bruteforce.gpu.distributed import DistributedBruteforceGPUSampler
result = DistributedBruteforceGPUSampler().sample(
bqm,
num_states=1,
num_fixed_vars=3, # 2 ** 3 = 8 independent subproblems
suffix_size=23,
grid_size=8192,
block_size=1024,
num_steps_per_kernel=8192,
partial_diff_buffer_depth=10,
)
Each subproblem is a Ray task requesting one GPU, so with fewer GPUs than subproblems Ray
simply queues them. This is worth remembering when benchmarking: the number of subproblems
is set by num_fixed_vars and is completely independent of how many devices are available.
Choosing the parameters
num_states
: Size of the low-energy spectrum to return. num_states=1 selects a separate, considerably
faster code path that tracks only the ground state, and is the right choice whenever a
certified optimum is all that is needed.
num_fixed_vars (\(k\))
: Number of frozen variables, giving \(2^k\) subproblems of \(N-k\) variables. Set it so that
every worker gets at least one subproblem — typically \(2^k\) equal to the number of GPUs.
Raising \(k\) by one halves the work per subproblem and doubles the number of results to
merge, so it should not be increased past the point where the merge starts to matter.
suffix_size
: Number of variables forming the resident enumeration chunk: \(2^{\texttt{suffix\_size}}\)
configurations are held in the GPU working set at a time and the search sweeps
\(2^{N-\texttt{suffix\_size}}\) such chunks. It is bounded by device memory and by the L2
budget; 23 is a good value on a 96 GB H100. Larger problems should be reached by raising
num_fixed_vars, not suffix_size.
grid_size, block_size
: CUDA launch geometry for the custom kernels. More resident threads generally means higher
throughput; 8192 blocks of 1024 threads works well on data-centre GPUs.
num_steps_per_kernel
: How many chunks a single kernel launch processes. Larger values amortize launch overhead;
the value also determines the granularity at which periodic re-anchoring can occur.
partial_diff_buffer_depth
: Depth of the incremental energy-difference buffers. Costs
\(\texttt{depth} \times 2^{\texttt{suffix\_size}}\) elements of device memory.
Size limits
The kernels keep each configuration in a single 64-bit word, so
BruteforceGPUSamplersupports \(N \le 64\);DistributedBruteforceGPUSamplersupports \(N - k \le 64\), i.e. \(N \le 64 + k\).
Both samplers validate this and raise ValueError rather than returning a silently
truncated answer.
Precision and numerical stability
The ground-state path accumulates energies incrementally, which over \(2^N\) updates can
accumulate roundoff. For dtype=np.float32 and kernels seeing at least 40 variables, the
backend therefore enables compensated updates, periodic exact re-anchoring of the energies
and a final refresh of the best-state buffer. This happens automatically and does not change
the API.
Two consequences worth knowing:
np.float64is a different numerical path, not merely a slower one — the stabilization applies to single precision only.- The returned configuration is an exact bit string. If a certified numerical value is needed, recompute the energy from the configuration in double precision:
best = result.first
exact_energy = bqm.energy(best.sample) # float64, from scratch
dtype accepts either NumPy types (np.float32, np.float64) or the names "float",
"float32", "single", "double", "float64". Note that "float" means single
precision, matching the --dtype choices of the CLI.
Reported timings
Both samplers put timings into result.info. The single-GPU sampler reports
solve_time_in_seconds, covering the CUDA search itself and excluding host-side assembly of
the QUBO matrix and decoding of the results. The distributed sampler additionally separates
the phases:
| Key | Meaning |
|---|---|
dispatch_time_in_seconds |
submitting all \(2^k\) subproblem tasks |
solve_time_in_seconds |
dispatch plus completion of every subproblem search |
merge_time_in_seconds |
combining the partial results and fetching the final one |
total_time_in_seconds |
the sum of the two above |
num_subproblems, num_merge_rounds |
shape of the merge hierarchy |
Partial results are combined by a hierarchy of Ray tasks rather than on the controller, so
the merge cost grows with the depth of the hierarchy, \(\log_{\texttt{merge\_batch\_size}}
2^k\), rather than linearly with the number of subproblems. Passing a merge_batch_size of at
least \(2^k\) recovers the single-shot merge used up to release 0.0.5, which is useful when
comparing against older measurements.