Devices and placement

CUDA-Q Logical separates the machines that most stacks blur into one “backend” object. The separation is a firewall: each stage sees only its own structure, and a program can never branch on a later-stage cost fact it should not know.

Machines, devices, architectures

  • A logical machine (@cudaq.logical.machine) is the P1 structure contract: regions, resource streams, capabilities, and plain integer capacities. No codes, no error rates:

    A logical machine with one capable region (examples/standalone/01_logical_placement.py).
    @cql.machine
    class TwoSlotMachine:
        compute = cql.architecture.region(
            capabilities=(
                cql.architecture.capability.logical_compute,
                cql.architecture.capability.logical_measurement,
            ),
            capacity=2,
        )
    
    
    

    Capabilities are typed keys in an open cudaq.logical.machine vocabulary; the compiler itself interprets logical_compute, logical_measurement, logical_factory, and resource_transfer.

  • A QEC machine carries the P2 refinement: encoded block pools, one selected encoding per region, and block capacities.

  • A physical machine carries the P3 refinement: physical resources, native operations and instruments, bindings, routing, and timing assumptions.

  • An immutable device binds adjacent machines explicitly. A device with only a logical machine is complete for placement work; binding an encoding per region grows it into a QEC device, and physical bindings complete a P3 device.

For homogeneous cases you never write the normalized containers explicitly. The device builder separates logical region facts from the vertical encoding binding:

import cudaq.logical as cql

builder = cql.devices.DeviceBuilder("SteaneMemory")
memory = builder.logical.add_memory(capacity=8)
builder.qec.bind(memory, encoding=cql.codes.Steane)
SteaneMemory = builder.build()

assert SteaneMemory.layers == (cql.stages.P1, cql.stages.P2)

builder.qec.bind(...) sets the QEC refinement of a logical region; the singular name is deliberate — each region has one selected encoding.

Stop at the layer your study needs

cudaq.logical.devices.DeviceBuilder is progressively complete. build() does not demand code facts that the requested compiler stage cannot use:

Declared layers

Builder boundary

Suitable work

P1

logical.add_compute(...), logical.add_memory(...), logical.add_factory(...), or logical.add_region(...)

logical placement, capacities, resource supply

P1 + P2

qec.bind(...)

code selection, gadgets, static/analytical estimates

P1 + P2 + P3

physical.add_qubits(...) or physical.add_resources(...), physical.bind(...), physical.set_operating_point(...)

physical lowering, scheduling, schedule estimates

Adding the physical layer is the same shape as adding the QEC layer. Declare the carriers and what they can natively do, bind the encoded region onto them, and state an operating point:

Binding an encoded region onto carriers (examples/standalone/04_physical_schedule.py).
carriers = builder.physical.add_qubits(
    surface_code.block.size,
    native_actions=cql.architecture.physical_actions.clifford_set(),
    native_instruments=(
        cql.architecture.physical_instruments.MZ,
        cql.architecture.physical_instruments.MPP,
    ),
)
builder.physical.bind(encoded_region, to=carriers)
builder.physical.set_operating_point(timing={"cycle_ns": 1.0})

physical.bind(encoded, to=carriers) is the vertical ownership boundary: the QEC region stays a P2 fact and the carrier allocation stays a P3 fact. set_operating_point turns dimensionless cycle counts into time. The compiler derives the event graph and the schedule from there; you do not hand-write either.

A placement-only device is therefore complete as written:

logical = cql.devices.DeviceBuilder("LogicalPlacement")
logical.logical.add_compute(capacity=64)
LogicalPlacement = logical.build()

assert LogicalPlacement.layers == (cql.stages.P1,)

The stack views contain only the layers actually declared, so a P1-only device cannot accidentally display P2 block pools. The builder infers common member labels: add_compute, add_memory, and add_factory attach typed capabilities and generate region and resource-stream labels with suffixes as needed. DeviceBuilder itself always has an explicit stable name.

Ready-made complete device stacks also ship as compilation targets — the surface_physical_target(distance=3, logical_capacity=1) stack in examples/02_surface_code_resource_estimate.py binds a rotated-surface-code QEC machine and a physical machine under a logical machine without any hand-written builder code.

Automatic placement: constraints, preferences, witnesses

Placement solves hard constraints and soft preferences over the machine, and the solution is inspectable evidence, not solver state:

Placing the Bell program and inspecting the witness (examples/standalone/01_logical_placement.py).
@cql.program
def bell() -> tuple[bool, bool]:
    qubits = cql.allocate(2, state=cql.types.zero, name="data")
    qubits[0] = cql.h(qubits[0])
    qubits[0], qubits[1] = cql.cx(qubits[0], qubits[1])
    return cql.measure_z(qubits[0]), cql.measure_z(qubits[1])


# %%
# Compile and place the logical owners into the machine's compute region.
logical = cql.compile(bell)
placed = cql.compiler.place(
    logical,
    device=TwoSlotMachine,
    placement=(cql.architecture.colocate(logical.values.data),),
)

assert placed.stage == cql.stages.P1
assert {binding.space for binding in placed.placement.bindings} == {"compute"}
assert {binding.slot for binding in placed.placement.bindings} == {0, 1}
assert cql.compiler.Build.replay(
    placed.serialize()).placement == placed.placement

place materializes P0 before invoking the selector. Address values through its typed view — p0.values.data for a named allocation group, p0.values[0] for structural ordinals — and they resolve immediately to LogicalValueRefs; Python variable names are diagnostics, never placement identity. Pass an existing P0 Build only when you intentionally inspected or reused it.

The witness records every binding, the machine, the objective, any soft preference the solver had to give up, and the deterministic tie-break:

  • p1.placement.bindings — one PlacementBinding per logical owner, naming its region and slot;

  • p1.placement.objective and p1.placement.relaxed_preferences — what was optimized and what was surrendered;

  • cudaq.logical.compiler.Build.replay(p1.serialize()).placement == p1.placement — the witness is part of the immutable, replayable build.

Hard constraints are hard. Requiring a capability that no region provides fails closed:

try:
    # ValueError: no machine space satisfies the placement constraints
    cql.compiler.place(
        p0,
        device=TwoSlotMachine,
        placement=(cql.architecture.require_capability(
            cql.architecture.capability.logical_factory),),
    )
except ValueError as exc:
    assert "no machine space satisfies the placement constraints" in str(exc)

Placement never silently relaxes a hard requirement. Soft preferences, by contrast, may be surrendered — and the surrender is reported:

p1 = cql.compiler.place(
    p0,
    device=TwoSlotMachine,
    placement=(
        cql.architecture.colocate(p0.values.data),
        cql.architecture.prefer(
            space=TwoSlotMachine.compute,
            for_=cql.architecture.lifecycle.ACTIVE,
        ),
    ),
    objective=cql.architecture.metric.expected_spacetime_volume,
)

assert p1.placement.relaxed_preferences == ()

The constraint vocabulary is cudaq.logical.architecture: colocate, allow_spaces, require_capability, prefer, and local for exact-slot pinning. Exact slots are singleton constraints, not a verification bypass — hand-authored placements produce the same verified witness as solved ones.

Code-agnostic P1

P1 placement never selects an encoding. cudaq.logical.architecture.colocate(p0.values.data) keeps several logical owners in one logical region, but each owner consumes a distinct P1 slot, and a PlacementBinding carries no encoding field. Encodings enter at P2 — bound per region through DeviceBuilder.qec.bind(...) or carried by a compilation target — and the P2 witness is separate from p1.placement: changing the encoding does not rewrite the verified P1 artifact.

Where the firewall pays off

Placement constraints speak machine vocabulary — regions, slots, capabilities — so a portable program re-places when you swap device=, and you can reuse one machine across many programs. Capacities and capabilities live on the machine; encodings live on the device binding. So when a study moves from one machine to another, neither the program nor the estimate code changes.

Continue from here