diff --git a/crates/accelerate/src/euler_one_qubit_decomposer.rs b/crates/accelerate/src/euler_one_qubit_decomposer.rs
index 98333cad39d2..e6ca094186ff 100644
--- a/crates/accelerate/src/euler_one_qubit_decomposer.rs
+++ b/crates/accelerate/src/euler_one_qubit_decomposer.rs
@@ -579,7 +579,7 @@ pub fn generate_circuit(

 const EULER_BASIS_SIZE: usize = 12;

-static EULER_BASES: [&[&str]; EULER_BASIS_SIZE] = [
+pub static EULER_BASES: [&[&str]; EULER_BASIS_SIZE] = [
     &["u3"],
     &["u3", "u2", "u1"],
     &["u"],
@@ -593,7 +593,7 @@ static EULER_BASES: [&[&str]; EULER_BASIS_SIZE] = [
     &["rz", "sx", "x"],
     &["rz", "sx"],
 ];
-static EULER_BASIS_NAMES: [EulerBasis; EULER_BASIS_SIZE] = [
+pub static EULER_BASIS_NAMES: [EulerBasis; EULER_BASIS_SIZE] = [
     EulerBasis::U3,
     EulerBasis::U321,
     EulerBasis::U,
diff --git a/crates/accelerate/src/lib.rs b/crates/accelerate/src/lib.rs
index 0f1576783209..5afe8c3259a0 100644
--- a/crates/accelerate/src/lib.rs
+++ b/crates/accelerate/src/lib.rs
@@ -48,6 +48,7 @@ pub mod synthesis;
 pub mod target_transpiler;
 pub mod two_qubit_decompose;
 pub mod uc_gate;
+pub mod unitary_synthesis;
 pub mod utils;
 pub mod vf2_layout;

diff --git a/crates/accelerate/src/two_qubit_decompose.rs b/crates/accelerate/src/two_qubit_decompose.rs
index ff084323b716..31d82854b953 100644
--- a/crates/accelerate/src/two_qubit_decompose.rs
+++ b/crates/accelerate/src/two_qubit_decompose.rs
@@ -494,6 +494,15 @@ pub struct TwoQubitWeylDecomposition {
 }

 impl TwoQubitWeylDecomposition {
+    pub fn a(&self) -> f64 {
+        self.a
+    }
+    pub fn b(&self) -> f64 {
+        self.b
+    }
+    pub fn c(&self) -> f64 {
+        self.c
+    }
     fn weyl_gate(
         &self,
         simplify: bool,
@@ -1214,6 +1223,7 @@ impl TwoQubitWeylDecomposition {

 type TwoQubitSequenceVec = Vec<(Option<StandardGate>, SmallVec<[f64; 3]>, SmallVec<[u8; 2]>)>;

+#[derive(Clone, Debug)]
 #[pyclass(sequence)]
 pub struct TwoQubitGateSequence {
     gates: TwoQubitSequenceVec,
@@ -1221,10 +1231,31 @@ pub struct TwoQubitGateSequence {
     global_phase: f64,
 }

+impl TwoQubitGateSequence {
+    pub fn gates(&self) -> &TwoQubitSequenceVec {
+        &self.gates
+    }
+
+    pub fn global_phase(&self) -> f64 {
+        self.global_phase
+    }
+
+    pub fn set_state(&mut self, state: (TwoQubitSequenceVec, f64)) {
+        self.gates = state.0;
+        self.global_phase = state.1;
+    }
+}
+
+impl Default for TwoQubitGateSequence {
+    fn default() -> Self {
+        Self::new()
+    }
+}
+
 #[pymethods]
 impl TwoQubitGateSequence {
     #[new]
-    fn new() -> Self {
+    pub fn new() -> Self {
         TwoQubitGateSequence {
             gates: Vec::new(),
             global_phase: 0.,
@@ -1256,6 +1287,8 @@ impl TwoQubitGateSequence {
         }
     }
 }
+
+#[derive(Clone, Debug)]
 #[allow(non_snake_case)]
 #[pyclass(module = "qiskit._accelerate.two_qubit_decompose", subclass)]
 pub struct TwoQubitBasisDecomposer {
@@ -1643,7 +1676,7 @@ impl TwoQubitBasisDecomposer {
         Ok(res)
     }

-    fn new_inner(
+    pub fn new_inner(
         gate: String,
         gate_matrix: ArrayView2<Complex64>,
         basis_fidelity: f64,
@@ -1781,7 +1814,7 @@ impl TwoQubitBasisDecomposer {
         })
     }

-    fn call_inner(
+    pub fn call_inner(
         &self,
         unitary: ArrayView2<Complex64>,
         basis_fidelity: Option<f64>,
diff --git a/crates/accelerate/src/unitary_synthesis.rs b/crates/accelerate/src/unitary_synthesis.rs
new file mode 100644
index 000000000000..1931f1e97b2b
--- /dev/null
+++ b/crates/accelerate/src/unitary_synthesis.rs
@@ -0,0 +1,1087 @@
+// This code is part of Qiskit.
+//
+// (C) Copyright IBM 2024
+//
+// This code is licensed under the Apache License, Version 2.0. You may
+// obtain a copy of this license in the LICENSE.txt file in the root directory
+// of this source tree or at http://www.apache.org/licenses/LICENSE-2.0.
+//
+// Any modifications or derivative works of this code must retain this
+// copyright notice, and modified files need to carry a notice indicating
+// that they have been altered from the originals.
+#![allow(clippy::too_many_arguments)]
+
+#[cfg(feature = "cache_pygates")]
+use std::cell::OnceCell;
+use std::f64::consts::PI;
+
+use approx::relative_eq;
+use hashbrown::{HashMap, HashSet};
+use indexmap::IndexMap;
+use itertools::Itertools;
+use ndarray::prelude::*;
+use num_complex::{Complex, Complex64};
+use numpy::IntoPyArray;
+use qiskit_circuit::circuit_instruction::{ExtraInstructionAttributes, OperationFromPython};
+use smallvec::{smallvec, SmallVec};
+
+use pyo3::intern;
+use pyo3::prelude::*;
+use pyo3::types::{IntoPyDict, PyDict, PyList, PyString};
+use pyo3::wrap_pyfunction;
+use pyo3::Python;
+
+use qiskit_circuit::converters::{circuit_to_dag, QuantumCircuitData};
+use qiskit_circuit::dag_circuit::{DAGCircuit, NodeType};
+use qiskit_circuit::imports;
+use qiskit_circuit::operations::{Operation, OperationRef, Param, StandardGate};
+use qiskit_circuit::packed_instruction::{PackedInstruction, PackedOperation};
+use qiskit_circuit::Qubit;
+
+use crate::euler_one_qubit_decomposer::{
+    unitary_to_gate_sequence_inner, EulerBasis, EulerBasisSet, EULER_BASES, EULER_BASIS_NAMES,
+};
+use crate::nlayout::PhysicalQubit;
+use crate::target_transpiler::{NormalOperation, Target};
+use crate::two_qubit_decompose::{
+    TwoQubitBasisDecomposer, TwoQubitGateSequence, TwoQubitWeylDecomposition,
+};
+use crate::QiskitError;
+
+const PI2: f64 = PI / 2.;
+const PI4: f64 = PI / 4.;
+
+#[derive(Clone, Debug)]
+enum DecomposerType {
+    TwoQubitBasisDecomposer(Box<TwoQubitBasisDecomposer>),
+    XXDecomposer(PyObject),
+}
+
+struct DecomposerElement {
+    decomposer: DecomposerType,
+    gate: NormalOperation,
+}
+
+#[derive(Clone, Debug)]
+struct TwoQubitUnitarySequence {
+    gate_sequence: TwoQubitGateSequence,
+    decomp_gate: NormalOperation,
+}
+
+// Used in get_2q_decomposers. If the found 2q basis is a subset of GOODBYE_SET,
+// then we know TwoQubitBasisDecomposer is an ideal decomposition and there is
+// no need to bother trying the XXDecomposer.
+static GOODBYE_SET: [&str; 3] = ["cx", "cz", "ecr"];
+
+fn get_target_basis_set(target: &Target, qubit: PhysicalQubit) -> EulerBasisSet {
+    let mut target_basis_set: EulerBasisSet = EulerBasisSet::new();
+    let target_basis_list = target.operation_names_for_qargs(Some(&smallvec![qubit]));
+    match target_basis_list {
+        Ok(basis_list) => {
+            EULER_BASES
+                .iter()
+                .enumerate()
+                .filter_map(|(idx, gates)| {
+                    if !gates.iter().all(|gate| basis_list.contains(gate)) {
+                        return None;
+                    }
+                    let basis = EULER_BASIS_NAMES[idx];
+                    Some(basis)
+                })
+                .for_each(|basis| target_basis_set.add_basis(basis));
+        }
+        Err(_) => target_basis_set.support_all(),
+    }
+    if target_basis_set.basis_supported(EulerBasis::U3)
+        && target_basis_set.basis_supported(EulerBasis::U321)
+    {
+        target_basis_set.remove(EulerBasis::U3);
+    }
+    if target_basis_set.basis_supported(EulerBasis::ZSX)
+        && target_basis_set.basis_supported(EulerBasis::ZSXX)
+    {
+        target_basis_set.remove(EulerBasis::ZSX);
+    }
+    target_basis_set
+}
+
+fn apply_synth_dag(
+    py: Python<'_>,
+    out_dag: &mut DAGCircuit,
+    out_qargs: &[Qubit],
+    synth_dag: &DAGCircuit,
+) -> PyResult<()> {
+    for out_node in synth_dag.topological_op_nodes()? {
+        let mut out_packed_instr = synth_dag.dag()[out_node].unwrap_operation().clone();
+        let synth_qargs = synth_dag.get_qargs(out_packed_instr.qubits);
+        let mapped_qargs: Vec<Qubit> = synth_qargs
+            .iter()
+            .map(|qarg| out_qargs[qarg.0 as usize])
+            .collect();
+        out_packed_instr.qubits = out_dag.qargs_interner.insert(&mapped_qargs);
+        out_dag.push_back(py, out_packed_instr)?;
+    }
+    out_dag.add_global_phase(py, &synth_dag.get_global_phase())?;
+    Ok(())
+}
+
+fn apply_synth_sequence(
+    py: Python<'_>,
+    out_dag: &mut DAGCircuit,
+    out_qargs: &[Qubit],
+    sequence: &TwoQubitUnitarySequence,
+) -> PyResult<()> {
+    let mut instructions = Vec::with_capacity(sequence.gate_sequence.gates().len());
+    for (gate, params, qubit_ids) in sequence.gate_sequence.gates() {
+        let gate_node = match gate {
+            None => sequence.decomp_gate.operation.standard_gate(),
+            Some(gate) => *gate,
+        };
+        let mapped_qargs: Vec<Qubit> = qubit_ids.iter().map(|id| out_qargs[*id as usize]).collect();
+        let new_params: Option<Box<SmallVec<[Param; 3]>>> = match gate {
+            Some(_) => Some(Box::new(params.iter().map(|p| Param::Float(*p)).collect())),
+            None => Some(Box::new(sequence.decomp_gate.params.clone())),
+        };
+        let instruction = PackedInstruction {
+            op: PackedOperation::from_standard(gate_node),
+            qubits: out_dag.qargs_interner.insert(&mapped_qargs),
+            clbits: out_dag.cargs_interner.get_default(),
+            params: new_params,
+            extra_attrs: ExtraInstructionAttributes::default(),
+            #[cfg(feature = "cache_pygates")]
+            py_op: OnceCell::new(),
+        };
+        instructions.push(instruction);
+    }
+    out_dag.extend(py, instructions.into_iter())?;
+    out_dag.add_global_phase(py, &Param::Float(sequence.gate_sequence.global_phase()))?;
+    Ok(())
+}
+
+fn synth_error(
+    py: Python<'_>,
+    synth_circuit: impl Iterator<
+        Item = (
+            String,
+            Option<SmallVec<[Param; 3]>>,
+            SmallVec<[PhysicalQubit; 2]>,
+        ),
+    >,
+    target: &Target,
+) -> f64 {
+    let (lower_bound, upper_bound) = synth_circuit.size_hint();
+    let mut gate_fidelities = match upper_bound {
+        Some(bound) => Vec::with_capacity(bound),
+        None => Vec::with_capacity(lower_bound),
+    };
+    let mut score_instruction =
+        |inst_name: &str,
+         inst_params: &Option<SmallVec<[Param; 3]>>,
+         inst_qubits: &SmallVec<[PhysicalQubit; 2]>| {
+            if let Ok(names) = target.operation_names_for_qargs(Some(inst_qubits)) {
+                for name in names {
+                    if let Ok(target_op) = target.operation_from_name(name) {
+                        let are_params_close = if let Some(params) = inst_params {
+                            params.iter().zip(target_op.params.iter()).all(|(p1, p2)| {
+                                p1.is_close(py, p2, 1e-10)
+                                    .expect("Unexpected parameter expression error.")
+                            })
+                        } else {
+                            false
+                        };
+                        let is_parametrized = target_op
+                            .params
+                            .iter()
+                            .any(|param| matches!(param, Param::ParameterExpression(_)));
+                        if target_op.operation.name() == inst_name
+                            && (is_parametrized || are_params_close)
+                        {
+                            match target[name].get(Some(inst_qubits)) {
+                                Some(Some(props)) => {
+                                    gate_fidelities.push(1.0 - props.error.unwrap_or(0.0))
+                                }
+                                _ => gate_fidelities.push(1.0),
+                            }
+                            break;
+                        }
+                    }
+                }
+            }
+        };
+
+    for (inst_name, inst_params, inst_qubits) in synth_circuit {
+        score_instruction(&inst_name, &inst_params, &inst_qubits);
+    }
+    1.0 - gate_fidelities.into_iter().product::<f64>()
+}
+
+// This is the outer-most run function. It is meant to be called from Python
+// in `UnitarySynthesis.run()`.
+#[pyfunction]
+#[pyo3(name = "run_default_main_loop", signature=(dag, qubit_indices, min_qubits, target, coupling_edges, approximation_degree=None, natural_direction=None))]
+fn py_run_main_loop(
+    py: Python,
+    dag: &mut DAGCircuit,
+    qubit_indices: Vec<usize>,
+    min_qubits: usize,
+    target: &Target,
+    coupling_edges: &Bound<'_, PyList>,
+    approximation_degree: Option<f64>,
+    natural_direction: Option<bool>,
+) -> PyResult<DAGCircuit> {
+    // We need to use the python converter because the currently available Rust conversion
+    // is lossy. We need `QuantumCircuit` instances to be used in `replace_blocks`.
+    let dag_to_circuit = imports::DAG_TO_CIRCUIT.get_bound(py);
+
+    let mut out_dag = dag.copy_empty_like(py, "alike")?;
+
+    // Iterate over dag nodes and determine unitary synthesis approach
+    for node in dag.topological_op_nodes()? {
+        let mut packed_instr = dag.dag()[node].unwrap_operation().clone();
+
+        if packed_instr.op.control_flow() {
+            let OperationRef::Instruction(py_instr) = packed_instr.op.view() else {
+                unreachable!("Control flow op must be an instruction")
+            };
+            let raw_blocks: Vec<PyResult<Bound<PyAny>>> = py_instr
+                .instruction
+                .getattr(py, "blocks")?
+                .bind(py)
+                .iter()?
+                .collect();
+            let mut new_blocks = Vec::with_capacity(raw_blocks.len());
+            for raw_block in raw_blocks {
+                let new_ids = dag
+                    .get_qargs(packed_instr.qubits)
+                    .iter()
+                    .map(|qarg| qubit_indices[qarg.0 as usize])
+                    .collect_vec();
+                let res = py_run_main_loop(
+                    py,
+                    &mut circuit_to_dag(
+                        py,
+                        QuantumCircuitData::extract_bound(&raw_block?)?,
+                        false,
+                        None,
+                        None,
+                    )?,
+                    new_ids,
+                    min_qubits,
+                    target,
+                    coupling_edges,
+                    approximation_degree,
+                    natural_direction,
+                )?;
+                new_blocks.push(dag_to_circuit.call1((res,))?);
+            }
+            let new_node = py_instr
+                .instruction
+                .bind(py)
+                .call_method1("replace_blocks", (new_blocks,))?;
+            let new_node_op: OperationFromPython = new_node.extract()?;
+            packed_instr = PackedInstruction {
+                op: new_node_op.operation,
+                qubits: packed_instr.qubits,
+                clbits: packed_instr.clbits,
+                params: (!new_node_op.params.is_empty()).then(|| Box::new(new_node_op.params)),
+                extra_attrs: new_node_op.extra_attrs,
+                #[cfg(feature = "cache_pygates")]
+                py_op: new_node.unbind().into(),
+            };
+        }
+        if !(packed_instr.op.name() == "unitary"
+            && packed_instr.op.num_qubits() >= min_qubits as u32)
+        {
+            out_dag.push_back(py, packed_instr)?;
+            continue;
+        }
+        let unitary: Array<Complex<f64>, Dim<[usize; 2]>> = match packed_instr.op.matrix(&[]) {
+            Some(unitary) => unitary,
+            None => return Err(QiskitError::new_err("Unitary not found")),
+        };
+        match unitary.shape() {
+            // Run 1q synthesis
+            [2, 2] => {
+                let qubit = dag.get_qargs(packed_instr.qubits)[0];
+                let target_basis_set = get_target_basis_set(target, PhysicalQubit::new(qubit.0));
+                let sequence = unitary_to_gate_sequence_inner(
+                    unitary.view(),
+                    &target_basis_set,
+                    qubit.0 as usize,
+                    None,
+                    true,
+                    None,
+                );
+                match sequence {
+                    Some(sequence) => {
+                        for (gate, params) in sequence.gates {
+                            let new_params: SmallVec<[Param; 3]> =
+                                params.iter().map(|p| Param::Float(*p)).collect();
+                            out_dag.apply_operation_back(
+                                py,
+                                gate.into(),
+                                &[qubit],
+                                &[],
+                                Some(new_params),
+                                ExtraInstructionAttributes::default(),
+                                #[cfg(feature = "cache_pygates")]
+                                None,
+                            )?;
+                        }
+                        out_dag.add_global_phase(py, &Param::Float(sequence.global_phase))?;
+                    }
+                    None => {
+                        out_dag.push_back(py, packed_instr)?;
+                    }
+                }
+            }
+            // Run 2q synthesis
+            [4, 4] => {
+                // "out_qargs" is used to append the synthesized instructions to the output dag
+                let out_qargs = dag.get_qargs(packed_instr.qubits);
+                // "ref_qubits" is used to access properties in the target. It accounts for control flow mapping.
+                let ref_qubits: &[PhysicalQubit; 2] = &[
+                    PhysicalQubit::new(qubit_indices[out_qargs[0].0 as usize] as u32),
+                    PhysicalQubit::new(qubit_indices[out_qargs[1].0 as usize] as u32),
+                ];
+                let apply_original_op = |out_dag: &mut DAGCircuit| -> PyResult<()> {
+                    out_dag.push_back(py, packed_instr.clone())?;
+                    Ok(())
+                };
+                run_2q_unitary_synthesis(
+                    py,
+                    unitary,
+                    ref_qubits,
+                    coupling_edges,
+                    target,
+                    approximation_degree,
+                    natural_direction,
+                    &mut out_dag,
+                    out_qargs,
+                    apply_original_op,
+                )?;
+            }
+            // Run 3q+ synthesis
+            _ => {
+                let qs_decomposition: &Bound<'_, PyAny> = imports::QS_DECOMPOSITION.get_bound(py);
+                let synth_circ = qs_decomposition.call1((unitary.into_pyarray_bound(py),))?;
+                let synth_dag = circuit_to_dag(
+                    py,
+                    QuantumCircuitData::extract_bound(&synth_circ)?,
+                    false,
+                    None,
+                    None,
+                )?;
+                out_dag = synth_dag;
+            }
+        }
+    }
+    Ok(out_dag)
+}
+
+fn run_2q_unitary_synthesis(
+    py: Python,
+    unitary: Array2<Complex64>,
+    ref_qubits: &[PhysicalQubit; 2],
+    coupling_edges: &Bound<'_, PyList>,
+    target: &Target,
+    approximation_degree: Option<f64>,
+    natural_direction: Option<bool>,
+    out_dag: &mut DAGCircuit,
+    out_qargs: &[Qubit],
+    mut apply_original_op: impl FnMut(&mut DAGCircuit) -> PyResult<()>,
+) -> PyResult<()> {
+    let decomposers = {
+        let decomposers_2q =
+            get_2q_decomposers_from_target(py, target, ref_qubits, approximation_degree)?;
+        decomposers_2q.unwrap_or_default()
+    };
+    // If there's a single decomposer, avoid computing synthesis score
+    if decomposers.len() == 1 {
+        let decomposer_item = decomposers.first().unwrap();
+        let preferred_dir = preferred_direction(
+            decomposer_item,
+            ref_qubits,
+            natural_direction,
+            coupling_edges,
+            target,
+        )?;
+        match decomposer_item.decomposer {
+            DecomposerType::TwoQubitBasisDecomposer(_) => {
+                let synth = synth_su4_sequence(
+                    &unitary,
+                    decomposer_item,
+                    preferred_dir,
+                    approximation_degree,
+                )?;
+                apply_synth_sequence(py, out_dag, out_qargs, &synth)?;
+            }
+            DecomposerType::XXDecomposer(_) => {
+                let synth = synth_su4_dag(
+                    py,
+                    &unitary,
+                    decomposer_item,
+                    preferred_dir,
+                    approximation_degree,
+                )?;
+                apply_synth_dag(py, out_dag, out_qargs, &synth)?;
+            }
+        }
+        return Ok(());
+    }
+
+    let mut synth_errors_sequence = Vec::new();
+    let mut synth_errors_dag = Vec::new();
+    for decomposer in &decomposers {
+        let preferred_dir = preferred_direction(
+            decomposer,
+            ref_qubits,
+            natural_direction,
+            coupling_edges,
+            target,
+        )?;
+        match &decomposer.decomposer {
+            DecomposerType::TwoQubitBasisDecomposer(_) => {
+                let sequence =
+                    synth_su4_sequence(&unitary, decomposer, preferred_dir, approximation_degree)?;
+                let scoring_info =
+                    sequence
+                        .gate_sequence
+                        .gates()
+                        .iter()
+                        .map(|(gate, params, qubit_ids)| {
+                            let inst_qubits =
+                                qubit_ids.iter().map(|q| ref_qubits[*q as usize]).collect();
+                            match gate {
+                                Some(gate) => (
+                                    gate.name().to_string(),
+                                    Some(params.iter().map(|p| Param::Float(*p)).collect()),
+                                    inst_qubits,
+                                ),
+                                None => (
+                                    sequence
+                                        .decomp_gate
+                                        .operation
+                                        .standard_gate()
+                                        .name()
+                                        .to_string(),
+                                    Some(params.iter().map(|p| Param::Float(*p)).collect()),
+                                    inst_qubits,
+                                ),
+                            }
+                        });
+                let synth_error_from_target = synth_error(py, scoring_info, target);
+                synth_errors_sequence.push((sequence, synth_error_from_target));
+            }
+            DecomposerType::XXDecomposer(_) => {
+                let synth_dag = synth_su4_dag(
+                    py,
+                    &unitary,
+                    decomposer,
+                    preferred_dir,
+                    approximation_degree,
+                )?;
+                let scoring_info = synth_dag
+                    .topological_op_nodes()
+                    .expect("Unexpected error in dag.topological_op_nodes()")
+                    .map(|node| {
+                        let NodeType::Operation(inst) = &synth_dag.dag()[node] else {
+                            unreachable!("DAG node must be an instruction")
+                        };
+                        let inst_qubits = synth_dag
+                            .get_qargs(inst.qubits)
+                            .iter()
+                            .map(|q| ref_qubits[q.0 as usize])
+                            .collect();
+                        (
+                            inst.op.name().to_string(),
+                            inst.params.clone().map(|boxed| *boxed),
+                            inst_qubits,
+                        )
+                    });
+                let synth_error_from_target = synth_error(py, scoring_info, target);
+                synth_errors_dag.push((synth_dag, synth_error_from_target));
+            }
+        }
+    }
+
+    let synth_sequence = synth_errors_sequence
+        .iter()
+        .enumerate()
+        .min_by(|error1, error2| error1.1 .1.partial_cmp(&error2.1 .1).unwrap())
+        .map(|(index, _)| &synth_errors_sequence[index]);
+
+    let synth_dag = synth_errors_dag
+        .iter()
+        .enumerate()
+        .min_by(|error1, error2| error1.1 .1.partial_cmp(&error2.1 .1).unwrap())
+        .map(|(index, _)| &synth_errors_dag[index]);
+
+    match (synth_sequence, synth_dag) {
+        (None, None) => apply_original_op(out_dag)?,
+        (Some((sequence, _)), None) => apply_synth_sequence(py, out_dag, out_qargs, sequence)?,
+        (None, Some((dag, _))) => apply_synth_dag(py, out_dag, out_qargs, dag)?,
+        (Some((sequence, sequence_error)), Some((dag, dag_error))) => {
+            if sequence_error > dag_error {
+                apply_synth_dag(py, out_dag, out_qargs, dag)?
+            } else {
+                apply_synth_sequence(py, out_dag, out_qargs, sequence)?
+            }
+        }
+    };
+    Ok(())
+}
+
+fn get_2q_decomposers_from_target(
+    py: Python,
+    target: &Target,
+    qubits: &[PhysicalQubit; 2],
+    approximation_degree: Option<f64>,
+) -> PyResult<Option<Vec<DecomposerElement>>> {
+    let qubits: SmallVec<[PhysicalQubit; 2]> = SmallVec::from_buf(*qubits);
+    let reverse_qubits: SmallVec<[PhysicalQubit; 2]> = qubits.iter().rev().copied().collect();
+    let mut available_2q_basis: IndexMap<&str, NormalOperation> = IndexMap::new();
+    let mut available_2q_props: IndexMap<&str, (Option<f64>, Option<f64>)> = IndexMap::new();
+
+    let mut qubit_gate_map = IndexMap::new();
+    match target.operation_names_for_qargs(Some(&qubits)) {
+        Ok(direct_keys) => {
+            qubit_gate_map.insert(&qubits, direct_keys);
+            if let Ok(reverse_keys) = target.operation_names_for_qargs(Some(&reverse_qubits)) {
+                qubit_gate_map.insert(&reverse_qubits, reverse_keys);
+            }
+        }
+        Err(_) => {
+            if let Ok(reverse_keys) = target.operation_names_for_qargs(Some(&reverse_qubits)) {
+                qubit_gate_map.insert(&reverse_qubits, reverse_keys);
+            } else {
+                return Err(QiskitError::new_err(
+                    "Target has no gates available on qubits to synthesize over.",
+                ));
+            }
+        }
+    }
+
+    #[inline]
+    fn replace_parametrized_gate(mut op: NormalOperation) -> NormalOperation {
+        if let Some(std_gate) = op.operation.try_standard_gate() {
+            match std_gate.name() {
+                "rxx" => {
+                    if let Param::ParameterExpression(_) = op.params[0] {
+                        op.params[0] = Param::Float(PI2)
+                    }
+                }
+                "rzx" => {
+                    if let Param::ParameterExpression(_) = op.params[0] {
+                        op.params[0] = Param::Float(PI4)
+                    }
+                }
+                "rzz" => {
+                    if let Param::ParameterExpression(_) = op.params[0] {
+                        op.params[0] = Param::Float(PI2)
+                    }
+                }
+                _ => (),
+            }
+        }
+        op
+    }
+
+    for (q_pair, gates) in qubit_gate_map {
+        for key in gates {
+            match target.operation_from_name(key) {
+                Ok(op) => {
+                    match op.operation.view() {
+                        OperationRef::Gate(_) => (),
+                        OperationRef::Standard(_) => (),
+                        _ => continue,
+                    }
+
+                    available_2q_basis.insert(key, replace_parametrized_gate(op.clone()));
+
+                    if target.contains_key(key) {
+                        available_2q_props.insert(
+                            key,
+                            match &target[key].get(Some(q_pair)) {
+                                Some(Some(props)) => (props.duration, props.error),
+                                _ => (None, None),
+                            },
+                        );
+                    } else {
+                        continue;
+                    }
+                }
+                _ => continue,
+            }
+        }
+    }
+    if available_2q_basis.is_empty() {
+        return Err(QiskitError::new_err(
+            "Target has no gates available on qubits to synthesize over.",
+        ));
+    }
+
+    let target_basis_set = get_target_basis_set(target, qubits[0]);
+    let available_1q_basis: HashSet<&str> =
+        HashSet::from_iter(target_basis_set.get_bases().map(|basis| basis.as_str()));
+    let mut decomposers: Vec<DecomposerElement> = Vec::new();
+
+    #[inline]
+    fn is_supercontrolled(op: &NormalOperation) -> bool {
+        match op.operation.matrix(&op.params) {
+            None => false,
+            Some(unitary_matrix) => {
+                let kak = TwoQubitWeylDecomposition::new_inner(unitary_matrix.view(), None, None)
+                    .unwrap();
+                relative_eq!(kak.a(), PI4) && relative_eq!(kak.c(), 0.0)
+            }
+        }
+    }
+
+    #[inline]
+    fn is_controlled(op: &NormalOperation) -> bool {
+        match op.operation.matrix(&op.params) {
+            None => false,
+            Some(unitary_matrix) => {
+                let kak = TwoQubitWeylDecomposition::new_inner(unitary_matrix.view(), None, None)
+                    .unwrap();
+                relative_eq!(kak.b(), 0.0) && relative_eq!(kak.c(), 0.0)
+            }
+        }
+    }
+
+    // Iterate over 1q and 2q supercontrolled basis, append TwoQubitBasisDecomposers
+    let supercontrolled_basis: IndexMap<&str, NormalOperation> = available_2q_basis
+        .iter()
+        .filter(|(_, v)| is_supercontrolled(v))
+        .map(|(k, v)| (*k, v.clone()))
+        .collect();
+
+    for basis_1q in &available_1q_basis {
+        for (basis_2q, gate) in supercontrolled_basis.iter() {
+            let mut basis_2q_fidelity: f64 = match available_2q_props.get(basis_2q) {
+                Some(&(_, Some(e))) => 1.0 - e,
+                _ => 1.0,
+            };
+            if let Some(approx_degree) = approximation_degree {
+                basis_2q_fidelity *= approx_degree;
+            }
+            let decomposer = TwoQubitBasisDecomposer::new_inner(
+                gate.operation.name().to_string(),
+                gate.operation.matrix(&gate.params).unwrap().view(),
+                basis_2q_fidelity,
+                basis_1q,
+                None,
+            )?;
+
+            decomposers.push(DecomposerElement {
+                decomposer: DecomposerType::TwoQubitBasisDecomposer(Box::new(decomposer)),
+                gate: gate.clone(),
+            });
+        }
+    }
+
+    // If our 2q basis gates are a subset of cx, ecr, or cz then we know TwoQubitBasisDecomposer
+    // is an ideal decomposition and there is no need to bother calculating the XX embodiments
+    // or try the XX decomposer
+    let available_basis_set: HashSet<&str> = available_2q_basis.keys().copied().collect();
+
+    #[inline]
+    fn check_goodbye(basis_set: &HashSet<&str>) -> bool {
+        basis_set.iter().all(|gate| GOODBYE_SET.contains(gate))
+    }
+
+    if check_goodbye(&available_basis_set) {
+        return Ok(Some(decomposers));
+    }
+
+    // Let's now look for possible controlled decomposers (i.e. XXDecomposer)
+    let controlled_basis: IndexMap<&str, NormalOperation> = available_2q_basis
+        .iter()
+        .filter(|(_, v)| is_controlled(v))
+        .map(|(k, v)| (*k, v.clone()))
+        .collect();
+    let mut pi2_basis: Option<&str> = None;
+    let xx_embodiments: &Bound<'_, PyAny> = imports::XX_EMBODIMENTS.get_bound(py);
+
+    // The xx decomposer args are the interaction strength (f64), basis_2q_fidelity (f64),
+    // and embodiments (Bound<'_, PyAny>).
+    let xx_decomposer_args = controlled_basis.iter().map(
+        |(name, op)| -> PyResult<(f64, f64, pyo3::Bound<'_, pyo3::PyAny>)> {
+            let strength = 2.0
+                * TwoQubitWeylDecomposition::new_inner(
+                    op.operation.matrix(&op.params).unwrap().view(),
+                    None,
+                    None,
+                )
+                .unwrap()
+                .a();
+            let mut fidelity_value = match available_2q_props.get(name) {
+                Some(&(_, error)) => 1.0 - error.unwrap_or_default(), // default is 0.0
+                None => 1.0,
+            };
+            if let Some(approx_degree) = approximation_degree {
+                fidelity_value *= approx_degree;
+            }
+            let mut embodiment =
+                xx_embodiments.get_item(op.to_object(py).getattr(py, "base_class")?)?;
+
+            if embodiment.getattr("parameters")?.len()? == 1 {
+                embodiment = embodiment.call_method1("assign_parameters", (vec![strength],))?;
+            }
+            // basis equivalent to CX are well optimized so use for the pi/2 angle if available
+            if relative_eq!(strength, PI2) && supercontrolled_basis.contains_key(name) {
+                pi2_basis = Some(op.operation.name());
+            }
+            Ok((strength, fidelity_value, embodiment))
+        },
+    );
+
+    let basis_2q_fidelity_dict = PyDict::new_bound(py);
+    let embodiments_dict = PyDict::new_bound(py);
+    for (strength, fidelity, embodiment) in xx_decomposer_args.flatten() {
+        basis_2q_fidelity_dict.set_item(strength, fidelity)?;
+        embodiments_dict.set_item(strength, embodiment.into_py(py))?;
+    }
+
+    // Iterate over 2q fidelities and select decomposers
+    if basis_2q_fidelity_dict.len() > 0 {
+        let xx_decomposer: &Bound<'_, PyAny> = imports::XX_DECOMPOSER.get_bound(py);
+        for basis_1q in available_1q_basis {
+            let pi2_decomposer = if let Some(pi_2_basis) = pi2_basis {
+                if pi_2_basis == "cx" && basis_1q == "ZSX" {
+                    let fidelity = match approximation_degree {
+                        Some(approx_degree) => approx_degree,
+                        None => match &target["cx"][Some(&qubits)] {
+                            Some(props) => 1.0 - props.error.unwrap_or_default(),
+                            None => 1.0,
+                        },
+                    };
+                    Some(TwoQubitBasisDecomposer::new_inner(
+                        pi_2_basis.to_string(),
+                        StandardGate::CXGate.matrix(&[]).unwrap().view(),
+                        fidelity,
+                        basis_1q,
+                        Some(true),
+                    )?)
+                } else {
+                    None
+                }
+            } else {
+                None
+            };
+
+            let decomposer = xx_decomposer.call1((
+                &basis_2q_fidelity_dict,
+                PyString::new_bound(py, basis_1q),
+                &embodiments_dict,
+                pi2_decomposer,
+            ))?;
+            let decomposer_gate = decomposer
+                .getattr(intern!(py, "gate"))?
+                .extract::<NormalOperation>()?;
+
+            decomposers.push(DecomposerElement {
+                decomposer: DecomposerType::XXDecomposer(decomposer.into()),
+                gate: decomposer_gate,
+            });
+        }
+    }
+    Ok(Some(decomposers))
+}
+
+fn preferred_direction(
+    decomposer: &DecomposerElement,
+    ref_qubits: &[PhysicalQubit; 2],
+    natural_direction: Option<bool>,
+    coupling_edges: &Bound<'_, PyList>,
+    target: &Target,
+) -> PyResult<Option<bool>> {
+    // Returns:
+    // * true if gate qubits are in the hardware-native direction
+    // * false if gate qubits must be flipped to match hardware-native direction
+    let qubits: [PhysicalQubit; 2] = *ref_qubits;
+    let mut reverse_qubits: [PhysicalQubit; 2] = qubits;
+    reverse_qubits.reverse();
+
+    let compute_cost =
+        |lengths: bool, q_tuple: [PhysicalQubit; 2], in_cost: f64| -> PyResult<f64> {
+            let cost = match target.qargs_for_operation_name(decomposer.gate.operation.name()) {
+                Ok(_) => match target[decomposer.gate.operation.name()].get(Some(
+                    &q_tuple
+                        .into_iter()
+                        .collect::<SmallVec<[PhysicalQubit; 2]>>(),
+                )) {
+                    Some(Some(_props)) => {
+                        if lengths {
+                            _props.duration.unwrap_or(in_cost)
+                        } else {
+                            _props.error.unwrap_or(in_cost)
+                        }
+                    }
+                    _ => in_cost,
+                },
+                Err(_) => in_cost,
+            };
+            Ok(cost)
+        };
+
+    let preferred_direction = match natural_direction {
+        Some(false) => None,
+        _ => {
+            // None or Some(true)
+            let mut edge_set = HashSet::new();
+            for item in coupling_edges.iter() {
+                if let Ok(tuple) = item.extract::<(usize, usize)>() {
+                    edge_set.insert(tuple);
+                }
+            }
+            let zero_one = edge_set.contains(&(qubits[0].0 as usize, qubits[1].0 as usize));
+            let one_zero = edge_set.contains(&(qubits[1].0 as usize, qubits[0].0 as usize));
+
+            match (zero_one, one_zero) {
+                (true, false) => Some(true),
+                (false, true) => Some(false),
+                _ => {
+                    let mut cost_0_1: f64 = f64::INFINITY;
+                    let mut cost_1_0: f64 = f64::INFINITY;
+
+                    // Try to find the cost in gate_lengths
+                    cost_0_1 = compute_cost(true, qubits, cost_0_1)?;
+                    cost_1_0 = compute_cost(true, reverse_qubits, cost_1_0)?;
+
+                    // If no valid cost was found in gate_lengths, check gate_errors
+                    if !(cost_0_1 < f64::INFINITY || cost_1_0 < f64::INFINITY) {
+                        cost_0_1 = compute_cost(false, qubits, cost_0_1)?;
+                        cost_1_0 = compute_cost(false, reverse_qubits, cost_1_0)?;
+                    }
+
+                    if cost_0_1 < cost_1_0 {
+                        Some(true)
+                    } else if cost_1_0 < cost_0_1 {
+                        Some(false)
+                    } else {
+                        None
+                    }
+                }
+            }
+        }
+    };
+
+    if natural_direction == Some(true) && preferred_direction.is_none() {
+        return Err(QiskitError::new_err(format!(
+            concat!(
+                "No preferred direction of gate on qubits {:?} ",
+                "could be determined from coupling map or gate lengths / gate errors."
+            ),
+            qubits
+        )));
+    }
+
+    Ok(preferred_direction)
+}
+
+fn synth_su4_sequence(
+    su4_mat: &Array2<Complex64>,
+    decomposer_2q: &DecomposerElement,
+    preferred_direction: Option<bool>,
+    approximation_degree: Option<f64>,
+) -> PyResult<TwoQubitUnitarySequence> {
+    let is_approximate = approximation_degree.is_none() || approximation_degree.unwrap() != 1.0;
+    let synth = if let DecomposerType::TwoQubitBasisDecomposer(decomp) = &decomposer_2q.decomposer {
+        decomp.call_inner(su4_mat.view(), None, is_approximate, None)?
+    } else {
+        unreachable!("synth_su4_sequence should only be called for TwoQubitBasisDecomposer.")
+    };
+    let sequence = TwoQubitUnitarySequence {
+        gate_sequence: synth,
+        decomp_gate: decomposer_2q.gate.clone(),
+    };
+
+    match preferred_direction {
+        None => Ok(sequence),
+        Some(preferred_dir) => {
+            let mut synth_direction: Option<SmallVec<[u8; 2]>> = None;
+            // if the gates in synthesis are in the opposite direction of the preferred direction
+            // resynthesize a new operator which is the original conjugated by swaps.
+            // this new operator is doubly mirrored from the original and is locally equivalent.
+            for (gate, _, qubits) in sequence.gate_sequence.gates() {
+                if gate.is_none() || gate.unwrap().name() == "cx" {
+                    synth_direction = Some(qubits.clone());
+                }
+            }
+
+            match synth_direction {
+                None => Ok(sequence),
+                Some(synth_direction) => {
+                    let synth_dir = match synth_direction.as_slice() {
+                        [0, 1] => true,
+                        [1, 0] => false,
+                        _ => unreachable!(),
+                    };
+                    if synth_dir != preferred_dir {
+                        reversed_synth_su4_sequence(
+                            su4_mat.clone(),
+                            decomposer_2q,
+                            approximation_degree,
+                        )
+                    } else {
+                        Ok(sequence)
+                    }
+                }
+            }
+        }
+    }
+}
+
+fn reversed_synth_su4_sequence(
+    mut su4_mat: Array2<Complex64>,
+    decomposer_2q: &DecomposerElement,
+    approximation_degree: Option<f64>,
+) -> PyResult<TwoQubitUnitarySequence> {
+    let is_approximate = approximation_degree.is_none() || approximation_degree.unwrap() != 1.0;
+    // Swap rows 1 and 2
+    let (mut row_1, mut row_2) = su4_mat.multi_slice_mut((s![1, ..], s![2, ..]));
+    azip!((x in &mut row_1, y in &mut row_2) (*x, *y) = (*y, *x));
+
+    // Swap columns 1 and 2
+    let (mut col_1, mut col_2) = su4_mat.multi_slice_mut((s![.., 1], s![.., 2]));
+    azip!((x in &mut col_1, y in &mut col_2) (*x, *y) = (*y, *x));
+
+    let synth = if let DecomposerType::TwoQubitBasisDecomposer(decomp) = &decomposer_2q.decomposer {
+        decomp.call_inner(su4_mat.view(), None, is_approximate, None)?
+    } else {
+        unreachable!(
+            "reversed_synth_su4_sequence should only be called for TwoQubitBasisDecomposer."
+        )
+    };
+
+    let flip_bits: [u8; 2] = [1, 0];
+    let mut reversed_gates = Vec::with_capacity(synth.gates().len());
+    for (gate, params, qubit_ids) in synth.gates() {
+        let new_qubit_ids = qubit_ids
+            .into_iter()
+            .map(|x| flip_bits[*x as usize])
+            .collect::<SmallVec<[u8; 2]>>();
+        reversed_gates.push((*gate, params.clone(), new_qubit_ids.clone()));
+    }
+
+    let mut reversed_synth: TwoQubitGateSequence = TwoQubitGateSequence::new();
+    reversed_synth.set_state((reversed_gates, synth.global_phase()));
+    let sequence = TwoQubitUnitarySequence {
+        gate_sequence: reversed_synth,
+        decomp_gate: decomposer_2q.gate.clone(),
+    };
+    Ok(sequence)
+}
+
+fn synth_su4_dag(
+    py: Python,
+    su4_mat: &Array2<Complex64>,
+    decomposer_2q: &DecomposerElement,
+    preferred_direction: Option<bool>,
+    approximation_degree: Option<f64>,
+) -> PyResult<DAGCircuit> {
+    let is_approximate = approximation_degree.is_none() || approximation_degree.unwrap() != 1.0;
+    let synth_dag = if let DecomposerType::XXDecomposer(decomposer) = &decomposer_2q.decomposer {
+        let kwargs: HashMap<&str, bool> = [("approximate", is_approximate), ("use_dag", true)]
+            .into_iter()
+            .collect();
+        decomposer
+            .call_bound(
+                py,
+                (su4_mat.clone().into_pyarray_bound(py),),
+                Some(&kwargs.into_py_dict_bound(py)),
+            )?
+            .extract::<DAGCircuit>(py)?
+    } else {
+        unreachable!("synth_su4_dag should only be called for XXDecomposer.")
+    };
+
+    match preferred_direction {
+        None => Ok(synth_dag),
+        Some(preferred_dir) => {
+            let mut synth_direction: Option<Vec<u32>> = None;
+            for node in synth_dag.topological_op_nodes()? {
+                let inst = &synth_dag.dag()[node].unwrap_operation();
+                if inst.op.num_qubits() == 2 {
+                    let qargs = synth_dag.get_qargs(inst.qubits);
+                    synth_direction = Some(vec![qargs[0].0, qargs[1].0]);
+                }
+            }
+            match synth_direction {
+                None => Ok(synth_dag),
+                Some(synth_direction) => {
+                    let synth_dir = match synth_direction.as_slice() {
+                        [0, 1] => true,
+                        [1, 0] => false,
+                        _ => unreachable!("There are no more than 2 possible synth directions."),
+                    };
+                    if synth_dir != preferred_dir {
+                        reversed_synth_su4_dag(
+                            py,
+                            su4_mat.clone(),
+                            decomposer_2q,
+                            approximation_degree,
+                        )
+                    } else {
+                        Ok(synth_dag)
+                    }
+                }
+            }
+        }
+    }
+}
+
+fn reversed_synth_su4_dag(
+    py: Python<'_>,
+    mut su4_mat: Array2<Complex64>,
+    decomposer_2q: &DecomposerElement,
+    approximation_degree: Option<f64>,
+) -> PyResult<DAGCircuit> {
+    let is_approximate = approximation_degree.is_none() || approximation_degree.unwrap() != 1.0;
+
+    // Swap rows 1 and 2
+    let (mut row_1, mut row_2) = su4_mat.multi_slice_mut((s![1, ..], s![2, ..]));
+    azip!((x in &mut row_1, y in &mut row_2) (*x, *y) = (*y, *x));
+
+    // Swap columns 1 and 2
+    let (mut col_1, mut col_2) = su4_mat.multi_slice_mut((s![.., 1], s![.., 2]));
+    azip!((x in &mut col_1, y in &mut col_2) (*x, *y) = (*y, *x));
+
+    let synth_dag = if let DecomposerType::XXDecomposer(decomposer) = &decomposer_2q.decomposer {
+        let kwargs: HashMap<&str, bool> = [("approximate", is_approximate), ("use_dag", true)]
+            .into_iter()
+            .collect();
+        decomposer
+            .call_bound(
+                py,
+                (su4_mat.clone().into_pyarray_bound(py),),
+                Some(&kwargs.into_py_dict_bound(py)),
+            )?
+            .extract::<DAGCircuit>(py)?
+    } else {
+        unreachable!("reversed_synth_su4_dag should only be called for XXDecomposer")
+    };
+
+    let mut target_dag = synth_dag.copy_empty_like(py, "alike")?;
+    let flip_bits: [Qubit; 2] = [Qubit(1), Qubit(0)];
+    for node in synth_dag.topological_op_nodes()? {
+        let mut inst = synth_dag.dag()[node].unwrap_operation().clone();
+        let qubits: Vec<Qubit> = synth_dag
+            .qargs_interner()
+            .get(inst.qubits)
+            .iter()
+            .map(|x| flip_bits[x.0 as usize])
+            .collect();
+        inst.qubits = target_dag.qargs_interner.insert_owned(qubits);
+        target_dag.push_back(py, inst)?;
+    }
+    Ok(target_dag)
+}
+
+#[pymodule]
+pub fn unitary_synthesis(m: &Bound<PyModule>) -> PyResult<()> {
+    m.add_wrapped(wrap_pyfunction!(py_run_main_loop))?;
+    Ok(())
+}
diff --git a/crates/circuit/src/dag_circuit.rs b/crates/circuit/src/dag_circuit.rs
index 8e0e73d72ca2..7faec999944f 100644
--- a/crates/circuit/src/dag_circuit.rs
+++ b/crates/circuit/src/dag_circuit.rs
@@ -250,9 +250,9 @@ pub struct DAGCircuit {
     cregs: Py<PyDict>,

     /// The cache used to intern instruction qargs.
-    qargs_interner: Interner<[Qubit]>,
+    pub qargs_interner: Interner<[Qubit]>,
     /// The cache used to intern instruction cargs.
-    cargs_interner: Interner<[Clbit]>,
+    pub cargs_interner: Interner<[Clbit]>,
     /// Qubits registered in the circuit.
     qubits: BitData<Qubit>,
     /// Clbits registered in the circuit.
@@ -3404,8 +3404,8 @@ def _format(operand):
     /// Raises:
     ///     DAGCircuitError: If replacement operation was incompatible with
     ///     location of target node.
-    #[pyo3(signature = (node, op, inplace=false, propagate_condition=true))]
-    fn substitute_node(
+    #[pyo3(name = "substitute_node", signature = (node, op, inplace=false, propagate_condition=true))]
+    pub fn py_substitute_node(
         &mut self,
         node: &Bound<PyAny>,
         op: &Bound<PyAny>,
diff --git a/crates/circuit/src/imports.rs b/crates/circuit/src/imports.rs
index 6e87bcb4f101..538f819a1150 100644
--- a/crates/circuit/src/imports.rs
+++ b/crates/circuit/src/imports.rs
@@ -116,6 +116,12 @@ pub static UNITARY_GATE: ImportOnceCell = ImportOnceCell::new(
     "qiskit.circuit.library.generalized_gates.unitary",
     "UnitaryGate",
 );
+pub static QS_DECOMPOSITION: ImportOnceCell =
+    ImportOnceCell::new("qiskit.synthesis.unitary.qsd", "qs_decomposition");
+pub static XX_DECOMPOSER: ImportOnceCell =
+    ImportOnceCell::new("qiskit.synthesis.two_qubit.xx_decompose", "XXDecomposer");
+pub static XX_EMBODIMENTS: ImportOnceCell =
+    ImportOnceCell::new("qiskit.synthesis.two_qubit.xx_decompose", "XXEmbodiments");

 /// A mapping from the enum variant in crate::operations::StandardGate to the python
 /// module path and class name to import it. This is used to populate the conversion table
diff --git a/crates/pyext/src/lib.rs b/crates/pyext/src/lib.rs
index b59f3c8d8eda..bc0d44a9dd44 100644
--- a/crates/pyext/src/lib.rs
+++ b/crates/pyext/src/lib.rs
@@ -60,6 +60,7 @@ fn _accelerate(m: &Bound<PyModule>) -> PyResult<()> {
     add_submodule(m, ::qiskit_accelerate::synthesis::synthesis, "synthesis")?;
     add_submodule(m, ::qiskit_accelerate::target_transpiler::target, "target")?;
     add_submodule(m, ::qiskit_accelerate::two_qubit_decompose::two_qubit_decompose, "two_qubit_decompose")?;
+    add_submodule(m, ::qiskit_accelerate::unitary_synthesis::unitary_synthesis, "unitary_synthesis")?;
     add_submodule(m, ::qiskit_accelerate::uc_gate::uc_gate, "uc_gate")?;
     add_submodule(m, ::qiskit_accelerate::utils::utils, "utils")?;
     add_submodule(m, ::qiskit_accelerate::vf2_layout::vf2_layout, "vf2_layout")?;
diff --git a/qiskit/__init__.py b/qiskit/__init__.py
index 196b10da3183..26a72de2f722 100644
--- a/qiskit/__init__.py
+++ b/qiskit/__init__.py
@@ -85,6 +85,7 @@
 sys.modules["qiskit._accelerate.elide_permutations"] = _accelerate.elide_permutations
 sys.modules["qiskit._accelerate.target"] = _accelerate.target
 sys.modules["qiskit._accelerate.two_qubit_decompose"] = _accelerate.two_qubit_decompose
+sys.modules["qiskit._accelerate.unitary_synthesis"] = _accelerate.unitary_synthesis
 sys.modules["qiskit._accelerate.vf2_layout"] = _accelerate.vf2_layout
 sys.modules["qiskit._accelerate.synthesis.permutation"] = _accelerate.synthesis.permutation
 sys.modules["qiskit._accelerate.synthesis.linear"] = _accelerate.synthesis.linear
diff --git a/qiskit/transpiler/passes/synthesis/unitary_synthesis.py b/qiskit/transpiler/passes/synthesis/unitary_synthesis.py
index e31f6918f452..883dbb6c4d68 100644
--- a/qiskit/transpiler/passes/synthesis/unitary_synthesis.py
+++ b/qiskit/transpiler/passes/synthesis/unitary_synthesis.py
@@ -73,6 +73,7 @@
 from qiskit.transpiler.passes.synthesis import plugin
 from qiskit.transpiler.target import Target

+from qiskit._accelerate.unitary_synthesis import run_default_main_loop

 GATE_NAME_MAP = {
     "cx": CXGate._standard_gate,
@@ -501,9 +502,27 @@ def run(self, dag: DAGCircuit) -> DAGCircuit:
             if plugin_method.supports_coupling_map or default_method.supports_coupling_map
             else {}
         )
-        return self._run_main_loop(
-            dag, qubit_indices, plugin_method, plugin_kwargs, default_method, default_kwargs
-        )
+
+        if self.method == "default" and isinstance(kwargs["target"], Target):
+            _coupling_edges = (
+                list(self._coupling_map.get_edges()) if self._coupling_map is not None else []
+            )
+
+            out = run_default_main_loop(
+                dag,
+                list(qubit_indices.values()),
+                self._min_qubits,
+                kwargs["target"],
+                _coupling_edges,
+                self._approximation_degree,
+                kwargs["natural_direction"],
+            )
+            return out
+        else:
+            out = self._run_main_loop(
+                dag, qubit_indices, plugin_method, plugin_kwargs, default_method, default_kwargs
+            )
+            return out

     def _run_main_loop(
         self, dag, qubit_indices, plugin_method, plugin_kwargs, default_method, default_kwargs

diff --git a/test/python/transpiler/test_unitary_synthesis.py b/test/python/transpiler/test_unitary_synthesis.py
index 4abf6511d8d2..aaad7b71279b 100644
--- a/test/python/transpiler/test_unitary_synthesis.py
+++ b/test/python/transpiler/test_unitary_synthesis.py
@@ -18,6 +18,7 @@

 import unittest
 import numpy as np
+import scipy
 from ddt import ddt, data

 from qiskit import transpile
@@ -60,11 +61,14 @@
 from qiskit.circuit import Measure
 from qiskit.circuit.controlflow import IfElseOp
 from qiskit.circuit import Parameter, Gate
+from qiskit.synthesis.unitary.qsd import qs_decomposition
+
 from test import combine  # pylint: disable=wrong-import-order
 from test import QiskitTestCase  # pylint: disable=wrong-import-order
 from test.python.providers.fake_mumbai_v2 import (  # pylint: disable=wrong-import-order
     FakeMumbaiFractionalCX,
 )
+
 from ..legacy_cmaps import YORKTOWN_CMAP

@@ -1033,6 +1037,18 @@ def test_parameterized_basis_gate_in_target(self):
         self.assertTrue(set(opcount).issubset({"rz", "rx", "rxx"}))
         self.assertTrue(np.allclose(Operator(qc_transpiled), Operator(qc)))

+    @data(1, 2, 3)
+    def test_qsd(self, opt):
+        """Test that the unitary synthesis pass runs qsd successfully with a target."""
+        num_qubits = 3
+        target = Target(num_qubits=num_qubits)
+        target.add_instruction(UGate(Parameter("theta"), Parameter("phi"), Parameter("lam")))
+        target.add_instruction(CXGate())
+        mat = scipy.stats.ortho_group.rvs(2**num_qubits)
+        qc = qs_decomposition(mat, opt_a1=True, opt_a2=False)
+        qc_transpiled = transpile(qc, target=target, optimization_level=opt)
+        self.assertTrue(np.allclose(mat, Operator(qc_transpiled).data))
+

 if __name__ == "__main__":
     unittest.main()

Port `UnitarySynthesis` to Rust
This issue tracks porting the `UnitarySynthesis` pass to rust as part of the #12208 epic. This pass in particular will still always require a fairly large python component, as the unitary synthesis plugin interface necessitates a Python execution mode. But when the a plugin is not specified we shouldn't need to use python at all. The other option is we can call Python from rust to pass a unitary array to the plugins and then operate exclusively from rust otherwise as well.

performance
Changelog: None
Rust
mod: transpiler


diff --git a/dask/local.py b/dask/local.py
index 9f035ef277a..664ae93aba2 100644
--- a/dask/local.py
+++ b/dask/local.py
@@ -181,7 +181,7 @@ def start_state_from_dask(dsk, cache=None, sortkey=None):
     waiting_data = dict((k, v.copy()) for k, v in dependents.items() if v)

     ready_set = set([k for k, v in waiting.items() if not v])
-    ready = sorted(ready_set, key=sortkey)
+    ready = sorted(ready_set, key=sortkey, reverse=True)
     waiting = dict((k, v) for k, v in waiting.items() if v)

     state = {
@@ -262,7 +262,7 @@ def finish_task(

     Mutates.  This should run atomically (with a lock).
     """
-    for dep in sorted(state["dependents"][key], key=sortkey):
+    for dep in sorted(state["dependents"][key], key=sortkey, reverse=True):
         s = state["waiting"][dep]
         s.remove(key)
         if not s:
@@ -463,11 +463,15 @@ def fire_tasks(chunksize):
                     ntasks = nready
                     chunksize = -(ntasks // -num_workers)
                 else:
-                    ntasks = min(nready, chunksize * num_workers)
+                    used_workers = -(len(state["running"]) // -chunksize)
+                    avail_workers = max(num_workers - used_workers, 0)
+                    ntasks = min(nready, chunksize * avail_workers)

                 # Prep all ready tasks for submission
                 args = []
-                for i, key in zip(range(ntasks), state["ready"]):
+                for _ in range(ntasks):
+                    # Get the next task to compute (most recently added)
+                    key = state["ready"].pop()
                     # Notify task is running
                     state["running"].add(key)
                     for f in pretask_cbs:
@@ -487,7 +491,6 @@ def fire_tasks(chunksize):
                             pack_exception,
                         )
                     )
-                del state["ready"][:ntasks]

                 # Batch submit
                 for i in range(-(len(args) // -chunksize)):

diff --git a/dask/tests/test_local.py b/dask/tests/test_local.py
index 8830a216e27..a45aab7b991 100644
--- a/dask/tests/test_local.py
+++ b/dask/tests/test_local.py
@@ -1,3 +1,5 @@
+import pytest
+
 import dask
 from dask.local import finish_task, get_sync, sortkey, start_state_from_dask
 from dask.order import order
@@ -170,3 +172,22 @@ def append(i):
     get_sync(dsk, "y")

     assert L == sorted(L)
+
+
+def test_complex_ordering():
+    da = pytest.importorskip("dask.array")
+    from dask.diagnostics import Callback
+
+    actual_order = []
+
+    def track_order(key, dask, state):
+        actual_order.append(key)
+
+    x = da.random.normal(size=(20, 20), chunks=(-1, -1))
+    res = (x.dot(x.T) - x.mean(axis=0)).std()
+    dsk = dict(res.__dask_graph__())
+    exp_order_dict = order(dsk)
+    exp_order = sorted(exp_order_dict.keys(), key=exp_order_dict.get)
+    with Callback(pretask=track_order):
+        get_sync(dsk, exp_order[-1])
+    assert actual_order == exp_order
diff --git a/dask/tests/test_order.py b/dask/tests/test_order.py
index 1d90bece50a..d58179d6691 100644
--- a/dask/tests/test_order.py
+++ b/dask/tests/test_order.py
@@ -351,7 +351,7 @@ def test_local_parents_of_reduction(abcde):
     b1, b2, b3 = [b + i for i in "123"]
     c1, c2, c3 = [c + i for i in "123"]

-    expected = [a3, b3, c3, a2, a1, b2, b1, c2, c1]
+    expected = [a3, a2, a1, b3, b2, b1, c3, c2, c1]

     log = []

Regression: poor memory management in threads and sync schedulers
Reopen after triage from https://github.com/pydata/xarray/issues/5165

```python
import dask.array as da
d = da.random.random((15000, 250000), chunks=(1, -1)).sum()
d.compute(optimize_graph=False)
```
Peak RAM usage:
Before #6322 (2021.3.1): 200 MiB
After #6322 (2021.4.0): 28 GiB

This regression impacts the ``threads`` and ``sync`` schedulers. ``processes`` and ``distributed`` are unaffected.

CC @jakirkham

Also cc @mrocklin @jrbourbeau . IMHO this should be treated as very high priority as it is something that is very likely to hit in the face a newbie user when he plays around with dask.array or dask.dataframe for the very first time.
Hi,

I'm also experiencing increased memory usage since ad0e5d140dfc934d94583f8ce50d6b00a04cea38
Is there a workaround for this other than using <=2021.03.1

I'm surprised to see the lack of discussion about this so far. Is there some other thread where this is being tracked?

Best regards,
Parashar

Sorry to hear about the increased memory usage @parashardhapola. If you're able to look into where this might be coming from, that would be welcome.

Gentle ping for @jakirkham in case you have any thoughts about this issue
@jakirkham in case you have some time to look into this.
I'm also having this issue. Something that would use about 3.70 gb ram now just uses so much memory it crashes.
I'm not using dask directly but I'm using xArray.
If you need any info to help diagnose the issue just let me know.
I'm also running into this issue here https://github.com/paoloserra/crystalball/issues/45. Whereas previously memory usage was stable for duration of execution, https://github.com/dask/dask/commit/ad0e5d140dfc934d94583f8ce50d6b00a04cea38 results in a quick OOM.

I wonder if the removal of the reverse keyword when traversing sorted dependencies might be causing the issue?

https://github.com/dask/dask/commit/ad0e5d140dfc934d94583f8ce50d6b00a04cea38#diff-fec9e4eca8d9878b2808d90c5875a3f5f347f88073e51f06a006841eeb3c0cfdR266

/cc @JSKenyon @o-smirnov
This also affect our satellite processing in https://github.com/pytroll/satpy/issues/1791
Feel free to ping me if we can be of any help testing/trying things.
Thanks all for bumping this issue.

@quasiben can you poke @jakirkham about this when he gets back?  This seems to be a priority issue that arose from his work on swapping in concurrent.futures.

Also cc @eriknw in case he is interested (although this may not be ordering related)
I wanted to see if @sjperkins was on to something with the `reverse` changes so I made this script:

```python
$ cat test_dask_memory_regression.py
from dask.diagnostics import ResourceProfiler, Profiler, CacheProfiler, visualize
import dask.array as da
from datetime import datetime

if __name__ == "__main__":
    with Profiler() as prof, ResourceProfiler(dt=0.5) as rprof, CacheProfiler() as cprof:
        d = da.random.random((5000, 250000), chunks=(1, -1)).sum()
        d.compute(optimize_graph=False)
    visualize(
            [rprof, prof, cprof],
            file_path=f'/tmp/profile_{datetime.utcnow():%Y%m%d_%H%M%S}.html',
            show=False
            )
```

Note I used a smaller number of rows to make the computation go faster for easier testing. Ran it locally and here's a screenshot of the results (github doesn't let me attach HTML files, I can put them somewhere if desired).

### Version 2021.3.1 - profile_20210813_132924.html

![image](https://user-images.githubusercontent.com/1828519/129364723-7b42c49f-6d8c-4ed9-bc96-3fba60997d8c.png)

Peak: ~220MB

### Version 2021.6.2 - profile_20210813_131334.html

![image](https://user-images.githubusercontent.com/1828519/129364880-f69882f9-79e9-4472-b257-ec98c6ff412c.png)

Peak: ~10GB

I then hacked my 2021.6.2 install to undo the `reverse` changes (without knowing why the changes were made or what purpose they serve) and did *NOT* see any differences in the profile.

**Edit**: I know I used 2021.6.2 instead of 2021.4.0. I just tested with 2021.4.0 and same result as 2021.6.2 (same appearance in the profile).
If you revert the concurrent.futures commit (is this possible?) do things
improve?

On Fri, Aug 13, 2021 at 8:35 AM David Hoese ***@***.***>
wrote:

> I wanted to see if @sjperkins <https://github.com/sjperkins> was on to
> something with the reverse changes so I made this script:
>
> $ cat test_dask_memory_regression.pyfrom dask.diagnostics import ResourceProfiler, Profiler, CacheProfiler, visualizeimport dask.array as dafrom datetime import datetime
> if __name__ == "__main__":
>     with Profiler() as prof, ResourceProfiler(dt=0.5) as rprof, CacheProfiler() as cprof:
>         d = da.random.random((5000, 250000), chunks=(1, -1)).sum()
>         d.compute(optimize_graph=False)
>     visualize(
>             [rprof, prof, cprof],
>             file_path=f'/tmp/profile_{datetime.utcnow():%Y%m%d_%H%M%S}.html',
>             show=False
>             )
>
> Note I used a smaller number of rows to make the computation go faster for
> easier testing. Ran it locally and here's a screenshot of the results
> (github doesn't let me attach HTML files, I can put them somewhere if
> desired).
> Version 2021.3.1 - profile_20210813_132924.html
>
> [image: image]
> <https://user-images.githubusercontent.com/1828519/129364723-7b42c49f-6d8c-4ed9-bc96-3fba60997d8c.png>
>
> Peak: ~220MB
> Version 2021.6.2 - profile_20210813_131334.html
>
> [image: image]
> <https://user-images.githubusercontent.com/1828519/129364880-f69882f9-79e9-4472-b257-ec98c6ff412c.png>
>
> Peak: ~10GB
>
> I then hacked my 2021.6.2 install to undo the reverse changes (without
> knowing why the changes were made or what purpose they serve) and did
> *NOT* see any differences in the profile.
>
> —
> You are receiving this because you were mentioned.
> Reply to this email directly, view it on GitHub
> <https://github.com/dask/dask/issues/7583#issuecomment-898462342>, or
> unsubscribe
> <https://github.com/notifications/unsubscribe-auth/AACKZTGPVRGUGPUFYWJBYITT4UNS3ANCNFSM43KBT6KA>
> .
> Triage notifications on the go with GitHub Mobile for iOS
> <https://apps.apple.com/app/apple-store/id1477376905?ct=notification-email&mt=8&pt=524675>
> or Android
> <https://play.google.com/store/apps/details?id=com.github.android&utm_campaign=notification-email>
> .
>

`git revert ad0e5d1` leaves a pile of conflicts since this change was ~10 releases ago. The problem was clearly introduced with the `concurrent.futures` change in this commit though. Performance is as expected on the previous commit ([fba94e65](https://github.com/dask/dask/commit/fba94e65e6053163fd1b1ed3c1803ab4f4aa541f)).
Stopping by to share that ordering hasn't changed and, indeed, probably isn't the issue.  Using https://github.com/dask/dask/pull/7992, this plot shows the number of dependencies in memory based on `dask.order` for the example in the original post:

![bokeh_plot (3)](https://user-images.githubusercontent.com/2058401/129389761-213f5f52-0ad8-4b00-87a3-fe0d0960816c.png)

This looks similar to the "Cache Size (count)" profile plot from https://github.com/dask/dask/issues/7583#issuecomment-898462342.  It may be interesting to create this profile plot for the original example too.
> If you revert the concurrent.futures commit (is this possible?) do things improve?

I was hoping to be able to walk through the commits (eventually git bisect) of the PR and find the exact issue. Unfortunately the PR was squashed and @jakirkham deleted the branch (totally expected behavior and is the same thing I would do, just too bad in this case). There may be an option to recover the deleted branch but unlikely, especially to us non-jakirkham people.

I can confirm @slevang's finding that before the concurrent futures PR it is fine and after that squashed commit it jumps up to the high memory usage.

Here's the profile for the original 15000 row example case @eriknw:

![image](https://user-images.githubusercontent.com/1828519/129408056-e8e3f4d4-7a58-4d17-9200-85f7a50874a1.png)

**Edit**: This worked `git fetch origin pull/6322/head:use_concurrent_futures` it seems.
I was able to run git bisect on the individual commits from the pull request and unless I'm doing something wrong it all worked fine until the second to last commit which was a merge of `main` into the PR branch. Not the easiest to decipher after that.

Edit: I was doing something wrong. :wink:
Ok so the first problem commit is https://github.com/dask/dask/pull/6322/commits/9d0aaa29d6b439ba9164d55c98a857f8907b165a

I updated my script for quick memory checking by replacing the visualize call with `print(max(x.mem for x in rprof.results))` which let me easily see if the memory usage was in the ~200s or ~10000s.

I took that problem commit, ran my script and noted the high memory usage, then I manually reverted the changes from that commit, ran my script and noted low memory usage.

This is difficult to go any further for me (I'm making it up as I go along but it's friday so I'm avoiding real work). It is at least semi-simple to understand these if statements in this commit, but @jakirkham goes on in this pull request to re-add `num_workers` which he removed in this commit and then refactor the `get_async` function to the point that I'm not sure where the conditions that these if statement changes were checking are (or should be) in the current code. This may be an overall logic bug in the refactor that is causing this.

Edit: Played around with current main in the `get_async` function to make it look more like it used to and couldn't get the memory usage lower. Even forcing to execute things not in batches didn't do anything. Something in concurrent futures must be holding on to something...also don't listen to me I don't know what I'm talking about.
Adding a diagnostic to main [here](https://github.com/dask/dask/blob/main/dask/local.py#L501):
```python
            while state["waiting"] or state["ready"] or state["running"]:
                print(len(state["waiting"]), len(state["ready"]), len(state["running"]))
```
reveals that all 5000 (or whatever number of) tasks are simultaneously in `state["running"]`, whereas in the last working commit ([e52bbbd](https://github.com/jakirkham/dask/blob/e52bbbd786c57c7aa44ccfde4d2f2f670d16d38c/dask/local.py#L479)) there are never more than `n_workers` tasks in `state["running"]`, which is 8 on my local machine.

In that commit, tasks are explicitly not fired unless `len(state["running"]) < num_workers`, but `get_async` was refactored significantly by the end of this PR so I'm not sure exactly what needs to change.
Thanks for pointing that out. That helps the understanding a lot more. I think I'm actually starting to understand the code, but don't know enough apparently to fix any memory issue. The running tasks are definitely not good and you can fix it by changing the code right under your print statement to say:

```diff
-                fire_tasks(chunksize)
+                print(len(state["waiting"]), len(state["ready"]), len(state["running"]))
+                if len(state["running"]) < num_workers:
+                    fire_tasks(chunksize)
```

But you also need to update the code a little higher so that when tasks are fired only available workers are used:

```diff
-                    ntasks = min(nready, chunksize * num_workers)
+                    avail_workers = max(num_workers - len(state["running"]), 0)
+                    ntasks = min(nready, chunksize * avail_workers)
```

But this doesn't help with the memory. Oh wait...`state['running']` is per-task, not per worker. One sec...

Edit: :facepalm: `chunksize` is 1 so it shouldn't matter. Here's the updated code anyway:

```diff
-                    ntasks = min(nready, chunksize * num_workers)
+                    used_workers = -(len(state["running"]) // -chunksize)
+                    avail_workers = max(num_workers - used_workers, 0)
+                    ntasks = min(nready, chunksize * avail_workers)
+                    print(nready, ntasks, num_workers, len(state["running"]), avail_workers, chunksize)
```

Edit 2: That running is per-task realization also means that the other chunk of code has to be updated:

```diff
-                fire_tasks(chunksize)
+                print(len(state["waiting"]), len(state["ready"]), len(state["running"]))
+                if -(len(state["running"]) // -chunksize) < num_workers:
+                    fire_tasks(chunksize)
```

Edit 3: Ending debugging tonight. It might be worth it to remove batch tasks and only submit one task at a time. That is technically what's happening by default here, but that would at least tell us it is the concurrent.futures change that is causing the memory usage and not something about the batch/chunksize handling (ex. passing a list of arguments to submit).

Edit 4: Here's a solution that works, doesn't actually run anything in batches (chunksize is 1) and still uses 10GB of memory:

```diff
diff --git a/dask/local.py b/dask/local.py
index 9f035ef2..9c95264d 100644
--- a/dask/local.py
+++ b/dask/local.py
@@ -463,7 +463,11 @@ def get_async(
                     ntasks = nready
                     chunksize = -(ntasks // -num_workers)
                 else:
-                    ntasks = min(nready, chunksize * num_workers)
+                    used_workers = -(len(state["running"]) // -chunksize)
+                    avail_workers = max(num_workers - used_workers, 0)
+                    #ntasks = min(nready, chunksize * num_workers)
+                    ntasks = min(nready, chunksize * avail_workers)
+                    print(nready, ntasks, num_workers, len(state["running"]), avail_workers, chunksize)

                 # Prep all ready tasks for submission
                 args = []
@@ -494,13 +498,18 @@ def get_async(
                     each_args = args[i * chunksize : (i + 1) * chunksize]
                     if not each_args:
                         break
-                    fut = submit(batch_execute_tasks, each_args)
+                    fut = submit(execute_task, *each_args[0])  # only works when chunksize is 1
+                    #fut = submit(batch_execute_tasks, each_args)
                     fut.add_done_callback(queue.put)

             # Main loop, wait on tasks to finish, insert new ones
             while state["waiting"] or state["ready"] or state["running"]:
+                print(len(state["waiting"]), len(state["ready"]), len(state["running"]))
                 fire_tasks(chunksize)
-                for key, res_info, failed in queue_get(queue).result():
+                if not len(state["running"]):
+                    print("Nothing is running")
+                    continue
+                for key, res_info, failed in [queue_get(queue).result()]:
                     if failed:
                         exc, tb = loads(res_info)
                         if rerun_exceptions_locally:
```

Makes me think concurrent futures is the problem. And the profile for this code:

![image](https://user-images.githubusercontent.com/1828519/129446092-3c9c9526-294e-4e38-ac26-694719f8b092.png)

So task graph looks the same as previously, but the cache isn't getting cleared up like it should. Sum tasks aren't getting done until all the `np.random` tasks are done.
Got it! See my edits above for the path to debugging, here's the current diff:

```diff
diff --git a/dask/local.py b/dask/local.py
index 9f035ef2..913a4706 100644
--- a/dask/local.py
+++ b/dask/local.py
@@ -463,11 +463,14 @@ def get_async(
                     ntasks = nready
                     chunksize = -(ntasks // -num_workers)
                 else:
-                    ntasks = min(nready, chunksize * num_workers)
+                    used_workers = -(len(state["running"]) // -chunksize)
+                    avail_workers = max(num_workers - used_workers, 0)
+                    ntasks = min(nready, chunksize * avail_workers)
+                    print(nready, ntasks, num_workers, len(state["running"]), avail_workers, chunksize)

                 # Prep all ready tasks for submission
                 args = []
-                for i, key in zip(range(ntasks), state["ready"]):
+                for key in state["ready"][nready-ntasks:nready]:
                     # Notify task is running
                     state["running"].add(key)
                     for f in pretask_cbs:
@@ -487,7 +490,7 @@ def get_async(
                             pack_exception,
                         )
                     )
-                del state["ready"][:ntasks]
+                del state["ready"][nready-ntasks:nready]

                 # Batch submit
                 for i in range(-(len(args) // -chunksize)):
@@ -499,7 +502,11 @@ def get_async(

             # Main loop, wait on tasks to finish, insert new ones
             while state["waiting"] or state["ready"] or state["running"]:
+                print(len(state["waiting"]), len(state["ready"]), len(state["running"]))
                 fire_tasks(chunksize)
+                if not len(state["running"]):
+                    print("Nothing is running")
+                    continue
                 for key, res_info, failed in queue_get(queue).result():
                     if failed:
                         exc, tb = loads(res_info)
```

I can make a pull request later today if this seems reasonable and we can discuss pros/cons of this approach (and see what tests fail). Here's the profile as proof:

![image](https://user-images.githubusercontent.com/1828519/129446945-85714179-568d-410c-addd-f87c47a12bb4.png)

So in summary:

1. The batch logic was submitting all tasks regardless of how many workers were available.
2. The reverse ordering of the ready tasks is needed otherwise we run into the situation in my last comment where all the data generation tasks are executed before the reduction/dependent tasks which fills up the memory. This is likely similar to the distributed memory back pressure discussions that have been going on elsewhere on this repository.


diff --git a/django/core/management/commands/shell.py b/django/core/management/commands/shell.py
index f55b346406aa..1619561fea2e 100644
--- a/django/core/management/commands/shell.py
+++ b/django/core/management/commands/shell.py
@@ -2,7 +2,9 @@
 import select
 import sys
 import traceback
+from collections import defaultdict

+from django.apps import apps
 from django.core.management import BaseCommand, CommandError
 from django.utils.datastructures import OrderedSet

@@ -26,6 +28,11 @@ def add_arguments(self, parser):
                 "variable and ~/.pythonrc.py script."
             ),
         )
+        parser.add_argument(
+            "--no-imports",
+            action="store_true",
+            help="Disable automatic imports of models.",
+        )
         parser.add_argument(
             "-i",
             "--interface",
@@ -47,18 +54,27 @@ def add_arguments(self, parser):
     def ipython(self, options):
         from IPython import start_ipython

-        start_ipython(argv=[])
+        start_ipython(
+            argv=[],
+            user_ns=self.get_and_report_namespace(
+                options["verbosity"], options["no_imports"]
+            ),
+        )

     def bpython(self, options):
         import bpython

-        bpython.embed()
+        bpython.embed(
+            self.get_and_report_namespace(options["verbosity"], options["no_imports"])
+        )

     def python(self, options):
         import code

         # Set up a dictionary to serve as the environment for the shell.
-        imported_objects = {}
+        imported_objects = self.get_and_report_namespace(
+            options["verbosity"], options["no_imports"]
+        )

         # We want to honor both $PYTHONSTARTUP and .pythonrc.py, so follow system
         # conventions and get $PYTHONSTARTUP first then .pythonrc.py.
@@ -111,10 +127,68 @@ def python(self, options):
         # Start the interactive interpreter.
         code.interact(local=imported_objects)

+    def get_and_report_namespace(self, verbosity, no_imports=False):
+        if no_imports:
+            return {}
+
+        namespace = self.get_namespace()
+
+        if verbosity < 1:
+            return namespace
+
+        amount = len(namespace)
+        msg = f"{amount} objects imported automatically"
+
+        if verbosity < 2:
+            self.stdout.write(f"{msg} (use -v 2 for details).", self.style.SUCCESS)
+            return namespace
+
+        imports_by_module = defaultdict(list)
+        for obj_name, obj in namespace.items():
+            if hasattr(obj, "__module__") and (
+                (hasattr(obj, "__qualname__") and obj.__qualname__.find(".") == -1)
+                or not hasattr(obj, "__qualname__")
+            ):
+                imports_by_module[obj.__module__].append(obj_name)
+            if not hasattr(obj, "__module__") and hasattr(obj, "__name__"):
+                tokens = obj.__name__.split(".")
+                if obj_name in tokens:
+                    module = ".".join(t for t in tokens if t != obj_name)
+                    imports_by_module[module].append(obj_name)
+
+        import_string = "\n".join(
+            [
+                f"  from {module} import {objects}"
+                for module, imported_objects in imports_by_module.items()
+                if (objects := ", ".join(imported_objects))
+            ]
+        )
+
+        try:
+            import isort
+        except ImportError:
+            pass
+        else:
+            import_string = isort.code(import_string)
+
+        self.stdout.write(
+            f"{msg}, including:\n\n{import_string}", self.style.SUCCESS, ending="\n\n"
+        )
+
+        return namespace
+
+    def get_namespace(self):
+        apps_models = apps.get_models()
+        namespace = {}
+        for model in reversed(apps_models):
+            if model.__module__:
+                namespace[model.__name__] = model
+        return namespace
+
     def handle(self, **options):
         # Execute the command and exit.
         if options["command"]:
-            exec(options["command"], globals())
+            exec(options["command"], {**globals(), **self.get_namespace()})
             return

         # Execute stdin if it has anything to read and exit.
@@ -124,7 +198,7 @@ def handle(self, **options):
             and not sys.stdin.isatty()
             and select.select([sys.stdin], [], [], 0)[0]
         ):
-            exec(sys.stdin.read(), globals())
+            exec(sys.stdin.read(), {**globals(), **self.get_namespace()})
             return

         available_shells = (
diff --git a/docs/howto/custom-management-commands.txt b/docs/howto/custom-management-commands.txt
index de6c38c1e0f9..ce8c85276cd3 100644
--- a/docs/howto/custom-management-commands.txt
+++ b/docs/howto/custom-management-commands.txt
@@ -157,6 +157,8 @@ Testing
 Information on how to test custom management commands can be found in the
 :ref:`testing docs <topics-testing-management-commands>`.

+.. _overriding-commands:
+
 Overriding commands
 ===================

diff --git a/docs/howto/custom-shell.txt b/docs/howto/custom-shell.txt
new file mode 100644
index 000000000000..07034dd197d4
--- /dev/null
+++ b/docs/howto/custom-shell.txt
@@ -0,0 +1,57 @@
+======================================
+How to customize the ``shell`` command
+======================================
+
+The Django :djadmin:`shell` is an interactive Python environment that provides
+access to models and settings, making it useful for testing code, experimenting
+with queries, and interacting with application data.
+
+Customizing the :djadmin:`shell` command allows adding extra functionality or
+pre-loading specific modules. To do this, create a new management command that subclasses
+``django.core.management.commands.shell.Command`` and overrides the existing
+``shell`` management command. For more details, refer to the guide on
+:ref:`overriding commands <overriding-commands>`.
+
+.. _customizing-shell-auto-imports:
+
+Customize automatic imports
+===========================
+
+.. versionadded:: 5.2
+
+To customize the automatic import behavior of the :djadmin:`shell` management
+command, override the ``get_namespace()`` method. For example:
+
+.. code-block:: python
+    :caption: ``polls/management/commands/shell.py``
+
+    from django.core.management.commands import shell
+
+
+    class Command(shell.Command):
+        def get_namespace(self):
+            from django.urls.base import resolve, reverse
+
+            return {
+                **super().get_namespace(),
+                "resolve": resolve,
+                "reverse": reverse,
+            }
+
+The above customization adds :func:`~django.urls.resolve` and
+:func:`~django.urls.reverse` to the default namespace, which includes all
+models from all apps. These two functions will then be available when the
+shell opens, without a manual import statement.
+
+If you prefer to not have models automatically imported, create a custom
+``get_namespace()`` that excludes the ``super().get_namespace()`` call:
+
+.. code-block:: python
+    :caption: ``polls/management/commands/shell.py``
+
+    from django.core.management.commands import shell
+
+
+    class Command(shell.Command):
+        def get_namespace(self):
+            return {}
diff --git a/docs/howto/index.txt b/docs/howto/index.txt
index d799ca79069d..d49a9b1206f2 100644
--- a/docs/howto/index.txt
+++ b/docs/howto/index.txt
@@ -58,8 +58,9 @@ Other guides

    auth-remote-user
    csrf
-   custom-management-commands
    custom-file-storage
+   custom-management-commands
+   custom-shell

 .. seealso::

diff --git a/docs/intro/tutorial02.txt b/docs/intro/tutorial02.txt
index d43c82c5d2fe..6a87d2d01c24 100644
--- a/docs/intro/tutorial02.txt
+++ b/docs/intro/tutorial02.txt
@@ -347,13 +347,13 @@ API Django gives you. To invoke the Python shell, use this command:
 We're using this instead of simply typing "python", because :file:`manage.py`
 sets the :envvar:`DJANGO_SETTINGS_MODULE` environment variable, which gives
 Django the Python import path to your :file:`mysite/settings.py` file.
+By default, the :djadmin:`shell` command automatically imports the models from
+your :setting:`INSTALLED_APPS`.

 Once you're in the shell, explore the :doc:`database API </topics/db/queries>`:

 .. code-block:: pycon

-    >>> from polls.models import Choice, Question  # Import the model classes we just wrote.
-
     # No questions are in the system yet.
     >>> Question.objects.all()
     <QuerySet []>
@@ -443,8 +443,6 @@ Save these changes and start a new Python interactive shell by running

 .. code-block:: pycon

-    >>> from polls.models import Choice, Question
-
     # Make sure our __str__() addition worked.
     >>> Question.objects.all()
     <QuerySet [<Question: What's up?>]>
diff --git a/docs/intro/tutorial05.txt b/docs/intro/tutorial05.txt
index 921670aa5ecb..219b7b313048 100644
--- a/docs/intro/tutorial05.txt
+++ b/docs/intro/tutorial05.txt
@@ -150,7 +150,6 @@ whose date lies in the future:

     >>> import datetime
     >>> from django.utils import timezone
-    >>> from polls.models import Question
     >>> # create a Question instance with pub_date 30 days in the future
     >>> future_question = Question(pub_date=timezone.now() + datetime.timedelta(days=30))
     >>> # was it published recently?
diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt
index 128abe558750..c393154d882d 100644
--- a/docs/ref/django-admin.txt
+++ b/docs/ref/django-admin.txt
@@ -1065,6 +1065,19 @@ Mails the email addresses specified in :setting:`ADMINS` using

 Starts the Python interactive interpreter.

+All models from installed apps are automatically imported into the shell
+environment. Models from apps listed earlier in :setting:`INSTALLED_APPS` take
+precedence. For a ``--verbosity`` of 2 or higher, the automatically imported
+objects will be listed. To disable automatic importing entirely, use the
+``--no-imports`` flag.
+
+See the guide on :ref:`customizing this behaviour
+<customizing-shell-auto-imports>` to add or remove automatic imports.
+
+.. versionchanged:: 5.2
+
+    Automatic models import was added.
+
 .. django-admin-option:: --interface {ipython,bpython,python}, -i {ipython,bpython,python}

 Specifies the shell to use. By default, Django will use IPython_ or bpython_ if
diff --git a/docs/releases/5.2.txt b/docs/releases/5.2.txt
index f1d68d234fee..35ce053861fd 100644
--- a/docs/releases/5.2.txt
+++ b/docs/releases/5.2.txt
@@ -31,6 +31,26 @@ and only officially support the latest release of each series.
 What's new in Django 5.2
 ========================

+Automatic models import in the ``shell``
+----------------------------------------
+
+The :djadmin:`shell` management command now automatically imports models from
+all installed apps. You can view further details of the imported objects by
+setting the ``--verbosity`` flag to 2 or more:
+
+.. code-block:: pycon
+
+    $ python -Wall manage.py shell --verbosity=2
+    6 objects imported automatically, including:
+
+      from django.contrib.admin.models import LogEntry
+      from django.contrib.auth.models import Group, Permission, User
+      from django.contrib.contenttypes.models import ContentType
+      from django.contrib.sessions.models import Session
+
+This :ref:`behavior can be customized <customizing-shell-auto-imports>` to add
+or remove automatic imports.
+
 Composite Primary Keys
 ----------------------

diff --git a/tests/shell/models.py b/tests/shell/models.py
new file mode 100644
index 000000000000..85b40bf2058e
--- /dev/null
+++ b/tests/shell/models.py
@@ -0,0 +1,9 @@
+from django.db import models
+
+
+class Marker(models.Model):
+    pass
+
+
+class Phone(models.Model):
+    name = models.CharField(max_length=50)
diff --git a/tests/shell/tests.py b/tests/shell/tests.py
index ca823f6290a6..49528cca8e6d 100644
--- a/tests/shell/tests.py
+++ b/tests/shell/tests.py
@@ -3,14 +3,25 @@
 from unittest import mock

 from django import __version__
+from django.contrib.auth.models import Group, Permission, User
+from django.contrib.contenttypes.models import ContentType
 from django.core.management import CommandError, call_command
 from django.core.management.commands import shell
+from django.db import models
 from django.test import SimpleTestCase
-from django.test.utils import captured_stdin, captured_stdout
+from django.test.utils import (
+    captured_stdin,
+    captured_stdout,
+    isolate_apps,
+    override_settings,
+)
+from django.urls.base import resolve, reverse
+
+from .models import Marker, Phone

 class ShellCommandTestCase(SimpleTestCase):
-    script_globals = 'print("__name__" in globals())'
+    script_globals = 'print("__name__" in globals() and "Phone" in globals())'
     script_with_inline_function = (
         "import django\ndef f():\n    print(django.__version__)\nf()"
     )
@@ -76,9 +87,12 @@ def test_ipython(self):
         mock_ipython = mock.Mock(start_ipython=mock.MagicMock())

         with mock.patch.dict(sys.modules, {"IPython": mock_ipython}):
-            cmd.ipython({})
+            cmd.ipython({"verbosity": 0, "no_imports": False})

-        self.assertEqual(mock_ipython.start_ipython.mock_calls, [mock.call(argv=[])])
+        self.assertEqual(
+            mock_ipython.start_ipython.mock_calls,
+            [mock.call(argv=[], user_ns=cmd.get_and_report_namespace(0))],
+        )

     @mock.patch("django.core.management.commands.shell.select.select")  # [1]
     @mock.patch.dict("sys.modules", {"IPython": None})
@@ -94,9 +108,11 @@ def test_bpython(self):
         mock_bpython = mock.Mock(embed=mock.MagicMock())

         with mock.patch.dict(sys.modules, {"bpython": mock_bpython}):
-            cmd.bpython({})
+            cmd.bpython({"verbosity": 0, "no_imports": False})

-        self.assertEqual(mock_bpython.embed.mock_calls, [mock.call()])
+        self.assertEqual(
+            mock_bpython.embed.mock_calls, [mock.call(cmd.get_and_report_namespace(0))]
+        )

     @mock.patch("django.core.management.commands.shell.select.select")  # [1]
     @mock.patch.dict("sys.modules", {"bpython": None})
@@ -112,9 +128,12 @@ def test_python(self):
         mock_code = mock.Mock(interact=mock.MagicMock())

         with mock.patch.dict(sys.modules, {"code": mock_code}):
-            cmd.python({"no_startup": True})
+            cmd.python({"verbosity": 0, "no_startup": True, "no_imports": False})

-        self.assertEqual(mock_code.interact.mock_calls, [mock.call(local={})])
+        self.assertEqual(
+            mock_code.interact.mock_calls,
+            [mock.call(local=cmd.get_and_report_namespace(0))],
+        )

     # [1] Patch select to prevent tests failing when the test suite is run
     # in parallel mode. The tests are run in a subprocess and the subprocess's
@@ -122,3 +141,166 @@ def test_python(self):
     # returns EOF and so select always shows that sys.stdin is ready to read.
     # This causes problems because of the call to select.select() toward the
     # end of shell's handle() method.
+
+
+class ShellCommandAutoImportsTestCase(SimpleTestCase):
+
+    @override_settings(
+        INSTALLED_APPS=["shell", "django.contrib.auth", "django.contrib.contenttypes"]
+    )
+    def test_get_namespace(self):
+        namespace = shell.Command().get_namespace()
+
+        self.assertEqual(
+            namespace,
+            {
+                "Marker": Marker,
+                "Phone": Phone,
+                "ContentType": ContentType,
+                "Group": Group,
+                "Permission": Permission,
+                "User": User,
+            },
+        )
+
+    @override_settings(INSTALLED_APPS=["basic", "shell"])
+    @isolate_apps("basic", "shell", kwarg_name="apps")
+    def test_get_namespace_precedence(self, apps):
+        class Article(models.Model):
+            class Meta:
+                app_label = "basic"
+
+        winner_article = Article
+
+        class Article(models.Model):
+            class Meta:
+                app_label = "shell"
+
+        with mock.patch("django.apps.apps.get_models", return_value=apps.get_models()):
+            namespace = shell.Command().get_namespace()
+            self.assertEqual(namespace, {"Article": winner_article})
+
+    @override_settings(
+        INSTALLED_APPS=["shell", "django.contrib.auth", "django.contrib.contenttypes"]
+    )
+    def test_get_namespace_overridden(self):
+        class TestCommand(shell.Command):
+            def get_namespace(self):
+                from django.urls.base import resolve, reverse
+
+                return {
+                    **super().get_namespace(),
+                    "resolve": resolve,
+                    "reverse": reverse,
+                }
+
+        namespace = TestCommand().get_namespace()
+
+        self.assertEqual(
+            namespace,
+            {
+                "resolve": resolve,
+                "reverse": reverse,
+                "Marker": Marker,
+                "Phone": Phone,
+                "ContentType": ContentType,
+                "Group": Group,
+                "Permission": Permission,
+                "User": User,
+            },
+        )
+
+    @override_settings(
+        INSTALLED_APPS=["shell", "django.contrib.auth", "django.contrib.contenttypes"]
+    )
+    def test_no_imports_flag(self):
+        for verbosity in (0, 1, 2, 3):
+            with self.subTest(verbosity=verbosity), captured_stdout() as stdout:
+                namespace = shell.Command().get_and_report_namespace(
+                    verbosity=verbosity, no_imports=True
+                )
+            self.assertEqual(namespace, {})
+            self.assertEqual(stdout.getvalue().strip(), "")
+
+    @override_settings(
+        INSTALLED_APPS=["shell", "django.contrib.auth", "django.contrib.contenttypes"]
+    )
+    def test_verbosity_zero(self):
+        with captured_stdout() as stdout:
+            cmd = shell.Command()
+            namespace = cmd.get_and_report_namespace(verbosity=0)
+        self.assertEqual(namespace, cmd.get_namespace())
+        self.assertEqual(stdout.getvalue().strip(), "")
+
+    @override_settings(
+        INSTALLED_APPS=["shell", "django.contrib.auth", "django.contrib.contenttypes"]
+    )
+    def test_verbosity_one(self):
+        with captured_stdout() as stdout:
+            cmd = shell.Command()
+            namespace = cmd.get_and_report_namespace(verbosity=1)
+        self.assertEqual(namespace, cmd.get_namespace())
+        self.assertEqual(
+            stdout.getvalue().strip(),
+            "6 objects imported automatically (use -v 2 for details).",
+        )
+
+    @override_settings(INSTALLED_APPS=["shell", "django.contrib.contenttypes"])
+    @mock.patch.dict(sys.modules, {"isort": None})
+    def test_message_with_stdout_listing_objects_with_isort_not_installed(self):
+        class TestCommand(shell.Command):
+            def get_namespace(self):
+                class MyClass:
+                    pass
+
+                constant = "constant"
+
+                return {
+                    **super().get_namespace(),
+                    "MyClass": MyClass,
+                    "constant": constant,
+                }
+
+        with captured_stdout() as stdout:
+            TestCommand().get_and_report_namespace(verbosity=2)
+
+        self.assertEqual(
+            stdout.getvalue().strip(),
+            "5 objects imported automatically, including:\n\n"
+            "  from django.contrib.contenttypes.models import ContentType\n"
+            "  from shell.models import Phone, Marker",
+        )
+
+    @override_settings(INSTALLED_APPS=["shell", "django.contrib.contenttypes"])
+    def test_message_with_stdout_listing_objects_with_isort(self):
+        sorted_imports = (
+            "  from shell.models import Marker, Phone\n\n"
+            "  from django.contrib.contenttypes.models import ContentType"
+        )
+        mock_isort_code = mock.Mock(code=mock.MagicMock(return_value=sorted_imports))
+
+        class TestCommand(shell.Command):
+            def get_namespace(self):
+                class MyClass:
+                    pass
+
+                constant = "constant"
+
+                return {
+                    **super().get_namespace(),
+                    "MyClass": MyClass,
+                    "constant": constant,
+                }
+
+        with (
+            mock.patch.dict(sys.modules, {"isort": mock_isort_code}),
+            captured_stdout() as stdout,
+        ):
+            TestCommand().get_and_report_namespace(verbosity=2)
+
+        self.assertEqual(
+            stdout.getvalue().strip(),
+            "5 objects imported automatically, including:\n\n"
+            "  from shell.models import Marker, Phone\n\n"
+            "  from django.contrib.contenttypes.models import ContentType",
+        )

Auto-importing models feature for shell-command
Description

This would be an update of the existing Django shell that auto-imports models for you from your app/project. Also, the goal would be to allow the user to subclass this shell to customize its behavior and import extra things.
wiki
proposal
Auto-importing models feature for shell-command
Description

This would be an update of the existing Django shell that auto-imports models for you from your app/project. Also, the goal would be to allow the user to subclass this shell to customize its behavior and import extra things.
wiki
proposal

['In dfac15d5: Fixed #35517, Refs #35515 -- Improved test coverage of shell command.', 1719481381.0]
['Discussion to decide on whether to have additional imports (and which ones): \u200bhttps://forum.djangoproject.com/t/default-automatic-imports-in-the-shell/33708', 1723261632.0]
['Based off the discussion, it looks likely there will be additional imports added', 1723445780.0]
['Agreed that the outcome of the discussion can be captured in a new ticket and worked on later', 1723462346.0]
["Want to make sure we're happy with what gets printed to the console", 1723683525.0]
['In dfac15d5: Fixed #35517, Refs #35515 -- Improved test coverage of shell command.', 1719481381.0]
['Discussion to decide on whether to have additional imports (and which ones): \u200bhttps://forum.djangoproject.com/t/default-automatic-imports-in-the-shell/33708', 1723261632.0]
['Based off the discussion, it looks likely there will be additional imports added', 1723445780.0]
['Agreed that the outcome of the discussion can be captured in a new ticket and worked on later', 1723462346.0]
["Want to make sure we're happy with what gets printed to the console", 1723683525.0]