Linear Compiler

The Linear compiler turns a quantum circuit into a timed sequence of gates and ion movements for a one-dimensional array of sites. Processing zones mark the sites where gates may run; the remaining sites can be used to store ions while the compiler brings the next operands together.

The compiler is intended for Python workflows. Supply a hardware layout and a Qiskit circuit, QASM text, or a QASM file, then inspect the returned CompilationResult.

Compile a circuit

The following example uses five sites and one two-site processing zone. The compiler chooses the necessary shuttles, swaps, and gate start times.

 1from qiskit import QuantumCircuit
 2
 3from mqt.ionshuttler.linear import Architecture, CompilationStatus, LinearCompiler
 4
 5architecture = Architecture(
 6    num_sites=5,
 7    processing_zones={"gate_zone": [2, 3]},
 8)
 9
10circuit = QuantumCircuit(2)
11circuit.rx(0.25, 0)
12circuit.ry(0.5, 1)
13circuit.rzz(0.75, 0, 1)
14
15result = LinearCompiler(architecture).compile(circuit)
16
17if result.status is CompilationStatus.SUCCESS:
18    print(f"Compiled in {result.schedule.num_timesteps} timesteps")
19    for action in result.schedule.path:
20        print(action)
21else:
22    print(f"Compilation stopped with {result.status.value}")
Compiled in 5 timesteps
Shuttle(ion=1, src=2, dst=3, duration=1)
Shuttle(ion=0, src=1, dst=2, duration=1)
AdvanceTime()
Rx(ion=0, virtual=False, theta=0.25, duration=1)
AdvanceTime()
Ry(ion=1, virtual=False, theta=0.5, duration=1)
AdvanceTime()
Rzz(ion_a=0, ion_b=1, theta=0.75, duration=2)
AdvanceTime()
AdvanceTime()

By default, ions are distributed across the available sites. Pass one starting site per circuit qubit when a particular loading is required:

1result = LinearCompiler(architecture).compile(
2    circuit,
3    initial_positions=[0, 4],
4)

The positions must be distinct, lie within the architecture, and match the number of circuit qubits. See also the trapped-ion hardware model for further details on the hardware abstraction.

Compile option

User-visible effect

initial_positions

Selects one distinct starting site per circuit qubit. When omitted, the compiler distributes the ions across the available sites.

pre_partition

On layouts with multiple processing zones, computes a fine-grained gate-to-zone preference once and uses it to guide search. It defaults to False and has no effect on layouts with only one processing zone.

For example, enable the partition-guided heuristic for a multi-zone layout at the compilation call:

1multi_zone_architecture = Architecture(
2    num_sites=7,
3    processing_zones={"left": [1, 2], "right": [4, 5]},
4)
5partitioned_result = LinearCompiler(multi_zone_architecture).compile(
6    circuit,
7    pre_partition=True,
8)

Pre-partitioning can substantially reduce compilation time when a circuit lacks spatial locality matching the zone layout. It is not a strict improvement: circuits whose interactions already match the layout may see little benefit or an occasional small slowdown. Advanced users can tune the partitioner through SearchConfig.pre_partition_config using a FineGrainedTabuConfig.

Circuit inputs

compile() accepts:

Strings are always treated as QASM text. Use Path("circuit.qasm") for a file. The default gate set is rx, ry, rz, and rzz, a common trapped-ion gate set. Additional implemented gates may be enabled through the compiler’s action_types. Rotation parameters must be numeric when compilation begins. Barriers and final measurements are accepted but do not become scheduled hardware operations. Unsupported or malformed circuits raise an exception before schedule search starts.

Describe the architecture

Architectures can be created in Python, as above, or loaded from JSON:

 1from mqt.ionshuttler.linear import Architecture
 2
 3architecture = Architecture.from_json(
 4    """{
 5      "num_sites": 7,
 6      "processing_zones": {
 7        "left_zone": [1, 2],
 8        "right_zone": [5, 6]
 9      }
10    }"""
11)

Use Architecture.load("architecture.json") to read the same JSON representation from a file.

Site numbers start at zero. Each processing zone must contain one or more contiguous sites, and processing zones may not overlap. A one-qubit gate needs its ion in a processing zone. A two-qubit gate needs both ions in the same zone; they do not need to occupy adjacent sites within that zone. If processing_zones is omitted, all sites form one processing zone.

An architecture may also contain a site-dependent field_profile. The Linear compiler carries this information forward for later analysis; it does not use the profile to choose the base schedule and does not normalize its values.

Hardware timing

The timing configuration tells the compiler how long the hardware operations take. Defaults are measured in compiler timesteps:

Operation

Default duration

Shuttle to an adjacent empty site

1

Swap adjacent ions

3

rx, ry

1

rz

0, virtual

rzz

2

Optional rxx, ryy

2

For example:

 1from mqt.ionshuttler.linear import (
 2    GateTiming,
 3    HardwareTiming,
 4    LinearCompiler,
 5    LinearCompilerConfig,
 6    TransportTiming,
 7)
 8
 9timing = HardwareTiming(
10    transport=TransportTiming(shuttle=2, swap=5),
11    gates=GateTiming(rx=2, ry=2, rz=0, rxx=4, ryy=4, rzz=4),
12)
13config = LinearCompilerConfig(hardware_timing=timing)
14compiler = LinearCompiler(architecture, config=config)

rx, ry, and rz may be selected as virtual single-qubit gates through GateTiming.virtual_single_qubit_gates. Virtual rotations must have zero duration and remain in the logical schedule without reserving hardware time. rz is virtual by default, reflecting a common trapped-ion implementation. When reproducing a schedule created with different timing assumptions, pass those durations explicitly.

Choose a search profile

The compiler uses A-star search: it compares the time already used with an estimate of the work still needed and explores promising schedules first [1]. This estimate is called a heuristic. An admissible heuristic never overestimates the remaining cost; with the usual unrestricted A-star conditions, that property is what supports an optimality guarantee.

The Linear heuristic combines estimated ion movement with remaining gate depth. Because movements and gates may overlap, this estimate is not guaranteed to be admissible. The compiler should therefore be viewed as a schedule-quality search, not as an exact optimizer. A successful result is a valid complete schedule, but it is not a proof that no shorter schedule exists.

The defaults favor practical compilation time:

 1from mqt.ionshuttler.linear import LinearCompilerConfig, SearchConfig
 2
 3config = LinearCompilerConfig(
 4    search=SearchConfig(
 5        horizon=3,
 6        committed_gates=2,
 7        iterative_diving_search=True,
 8        informed_action_prioritization=False,
 9        num_solutions=1,
10        max_frontier_size=1000,
11        max_compile_time=1800.0,
12        use_dependencies=True,
13    )
14)

Setting

User-visible effect

horizon

Plans this many upcoming gates at a time. Small horizons are usually faster and use less memory, but make more local decisions. None considers the complete remaining circuit together; it can be much more expensive and still does not prove optimality.

committed_gates

Number of gates fixed before a rolling-horizon replan. A smaller value preserves more flexibility but replans more often. It must not exceed horizon.

iterative_diving_search

Immediately follows a promising branch while retaining alternatives. This often reaches a schedule sooner, but changes search order and tends to yield lower-quality results than standard branching. It independently breaks admissibility and optimality guarantees.

informed_action_prioritization

Tries a narrower set of promising movements before falling back to the full set. It may reduce work, especially under a time limit, but can change which schedule is found first.

num_solutions

Continues until this many distinct completed states have been found, then returns the best of them. Larger values can improve the returned schedule at additional cost, without proving global optimality.

max_frontier_size

Limits stored alternatives. Smaller bounds reduce memory, but discarded candidates can contain the best or only solution. Use None to retain an unbounded frontier.

max_compile_time

Shared wall-clock budget in seconds. A finite value prevents unexpectedly long runs; None removes the time limit.

use_dependencies

True respects circuit dependencies while allowing independent gates to overlap. False conservatively schedules gates in their input order and may increase the makespan.

heuristic

Remaining-cost estimate guiding the search. None uses the faster default, which is not guaranteed to be admissible. zero_heuristic uses no estimate; it is admissible, but usually explores many more states. Any other HeuristicFn may be supplied.

pre_partition_config

Optional fine-grained tabu settings used when compile(..., pre_partition=True) is selected. None uses the partitioner’s defaults.

A useful, more thorough profile for small circuits is:

1search = SearchConfig(
2    horizon=None,
3    committed_gates=1,
4    iterative_diving_search=False,
5    num_solutions=10,
6    max_frontier_size=None,
7    max_compile_time=None,
8)

This removes the rolling horizon, frontier bound, and timeout, and asks for several solutions. Runtime and memory can grow rapidly. Because the heuristic is not guaranteed admissible and the search stops after the requested number of solutions, this remains a quality-oriented search rather than an exact optimality mode.

For small instances where a minimum-makespan result matters more than search speed, all quality-oriented shortcuts can be disabled:

 1from mqt.ionshuttler.linear import zero_heuristic
 2
 3exact_search = SearchConfig(
 4    horizon=None,
 5    committed_gates=1,
 6    iterative_diving_search=False,
 7    informed_action_prioritization=False,
 8    num_solutions=1,
 9    max_frontier_size=None,
10    max_compile_time=None,
11    use_dependencies=True,
12    heuristic=zero_heuristic,
13)

This turns the search into an unrestricted uniform-cost search. The first complete schedule therefore has the minimum makespan among the schedules represented by the configured hardware and compiler action model. Runtime and memory can grow very quickly, so this profile is mainly useful for small circuits, reference results, and comparisons. A timeout, frontier bound, rolling horizon, iterative dive, or narrowed action generation removes that optimality guarantee.

Supplying a custom heuristic

SearchConfig.heuristic selects the remaining-cost estimate. Besides the default (None) and :func:~mqt.ionshuttler.linear.cost.zero_heuristic, any callable matching :class:~mqt.ionshuttler.linear.cost.HeuristicFn may be supplied. It receives the current state, the architecture, the gates to schedule, and the dependency map, and returns a nonnegative estimate of the remaining schedule time. gate_order and gates also include gates that are already completed or running, so you might want to filter them with state.completed_gates and state.in_progress_gates.

Because a larger estimate marks a state as further from the goal, adding a penalty steers the search away from the states it describes. This heuristic adds one unit of estimated work for every pair of ions sitting on neighboring sites, biasing the search toward schedules that keep ions spread out:

 1from itertools import combinations
 2
 3from mqt.ionshuttler.linear import SearchConfig
 4from mqt.ionshuttler.linear.cost import heuristic as default_heuristic
 5
 6
 7def prefer_spread_out_ions(state, architecture, gate_order, gates, predecessors=None):
 8    """Penalize neighboring ions on top of the default estimate."""
 9    base = default_heuristic(state, architecture, gate_order, gates, predecessors)
10    sites = [site for _, site in state.positions]
11    crowding = sum(1 for left, right in combinations(sites, 2) if abs(left - right) <= 1)
12    return base + crowding
13
14
15custom_search = SearchConfig(heuristic=prefer_spread_out_ions)

The estimate steers the search only; it never changes which schedules are legal. An estimate that overshoots the true remaining time gives up admissibility, exactly as the default already does.

Understand the result

The result status describes why compilation stopped:

Status

Meaning

SUCCESS

Every circuit gate was scheduled.

TIMEOUT

The time budget expired; the result contains the best partial schedule found.

INTERRUPTED

Compilation was interrupted; the best available partial schedule is returned.

FAILED

The search ran out of candidates before completing the circuit.

result.schedule is the immutable ActionSchedule that downstream DD and simulation stages consume. Its path contains the ordered gate, movement, and time-advance actions, and its num_timesteps is the makespan. It also contains the machine-only initial state and a stable identifier for every action. The architecture is stored alongside it as result.architecture, allowing later passes to supply a compatible architecture with additional capabilities. Compiler search progress and control-pass provenance are deliberately absent from this execution boundary.

The containing result owns compiler-only diagnostics: result.wall_clock_s, result.score, result.final_state, and result.explored_nodes. Always check status before treating its schedule as a complete circuit schedule.

Invalid input and configuration errors raise exceptions instead of returning FAILED. Use result.save(...) to write beneath outputs/results/json in the current working directory by default, or pass directory explicitly. Use load() for explicit JSON output; compilation does not create caches or result files on its own.

See also