Skip to the content.

Switching Between Local and Azure Backends

How to switch between local simulation and Azure Quantum execution

Overview

FSharp.Azure.Quantum provides a unified API through the IQuantumBackend interface (in FSharp.Azure.Quantum.Core.BackendAbstraction) that works with both:

  1. Local Simulator - Fast, free, offline simulation (width derived from available memory, up to 30 qubits)
  2. 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 Unified API

Current Implementation

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 below

That’s it! Same solver API, different backends - just swap the backend creation.

Simple Backend Switching

Example: Switching with a Single Line

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

Uniform Result Format

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:

Configuration-Based Switching

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 run

Unified High-Level API

The 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:

Comparison: Local vs Azure

When to Use Local Simulator

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

When to Use Azure Quantum

Scenario Local Azure
Large problems (beyond simulator width) ❌ Not supported ✅ Scales further
Real hardware noise ⚠️ Only a simple depolarizing model (NoisyLocalBackend) ✅ Actual device behaviour
Hardware results ❌ Simulation only ✅ IonQ, Rigetti, Quantinuum, etc.

Feature Comparison

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.

Qubit Limit Awareness with IQubitLimitedBackend

The IQubitLimitedBackend Interface

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 option

Key design points:

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).

Querying Backend Limits

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"

UnifiedBackend Helpers

For convenience, UnifiedBackend exposes helpers that wrap the pattern-match logic above:

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 settings

What Solvers Do With the Limit

Solvers check UnifiedBackend.getRunnableQubits before running:

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
}

Async Backend Execution

All backends support Task-based async execution with CancellationToken support. This is especially important for cloud backends where network I/O introduces latency.

IQuantumBackend Async Interface

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.

Async Usage Example

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.RunSynchronously

Async with Cloud Backends

Cloud 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.

Parallel Async Execution

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.

UnifiedBackend Async Helpers

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 ct

Cloud Backend Factory

Create 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" 1000

Hardware 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:

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.MaxJobs

Summary

Current Implementation:

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:

Next Steps


Last Updated: 2026-09-30
Status: Current - Local and cloud backends supported with sync and async APIs