| layout | default |
|---|---|
| title | Switching Between Local and Azure Backends |
How to switch between local simulation and Azure Quantum execution
FSharp.Azure.Quantum provides a unified API through the IQuantumBackend interface (in FSharp.Azure.Quantum.Core.BackendAbstraction) that works with both:
- Local Simulator - Fast, free, offline simulation (width derived from available memory, up to 30 qubits)
- Azure Quantum - Cloud execution on simulators and real quantum hardware (requires an Azure subscription and a Quantum workspace)
Key Feature: Solvers and algorithms take an IQuantumBackend parameter, so switching backends is a one-line code change: construct a different backend and pass it in.
The library provides a unified backend abstraction that works with both local simulation and cloud quantum backends:
open System.Threading
open FSharp.Azure.Quantum.Quantum.QuantumTspSolver
open FSharp.Azure.Quantum.Backends
open FSharp.Azure.Quantum.Core.BackendAbstraction
// Define TSP problem (3 cities)
let distances = array2D [
[ 0.0; 1.0; 2.0 ]
[ 1.0; 0.0; 1.5 ]
[ 2.0; 1.5; 0.0 ]
]
/// Run the quantum TSP solver on any backend
let solveTsp (backend: IQuantumBackend) (distances: float[,]) (cancellationToken: CancellationToken) =
solveAsync backend distances defaultConfig cancellationToken
// Local simulator backend (width derived from available memory)
let localBackend = LocalBackendFactory.createUnified()
task {
match! solveTsp localBackend distances CancellationToken.None with
| Ok solution -> printfn "Tour: %A, Length: %.2f" solution.Tour solution.TourLength
| Error err -> printfn "Error: %s" err.Message
}
// Cloud backends (IonQ, Rigetti, ...) are created with CloudBackendFactory - see belowThat's it! Same solver API, different backends - just swap the backend creation.
/// Solve TSP problem with a chosen backend
let solveWithChosenBackend distances (cancellationToken: CancellationToken) =
// CHANGE THIS ONE LINE TO SWITCH BACKENDS:
let backend = LocalBackendFactory.createUnified() // ← Local simulation
// let backend = CloudBackends.CloudBackendFactory.createIonQ httpClient workspaceUrl "ionq.simulator" 1000
// Same solver API for all backends
solveTsp backend distances cancellationToken
// Use it
let distances2 = array2D [
[ 0.0; 1.0; 2.0 ]
[ 1.0; 0.0; 1.5 ]
[ 2.0; 1.5; 0.0 ]
]
task {
match! solveWithChosenBackend distances2 CancellationToken.None with
| Ok solution ->
printfn "Best tour: %A" solution.Tour
printfn "Tour length: %.2f" solution.TourLength
| Error err ->
eprintfn "Error: %s" err.Message
}The TSP solver returns the same QuantumTspSolution record whichever backend it ran on (abridged):
type QuantumTspSolution = {
/// Shortest tour among the measurements that are valid tours (city visit order, starting at city 0)
Tour: int array
/// Total tour length (distance), the return to the first city included
TourLength: float
/// Name of the backend that ran the circuits
BackendName: string
/// Number of measurement shots
NumShots: int
/// Wall-clock time in milliseconds
ElapsedMs: float
/// QAOA parameters (γ, β) of every layer of the final circuit
LayerParameters: (float * float)[]
/// The first layer's optimized (γ, β), when optimization ran
OptimizedParameters: (float * float) option
/// Whether the optimizer converged, when optimization ran
OptimizationConverged: bool option
/// Shots, valid tours among them (Valid) and shots that are the returned tour (Hits)
Sampling: FSharp.Azure.Quantum.Core.QaoaExecutionHelpers.SampleStatistics option
// ... plus BestEnergy, TopSolutions and OptimizationIterations
}A backend whose shots contain no valid tour (no measurement with every city in exactly one time slot) gives an Error that names the number of shots, not a tour.
This means:
- ✅ Analysis code works with any backend
- ✅ Logging and metrics are consistent
- ✅ Visualization tools are backend-agnostic
- ✅ Easy to compare local vs cloud results
For production applications, use configuration to control backend selection:
open System
open FSharp.Azure.Quantum.Core
open FSharp.Azure.Quantum.Backends.CloudBackends
module BackendConfig =
type Config =
| Local
| IonQ of workspaceUrl: string * target: string
let getBackend (config: Config) : IQuantumBackend =
match config with
| Local ->
LocalBackendFactory.createUnified()
| IonQ(workspaceUrl, target) ->
// DefaultAzureCredential: `az login`, environment variables or managed identity
let credential = Authentication.CredentialProviders.createDefaultCredential ()
let httpClient = Authentication.createAuthenticatedClient credential
CloudBackendFactory.createIonQ httpClient workspaceUrl target 1000
// Load config from environment
let fromEnvironment () =
let backendName = Environment.GetEnvironmentVariable "QUANTUM_BACKEND"
let workspaceUrl = Environment.GetEnvironmentVariable "AZURE_QUANTUM_WORKSPACE_URL"
match backendName with
| "ionq" when not (String.IsNullOrWhiteSpace workspaceUrl) -> IonQ(workspaceUrl, "ionq.simulator")
| _ -> Local // Default: local simulator
// Usage
let config = BackendConfig.fromEnvironment ()
let backend = BackendConfig.getBackend config
task {
match! solveTsp backend distances CancellationToken.None with
| Ok solution -> printfn "Solution: %A" solution
| Error err -> printfn "Error: %s" err.Message
}Set backend via environment variable:
# Local execution (default)
export QUANTUM_BACKEND=local
dotnet run
# IonQ simulator on Azure Quantum (run `az login` first)
export QUANTUM_BACKEND=ionq
export AZURE_QUANTUM_WORKSPACE_URL=https://<location>.quantum.azure.com/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Quantum/workspaces/<ws>
dotnet runThe same pattern holds for the other solvers and algorithms: each takes an IQuantumBackend (or an IQuantumBackend option, where None means the local simulator).
open FSharp.Azure.Quantum
// MaxCut with an explicit backend; pass None to use the local simulator
let square =
MaxCut.createProblem
[ "A"; "B"; "C"; "D" ]
[ ("A", "B", 1.0); ("B", "C", 1.0); ("C", "D", 1.0); ("D", "A", 1.0) ]
task {
match! MaxCut.solveAsync square (Some localBackend) CancellationToken.None with
| Ok solution ->
printfn "Cut value: %.1f" solution.CutValue
printfn "Partition S: %A" solution.PartitionS
| Error err ->
eprintfn "Error: %s" err.Message
}This API provides:
- ✅ Unified backend abstraction (local and cloud)
- ✅ Unified result format per solver
- ✅ Error handling with Result type
- ✅ Easy backend switching (one-line change)
| Scenario | Local | Azure |
|---|---|---|
| Development | ✅ Instant feedback | ❌ Network latency |
| Unit Testing | ✅ Fast, reliable | ❌ Slow, costs money |
| Small problems (within simulator width) | ✅ Free, fast | ❌ Overkill |
| Offline work | ✅ No internet needed | ❌ Requires connection |
| Algorithm prototyping | ✅ Rapid iteration | ❌ Slower iteration |
| Scenario | Local | Azure |
|---|---|---|
| Large problems (beyond simulator width) | ❌ Not supported | ✅ Scales further |
| Real hardware noise | NoisyLocalBackend) |
✅ Actual device behaviour |
| Hardware results | ❌ Simulation only | ✅ IonQ, Rigetti, Quantinuum, etc. |
| Feature | Local Simulator | Azure Quantum |
|---|---|---|
| Qubit limit | memory-derived, ≤30 (2ⁿ × 16 bytes); solvers run up to 20 by default | Depends on the target device |
| Cost | Free | Pay per shot / job |
| Network | Offline capable | Requires internet |
| Speed (3 cities) | <100ms | Seconds to minutes (network + queue) |
| Use cases | Development, testing, small problems | Production, large problems, research |
| Hardware access | ❌ Simulation only | ✅ IonQ, Rigetti, etc. |
Some backends have a maximum number of qubits they can handle. The IQubitLimitedBackend interface provides a non-breaking, opt-in extension to IQuantumBackend that lets backends advertise their capacity. Its definition in BackendAbstraction:
/// Optional interface for backends that have qubit limits.
/// Inherits from IQuantumBackend — existing backends are unaffected.
type IQubitLimitedBackend =
inherit IQuantumBackend
/// Maximum number of qubits this backend supports, or None if unlimited.
abstract MaxQubits : int optionKey design points:
- ✅ Non-breaking — backends that don't implement it continue to work unchanged
- ✅ Optional — callers use a type-test pattern to check at runtime
- ✅
LocalBackendimplements it withMaxQubits = Some StateVector.maxQubits, which is derived from available memory (at least 20, at most 30; override with theFSAQ_MAX_QUBITSenvironment variable)
A second optional interface, IWallClockLimitedBackend, reports PracticalQubits: the widest circuit worth running, as opposed to the widest state that fits in memory. LocalBackend reports StateVector.practicalCircuitQubits (20 by default; override with FSAQ_MAX_CIRCUIT_QUBITS).
Use standard F# pattern matching to check whether a backend reports a qubit limit:
open FSharp.Azure.Quantum.Backends
open FSharp.Azure.Quantum.Core.BackendAbstraction
let limitedBackend = LocalBackendFactory.createUnified()
// Pattern match to discover qubit limits
let maxQubits =
match limitedBackend with
| :? IQubitLimitedBackend as lb -> lb.MaxQubits
| _ -> None
match maxQubits with
| Some n -> printfn "Backend supports up to %d qubits" n
| None -> printfn "Backend does not report a qubit limit"For convenience, UnifiedBackend exposes helpers that wrap the pattern-match logic above:
UnifiedBackend.getMaxQubits backend- theIQubitLimitedBackendcapacity, orNoneUnifiedBackend.getRunnableQubits backend- the smaller of capacity andPracticalQubits, orNonewhen the backend reports neither. Use this one to admit or refuse a problem.
let capacity = UnifiedBackend.getMaxQubits limitedBackend
// Some n for LocalBackend, where 20 <= n <= 30 depending on available memory
let runnable = UnifiedBackend.getRunnableQubits limitedBackend
// Some 20 for LocalBackend with default settingsSolvers check UnifiedBackend.getRunnableQubits before running:
- TSP needs N² qubits for N cities. If that exceeds the backend's runnable width,
solveAsyncreturns aValidationErrorthat names the problem size and the limit; it does not split the problem. On the local simulator with default settings that means at most 4 cities. - Vertex cover, clique and matching use
ProblemDecomposition.solveWithDecomposition: when the problem is wider than the limit and the graph has several connected components, each component is solved separately on the same backend and the results are merged. A single component that is too wide is still run as a whole. - Set cover, SAT, bin packing and binary ILP go through the same decomposition entry point, but they do not split problems yet.
open FSharp.Azure.Quantum.Quantum
// Two separate triangles: two connected components
let twoTriangles : QuantumVertexCoverSolver.Problem =
{ Vertices = [ for i in 0 .. 5 -> { Id = $"v{i}"; Weight = 1.0 } ]
Edges = [ (0, 1); (1, 2); (2, 0); (3, 4); (4, 5); (5, 3) ] }
// If the graph were wider than the backend's runnable width, each triangle
// would be solved on its own and the covers combined.
let coverConfig = { QuantumVertexCoverSolver.defaultConfig with FinalShots = 1000 }
task {
match! QuantumVertexCoverSolver.solveWithConfigAsync localBackend twoTriangles coverConfig CancellationToken.None with
| Ok solution -> printfn "Cover: %A (valid: %b)" (solution.CoverVertices |> List.map _.Id) solution.IsValid
| Error err -> eprintfn "Error: %s" err.Message
}All backends support Task-based async execution with CancellationToken support. This is especially important for cloud backends where network I/O introduces latency.
The interface members, from BackendAbstraction:
| Member | Signature |
|---|---|
ExecuteToState |
ICircuit -> Result<QuantumState, QuantumError> |
ApplyOperation |
QuantumOperation -> QuantumState -> Result<QuantumState, QuantumError> |
ExecuteToStateAsync |
ICircuit -> CancellationToken -> Task<Result<QuantumState, QuantumError>> |
ApplyOperationAsync |
QuantumOperation -> QuantumState -> CancellationToken -> Task<Result<QuantumState, QuantumError>> |
Name |
string |
NativeStateType |
QuantumStateType |
SupportsOperation |
QuantumOperation -> bool |
InitializeState |
int -> Result<QuantumState, QuantumError> |
ICircuit is the circuit interface in Core.CircuitAbstraction; wrap a CircuitBuilder.Circuit with CircuitAbstraction.wrapCircuit. Local backends complete these tasks synchronously; cloud backends submit a job and poll until it finishes.
open System
open System.Threading
open System.Threading.Tasks
open FSharp.Azure.Quantum
open FSharp.Azure.Quantum.Core.CircuitAbstraction
// A 2-qubit Bell circuit, wrapped as an ICircuit
let bell =
CircuitBuilder.empty 2
|> CircuitBuilder.addGate (CircuitBuilder.H 0)
|> CircuitBuilder.addGate (CircuitBuilder.CNOT(0, 1))
|> wrapCircuit
// Use task { } computation expression for async workflows
let executeAsync (backend: IQuantumBackend) (circuit: ICircuit) (ct: CancellationToken) =
task {
let! result = backend.ExecuteToStateAsync circuit ct
match result with
| Ok state -> return Ok state
| Error err -> return Error err
}
// Run with cancellation support
let cts = new CancellationTokenSource(TimeSpan.FromSeconds(30.0))
let result =
executeAsync localBackend bell cts.Token
|> Async.AwaitTask |> Async.RunSynchronouslyCloud backends benefit most from async since they involve HTTP calls, job submission, and polling:
open FSharp.Azure.Quantum.Core
// Create an authenticated cloud backend via the factory
let workspaceUrl = "https://<location>.quantum.azure.com/subscriptions/<sub>/resourceGroups/<rg>/providers/Microsoft.Quantum/workspaces/<ws>"
let credential = Authentication.CredentialProviders.createDefaultCredential ()
let httpClient = Authentication.createAuthenticatedClient credential
let ionqBackend = CloudBackendFactory.createIonQ httpClient workspaceUrl "ionq.simulator" 1000
// Async execution avoids blocking threads during cloud I/O
let executeOnCloud (circuit: ICircuit) (ct: CancellationToken) =
task {
let! result = ionqBackend.ExecuteToStateAsync circuit ct
return result
}Cloud backends turn the returned measurement histogram into an approximate state, so read results with Primitives.sample, which returns the job's own counts, rather than relying on amplitudes. Such a state carries the job's recorded counts: UnifiedBackend.measureState on it returns the job's own shots, drawn without replacement and never more than the job measured, so the array can be shorter than the number requested; UnifiedBackend.recordedShots returns all of them.
Run multiple circuits concurrently using Task.WhenAll:
let executeParallel (backend: IQuantumBackend) (circuits: ICircuit list) (ct: CancellationToken) =
task {
let tasks =
circuits
|> List.map (fun c -> backend.ExecuteToStateAsync c ct)
|> Array.ofList
let! results = Task.WhenAll(tasks)
return results |> Array.toList
}Primitives.sampleBatchAsync and Primitives.observeBatchAsync do the same for CircuitBuilder circuits and return histograms or expectation values.
The UnifiedBackend module provides higher-level async utilities. They apply operations one at a time, so they need a backend that accepts incremental ApplyOperation (the local simulators and the topological backend); a cloud backend returns an Error for them, and takes the same gates as one circuit through ExecuteToStateAsync or UnifiedBackend.submitAsCircuit:
open FSharp.Azure.Quantum.Core
// Apply a sequence of operations asynchronously, starting from |00⟩
let applyBellAsync (backend: IQuantumBackend) (ct: CancellationToken) =
task {
match backend.InitializeState 2 with
| Error err -> return Error err
| Ok initialState ->
let operations =
[ QuantumOperation.Gate(CircuitBuilder.H 0)
QuantumOperation.Gate(CircuitBuilder.CNOT(0, 1)) ]
return! UnifiedBackend.applySequenceAsync backend operations initialState ct
}
// Apply one operation, converting the state to the backend's native representation if needed
let applyXAsync (backend: IQuantumBackend) (state: QuantumState) (ct: CancellationToken) =
UnifiedBackend.applyWithConversionAsync backend (QuantumOperation.Gate(CircuitBuilder.X 0)) state ctCreate cloud backends for different quantum hardware providers. Each factory function takes an authenticated HttpClient, the workspace URL, a target name and a shot count:
// Rigetti (superconducting qubits)
let rigetti = CloudBackendFactory.createRigetti httpClient workspaceUrl "rigetti.sim.qvm" 1000
// IonQ (trapped ions)
let ionq = CloudBackendFactory.createIonQ httpClient workspaceUrl "ionq.simulator" 1000
// Quantinuum (trapped ions)
let quantinuum = CloudBackendFactory.createQuantinuum httpClient workspaceUrl "quantinuum.sim.h1-1sc" 1000
// Atom Computing (neutral atoms)
let atomComputing = CloudBackendFactory.createAtomComputing httpClient workspaceUrl "atom-computing.sim" 1000
// IQM (superconducting qubits)
let iqm = CloudBackendFactory.createIqm httpClient workspaceUrl "iqm.sim" 1000Hardware targets follow the same pattern, for example "ionq.qpu.aria-1", "rigetti.qpu.ankaa-3", "quantinuum.qpu.h1-1", "atom-computing.qpu.phoenix" or "iqm.qpu.garnet"; check your workspace for the targets it offers. CloudBackendFactory.createRigettiRouted additionally takes a device coupling map and inserts SWAP gates so two-qubit gates respect the hardware connectivity.
All cloud backends implement the same IQuantumBackend interface (sync and async), so they are interchangeable with LocalBackend.
What differs on cloud backends:
- Whole circuits only. They refuse incremental
ApplyOperationand claim no algorithm intent (QFT, QPE, Grover…), so algorithms build the complete gate circuit and submit it withExecuteToState. Before conversion each backend transpiles the circuit to its provider's gates (GateTranspiler.transpileForBackendFully): T/TDG, CP, CRZ, CCX, MCZ and the other composite gates are decomposed, and the Braket backend does the same by device ARN. - Measured shots, not amplitudes. They implement
IShotSamplingBackend: the returned state holds √(count/shots) with no phases.Primitives.observetherefore measures each qubit-wise commuting group of Pauli terms in its own rotated basis (Primitives.sampledExpectation, one job per group, with a standard error), ADAPT-VQE and ADAPT-QAOA switch to measured energies with parameter-shift gradients, andPrimitives.samplereturns the backend's own counts (the requested shot count must equal the backend's).QRNG.generateWithBackendAsyncneeds a backend created withshots = 1: its bits are that one measured shot. Algorithms that measure the returned state (UnifiedBackend.measureState) get the job's own recorded shots, never resampled ones and never more than the job measured. Protocols made of many independent trials (BB84 transmissions, E91 pairs, teleportation tomography) run their trials side by side in circuits as wide as the backend runs (at most 16 qubits,WholeCircuit.runTrials) and use every shot of every job. - Every
ExecuteToStateis a separately billed job. Iterative algorithms submit many (one per energy, gradient term or sample; a 3-city TSP by QAOA is several hundred). Pass aJobBudgetto cap them; the job after the limit is refused with aQuotaExceedederror before it is submitted. A budget can be shared by several backends, and every cloud backend exposes its budget throughIJobCountingBackend, including how many jobs it has submitted. Without one, jobs are counted but not limited. - The qubit limit is a default. Each backend reports the width of its target as it was when this version was released, and solvers refuse a wider problem before submitting. Pass
maxQubitsto the constructor to replace the figure when a device has grown:CloudBackends.IonQCloudBackend(httpClient, workspaceUrl, "ionq.qpu.forte-1", maxQubits = 64).
open FSharp.Azure.Quantum.Backends
let budget = CloudBackendHelpers.JobBudget.Limit 200
let limitedIonQ =
CloudBackends.IonQCloudBackend(httpClient, workspaceUrl, "ionq.simulator", 1000, jobBudget = budget)
// ... run an algorithm on limitedIonQ ...
printfn "Jobs submitted: %d of %A" budget.Submitted budget.MaxJobsCurrent Implementation:
- ✅ Local simulation: Fully functional (memory-derived width; TSP up to 4 cities with default settings)
- ✅ Unified API: The same solver calls work with every backend (sync and async)
- ✅ Async support: Task-based async with CancellationToken on all backends
- ✅ Cloud backends: Rigetti, IonQ, Quantinuum, Atom Computing and IQM via
CloudBackends.CloudBackendFactory - ✅ Algorithms on cloud: QAOA solvers, chemistry VQE and QPE, Grover and its builders, amplitude amplification, QFT, QPE, Shor, HHL (magnitudes; signs and phases with
HHL.executeWithRelativePhases, which HHL regression uses), arithmetic, ADAPT-VQE/ADAPT-QAOA, QML, quantum Monte Carlo andPrimitivessubmit whole circuits; aJobBudgetcaps the billed jobs ⚠️ Cloud integration: Requires Azure Quantum workspace configuration and credentials
Key Achievement: Backend switching is a one-line code change - no refactoring needed!
// Local simulation:
let chosenBackend = LocalBackendFactory.createUnified()
// Everything else stays the same!
task {
match! solveTsp chosenBackend distances CancellationToken.None with
| Ok solution -> printfn "Solution: %A" solution
| Error err -> eprintfn "Error: %s" err.Message
}Benefits:
- ✅ Write once, run anywhere (local or cloud): the solvers and algorithms pick the whole-circuit route on a cloud backend themselves. Only code that continues from a returned state with
ApplyOperationneeds a simulator - ✅ Test locally without Azure credentials
- ✅ No code changes needed to switch backends
- ✅ Same result format for analysis/visualization
- ✅ Async execution for non-blocking cloud I/O
- Local Simulation Guide - Complete local simulator documentation
- Getting Started Guide - Installation and first steps with backends
- API Reference - Full API documentation
Last Updated: 2026-09-30
Status: Current - Local and cloud backends supported with sync and async APIs