From f93c95b3a149ce008a031405c597f1181ccaa21f Mon Sep 17 00:00:00 2001 From: jaunatisblue Date: Mon, 18 May 2026 22:58:57 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BB=A3=E7=A0=81=E5=B0=81=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../site-packages/quimb/tensor/circuit.py | 163 ++- .../site-packages/quimb/tensor/tn1d/core.py | 2 - README.md | 21 +- benchmark_cpu_expectation.py | 285 ----- docs/contest_runners.md | 94 +- docs/home.md | 26 + docs/xianchang.md | 42 - requirements.txt | 13 +- run_vidal_mps_cases.sh | 135 -- src/qibotn/__init__.py | 102 ++ src/qibotn/backends/cpu.py | 208 ++-- src/qibotn/backends/cutensornet_helpers.py | 321 +++++ src/qibotn/backends/qmatchatea.py | 208 ++++ src/qibotn/backends/quimb.py | 1101 +++++++++++++++-- src/qibotn/backends/vidal.py | 509 ++++++++ src/qibotn/benchmark_cases.py | 24 +- src/qibotn/circuit_convertor.py | 263 ---- src/qibotn/circuit_to_mps.py | 63 - src/qibotn/contest_cases.py | 241 ++++ src/qibotn/eval.py | 8 +- src/qibotn/expectation_runner.py | 167 ++- src/qibotn/mps_contraction_helper.py | 131 -- src/qibotn/mps_utils.py | 111 -- src/qibotn/observables.py | 12 +- src/qibotn/parallel.py | 417 ++++++- src/qibotn/torch_utils.py | 90 ++ tests/test_cpu_backend.py | 30 + tools/README.md | 19 - tools/baseline_mps_expectation.py | 201 --- tools/benchmark_contract_sliced.py | 56 - tools/benchmark_qredtea_svd_controls.py | 157 --- tools/benchmark_search.py | 34 - tools/benchmark_slice.py | 16 - tools/benchmark_tn_mpi.py | 378 ------ tools/check_tree.py | 25 - tools/compare_vidal_backend_qmatchatea.py | 137 -- tools/example_tn_case.py | 33 - tools/inspect_contraction_tree.py | 208 ---- tools/manage_tn_dask_cluster.sh | 223 ---- tools/mpi_torch_thread_probe.py | 182 --- tools/mps_contest_runner.py | 313 ----- tools/profile_vidal_chrome.py | 72 -- tools/qibojit_reference_expectation.py | 109 -- tools/qibotn_torch_mt_env.sh | 22 - tools/run_cpu_large_cases.sh | 128 -- tools/run_cpu_single_cases.sh | 149 --- tools/run_tn_custom.py | 243 ---- tools/run_tn_dask_mpi_all.sh | 260 ---- tools/run_vidal_mpi_contest_cases.sh | 414 ------- tools/run_vidal_segment_mpi_scan.sh | 70 -- tools/slice_existing_tree.py | 59 - tools/tn_contest_runner.py | 443 ------- tools/torch_profile_tn_complex64.py | 114 -- tools/validate_vidal_mpi_correctness.py | 202 --- tools/vidal_mpi_contest_runner.py | 209 ---- .../main1_long_z_string_34q20l_auto.pkl | Bin 451239 -> 0 bytes 56 files changed, 3414 insertions(+), 5849 deletions(-) delete mode 100644 benchmark_cpu_expectation.py create mode 100644 docs/home.md delete mode 100644 docs/xianchang.md delete mode 100755 run_vidal_mps_cases.sh create mode 100644 src/qibotn/backends/cutensornet_helpers.py delete mode 100644 src/qibotn/circuit_convertor.py delete mode 100644 src/qibotn/circuit_to_mps.py create mode 100644 src/qibotn/contest_cases.py delete mode 100644 src/qibotn/mps_contraction_helper.py delete mode 100644 src/qibotn/mps_utils.py create mode 100644 src/qibotn/torch_utils.py delete mode 100644 tools/README.md delete mode 100644 tools/baseline_mps_expectation.py delete mode 100644 tools/benchmark_contract_sliced.py delete mode 100644 tools/benchmark_qredtea_svd_controls.py delete mode 100644 tools/benchmark_search.py delete mode 100644 tools/benchmark_slice.py delete mode 100644 tools/benchmark_tn_mpi.py delete mode 100644 tools/check_tree.py delete mode 100644 tools/compare_vidal_backend_qmatchatea.py delete mode 100644 tools/example_tn_case.py delete mode 100644 tools/inspect_contraction_tree.py delete mode 100755 tools/manage_tn_dask_cluster.sh delete mode 100644 tools/mpi_torch_thread_probe.py delete mode 100644 tools/mps_contest_runner.py delete mode 100644 tools/profile_vidal_chrome.py delete mode 100644 tools/qibojit_reference_expectation.py delete mode 100644 tools/qibotn_torch_mt_env.sh delete mode 100755 tools/run_cpu_large_cases.sh delete mode 100755 tools/run_cpu_single_cases.sh delete mode 100644 tools/run_tn_custom.py delete mode 100755 tools/run_tn_dask_mpi_all.sh delete mode 100755 tools/run_vidal_mpi_contest_cases.sh delete mode 100755 tools/run_vidal_segment_mpi_scan.sh delete mode 100644 tools/slice_existing_tree.py delete mode 100644 tools/tn_contest_runner.py delete mode 100644 tools/torch_profile_tn_complex64.py delete mode 100644 tools/validate_vidal_mpi_correctness.py delete mode 100644 tools/vidal_mpi_contest_runner.py delete mode 100644 trees/contest_tn/main1_long_z_string_34q20l_auto.pkl diff --git a/.venv/lib/python3.12/site-packages/quimb/tensor/circuit.py b/.venv/lib/python3.12/site-packages/quimb/tensor/circuit.py index 1bbf75f..b75c52d 100644 --- a/.venv/lib/python3.12/site-packages/quimb/tensor/circuit.py +++ b/.venv/lib/python3.12/site-packages/quimb/tensor/circuit.py @@ -1573,6 +1573,23 @@ def _combine_1q_gate_run(gates, array_fn=None): return Gate.from_raw(G, gates[0].qubits) +def _combine_2q_gate_run(gates, array_fn=None): + """Combine a run of two qubit gates in application order.""" + gates = tuple(gate for _, gate in gates) + G = gates[0].array + if array_fn is not None: + G = array_fn(G) + G = reshape(G, (4, 4)) + + for gate in gates[1:]: + Gi = gate.array + if array_fn is not None: + Gi = array_fn(Gi) + G = reshape(Gi, (4, 4)) @ G + + return Gate.from_raw(reshape(G, (2, 2, 2, 2)), gates[0].qubits) + + def _can_merge_1q_gate(gate): return ( (gate.controls is None) @@ -1583,48 +1600,96 @@ def _can_merge_1q_gate(gate): ) -def _iter_gates_with_merged_1q_runs(gates): +def _can_merge_2q_gate(gate): + return ( + (gate.controls is None) + and (not gate.special) + and (not gate.parametrize) + and (gate.qubits is not None) + and (len(gate.qubits) == 2) + ) + + +def _iter_gates_with_merged_runs(gates, merge_1q=True, merge_2q=True): """Yield ``(gate_to_apply, gates_to_record)``, merging adjacent runs of - single qubit gates that are not interrupted by any operation touching the - same qubit. + local gates that are not interrupted by any operation touching the same + qubits. """ - pending = {} + pending_1q = {} + pending_2q = {} def flush_qubit(q): - run = pending.pop(q, None) + run = pending_1q.pop(q, None) if run is None: return if len(run) == 1: return run[0][1], run return None, run + def flush_pair(pair): + run = pending_2q.pop(pair, None) + if run is None: + return + if len(run) == 1: + return run[0][1], run + return None, run + + def flush_touched(touched, keep_qubit=None, keep_pair=None): + for q in tuple(pending_1q): + if q == keep_qubit: + continue + if q in touched: + item = flush_qubit(q) + if item is not None: + yield item + + for pair in tuple(pending_2q): + if pair == keep_pair: + continue + if touched.intersection(pair): + item = flush_pair(pair) + if item is not None: + yield item + def flush_all(): - for q in tuple(pending): + for q in tuple(pending_1q): item = flush_qubit(q) if item is not None: yield item + for pair in tuple(pending_2q): + item = flush_pair(pair) + if item is not None: + yield item for i, gate in enumerate(gates): - if _can_merge_1q_gate(gate): + if merge_1q and _can_merge_1q_gate(gate): (q,) = gate.qubits - pending.setdefault(q, []).append((i, gate)) + yield from flush_touched({q}, keep_qubit=q) + pending_1q.setdefault(q, []).append((i, gate)) + continue + + if merge_2q and _can_merge_2q_gate(gate): + pair = gate.qubits + yield from flush_touched(set(pair), keep_pair=pair) + pending_2q.setdefault(pair, []).append((i, gate)) continue touched = set(gate.qubits or ()) if gate.controls: touched.update(gate.controls) - for q in tuple(pending): - if q in touched: - item = flush_qubit(q) - if item is not None: - yield item + yield from flush_touched(touched) yield gate, ((i, gate),) yield from flush_all() +_iter_gates_with_merged_1q_runs = functools.partial( + _iter_gates_with_merged_runs, merge_1q=True, merge_2q=False +) + + # --------------------------- main circuit class ---------------------------- # @@ -2103,6 +2168,24 @@ class Circuit: self._psi.gate_(G, gates[0][1].qubits, tags=tags, **opts) + def _apply_merged_2q_gate_run(self, gates, gate_number_offset=0, **gate_opts): + tags = tags_to_oset(gate_opts.pop("tags", None)) + for i, gate in gates: + tags |= self._gate_tags_for_record( + gate, gate_number=gate_number_offset + i + ) + + opts = {**self.gate_opts, **gate_opts} + + if self.convert_eager: + G = _combine_2q_gate_run( + gates, array_fn=self._maybe_convert_gate_array + ).array + else: + G = _combine_2q_gate_run(gates).array + + self._psi.gate_(G, gates[0][1].qubits, tags=tags, **opts) + def apply_gate( self, gate_id, @@ -2178,11 +2261,14 @@ class Circuit: Supplied to :meth:`~quimb.tensor.circuit.Circuit.apply_gate`. """ merge_1q = gate_opts.pop("merge_1q", "auto") + merge_2q = gate_opts.pop("merge_2q", "auto") if merge_1q == "auto": merge_1q = True + if merge_2q == "auto": + merge_2q = True - if merge_1q: + if merge_1q or merge_2q: gates = tuple( gate if isinstance(gate, Gate) else parse_to_gate(gate) for gate in gates @@ -2195,15 +2281,22 @@ class Circuit: pbar = _progbar(total=len(gates)) gate_number_offset = len(self._gates) - for gate, gates_to_record in _iter_gates_with_merged_1q_runs( - gates + for gate, gates_to_record in _iter_gates_with_merged_runs( + gates, merge_1q=merge_1q, merge_2q=merge_2q ): if gate is None: - self._apply_merged_1q_gate_run( - gates_to_record, - gate_number_offset=gate_number_offset, - **gate_opts, - ) + if len(gates_to_record[0][1].qubits) == 1: + self._apply_merged_1q_gate_run( + gates_to_record, + gate_number_offset=gate_number_offset, + **gate_opts, + ) + else: + self._apply_merged_2q_gate_run( + gates_to_record, + gate_number_offset=gate_number_offset, + **gate_opts, + ) else: self._apply_gate( gate, @@ -4892,11 +4985,16 @@ class CircuitMPS(Circuit): def apply_gates(self, gates, progbar=False, **gate_opts): merge_1q = gate_opts.pop("merge_1q", "auto") + merge_2q = gate_opts.pop("merge_2q", "auto") if merge_1q == "auto": merge_1q = True + if merge_2q == "auto": + # MPS truncation semantics are sensitive to when a 2q gate is + # materialized, so keep the default conservative here. + merge_2q = False - if merge_1q: + if merge_1q or merge_2q: gates = tuple( gate if isinstance(gate, Gate) else parse_to_gate(gate) for gate in gates @@ -4913,15 +5011,22 @@ class CircuitMPS(Circuit): ) gate_number_offset = len(self._gates) - for gate, gates_to_record in _iter_gates_with_merged_1q_runs( - gates + for gate, gates_to_record in _iter_gates_with_merged_runs( + gates, merge_1q=merge_1q, merge_2q=merge_2q ): if gate is None: - self._apply_merged_1q_gate_run( - gates_to_record, - gate_number_offset=gate_number_offset, - **gate_opts, - ) + if len(gates_to_record[0][1].qubits) == 1: + self._apply_merged_1q_gate_run( + gates_to_record, + gate_number_offset=gate_number_offset, + **gate_opts, + ) + else: + self._apply_merged_2q_gate_run( + gates_to_record, + gate_number_offset=gate_number_offset, + **gate_opts, + ) gate_for_progress = gates_to_record[-1][1] else: self._apply_gate( diff --git a/.venv/lib/python3.12/site-packages/quimb/tensor/tn1d/core.py b/.venv/lib/python3.12/site-packages/quimb/tensor/tn1d/core.py index a27060f..43ca8d8 100644 --- a/.venv/lib/python3.12/site-packages/quimb/tensor/tn1d/core.py +++ b/.venv/lib/python3.12/site-packages/quimb/tensor/tn1d/core.py @@ -5050,8 +5050,6 @@ class TNLinearOperator1D(spla.LinearOperator): if self.is_conj: T = T.conj() - print(T) - assert(0) return T.to_dense(self.left_inds, self.right_inds) def toarray(self): diff --git a/README.md b/README.md index 150b8e8..440a9fa 100644 --- a/README.md +++ b/README.md @@ -28,15 +28,24 @@ Currently, the supported tensor network libraries are: ## CPU expectation benchmarks -The current CPU expectation entrypoint is: +Use the library APIs directly: -```sh -python -u benchmark_cpu_expectation.py --ansatz mps --nqubits 40 --nlayers 10 --bond 2048 --circuits brickwall_cnot --observables ring_xz +```py +import qibotn + +records = qibotn.run_cpu_benchmark_cases( + ansatz="mps", + nqubits=40, + nlayers=10, + bond=2048, + circuits=("brickwall_cnot",), + observables=("ring_xz",), +) ``` -Use `--ansatz tn` for the generic TN path and `--mpi` under `mpiexec` for MPI runs. -Reusable circuit and observable builders live in `src/qibotn/benchmark_cases.py`; execution logic lives in `src/qibotn/expectation_runner.py`. -For Vidal/MPS 1D-chain scale tests, use `run_vidal_mps_cases.sh`. +For generic TN use `ansatz="tn"`. Contest/custom runners are available as +`qibotn.run_contest_tn_case`, `qibotn.run_custom_tn_expectation`, +`qibotn.run_contest_mps_case`, and `qibotn.run_vidal_validation_cases`. ## Installation diff --git a/benchmark_cpu_expectation.py b/benchmark_cpu_expectation.py deleted file mode 100644 index 3d5897d..0000000 --- a/benchmark_cpu_expectation.py +++ /dev/null @@ -1,285 +0,0 @@ -"""CLI for CPU TN/MPS expectation benchmarks.""" - -from __future__ import annotations - -import argparse -import os -import subprocess -from pathlib import Path -from urllib.parse import urlparse - -from qibotn.benchmark_cases import ( - CIRCUITS, - OBSERVABLES, - build_circuit, - observable_terms, - parse_names, - terms_to_dict, -) -from qibotn.expectation_runner import ( - ExpectationConfig, - exact_for_observable, - run_cpu_expectation, -) - - -def optional_int(text): - if isinstance(text, str) and text.lower() in {"none", "null", "inf", "unlimited"}: - return None - return int(text) - - -def optional_float(text): - if isinstance(text, str) and text.lower() in {"none", "null", "inf", "unlimited"}: - return None - return float(text) - - -def format_optional(value, fmt="g"): - return "None" if value is None else format(value, fmt) - - -def should_stop_dask(args): - return ( - not args.keep_dask - and args.tn_search_backend == "dask" - and args.dask_address is not None - and args.tn_load_tree is None - ) - - -def stop_dask_cluster(args, rank): - if rank != 0 or not should_stop_dask(args): - return - script = Path(__file__).resolve().parent / "tools" / "manage_tn_dask_cluster.sh" - if not script.exists(): - print(f"dask_stop_skipped reason=missing_script path={script}", flush=True) - return - - env = os.environ.copy() - parsed = urlparse(args.dask_address) - if parsed.hostname: - env.setdefault("SCHEDULER_HOST", parsed.hostname) - if parsed.port: - env.setdefault("SCHEDULER_PORT", str(parsed.port)) - - print("dask_stop_after_search start", flush=True) - subprocess.run([str(script), "stop"], cwd=str(script.parent.parent), env=env, check=False) - print("dask_stop_after_search done", flush=True) - - -def build_parallel_opts(args): - slicing_opts = {} - if args.tn_target_slices is not None: - slicing_opts["target_slices"] = args.tn_target_slices - if args.tn_target_size is not None: - slicing_opts["target_size"] = args.tn_target_size - - opts = { - "slicing_opts": slicing_opts or None, - "search_workers": args.tn_search_workers or args.torch_threads, - "max_repeats": args.tn_search_repeats, - "max_time": args.tn_search_time, - "print_stats": not args.no_tn_stats, - } - if args.tn_search_backend is not None: - opts["search_backend"] = args.tn_search_backend - if args.dask_address is not None: - opts["dask_address"] = args.dask_address - if args.tn_save_tree is not None: - opts["save_tree_path"] = args.tn_save_tree - if args.tn_load_tree is not None: - opts["load_tree_path"] = args.tn_load_tree - if args.tn_search_only: - opts["search_only"] = True - if args.tn_debug_trials: - opts["debug_trials"] = True - if args.tn_contract_implementation is not None: - opts["contract_implementation"] = args.tn_contract_implementation - if args.dask_close_workers: - opts["dask_close_workers"] = True - return opts - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--nqubits", type=int, default=40) - parser.add_argument("--nlayers", type=int, default=30) - parser.add_argument("--bond", "--bonds", dest="bond", type=optional_int, default=1024) - parser.add_argument("--cut-ratio", type=optional_float, default=1e-12) - parser.add_argument("--seed", type=int, default=42) - parser.add_argument("--torch-threads", type=int, default=8) - parser.add_argument("--quimb-backend", choices=("numpy", "torch"), default="torch") - parser.add_argument( - "--dtype", - choices=("complex128", "complex64"), - default="complex128", - ) - parser.add_argument("--ansatz", choices=("tn", "mps"), default=None) - parser.add_argument("--mps", action="store_true") - parser.add_argument("--mpi", action="store_true") - parser.add_argument("--exact", action="store_true") - parser.add_argument("--exact-max-qubits", type=int, default=24) - parser.add_argument("--circuits", nargs="+", default=["brickwall_cnot"]) - parser.add_argument("--observables", nargs="+", default=["ring_xz"]) - parser.add_argument("--pauli-pattern") - parser.add_argument("--tn-target-slices", type=int) - parser.add_argument("--tn-target-size", type=int,default=2**32) - parser.add_argument("--tn-search-workers", type=int) - parser.add_argument("--tn-search-repeats", type=int, default=128) - parser.add_argument("--tn-search-time", type=float, default=60.0) - parser.add_argument( - "--no-tn-stats", - action="store_true", - help="Do not print per-term TN search/contraction diagnostics.", - ) - parser.add_argument( - "--tn-search-backend", - choices=("processpool", "dask"), - default="dask", - help="Path-search backend. In MPI mode, dask search runs only on rank 0 and broadcasts the tree.", - ) - parser.add_argument( - "--dask-address", - help="Dask scheduler address, for example tcp://host:8786. If omitted with dask search, a local cluster is created.", - ) - parser.add_argument( - "--dask-close-workers", - action="store_true", - help="After dask path search, ask the scheduler to close all currently connected workers.", - ) - parser.add_argument( - "--keep-dask", - action="store_true", - help=( - "Keep an external dask cluster running after search. By default, " - "tools/manage_tn_dask_cluster.sh stop is called after search when " - "--dask-address is used." - ), - ) - parser.add_argument( - "--tn-save-tree", - help="Save searched cotengra contraction tree(s) to this pickle file.", - ) - parser.add_argument( - "--tn-load-tree", - help="Load cotengra contraction tree(s) from this pickle file and skip path search.", - ) - parser.add_argument( - "--tn-search-only", - action="store_true", - help="Only run path search and optional --tn-save-tree; skip contraction.", - ) - parser.add_argument( - "--tn-debug-trials", - action="store_true", - help="Print dask worker summary and per-trial worker start/done logs.", - ) - parser.add_argument( - "--tn-contract-implementation", - choices=("auto", "cotengra", "autoray", "cpp"), - help="cotengra contraction implementation for TN contraction.", - ) - args = parser.parse_args() - - ansatz = "mps" if args.mps else (args.ansatz or "tn") - circuits = parse_names(args.circuits, CIRCUITS, "circuits") - observables = [] if args.pauli_pattern else parse_names( - args.observables, OBSERVABLES, "observables" - ) - - rank = 0 - if args.mpi: - from mpi4py import MPI - - rank = MPI.COMM_WORLD.Get_rank() - - config = ExpectationConfig( - ansatz=ansatz, - mpi=args.mpi, - bond=args.bond, - cut_ratio=args.cut_ratio, - tensor_module="torch", - quimb_backend=args.quimb_backend, - dtype=args.dtype, - torch_threads=args.torch_threads, - parallel_opts=build_parallel_opts(args), - ) - - if rank == 0: - mode = "MPI" if args.mpi else "serial" - print( - f"backend=cpu ansatz={ansatz.upper()} mode={mode} " - f"nqubits={args.nqubits} nlayers={args.nlayers} " - f"bond={format_optional(args.bond)} " - f"cut_ratio={format_optional(args.cut_ratio)} seed={args.seed} " - f"quimb_backend={args.quimb_backend} dtype={args.dtype} " - f"torch_threads={args.torch_threads} " - f"tn_search_backend={args.tn_search_backend}" - ) - print("circuit observable exact value abs_error rel_error seconds") - - try: - for circuit_kind in circuits: - circuit = build_circuit(circuit_kind, args.nqubits, args.nlayers, args.seed) - named_observables = ( - [(f"pattern:{args.pauli_pattern}", {"pauli_string_pattern": args.pauli_pattern})] - if args.pauli_pattern - else [ - (obs_kind, terms_to_dict(observable_terms(obs_kind, args.nqubits))) - for obs_kind in observables - ] - ) - - for obs_name, observable in named_observables: - exact = None - if args.exact and rank == 0: - if args.nqubits > args.exact_max_qubits: - raise ValueError( - f"--exact is limited to {args.exact_max_qubits} qubits by default." - ) - exact = exact_for_observable(circuit, observable, args.nqubits) - - result = run_cpu_expectation(circuit, observable, config) - if args.mpi and result.rank != 0: - continue - - abs_error = float("nan") if exact is None else abs(result.value - exact) - rel_error = ( - float("nan") - if exact is None - else abs_error / max(abs(exact), 1e-15) - ) - exact_text = "nan" if exact is None else f"{exact:.16e}" - print( - f"{circuit_kind} {obs_name} {exact_text} {result.value:.16e} " - f"{abs_error:.6e} {rel_error:.6e} {result.seconds:.3f}" - ) - for stat in result.parallel_stats or (): - cost = stat["path_cost"] - search_stats = stat.get("search_stats", {}) - print( - "tn_term_summary " - f"term={stat.get('term_index', 0)} " - f"search_seconds={stat.get('search_seconds', float('nan')):.3f} " - f"contract_seconds={stat.get('contract_seconds', float('nan')):.3f} " - f"completed_trials={search_stats.get('completed_trials', 'na')} " - f"finite_trials={search_stats.get('finite_trials', 'na')} " - f"failed_trials={search_stats.get('failed_trials', 'na')} " - f"requested_trials={search_stats.get('requested_trials', 'na')} " - f"best_score={search_stats.get('best_score', float('nan')):.6g} " - f"slices={cost['nslices']} " - f"log10_flops={cost['log10_flops']:.3f} " - f"log10_write={cost['log10_write']:.3f} " - f"log2_size={cost['log2_size']:.3f} " - f"log10_combo={cost['log10_combo']:.3f} " - f"peak_memory_gib={cost['peak_memory_gib']:.6g} " - f"slicing_overhead={cost['slicing_overhead']:.6g} " - f"rank_slices={stat.get('rank_slices', 'na')}" - ) - finally: - stop_dask_cluster(args, rank) - - -if __name__ == "__main__": - main() diff --git a/docs/contest_runners.md b/docs/contest_runners.md index 5298328..406b68e 100644 --- a/docs/contest_runners.md +++ b/docs/contest_runners.md @@ -1,88 +1,12 @@ -# TN -```bash -# search + contract,Open MPI 多节点:每节点 2 rank,每 rank 绑定 1 个 NUMA。 -# MPI_HOSTS 里每个节点写 :2,MPI_RANKS = 节点数 * 2。 -# 每个 rank 使用 MPI_PE 个 core;这台 2-NUMA AMD 节点用 MPI_PE=128。 +# Contest Runners -NQUBITS=40 \ -TN_DEBUG_TRIALS=1 \ -SCHEDULER_HOST=10.20.1.100 \ -DASK_ADDRESS=tcp://10.20.1.100:8786 \ -WORKER_HOSTS="10.20.1.100 10.20.1.101 10.20.1.102 10.20.1.103" \ -CASE=main1 \ -OBSERVABLES=long_z_string \ -TORCH_THREADS=80 \ -MPI_PE=80 \ -MPI_MAP_BY=ppr:1:numa:PE=80 \ -MPI_BIND_TO=core \ -OMP_NUM_THREADS=80 \ -MKL_NUM_THREADS=80 \ -BLIS_NUM_THREADS=80 \ -MPI_HOSTS="node-0:2,node-1:2,node-2:2,node-3:2" \ -MPI_RANKS=8 \ -NWORKERS=96 \ -TN_TARGET_SIZE=17179869184 \ -tools/run_tn_dask_mpi_all.sh +The reusable implementations live in `src/qibotn/backends/`. -# 单独缩并contract计算 +- `qibotn.run_contest_tn_case`: quimb+torch TN search/contract cases. +- `qibotn.run_contest_mps_case`: Vidal/MPS contest expectation cases. +- `qibotn.run_vidal_mpi_contest_case`: direct Vidal MPI observable sweep. +- `qibotn.run_custom_tn_expectation`: custom quimb+torch TN cases. -mpirun --map-by ppr:1:numa:PE=80 --bind-to core --report-bindings \ - -x LD_PRELOAD=/home/aocc/aocl/5.2.0/aocc/lib_LP64/libblis-mt.so.5 \ - -x BLIS_NUM_THREADS=80 \ - -x OMP_NUM_THREADS=80 \ - -x MKL_NUM_THREADS=80 \ - -x OMP_PROC_BIND=close \ - -x OMP_PLACES=cores \ - -np 8 \ - -host node-0:2,node-1:2,node-2:2,node-3:2 \ - .venv/bin/python -u tools/tn_contest_runner.py contract \ - --mpi \ - --case main1 \ - --nqubits 34 \ - --nlayers 20 \ - --observables long_z_string \ - --tree-dir trees/contest_tn \ - --torch-threads 80 \ - --dtype complex64 -``` - -# MPS -``` -cd /home/qibo/qibotn - -MPIEXEC=mpirun \ -MPI_HOSTS="node-2:4,node-3:4" \ -MPI_RANKS=8 \ -MPI_PE=48 \ -MPI_MAP_BY=ppr:2:numa:PE=48 \ -MPI_BIND_TO=core \ -MPI_REPORT_BINDINGS=1 \ -TORCH_THREADS=48 \ -OMP_NUM_THREADS=48 \ -MKL_NUM_THREADS=48 \ -BLIS_NUM_THREADS=48 \ -OBS_FILTER=ring_xz \ -MAIN1_NQ=128 \ -MAIN1_LAYERS=24 \ -MAIN1_BOND=1024 \ -tools/run_vidal_mpi_contest_cases.sh main1 - - - -MPIEXEC=mpirun \ -MPI_HOSTS="node-2:4" \ -MPI_RANKS=4 \ -MPI_PE=48 \ -MPI_MAP_BY=ppr:2:numa:PE=48 \ -MPI_BIND_TO=core \ -MPI_REPORT_BINDINGS=1 \ -TORCH_THREADS=48 \ -OMP_NUM_THREADS=48 \ -MKL_NUM_THREADS=48 \ -BLIS_NUM_THREADS=48 \ -OBS_FILTER=ring_xz \ -MAIN1_NQ=128 \ -MAIN1_LAYERS=24 \ -MAIN1_BOND=1024 \ -tools/run_vidal_mpi_contest_cases.sh main1 -``` +`src/qibotn/backends/quimb.py` holds the TN helpers, +`src/qibotn/backends/qmatchatea.py` holds the qmatchatea MPS helpers, +and `src/qibotn/backends/vidal.py` holds the Vidal helpers. diff --git a/docs/home.md b/docs/home.md new file mode 100644 index 0000000..a6bb8c1 --- /dev/null +++ b/docs/home.md @@ -0,0 +1,26 @@ +# qibotn + +Core reusable code lives under `src/qibotn/`. Prefer importing from `qibotn` +or `qibotn.backends.*`; benchmark and runner helpers have been folded into the +package instead of being kept as standalone scripts. + +- `backends/quimb.py`: TN + torch helpers for quimb. +- `backends/qmatchatea.py`: qmatchatea + torch MPS helpers. +- `backends/vidal.py`: Vidal + torch helpers. +- `contest_cases.py`: shared contest circuits, observables, and case specs. +- `torch_utils.py`: shared torch array/thread helpers. + +Quimb TN reusable entrypoints include `build_quimb_backend_circuit`, +`build_expectation_tn`, `run_quimb_torch_expectation`, +`compare_quimb_gate_merge`, `compare_quimb_gate_merge_expectation`, +`profile_quimb_torch_expectation`, and `time_quimb_contract_implementations`. + +Common public imports include `qibotn.cpu_expectation`, +`qibotn.mps_expectation`, `qibotn.run_qmatchatea_expectation`, +`qibotn.run_vidal_expectation`, `qibotn.build_contest_circuit`, and +`qibotn.build_contest_observable`. + +Former script entrypoints are available as importable functions: +`qibotn.run_cpu_benchmark_cases`, `qibotn.run_contest_tn_case`, +`qibotn.run_custom_tn_expectation`, `qibotn.run_contest_mps_case`, +`qibotn.run_vidal_mpi_contest_case`, and `qibotn.run_vidal_validation_cases`. diff --git a/docs/xianchang.md b/docs/xianchang.md deleted file mode 100644 index 57411cc..0000000 --- a/docs/xianchang.md +++ /dev/null @@ -1,42 +0,0 @@ -mpirun --map-by ppr:1:numa:PE=80 --bind-to core --report-bindings \ - -x LD_PRELOAD=/home/aocc/aocl/5.2.0/aocc/lib_LP64/libblis-mt.so.5 \ - -x BLIS_NUM_THREADS=80 \ - -x OMP_NUM_THREADS=80 \ - -x MKL_NUM_THREADS=80 \ - -x OMP_PROC_BIND=close \ - -x OMP_PLACES=cores \ - -np 4 \ - -host node-0:2,node-1:2,node-2:2,node-3:2 \ - .venv/bin/python -u tools/tn_contest_runner.py contract \ - --mpi \ - --case main1 \ - --nqubits 34 \ - --nlayers 20 \ - --observables long_z_string \ - --tree-dir trees/contest_tn \ - --torch-threads 80 \ - --dtype complex64 - - -SEARCH_TIME=300 NQUBITS=40 TN_DEBUG_TRIALS=1 SCHEDULER_HOST=10.20.1.102 DASK_ADDRESS=tcp://10.20.1.102:8786 WORKER_HOSTS="10.20.1.102 10.20.1.103" CASE=main1 OBSERVABLES=long_z_string TORCH_THREADS=80 MPI_PE=80 MPI_MAP_BY=ppr:1:numa:PE=80 MPI_BIND_TO=core OMP_NUM_THREADS=80 MKL_NUM_THREADS=80 BLIS_NUM_THREADS=80 MPI_HOSTS="node-2:2,node-3:2" MPI_RANKS=4 NWORKERS=128 TN_TARGET_SIZE=17179869184 tools/run_tn_dask_mpi_all.sh - - -NQUBITS=40 \ -TN_DEBUG_TRIALS=1 \ -SCHEDULER_HOST=10.20.1.102 \ -DASK_ADDRESS=tcp://10.20.1.102:8786 \ -WORKER_HOSTS="10.20.1.102 10.20.1.103" \ -CASE=main1 \ -OBSERVABLES=long_z_string \ -TORCH_THREADS=80 \ -MPI_PE=80 \ -MPI_MAP_BY=ppr:1:numa:PE=80 \ -MPI_BIND_TO=core \ -OMP_NUM_THREADS=80 \ -MKL_NUM_THREADS=80 \ -BLIS_NUM_THREADS=80 \ -MPI_HOSTS="node-2:2,node-3:2" \ -MPI_RANKS=4 \ -NWORKERS=96 \ -TN_TARGET_SIZE=17179869184 \ -tools/run_tn_dask_mpi_all.sh \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 7ac26d8..6d668fe 100644 --- a/requirements.txt +++ b/requirements.txt @@ -60,7 +60,7 @@ mpmath==1.3.0 msgpack==1.1.2 networkx==3.6.1 numba==0.61.2 -numpy==2.0.1 +numpy @ file:///home/yx/numpy openqasm3==1.0.1 opt_einsum==3.4.0 optuna==4.8.0 @@ -93,7 +93,7 @@ python-multipart==0.0.26 PyYAML==6.0.3 qibo==0.3.2 qibojit==0.1.15 --e git+https://git.nudt.space/jaunatisblue/qibotn.git@4c7a10d026d514897dcc501b507fa604fb4e52d4#egg=qibotn +-e git+https://git.nudt.space/jaunatisblue/qibotn.git@eed42dcfa9739c609a58f7367fe403abf2e992a9#egg=qibotn qiskit==1.4.5 qmatchatea==1.5.8 qredtea==0.3.15 @@ -106,7 +106,7 @@ regex==2026.4.4 requests==2.33.1 rpds-py==0.30.0 rustworkx==0.17.1 -scipy==1.17.1 +scipy @ file:///home/yx/scipy setuptools==70.2.0 six==1.17.0 sniffio==1.3.1 @@ -118,13 +118,15 @@ stack-data==0.6.3 starlette==1.0.0 stevedore==5.7.0 symengine==0.13.0 -sympy==1.13.1 +sympy==1.14.0 tabulate==0.9.0 tblib==3.2.2 texttable==1.7.0 threadpoolctl==3.6.0 toolz==1.1.0 -torch @ file:///home/qibo/qibotn/wheels/torch-2.10.0a0+a36e1d3-cp312-cp312-linux_x86_64.whl +torch==2.11.0+cpu +torchaudio==2.11.0+cpu +torchvision==0.26.0+cpu tornado==6.5.5 tqdm==4.67.3 traitlets==5.14.3 @@ -135,4 +137,3 @@ uvicorn==0.46.0 wcwidth==0.6.0 webencodings==0.5.1 zict==3.0.0 - diff --git a/run_vidal_mps_cases.sh b/run_vidal_mps_cases.sh deleted file mode 100755 index 93d0268..0000000 --- a/run_vidal_mps_cases.sh +++ /dev/null @@ -1,135 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Focused Vidal/MPS expectation test cases for 1D chain circuits. -# -# These cases intentionally avoid qmatchatea and generic TN paths. They target -# the current supported scope: one-qubit gates, adjacent two-qubit gates, and -# Pauli-sum expectation values on a 1D chain. - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -cd "$ROOT_DIR" - -PYTHON_BIN="${PYTHON_BIN:-.venv/bin/python}" -MPIEXEC="${MPIEXEC:-mpiexec}" -HOSTFILE="${HOSTFILE:-hostfile}" - -THREADS="${THREADS:-32}" -MPI_RANKS="${MPI_RANKS:-16}" -MPI_THREADS="${MPI_THREADS:-12}" - -export OMP_NUM_THREADS="${OMP_NUM_THREADS:-1}" -export MKL_NUM_THREADS="${MKL_NUM_THREADS:-1}" -source "$ROOT_DIR/tools/qibotn_torch_mt_env.sh" - -run() { - echo - echo "--------------------------------------------------------------------------------" - echo "$*" - echo "--------------------------------------------------------------------------------" - "$@" -} - -case "${1:-help}" in - smoke) - # Short correctness-oriented run. Useful before starting long jobs. - run "$PYTHON_BIN" -u benchmark_cpu_expectation.py \ - --mps \ - --nqubits 40 \ - --nlayers 10 \ - --bond 2048 \ - --torch-threads "$THREADS" \ - --circuits brickwall_cnot reversed_cnot shifted_cz rxx_rzz \ - --observables ring_xz open_zz range2_xx long_z_string - ;; - - convergence) - # Same circuit/observable, increasing bond. Check value convergence. - for bond in ${BONDS:-4096 16384 65536}; do - run "$PYTHON_BIN" -u benchmark_cpu_expectation.py \ - --mps \ - --nqubits "${NQ:-80}" \ - --nlayers "${LAYERS:-16}" \ - --bond "$bond" \ - --torch-threads "$THREADS" \ - --circuits "${CIRCUIT:-brickwall_cnot}" \ - --observables "${OBSERVABLE:-ring_xz}" - done - ;; - - single-long) - # Single long Vidal run. On node-3, a similar n=40,l=30,bond=2048 case - # took about 9 minutes for one expectation. This one is meant to be longer. - run "$PYTHON_BIN" -u benchmark_cpu_expectation.py \ - --mps \ - --nqubits "${NQ:-80}" \ - --nlayers "${LAYERS:-16}" \ - --bond "${BOND:-65536}" \ - --torch-threads "$THREADS" \ - --circuits "${CIRCUIT:-brickwall_cnot}" \ - --observables "${OBSERVABLE:-ring_xz}" - ;; - - suite-long) - # Application-style multi-circuit, multi-observable MPS run. - # This is intentionally multi-term and should run much longer than single-long. - run "$PYTHON_BIN" -u benchmark_cpu_expectation.py \ - --mps \ - --nqubits "${NQ:-80}" \ - --nlayers "${LAYERS:-16}" \ - --bond "${BOND:-65536}" \ - --torch-threads "$THREADS" \ - --circuits brickwall_cnot reversed_cnot shifted_cz rxx_rzz \ - --observables ring_xz open_zz mixed_local range2_xx long_z_string - ;; - - mpi-long) - # Multi-node Vidal segmented MPS run. Uses HOSTFILE. - run "$MPIEXEC" -hostfile "$HOSTFILE" -n "$MPI_RANKS" "$PYTHON_BIN" -u benchmark_cpu_expectation.py \ - --mpi --mps \ - --nqubits "${NQ:-80}" \ - --nlayers "${LAYERS:-16}" \ - --bond "${BOND:-65536}" \ - --torch-threads "$MPI_THREADS" \ - --circuits brickwall_cnot reversed_cnot shifted_cz rxx_rzz \ - --observables ring_xz open_zz mixed_local range2_xx long_z_string - ;; - - stress) - # Heavier entanglement. Start only after single-long is stable. - run "$PYTHON_BIN" -u benchmark_cpu_expectation.py \ - --mps \ - --nqubits "${NQ:-80}" \ - --nlayers "${LAYERS:-18}" \ - --bond "${BOND:-262144}" \ - --torch-threads "${THREADS:-48}" \ - --circuits "${CIRCUIT:-rxx_rzz}" \ - --observables ring_xz open_zz range2_xx - ;; - - help|*) - cat <<'EOF' -Usage: ./run_vidal_mps_cases.sh [smoke|convergence|single-long|suite-long|mpi-long|stress] - -Common overrides: - PYTHON_BIN=.venv/bin/python - THREADS=32 - OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 - -Single-node scale overrides: - NQ=80 LAYERS=16 BOND=65536 - CIRCUIT=brickwall_cnot - OBSERVABLE=ring_xz - BONDS="4096 16384 65536" # for convergence mode - -Multi-node overrides: - HOSTFILE=hostfile - MPI_RANKS=16 MPI_THREADS=12 - -Recommended first runs: - ./run_vidal_mps_cases.sh smoke - ./run_vidal_mps_cases.sh convergence - ./run_vidal_mps_cases.sh single-long -EOF - ;; -esac diff --git a/src/qibotn/__init__.py b/src/qibotn/__init__.py index fb2c1f7..9a1ee8a 100644 --- a/src/qibotn/__init__.py +++ b/src/qibotn/__init__.py @@ -8,6 +8,108 @@ _LAZY_EXPORTS = { "cpu_expectation": ("qibotn.expectation_runner", "cpu_expectation"), "mps_expectation": ("qibotn.expectation_runner", "mps_expectation"), "cpu_runcard": ("qibotn.expectation_runner", "cpu_runcard"), + "ExpectationConfig": ("qibotn.expectation_runner", "ExpectationConfig"), + "exact_for_observable": ("qibotn.expectation_runner", "exact_for_observable"), + "run_cpu_expectation": ("qibotn.expectation_runner", "run_cpu_expectation"), + "cpu_benchmark_parallel_opts": ( + "qibotn.expectation_runner", + "cpu_benchmark_parallel_opts", + ), + "run_cpu_benchmark_cases": ( + "qibotn.expectation_runner", + "run_cpu_benchmark_cases", + ), + "build_benchmark_circuit": ("qibotn.benchmark_cases", "build_circuit"), + "benchmark_observable_terms": ("qibotn.benchmark_cases", "observable_terms"), + "exact_pauli_sum": ("qibotn.benchmark_cases", "exact_pauli_sum"), + "ring_xz_statevector_expectation": ( + "qibotn.benchmark_cases", + "ring_xz_statevector_expectation", + ), + "terms_to_dict": ("qibotn.benchmark_cases", "terms_to_dict"), + "build_contest_circuit": ("qibotn.contest_cases", "build_contest_circuit"), + "build_contest_observable": ( + "qibotn.contest_cases", + "build_contest_observable", + ), + "contest_cases": ("qibotn.contest_cases", "CASES"), + "analyze_contraction_tree": ("qibotn.parallel", "analyze_contraction_tree"), + "load_tree_payload": ("qibotn.parallel", "load_tree_payload"), + "save_tree_payload": ("qibotn.parallel", "save_tree_payload"), + "slice_tree_payload": ("qibotn.parallel", "slice_tree_payload"), + "make_qmatchatea_backend": ( + "qibotn.backends.qmatchatea", + "make_qmatchatea_backend", + ), + "build_qmatchatea_backend": ( + "qibotn.backends.qmatchatea", + "build_qmatchatea_backend", + ), + "benchmark_qmatchatea_svd_control": ( + "qibotn.backends.qmatchatea", + "benchmark_qmatchatea_svd_control", + ), + "run_qmatchatea_expectation": ( + "qibotn.backends.qmatchatea", + "run_qmatchatea_expectation", + ), + "exact_mps_expectation": ( + "qibotn.backends.qmatchatea", + "exact_mps_expectation", + ), + "make_vidal_backend": ("qibotn.backends.vidal", "make_vidal_backend"), + "compare_vidal_backend_qmatchatea": ( + "qibotn.backends.vidal", + "compare_vidal_backend_qmatchatea", + ), + "run_vidal_expectation": ("qibotn.backends.vidal", "run_vidal_expectation"), + "run_segmented_vidal_ring_xz": ( + "qibotn.backends.vidal", + "run_segmented_vidal_ring_xz", + ), + "build_expectation_tn": ("qibotn.backends.quimb", "build_expectation_tn"), + "build_quimb_circuit_stats": ( + "qibotn.backends.quimb", + "build_quimb_circuit_stats", + ), + "compare_quimb_gate_merge": ( + "qibotn.backends.quimb", + "compare_quimb_gate_merge", + ), + "compare_quimb_gate_merge_expectation": ( + "qibotn.backends.quimb", + "compare_quimb_gate_merge_expectation", + ), + "contract_tn": ("qibotn.backends.quimb", "contract_tn"), + "load_custom_case_module": ("qibotn.backends.quimb", "load_custom_case_module"), + "profile_quimb_torch_expectation": ( + "qibotn.backends.quimb", + "profile_quimb_torch_expectation", + ), + "qibo_circuit_to_quimb_torch": ( + "qibotn.backends.quimb", + "qibo_circuit_to_quimb_torch", + ), + "search_contraction_tree": ("qibotn.backends.quimb", "search_contraction_tree"), + "sorted_tree": ("qibotn.backends.quimb", "sorted_tree"), + "run_contest_tn_case": ("qibotn.backends.quimb", "run_contest_tn_case"), + "run_custom_tn_expectation": ( + "qibotn.backends.quimb", + "run_custom_tn_expectation", + ), + "time_quimb_contract_implementations": ( + "qibotn.backends.quimb", + "time_quimb_contract_implementations", + ), + "run_contest_mps_case": ("qibotn.backends.vidal", "run_contest_mps_case"), + "run_vidal_mpi_contest_case": ( + "qibotn.backends.vidal", + "run_vidal_mpi_contest_case", + ), + "run_vidal_validation_cases": ( + "qibotn.backends.vidal", + "run_vidal_validation_cases", + ), "pauli_pattern": ("qibotn.observables", "pauli_pattern"), "pauli_sum": ("qibotn.observables", "pauli_sum"), } diff --git a/src/qibotn/backends/cpu.py b/src/qibotn/backends/cpu.py index 91b0528..c724cd9 100644 --- a/src/qibotn/backends/cpu.py +++ b/src/qibotn/backends/cpu.py @@ -18,6 +18,7 @@ from qibotn.backends.vidal import ( _unsupported_reason, ) from qibotn.observables import check_observable +from qibotn.torch_utils import arrays_to_backend, torch_cpu_array, torch_dtype def _as_bool_or_dict(value, name): @@ -310,10 +311,12 @@ class CpuTensorNet(QibotnBackend, NumpyBackend): def _quimb_backend(self): import qibotn.backends.quimb as qmb - return qmb.BACKENDS[self.quimb_backend]( + backend = qmb.BACKENDS[self.quimb_backend]( quimb_backend=self.quimb_backend, contraction_optimizer=self.contraction_optimizer, ) + backend.dtype = self.dtype + return backend def _bind_rank_to_numa_domain(self, rank): self.numa_domain = _bind_numa_node(rank) @@ -375,6 +378,12 @@ class CpuTensorNet(QibotnBackend, NumpyBackend): dask_close_workers = bool(opts.get("dask_close_workers", False)) print_stats = bool(opts.get("print_stats", False)) debug_trials = bool(opts.get("debug_trials", False)) + search_seed = int(opts.get("search_seed", 0)) + merge_1q = opts.get("merge_1q", "auto") + merge_2q = opts.get("merge_2q", "auto") + sort_contract_indices = opts.get("sort_contract_indices", "auto") + if sort_contract_indices == "auto": + sort_contract_indices = self.quimb_backend == "torch" search_only = bool(opts.get("search_only", False)) save_tree_path = opts.get("save_tree_path") load_tree_path = opts.get("load_tree_path") @@ -382,6 +391,38 @@ class CpuTensorNet(QibotnBackend, NumpyBackend): saved_trees = [] saved_costs = [] + def term_stats( + term_index, + factors, + path_cost, + search_stats, + tree_slices, + slice_assignment, + rank_slices, + search_seconds, + contract_seconds, + ): + return { + "term_index": term_index, + "term_factors": tuple(factors), + "path_cost": path_cost, + "search_stats": search_stats, + "tree_slices": tree_slices, + "slice_assignment": slice_assignment, + "rank_slices": rank_slices, + "search_seconds": search_seconds, + "contract_seconds": contract_seconds, + "search_workers": search_workers, + "search_repeats": search_repeats, + "search_time": search_time, + "search_backend": search_backend or method, + "search_seed": search_seed, + "merge_1q": merge_1q, + "merge_2q": merge_2q, + "dask_address": dask_address, + "numa_domain": getattr(self, "numa_domain", None), + } + if load_tree_path: with Path(load_tree_path).open("rb") as f: payload = pickle.load(f) @@ -396,6 +437,8 @@ class CpuTensorNet(QibotnBackend, NumpyBackend): "max_bond": self.max_bond_dimension, "cutoff": self.cut_ratio, }, + merge_1q=merge_1q, + merge_2q=merge_2q, ) total_value = 0.0 + 0.0j @@ -415,6 +458,8 @@ class CpuTensorNet(QibotnBackend, NumpyBackend): ) else: op, where = _pauli_term_to_dense_operator(factors) + if self.quimb_backend == "torch": + op = torch_cpu_array(op, dtype=torch_dtype(self.dtype)) tn = qc.local_expectation( op, where, @@ -455,10 +500,18 @@ class CpuTensorNet(QibotnBackend, NumpyBackend): debug_trials=debug_trials, dask_close_workers=dask_close_workers, expected_workers=dask_expected_workers, + search_seed=search_seed, ) search_seconds = time.perf_counter() - search_start if tree is None: raise RuntimeError("Failed to find a contraction tree for CPU TN MPI.") + if sort_contract_indices and hasattr(tree, "sort_contraction_indices"): + tree.sort_contraction_indices( + priority=opts.get("sort_contract_indices_priority", "flops"), + make_output_contig=True, + make_contracted_contig=True, + reset=True, + ) if self.parallel_opts.get("contract_implementation") == "cpp": from qibotn.torch_contractor import prepare_torch_cpp_contractor @@ -490,23 +543,17 @@ class CpuTensorNet(QibotnBackend, NumpyBackend): if search_only: self.parallel_stats.append( - { - "term_index": term_index, - "term_factors": tuple(factors), - "path_cost": path_cost, - "search_stats": search_stats, - "tree_slices": int(getattr(tree, "multiplicity", 1)), - "slice_assignment": "search_only", - "rank_slices": [], - "search_seconds": search_seconds, - "contract_seconds": 0.0, - "search_workers": search_workers, - "search_repeats": search_repeats, - "search_time": search_time, - "search_backend": search_backend or method, - "dask_address": dask_address, - "numa_domain": getattr(self, "numa_domain", None), - } + term_stats( + term_index, + factors, + path_cost, + search_stats, + int(getattr(tree, "multiplicity", 1)), + "search_only", + [], + search_seconds, + 0.0, + ) ) continue @@ -523,23 +570,17 @@ class CpuTensorNet(QibotnBackend, NumpyBackend): flush=True, ) self.parallel_stats.append( - { - "term_index": term_index, - "term_factors": tuple(factors), - "path_cost": path_cost, - "search_stats": search_stats, - "tree_slices": 1, - "slice_assignment": "root", - "rank_slices": [1] + [0] * (size - 1), - "search_seconds": search_seconds, - "contract_seconds": contract_seconds, - "search_workers": search_workers, - "search_repeats": search_repeats, - "search_time": search_time, - "search_backend": search_backend or method, - "dask_address": dask_address, - "numa_domain": getattr(self, "numa_domain", None), - } + term_stats( + term_index, + factors, + path_cost, + search_stats, + 1, + "root", + [1] + [0] * (size - 1), + search_seconds, + contract_seconds, + ) ) total_value += coeff * complex(value) continue @@ -556,36 +597,31 @@ class CpuTensorNet(QibotnBackend, NumpyBackend): flush=True, ) self.parallel_stats.append( - { - "term_index": term_index, - "term_factors": tuple(factors), - "path_cost": path_cost, - "search_stats": search_stats, - "tree_slices": int(getattr(tree, "multiplicity", 1)), - "slice_assignment": "local", - "rank_slices": [int(getattr(tree, "multiplicity", 1))], - "search_seconds": search_seconds, - "contract_seconds": contract_seconds, - "search_workers": search_workers, - "search_repeats": search_repeats, - "search_time": search_time, - "search_backend": search_backend or method, - "dask_address": dask_address, - "numa_domain": getattr(self, "numa_domain", None), - } + term_stats( + term_index, + factors, + path_cost, + search_stats, + int(getattr(tree, "multiplicity", 1)), + "local", + [int(getattr(tree, "multiplicity", 1))], + search_seconds, + contract_seconds, + ) ) total_value += coeff * complex(np.asarray(value).reshape(-1)[0]) continue contract_start = time.perf_counter() arrays = self._term_arrays(tn, backend) + contract_implementation = self._contract_implementation(backend) value, stats = parallel_contract( tree, arrays, method="mpi", comm=comm, return_stats=True, - implementation=self.parallel_opts.get("contract_implementation"), + implementation=contract_implementation, ) contract_seconds = time.perf_counter() - contract_start gathered_stats = comm.gather(stats, root=0) @@ -598,25 +634,17 @@ class CpuTensorNet(QibotnBackend, NumpyBackend): flush=True, ) self.parallel_stats.append( - { - "term_index": term_index, - "term_factors": tuple(factors), - "path_cost": path_cost, - "search_stats": search_stats, - "tree_slices": stats.nslices, - "slice_assignment": stats.assignment, - "rank_slices": [ - item.local_slices for item in gathered_stats - ], - "search_seconds": search_seconds, - "contract_seconds": contract_seconds, - "search_workers": search_workers, - "search_repeats": search_repeats, - "search_time": search_time, - "search_backend": search_backend or method, - "dask_address": dask_address, - "numa_domain": getattr(self, "numa_domain", None), - } + term_stats( + term_index, + factors, + path_cost, + search_stats, + stats.nslices, + stats.assignment, + [item.local_slices for item in gathered_stats], + search_seconds, + contract_seconds, + ) ) total_value += coeff * complex(np.asarray(value).reshape(-1)[0]) @@ -644,18 +672,20 @@ class CpuTensorNet(QibotnBackend, NumpyBackend): return np.nan if rank != 0 else float(np.real(total_value)) + def _contract_implementation(self, backend): + implementation = self.parallel_opts.get("contract_implementation") + if implementation is None and backend.backend == "torch": + return "autoray" + return implementation + def _contract_term_unsliced(self, tn, tree, backend): - contract_implementation = self.parallel_opts.get("contract_implementation") + contract_implementation = self._contract_implementation(backend) if contract_implementation == "cpp": if backend.backend != "torch": raise ValueError("contract_implementation='cpp' requires torch backend.") - from qibotn.backends.quimb import _torch_cpu_array, _torch_dtype from qibotn.torch_contractor import contract_tree_cpp - arrays = [ - _torch_cpu_array(array, dtype=_torch_dtype(self.dtype)) - for array in tn.arrays - ] + arrays = arrays_to_backend(tn.arrays, "torch", dtype=self.dtype) nslices = int(getattr(tree, "multiplicity", 1)) if nslices > 1: total = None @@ -666,12 +696,10 @@ class CpuTensorNet(QibotnBackend, NumpyBackend): return contract_tree_cpp(tree, arrays) if backend.backend == "torch": - from qibotn.backends.quimb import _torch_cpu_array, _torch_dtype - for tensor in tn.tensors: - tensor._data = _torch_cpu_array( + tensor._data = torch_cpu_array( tensor._data, - dtype=_torch_dtype(self.dtype), + dtype=torch_dtype(self.dtype), ) return tn.contract( all, @@ -693,13 +721,9 @@ class CpuTensorNet(QibotnBackend, NumpyBackend): return None if user_slicing_opts is None else dict(user_slicing_opts) def _term_arrays(self, tn, backend): - if backend.backend == "torch": - from qibotn.backends.quimb import _torch_cpu_array, _torch_dtype - - return [ - _torch_cpu_array(array, dtype=_torch_dtype(self.dtype)) - for array in tn.arrays - ] - from qibotn.backends.quimb import _numpy_dtype - - return [backend.engine.asarray(array, dtype=_numpy_dtype(self.dtype)) for array in tn.arrays] + return arrays_to_backend( + tn.arrays, + backend.backend, + engine=backend.engine, + dtype=self.dtype, + ) diff --git a/src/qibotn/backends/cutensornet_helpers.py b/src/qibotn/backends/cutensornet_helpers.py new file mode 100644 index 0000000..1ba4511 --- /dev/null +++ b/src/qibotn/backends/cutensornet_helpers.py @@ -0,0 +1,321 @@ +"""cuTensorNet circuit and MPS conversion helpers.""" + +from __future__ import annotations + +import numpy as np + +try: + import cupy as cp + import cuquantum.bindings.cutensornet as cutn + from cuquantum.tensornet import contract, contract_path + from cuquantum.tensornet.experimental import contract_decompose +except ImportError: # pragma: no cover - exercised on CPU-only installations + cp = None + cutn = None + contract = None + contract_path = None + contract_decompose = None + + +def _require_cupy(): + if cp is None: + raise ImportError( + "The cuQuantum circuit converter requires cupy. " + "Install the GPU dependencies or use the CPU backend." + ) + return cp + + +def _require_cutensornet(): + if cp is None or cutn is None: + raise ImportError( + "The cuQuantum MPS converter requires cupy and cuquantum. " + "Install the GPU dependencies or use the CPU backend." + ) + + +def _require_tensornet_mps(): + if cp is None or contract is None or contract_decompose is None: + raise ImportError( + "The cuQuantum MPS helpers require cupy and cuquantum. " + "Install the GPU dependencies or use the CPU backend." + ) + + +def _require_contract(): + if contract is None or contract_path is None: + raise ImportError( + "The cuQuantum MPS contraction helper requires cuquantum. " + "Install the GPU dependencies or use the CPU backend." + ) + + +class QiboCircuitToEinsum: + """Convert a Qibo circuit to cuQuantum interleaved TN operands.""" + + def __init__(self, circuit, dtype="complex128"): + self.backend = _require_cupy() + self.dtype = getattr(self.backend, dtype) + self.init_basis_map(self.backend, dtype) + self.init_intermediate_circuit(circuit) + self.circuit = circuit + + def state_vector_operands(self): + input_bitstring = "0" * len(self.active_qubits) + input_operands = self._get_bitstring_tensors(input_bitstring) + mode_labels, qubits_frontier, next_frontier = self._init_mode_labels_from_qubits( + self.active_qubits + ) + gate_mode_labels, gate_operands = self._parse_gates_to_mode_labels_operands( + self.gate_tensors, qubits_frontier, next_frontier + ) + operands = input_operands + gate_operands + mode_labels += gate_mode_labels + out_list = [qubits_frontier[key] for key in qubits_frontier] + operand_exp_interleave = [x for y in zip(operands, mode_labels) for x in y] + operand_exp_interleave.append(out_list) + return operand_exp_interleave + + def _init_mode_labels_from_qubits(self, qubits): + nqubits = len(qubits) + frontier_dict = {q: i for i, q in enumerate(qubits)} + mode_labels = [[i] for i in range(nqubits)] + return mode_labels, frontier_dict, nqubits + + def _get_bitstring_tensors(self, bitstring): + return [self.basis_map[ibit] for ibit in bitstring] + + def _parse_gates_to_mode_labels_operands(self, gates, qubits_frontier, next_frontier): + mode_labels = [] + operands = [] + for tensor, gate_qubits in gates: + operands.append(tensor) + input_mode_labels = [] + output_mode_labels = [] + for qubit in gate_qubits: + input_mode_labels.append(qubits_frontier[qubit]) + output_mode_labels.append(next_frontier) + qubits_frontier[qubit] = next_frontier + next_frontier += 1 + mode_labels.append(output_mode_labels + input_mode_labels) + return mode_labels, operands + + def op_shape_from_qubits(self, nqubits): + return (2, 2) * nqubits + + def init_intermediate_circuit(self, circuit): + self.gate_tensors = [] + gates_qubits = [] + for gate in circuit.queue: + gate_qubits = gate.control_qubits + gate.target_qubits + gates_qubits.extend(gate_qubits) + required_shape = self.op_shape_from_qubits(len(gate_qubits)) + self.gate_tensors.append( + ( + self.backend.asarray(gate.matrix(), dtype=self.dtype).reshape( + required_shape + ), + gate_qubits, + ) + ) + self.active_qubits = np.unique(gates_qubits) + + def init_basis_map(self, backend, dtype): + asarray = backend.asarray + self.basis_map = { + "0": asarray([1, 0], dtype=dtype), + "1": asarray([0, 1], dtype=dtype), + } + + def init_inverse_circuit(self, circuit): + self.gate_tensors_inverse = [] + gates_qubits_inverse = [] + for gate in circuit.queue: + gate_qubits = gate.control_qubits + gate.target_qubits + gates_qubits_inverse.extend(gate_qubits) + required_shape = self.op_shape_from_qubits(len(gate_qubits)) + self.gate_tensors_inverse.append( + (self.backend.asarray(gate.matrix()).reshape(required_shape), gate_qubits) + ) + self.active_qubits_inverse = np.unique(gates_qubits_inverse) + + def get_pauli_gates(self, pauli_map, dtype="complex128", backend=None): + if backend is None: + backend = _require_cupy() + asarray = backend.asarray + operand_map = { + "I": asarray([[1, 0], [0, 1]], dtype=dtype), + "X": asarray([[0, 1], [1, 0]], dtype=dtype), + "Y": asarray([[0, -1j], [1j, 0]], dtype=dtype), + "Z": asarray([[1, 0], [0, -1]], dtype=dtype), + } + gates = [] + for qubit, pauli_char in pauli_map.items(): + operand = operand_map.get(pauli_char) + if operand is None: + raise ValueError("pauli string character must be one of I/X/Y/Z") + gates.append((operand, (qubit,))) + return gates + + def expectation_operands(self, ham_gates): + input_bitstring = "0" * self.circuit.nqubits + input_operands = self._get_bitstring_tensors(input_bitstring) + mode_labels, qubits_frontier, next_frontier = self._init_mode_labels_from_qubits( + range(self.circuit.nqubits) + ) + gate_mode_labels, gate_operands = self._parse_gates_to_mode_labels_operands( + self.gate_tensors, qubits_frontier, next_frontier + ) + operands = input_operands + gate_operands + mode_labels += gate_mode_labels + + self.init_inverse_circuit(self.circuit.invert()) + next_frontier = max(qubits_frontier.values()) + 1 + gates_inverse = ham_gates + self.gate_tensors_inverse + gate_mode_labels_inverse, gate_operands_inverse = ( + self._parse_gates_to_mode_labels_operands( + gates_inverse, qubits_frontier, next_frontier + ) + ) + mode_labels = ( + mode_labels + + gate_mode_labels_inverse + + [[qubits_frontier[ix]] for ix in range(self.circuit.nqubits)] + ) + operands = operands + gate_operands_inverse + operands[: self.circuit.nqubits] + operand_exp_interleave = [x for y in zip(operands, mode_labels) for x in y] + operand_exp_interleave.append([]) + return operand_exp_interleave + + +def initial_mps(num_qubits, dtype): + _require_tensornet_mps() + state_tensor = cp.asarray([1, 0], dtype=dtype).reshape(1, 2, 1) + return [state_tensor] * num_qubits + + +def mps_site_right_swap(mps_tensors, i, **kwargs): + _require_tensornet_mps() + left, _, right = contract_decompose( + "ipj,jqk->iqj,jpk", + *mps_tensors[i : i + 2], + algorithm=kwargs.get("algorithm", None), + options=kwargs.get("options", None), + ) + mps_tensors[i : i + 2] = (left, right) + return mps_tensors + + +def apply_mps_gate(mps_tensors, gate, qubits, **kwargs): + _require_tensornet_mps() + n_qubits = len(qubits) + if n_qubits == 1: + site = qubits[0] + mps_tensors[site] = contract( + "ipj,qp->iqj", + mps_tensors[site], + gate, + options=kwargs.get("options", None), + ) + elif n_qubits == 2: + left, right = qubits + if left > right: + return apply_mps_gate( + mps_tensors, gate.transpose(1, 0, 3, 2), (right, left), **kwargs + ) + if left + 1 == right: + a_tensor, _, b_tensor = contract_decompose( + "ipj,jqk,rspq->irj,jsk", + *mps_tensors[left : left + 2], + gate, + algorithm=kwargs.get("algorithm", None), + options=kwargs.get("options", None), + ) + mps_tensors[left : left + 2] = (a_tensor, b_tensor) + else: + mps_site_right_swap(mps_tensors, left, **kwargs) + apply_mps_gate(mps_tensors, gate, (left + 1, right), **kwargs) + mps_site_right_swap(mps_tensors, left, **kwargs) + else: + raise NotImplementedError("Only one- and two-qubit gates supported") + + +class QiboCircuitToMPS: + """Convert a Qibo circuit to a cuTensorNet MPS representation.""" + + def __init__(self, circ_qibo, gate_algo, dtype="complex128", rand_seed=0): + _require_cutensornet() + np.random.seed(rand_seed) + cp.random.seed(rand_seed) + self.num_qubits = circ_qibo.nqubits + self.handle = cutn.create() + self.dtype = dtype + self.mps_tensors = initial_mps(self.num_qubits, dtype=dtype) + circuitconvertor = QiboCircuitToEinsum(circ_qibo, dtype=dtype) + for gate, qubits in circuitconvertor.gate_tensors: + apply_mps_gate( + self.mps_tensors, + gate, + qubits, + algorithm=gate_algo, + options={"handle": self.handle}, + ) + + def __del__(self): + handle = getattr(self, "handle", None) + if cutn is not None and handle is not None: + cutn.destroy(handle) + + +class MPSContractionHelper: + """Contract cuTensorNet MPS tensors to norms, states, or expectations.""" + + def __init__(self, num_qubits): + self.num_qubits = num_qubits + self.bra_modes = [(2 * i, 2 * i + 1, 2 * i + 2) for i in range(num_qubits)] + offset = 2 * num_qubits + 1 + self.ket_modes = [ + (i + offset, 2 * i + 1, i + 1 + offset) for i in range(num_qubits) + ] + + def contract_norm(self, mps_tensors, options=None): + interleaved_inputs = [] + for i, tensor in enumerate(mps_tensors): + interleaved_inputs.extend( + [tensor, self.bra_modes[i], tensor.conj(), self.ket_modes[i]] + ) + interleaved_inputs.append([]) + return self._contract(interleaved_inputs, options=options).real + + def contract_state_vector(self, mps_tensors, options=None): + interleaved_inputs = [] + for i, tensor in enumerate(mps_tensors): + interleaved_inputs.extend([tensor, self.bra_modes[i]]) + output_modes = tuple([bra_modes[1] for bra_modes in self.bra_modes]) + interleaved_inputs.append(output_modes) + return self._contract(interleaved_inputs, options=options) + + def contract_expectation( + self, mps_tensors, operator, qubits, options=None, normalize=False + ): + interleaved_inputs = [] + extra_mode = 3 * self.num_qubits + 2 + operator_modes = [None] * len(qubits) + [self.bra_modes[q][1] for q in qubits] + qubits = list(qubits) + for i, tensor in enumerate(mps_tensors): + interleaved_inputs.extend([tensor, self.bra_modes[i]]) + ket_modes = self.ket_modes[i] + if i in qubits: + ket_modes = (ket_modes[0], extra_mode, ket_modes[2]) + operator_modes[qubits.index(i)] = extra_mode + extra_mode += 1 + interleaved_inputs.extend([tensor.conj(), ket_modes]) + interleaved_inputs.extend([operator, tuple(operator_modes)]) + interleaved_inputs.append([]) + norm = self.contract_norm(mps_tensors, options=options) if normalize else 1 + return self._contract(interleaved_inputs, options=options) / norm + + def _contract(self, interleaved_inputs, options=None): + _require_contract() + path = contract_path(*interleaved_inputs, options=options)[0] + return contract(*interleaved_inputs, options=options, optimize={"path": path}) diff --git a/src/qibotn/backends/qmatchatea.py b/src/qibotn/backends/qmatchatea.py index 41381dc..b76424f 100644 --- a/src/qibotn/backends/qmatchatea.py +++ b/src/qibotn/backends/qmatchatea.py @@ -1,6 +1,9 @@ """Implementation of Quantum Matcha Tea backend.""" +from __future__ import annotations + import re +import time from dataclasses import dataclass import numpy as np @@ -12,6 +15,7 @@ from qibo.config import raise_error from qmatchatea.utils import MPISettings from qibotn.backends.abstract import QibotnBackend +from qibotn.benchmark_cases import exact_pauli_sum from qibotn.observables import check_observable from qibotn.result import TensorNetworkResult @@ -364,3 +368,207 @@ class QMatchaTeaBackend(QibotnBackend, NumpyBackend): use_itpo=False, ) return obs_sum + + +@dataclass(frozen=True) +class QMatchaTeaExpectationResult: + value: float + seconds: float + backend: object + + +@dataclass(frozen=True) +class QMatchaTeaBuildResult: + backend: object + build_seconds: float + + +@dataclass(frozen=True) +class QMatchaTeaSvdControlResult: + ctrl: str + contract_singvals: str + status: str + median_ms: float + min_ms: float + rel_error: float | None + kept: int | None + error: str + + +def make_qmatchatea_backend( + *, + bond=10, + cut_ratio=1e-9, + tensor_module="torch", + svd_control="E!", + compile_circuit=True, + track_memory=False, + mpi_approach="SR", + mpi_num_procs=1, + mpi_where_barriers=-1, + mpi_isometrization=-1, +): + backend = QMatchaTeaBackend() + backend.configure_tn_simulation( + ansatz="MPS", + max_bond_dimension=bond, + cut_ratio=cut_ratio, + svd_control=svd_control, + tensor_module=tensor_module, + compile_circuit=compile_circuit, + track_memory=track_memory, + mpi_approach=mpi_approach, + mpi_num_procs=mpi_num_procs, + mpi_where_barriers=mpi_where_barriers, + mpi_isometrization=mpi_isometrization, + ) + return backend + + +def build_qmatchatea_backend( + *, + bond=10, + cut_ratio=1e-9, + tensor_module="torch", + svd_control="E!", + compile_circuit=True, + track_memory=False, + mpi_approach="SR", + mpi_num_procs=1, + mpi_where_barriers=-1, + mpi_isometrization=-1, +): + start = time.perf_counter() + backend = make_qmatchatea_backend( + bond=bond, + cut_ratio=cut_ratio, + tensor_module=tensor_module, + svd_control=svd_control, + compile_circuit=compile_circuit, + track_memory=track_memory, + mpi_approach=mpi_approach, + mpi_num_procs=mpi_num_procs, + mpi_where_barriers=mpi_where_barriers, + mpi_isometrization=mpi_isometrization, + ) + return QMatchaTeaBuildResult(backend=backend, build_seconds=time.perf_counter() - start) + + +def exact_mps_expectation(circuit, observable, nqubits): + if isinstance(observable, dict) and "terms" in observable: + terms = [ + ( + term["coefficient"], + tuple((name, site) for name, site in term["operators"]), + ) + for term in observable["terms"] + ] + return exact_pauli_sum(circuit, terms, nqubits) + + hamiltonian = check_observable(observable, nqubits) + return float(hamiltonian.expectation_from_state(circuit().state(numpy=True)).real) + + +def run_qmatchatea_expectation( + circuit, + observable, + *, + bond=10, + cut_ratio=1e-9, + tensor_module="torch", + svd_control="E!", + compile_circuit=True, + preprocess=True, + track_memory=False, + mpi_approach="SR", + mpi_num_procs=1, + mpi_where_barriers=-1, + mpi_isometrization=-1, +): + built = build_qmatchatea_backend( + bond=bond, + cut_ratio=cut_ratio, + tensor_module=tensor_module, + svd_control=svd_control, + compile_circuit=compile_circuit, + track_memory=track_memory, + mpi_approach=mpi_approach, + mpi_num_procs=mpi_num_procs, + mpi_where_barriers=mpi_where_barriers, + mpi_isometrization=mpi_isometrization, + ) + start = time.perf_counter() + value = built.backend.expectation( + circuit, + observable, + preprocess=preprocess, + compile_circuit=compile_circuit, + ) + return QMatchaTeaExpectationResult( + value=float(np.real(value)), + seconds=time.perf_counter() - start, + backend=built.backend, + ) + + +def benchmark_qmatchatea_svd_control(matrix, *, ctrl, max_bond, contract_singvals, repeats): + import gc + import statistics + + import torch + + from qredtea.torchapi import QteaTorchTensor + + conv = qmatchatea.QCConvergenceParameters( + max_bond_dimension=max_bond, + cut_ratio=0.0, + svd_ctrl=ctrl, + ) + qtensor = QteaTorchTensor.from_elem_array(matrix, dtype=matrix.dtype, device="cpu") + + times = [] + rel_error = None + kept = None + status = "ok" + error = "" + + for i in range(repeats): + gc.collect() + if torch.cuda.is_available(): + torch.cuda.synchronize() + t0 = time.perf_counter() + try: + left, right, singvals, _ = qtensor.split_svd( + [0], + [1], + contract_singvals=contract_singvals, + conv_params=conv, + ) + except Exception as exc: # noqa: BLE001 + status = "error" + error = repr(exc) + break + if torch.cuda.is_available(): + torch.cuda.synchronize() + times.append(time.perf_counter() - t0) + + if i == repeats - 1: + left_matrix = left.elem.reshape(matrix.shape[0], -1) + right_matrix = right.elem.reshape(-1, matrix.shape[1]) + recon = left_matrix @ right_matrix + rel_error = ( + torch.linalg.vector_norm(matrix - recon) + / torch.linalg.vector_norm(matrix) + ).item() + kept = int(singvals.numel()) + + return QMatchaTeaSvdControlResult( + ctrl=ctrl, + contract_singvals=contract_singvals, + status=status, + median_ms=float("nan") if not times else statistics.median(times) * 1000, + min_ms=float("nan") if not times else min(times) * 1000, + rel_error=rel_error, + kept=kept, + error=error, + ) diff --git a/src/qibotn/backends/quimb.py b/src/qibotn/backends/quimb.py index 3d49b00..3d20cbb 100644 --- a/src/qibotn/backends/quimb.py +++ b/src/qibotn/backends/quimb.py @@ -1,6 +1,14 @@ +import copy +import importlib.util +import inspect +import json +import time from collections import Counter +from dataclasses import dataclass +from pathlib import Path from typing import Optional +import numpy as np import quimb as qu import quimb.tensor as qtn from qibo.config import raise_error @@ -8,7 +16,39 @@ from qibo.gates.abstract import ParametrizedGate from qibo.models import Circuit from qibotn.backends.abstract import QibotnBackend +from qibotn.observables import extract_gates_and_qubits +from qibotn.parallel import contraction_tree_costs, parallel_path_search from qibotn.result import TensorNetworkResult +from qibotn.torch_utils import ( + arrays_to_backend as _arrays_to_backend, + numpy_dtype as _numpy_dtype, + torch_cpu_array as _torch_cpu_array, + torch_dtype as _torch_dtype, +) + + +def _real_scalar(x): + return float(x.real) + + +def torch_contract_implementation(backend="torch", implementation=None): + if implementation is not None: + return implementation + return "autoray" if backend == "torch" else None + + +def _quimb_should_parametrize(gate): + """Use quimb parametrized tensors only for non-plain numeric parameters.""" + if not isinstance(gate, ParametrizedGate) or not getattr(gate, "trainable", True): + return False + for param in getattr(gate, "parameters", ()): + if isinstance(param, (int, float, complex, np.number)): + continue + if isinstance(param, np.ndarray) and param.ndim == 0: + continue + return True + return False + GATE_MAP = { "h": "H", @@ -20,6 +60,9 @@ GATE_MAP = { "rx": "RX", "ry": "RY", "rz": "RZ", + "rxx": "RXX", + "ryy": "RYY", + "rzz": "RZZ", "u3": "U3", "cx": "CX", "cnot": "CNOT", @@ -40,50 +83,6 @@ GATE_MAP = { PAULI_DENSE_MAX_QUBITS = 8 -def _torch_cpu_array(data, dtype=None): - """Convert array-like data to a contiguous CPU torch tensor.""" - import numpy as np - import torch - - if isinstance(data, torch.Tensor): - x = data - else: - array = np.asarray(data) - if any(stride < 0 for stride in array.strides): - array = np.ascontiguousarray(array) - x = torch.from_numpy(array) - - if x.device.type != "cpu": - x = x.cpu() - if dtype is not None and x.dtype != dtype: - x = x.to(dtype) - if not x.is_contiguous(): - x = x.contiguous() - return x - - -def _torch_dtype(dtype): - import torch - - if dtype in ("complex64", "single"): - return torch.complex64 - return torch.complex128 - - -def _numpy_dtype(dtype): - import numpy as np - - if dtype in ("complex64", "single"): - return np.complex64 - return np.complex128 - - -def _arrays_to_backend(arrays, backend, engine, dtype="complex128"): - if backend == "torch": - return [_torch_cpu_array(array, dtype=_torch_dtype(dtype)) for array in arrays] - return [engine.asarray(array, dtype=_numpy_dtype(dtype)) for array in arrays] - - def _pauli_term_to_dense_operator(factors): op = None where = [] @@ -101,45 +100,54 @@ def pauli_product_expectation_tn( simplify_atol=1e-12, simplify_equalize_norms=True, ): - """Build the scalar TN for ```` without dense Pauli strings.""" + """Build the scalar TN for ```` without dense Pauli strings. + + Use quimb's reverse-lightcone reduced-density TN for the Pauli support, + then attach one 2x2 operator tensor per acted-on site. This keeps long + Pauli products sparse without adding identity tensors outside the support. + """ import numpy as np + from autoray import infer_backend op_by_site = { int(qubit): qu.pauli(str(gate_name).lower()) for qubit, gate_name in factors if str(gate_name).upper() != "I" } - ket = quimb_circuit.get_psi_simplified( - seq=simplify_sequence, - atol=simplify_atol, - equalize_norms=simplify_equalize_norms, - ) - bra = ket.conj().reindex( - { - quimb_circuit.ket_site_ind(qubit): quimb_circuit.bra_site_ind(qubit) - for qubit in range(quimb_circuit.N) - } - ) + if not op_by_site: + return qtn.TensorNetwork( + [qtn.Tensor(data=np.asarray(1.0 + 0.0j), inds=())] + ) - tn = bra | ket - identity = np.eye(2, dtype=complex) - for qubit in range(quimb_circuit.N): - data = op_by_site.get(qubit, identity) - tn |= qtn.Tensor( - data=data, + where = tuple(sorted(op_by_site)) + fs_opts = { + "seq": simplify_sequence, + "atol": simplify_atol, + "equalize_norms": simplify_equalize_norms, + } + rho = quimb_circuit.get_rdm_lightcone_simplified( + where=where, + **fs_opts, + ) + rho_backend = infer_backend(rho.tensors[0].data) if rho.tensors else "numpy" + for qubit in where: + op = op_by_site[qubit] + if rho_backend == "torch": + dtype = getattr(quimb_circuit, "dtype", None) or "complex128" + op = _torch_cpu_array(op, dtype=_torch_dtype(dtype)) + rho |= qtn.Tensor( + data=op, inds=( quimb_circuit.bra_site_ind(qubit), quimb_circuit.ket_site_ind(qubit), ), ) - tn.full_simplify_( + rho.full_simplify_( output_inds=(), - seq=simplify_sequence, - atol=simplify_atol, - equalize_norms=simplify_equalize_norms, + **fs_opts, ) - return tn + return rho def pauli_product_expectation( @@ -156,7 +164,13 @@ def pauli_product_expectation( simplify_sequence=simplify_sequence, simplify_atol=simplify_atol, ) - return tn.contract(all, output_inds=(), optimize=optimize, backend=backend) + return tn.contract( + all, + output_inds=(), + optimize=optimize, + backend=backend, + implementation=torch_contract_implementation(backend), + ) def __init__(self, quimb_backend="torch", contraction_optimizer="auto-hq"): @@ -287,8 +301,14 @@ def execute_circuit( elif initial_state is not None: raise_error(ValueError, "Initial state not None supported only for MPS ansatz.") + gate_opts = { + "max_bond": self.max_bond_dimension, + "cutoff": self.svd_cutoff, + } circ_quimb = self.circuit_ansatz.from_openqasm2_str( - circuit.to_qasm(), psi0=initial_state, gate_opts={"max_bond": self.max_bond_dimension, "cutoff": self.svd_cutoff} + circuit.to_qasm(), + psi0=initial_state, + gate_opts=gate_opts, ) if nshots: @@ -390,7 +410,7 @@ def exp_value_observable_symbolic( expectation_value = expectation_value + coeff * exp_values - return self.real(expectation_value) + return _real_scalar(expectation_value) def _qibo_circuit_to_quimb( @@ -414,7 +434,19 @@ def _qibo_circuit_to_quimb( The converted circuit. """ nqubits = qibo_circ.nqubits + merge_1q = circuit_kwargs.pop("merge_1q", "auto") + merge_2q = circuit_kwargs.pop("merge_2q", "auto") + if self.backend == "torch": + circuit_kwargs.setdefault("to_backend", _torch_cpu_array) + circuit_kwargs.setdefault("convert_eager", True) + circuit_kwargs.setdefault("dtype", getattr(self, "dtype", "complex128")) circ = quimb_circuit_type(nqubits, **circuit_kwargs) + pending_gates = [] + + def flush_pending_gates(): + if pending_gates: + circ.apply_gates(pending_gates, merge_1q=merge_1q, merge_2q=merge_2q) + pending_gates.clear() for gate in qibo_circ.queue: gate_name = getattr(gate, "name", None) @@ -424,34 +456,39 @@ def _qibo_circuit_to_quimb( if gate_name == "cu1": theta = gate.parameters[0] c, t = gate.qubits - circ.apply_gate("RZ", theta / 2, c) - circ.apply_gate("RZ", theta / 2, t) - circ.apply_gate("CNOT", c, t) - circ.apply_gate("RZ", -theta / 2, t) - circ.apply_gate("CNOT", c, t) + pending_gates.extend( + ( + ("RZ", theta / 2, c), + ("RZ", theta / 2, t), + ("CNOT", c, t), + ("RZ", -theta / 2, t), + ("CNOT", c, t), + ) + ) continue if quimb_gate_name is None: if hasattr(gate, "matrix"): - circ.apply_gate_raw(gate.matrix(), getattr(gate, "qubits", ())) + pending_gates.append((gate.matrix(), *getattr(gate, "qubits", ()))) continue raise_error(ValueError, f"Gate {gate_name} not supported in Quimb backend.") params = getattr(gate, "parameters", ()) qubits = getattr(gate, "qubits", ()) - is_parametrized = isinstance(gate, ParametrizedGate) and getattr( - gate, "trainable", True - ) + is_parametrized = _quimb_should_parametrize(gate) if is_parametrized: - circ.apply_gate( - quimb_gate_name, *params, *qubits, parametrized=is_parametrized - ) - else: + flush_pending_gates() circ.apply_gate( quimb_gate_name, *params, *qubits, + parametrize=True, ) + continue + + pending_gates.append((quimb_gate_name, *params, *qubits)) + + flush_pending_gates() return circ @@ -509,7 +546,6 @@ def expectation(self, circuit, observable, parallel=None, parallel_opts=None): if parallel is None: # Use original implementation - from qibotn.observables import extract_gates_and_qubits all_terms = extract_gates_and_qubits(observable) qc = self._qibo_circuit_to_quimb( @@ -532,7 +568,8 @@ def expectation(self, circuit, observable, parallel=None, parallel_opts=None): else: op, where = _pauli_term_to_dense_operator(factors) val = qc.local_expectation( - op, where, + op, + where, backend=self.backend, optimize=self.contractions_optimizer, simplify_sequence="ADCRS", @@ -540,7 +577,7 @@ def expectation(self, circuit, observable, parallel=None, parallel_opts=None): ) exp_val += coeff * val - return self.real(exp_val) + return _real_scalar(exp_val) else: # Use parallel implementation @@ -549,10 +586,11 @@ def expectation(self, circuit, observable, parallel=None, parallel_opts=None): def _expectation_parallel(self, circuit, observable, method, opts): """Parallel expectation value computation.""" - from qibotn.observables import extract_gates_and_qubits - from qibotn.parallel import parallel_path_search, parallel_contract import torch + from qibotn.observables import extract_gates_and_qubits + from qibotn.parallel import parallel_contract, parallel_path_search + try: from mpi4py import MPI comm = MPI.COMM_WORLD if method == 'mpi' else None @@ -568,11 +606,16 @@ def _expectation_parallel(self, circuit, observable, method, opts): torch_threads = opts.get('torch_threads', None) slicing_opts = opts.get('slicing_opts', None) trial_timeout = opts.get('trial_timeout', None) + search_seed = opts.get('search_seed', 0) + merge_1q = opts.get("merge_1q", "auto") + merge_2q = opts.get("merge_2q", "auto") qc = self._qibo_circuit_to_quimb( circuit, quimb_circuit_type=self.circuit_ansatz, gate_opts={"max_bond": self.max_bond_dimension, "cutoff": self.svd_cutoff}, + merge_1q=merge_1q, + merge_2q=merge_2q, ) all_terms = extract_gates_and_qubits(observable) @@ -599,6 +642,7 @@ def _expectation_parallel(self, circuit, observable, method, opts): n_workers=search_workers, slicing_opts=slicing_opts, trial_timeout=trial_timeout, + search_seed=search_seed, ) if tree is None: @@ -611,7 +655,8 @@ def _expectation_parallel(self, circuit, observable, method, opts): if self.backend == "torch": for tensor in tn.tensors: tensor._data = _torch_cpu_array( - tensor._data, dtype=torch.complex128 + tensor._data, + dtype=_torch_dtype(getattr(self, "dtype", "complex128")), ) val = complex( tn.contract( @@ -619,6 +664,7 @@ def _expectation_parallel(self, circuit, observable, method, opts): output_inds=(), optimize=tree, backend="torch", + implementation=torch_contract_implementation(self.backend), ) ) else: @@ -637,10 +683,10 @@ def _expectation_parallel(self, circuit, observable, method, opts): all_exp = comm.gather(my_exp, root=0) if rank == 0: total_exp = sum(all_exp) - return self.real(total_exp) + return _real_scalar(total_exp) return 0.0 - return self.real(my_exp) + return _real_scalar(my_exp) CLASSES_ROOTS = {"numpy": "Numpy", "torch": "PyTorch", "jax": "Jax"} @@ -701,3 +747,876 @@ def __getattr__(name): return BACKENDS[name] except KeyError: raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None + + +@dataclass(frozen=True) +class CircuitBuildResult: + quimb_circuit: object + build_seconds: float + + +@dataclass(frozen=True) +class ExpectationTN: + coeff: complex + factors: tuple + tn: object + quimb_circuit: object + build_seconds: float + tn_seconds: float + + +@dataclass(frozen=True) +class TreeSearchResult: + tree: object + seconds: float + costs: dict + stats: dict + + +@dataclass(frozen=True) +class QuimbTorchRunResult: + built: ExpectationTN + search: TreeSearchResult + value: complex + contract_seconds: float + + +@dataclass(frozen=True) +class QuimbCircuitStats: + build_seconds: float + num_gates: int + num_tensors: int + num_indices: int + + +@dataclass(frozen=True) +class QuimbTNProfile: + value: complex + build_seconds: float + expectation_tn_seconds: float + search_seconds: float + contract_seconds: float + circuit_num_gates: int + circuit_num_tensors: int + tn_num_tensors: int + tn_num_indices: int + tn_outer_indices: int + search_costs: dict + search_stats: dict + + +@dataclass(frozen=True) +class QuimbContractTiming: + implementation: str | None + sort_indices: bool + value: complex + best_seconds: float + mean_seconds: float + + +@dataclass(frozen=True) +class QuimbGateMergeComparison: + merge_stats: QuimbCircuitStats + nomerge_stats: QuimbCircuitStats + tensor_reduction: float + build_speedup: float + + +@dataclass(frozen=True) +class QuimbGateMergeExpectationComparison: + merge: QuimbTorchRunResult + nomerge: QuimbTorchRunResult + value_diff: float + total_speedup: float + build_speedup: float + tensor_reduction: float + + +def make_quimb_backend( + *, + quimb_backend="torch", + contraction_optimizer="auto-hq", + dtype="complex128", +): + backend = BACKENDS[quimb_backend]( + quimb_backend=quimb_backend, + contraction_optimizer=contraction_optimizer, + ) + backend.dtype = dtype + return backend + + +def torch_quimb_backend(dtype="complex128", contraction_optimizer="auto-hq"): + return make_quimb_backend( + quimb_backend="torch", + contraction_optimizer=contraction_optimizer, + dtype=dtype, + ) + + +def build_quimb_backend_circuit( + circuit, + *, + quimb_backend="torch", + ansatz="tn", + dtype="complex128", + max_bond=None, + cutoff=1e-12, + merge_1q="auto", + merge_2q="auto", + contraction_optimizer="auto-hq", +): + backend = make_quimb_backend( + quimb_backend=quimb_backend, + contraction_optimizer=contraction_optimizer, + dtype=dtype, + ) + start = time.perf_counter() + backend.configure_tn_simulation( + ansatz="mps" if ansatz == "mps" else None, + max_bond_dimension=max_bond, + svd_cutoff=cutoff, + ) + qc = backend._qibo_circuit_to_quimb( + circuit, + quimb_circuit_type=backend.circuit_ansatz, + gate_opts={"max_bond": max_bond, "cutoff": cutoff}, + dtype=dtype, + merge_1q=merge_1q, + merge_2q=merge_2q, + ) + return CircuitBuildResult(qc, time.perf_counter() - start) + + +def quimb_circuit_stats(quimb_circuit, build_seconds=0.0): + return QuimbCircuitStats( + build_seconds=float(build_seconds), + num_gates=int(getattr(quimb_circuit, "num_gates", 0)), + num_tensors=len(quimb_circuit.psi.tensor_map), + num_indices=len(quimb_circuit.psi.ind_map), + ) + + +def build_quimb_circuit_stats(circuit, **kwargs): + built = build_quimb_backend_circuit(circuit, **kwargs) + return quimb_circuit_stats(built.quimb_circuit, built.build_seconds) + + +def compare_quimb_gate_merge(circuit, **kwargs): + merge_kwargs = dict(kwargs) + nomerge_kwargs = dict(kwargs) + merge_kwargs.update({"merge_1q": True, "merge_2q": True}) + nomerge_kwargs.update({"merge_1q": False, "merge_2q": False}) + merge_stats = build_quimb_circuit_stats(circuit, **merge_kwargs) + nomerge_stats = build_quimb_circuit_stats(circuit, **nomerge_kwargs) + tensor_reduction = ( + float(nomerge_stats.num_tensors) / max(float(merge_stats.num_tensors), 1.0) + ) + build_speedup = ( + float(nomerge_stats.build_seconds) / max(float(merge_stats.build_seconds), 1e-15) + ) + return QuimbGateMergeComparison( + merge_stats=merge_stats, + nomerge_stats=nomerge_stats, + tensor_reduction=tensor_reduction, + build_speedup=build_speedup, + ) + + +def build_quimb_torch_circuit( + circuit, + *, + ansatz="tn", + dtype="complex128", + max_bond=None, + cutoff=1e-12, + merge_1q="auto", + merge_2q="auto", + contraction_optimizer="auto-hq", +): + return build_quimb_backend_circuit( + circuit, + quimb_backend="torch", + ansatz=ansatz, + dtype=dtype, + max_bond=max_bond, + cutoff=cutoff, + merge_1q=merge_1q, + merge_2q=merge_2q, + contraction_optimizer=contraction_optimizer, + ) + + +def qibo_circuit_to_quimb_torch( + circuit, + *, + ansatz="tn", + dtype="complex128", + max_bond=None, + cutoff=1e-12, + merge_1q="auto", + merge_2q="auto", + contraction_optimizer="auto-hq", +): + return build_quimb_torch_circuit( + circuit, + ansatz=ansatz, + dtype=dtype, + max_bond=max_bond, + cutoff=cutoff, + merge_1q=merge_1q, + merge_2q=merge_2q, + contraction_optimizer=contraction_optimizer, + ).quimb_circuit + + +def pauli_term_expectation_tn( + quimb_circuit, + factors, + *, + dtype="complex128", + simplify_sequence="ADCRS", + simplify_atol=1e-12, +): + if len(factors) > PAULI_DENSE_MAX_QUBITS: + tn = pauli_product_expectation_tn( + quimb_circuit, + factors, + simplify_sequence=simplify_sequence, + simplify_atol=simplify_atol, + ) + else: + op, where = _pauli_term_to_dense_operator(factors) + op = _torch_cpu_array(op, dtype=_torch_dtype(dtype)) + tn = quimb_circuit.local_expectation( + op, + where, + rehearse="tn", + simplify_sequence=simplify_sequence, + simplify_atol=simplify_atol, + ) + ensure_torch_tn(tn, dtype=dtype) + return tn + + +def build_expectation_tn( + circuit, + observable, + *, + term_index=0, + ansatz="tn", + dtype="complex128", + max_bond=None, + cutoff=1e-12, + merge_1q="auto", + merge_2q="auto", + contraction_optimizer="auto-hq", +): + terms = extract_gates_and_qubits(observable) + coeff, factors = terms[term_index] + built = build_quimb_torch_circuit( + circuit, + ansatz=ansatz, + dtype=dtype, + max_bond=max_bond, + cutoff=cutoff, + merge_1q=merge_1q, + merge_2q=merge_2q, + contraction_optimizer=contraction_optimizer, + ) + start = time.perf_counter() + tn = pauli_term_expectation_tn(built.quimb_circuit, factors, dtype=dtype) + return ExpectationTN( + coeff=coeff, + factors=tuple(factors), + tn=tn, + quimb_circuit=built.quimb_circuit, + build_seconds=built.build_seconds, + tn_seconds=time.perf_counter() - start, + ) + + +def ensure_torch_tn(tn, dtype="complex128"): + target_dtype = _torch_dtype(dtype) + for tensor in tn.tensors: + tensor._data = _torch_cpu_array(tensor._data, dtype=target_dtype) + return tn + + +def term_arrays(tn, dtype="complex128"): + return [_torch_cpu_array(array, dtype=_torch_dtype(dtype)) for array in tn.arrays] + + +def search_contraction_tree( + tn, + *, + method="processpool", + total_repeats=128, + max_time=60, + n_workers=4, + slicing_opts=None, + trial_timeout=None, + search_backend=None, + dask_address=None, + debug_trials=False, + dask_close_workers=False, + expected_workers=None, + search_seed=0, + sort_indices=False, + sort_priority="flops", + dtype="complex128", +): + start = time.perf_counter() + tree = parallel_path_search( + tn, + tn.outer_inds(), + method=method, + total_repeats=total_repeats, + max_time=max_time, + n_workers=n_workers, + slicing_opts=slicing_opts, + trial_timeout=trial_timeout, + search_backend=search_backend, + dask_address=dask_address, + debug_trials=debug_trials, + dask_close_workers=dask_close_workers, + expected_workers=expected_workers, + search_seed=search_seed, + ) + if sort_indices and hasattr(tree, "sort_contraction_indices"): + tree.sort_contraction_indices( + priority=sort_priority, + make_output_contig=True, + make_contracted_contig=True, + reset=True, + ) + costs = contraction_tree_costs( + tree, + dtype_bytes=8 if dtype in ("complex64", "single", np.complex64) else 16, + ) + return TreeSearchResult( + tree=tree, + seconds=time.perf_counter() - start, + costs=costs, + stats=getattr(tree, "qibotn_search_stats", {}) or {}, + ) + + +def sorted_tree(tree, enabled=True, priority="flops"): + work_tree = copy.deepcopy(tree) + if enabled and hasattr(work_tree, "sort_contraction_indices"): + work_tree.sort_contraction_indices( + priority=priority, + make_output_contig=True, + make_contracted_contig=True, + reset=True, + ) + return work_tree + + +def contract_tn( + tn, + tree, + *, + dtype="complex128", + backend="torch", + implementation=None, +): + if backend == "torch": + ensure_torch_tn(tn, dtype=dtype) + return tn.contract( + all, + output_inds=(), + optimize=tree, + backend=backend, + implementation=torch_contract_implementation(backend, implementation), + ) + + +def run_quimb_backend_expectation( + circuit, + observable, + *, + quimb_backend="torch", + ansatz="tn", + dtype="complex128", + max_bond=None, + cutoff=1e-12, + contraction_optimizer="auto-hq", +): + backend = make_quimb_backend( + quimb_backend=quimb_backend, + contraction_optimizer=contraction_optimizer, + dtype=dtype, + ) + backend.configure_tn_simulation( + ansatz="mps" if ansatz == "mps" else None, + max_bond_dimension=max_bond, + svd_cutoff=cutoff, + ) + start = time.perf_counter() + value = backend.expectation(circuit, observable) + return value, time.perf_counter() - start + + +def run_quimb_torch_expectation( + circuit, + observable, + *, + term_index=0, + ansatz="tn", + dtype="complex128", + max_bond=None, + cutoff=1e-12, + merge_1q="auto", + merge_2q="auto", + contraction_optimizer="auto-hq", + search_method="processpool", + total_repeats=128, + max_time=60, + n_workers=4, + slicing_opts=None, + trial_timeout=None, + search_backend=None, + dask_address=None, + debug_trials=False, + dask_close_workers=False, + expected_workers=None, + search_seed=0, + sort_indices=False, + sort_priority="flops", + contract_backend="torch", + contract_implementation=None, +): + built = build_expectation_tn( + circuit, + observable, + term_index=term_index, + ansatz=ansatz, + dtype=dtype, + max_bond=max_bond, + cutoff=cutoff, + merge_1q=merge_1q, + merge_2q=merge_2q, + contraction_optimizer=contraction_optimizer, + ) + search = search_contraction_tree( + built.tn, + method=search_method, + total_repeats=total_repeats, + max_time=max_time, + n_workers=n_workers, + slicing_opts=slicing_opts, + trial_timeout=trial_timeout, + search_backend=search_backend, + dask_address=dask_address, + debug_trials=debug_trials, + dask_close_workers=dask_close_workers, + expected_workers=expected_workers, + search_seed=search_seed, + sort_indices=sort_indices, + sort_priority=sort_priority, + dtype=dtype, + ) + start = time.perf_counter() + value = contract_tn( + built.tn, + search.tree, + dtype=dtype, + backend=contract_backend, + implementation=contract_implementation, + ) + return QuimbTorchRunResult( + built=built, + search=search, + value=built.coeff * complex(value), + contract_seconds=time.perf_counter() - start, + ) + + +def profile_quimb_torch_expectation(circuit, observable, **kwargs): + result = run_quimb_torch_expectation(circuit, observable, **kwargs) + return QuimbTNProfile( + value=result.value, + build_seconds=result.built.build_seconds, + expectation_tn_seconds=result.built.tn_seconds, + search_seconds=result.search.seconds, + contract_seconds=result.contract_seconds, + circuit_num_gates=int(result.built.quimb_circuit.num_gates), + circuit_num_tensors=len(result.built.quimb_circuit.psi.tensor_map), + tn_num_tensors=len(result.built.tn.tensor_map), + tn_num_indices=len(result.built.tn.ind_map), + tn_outer_indices=len(result.built.tn.outer_inds()), + search_costs=result.search.costs, + search_stats=result.search.stats, + ) + + +def compare_quimb_gate_merge_expectation(circuit, observable, **kwargs): + """Run the quimb+torch expectation pipeline with gate merging on and off. + + Each variant builds its own tensor network and contraction tree. Trees are + structure-specific, so callers should compare the returned ``merge`` and + ``nomerge`` results rather than reusing a tree between variants. + """ + merge_kwargs = dict(kwargs) + nomerge_kwargs = dict(kwargs) + merge_kwargs.update({"merge_1q": True, "merge_2q": True}) + nomerge_kwargs.update({"merge_1q": False, "merge_2q": False}) + nomerge = run_quimb_torch_expectation(circuit, observable, **nomerge_kwargs) + merge = run_quimb_torch_expectation(circuit, observable, **merge_kwargs) + + merge_total = ( + merge.built.build_seconds + merge.built.tn_seconds + merge.search.seconds + + merge.contract_seconds + ) + nomerge_total = ( + nomerge.built.build_seconds + nomerge.built.tn_seconds + + nomerge.search.seconds + nomerge.contract_seconds + ) + return QuimbGateMergeExpectationComparison( + merge=merge, + nomerge=nomerge, + value_diff=abs(merge.value - nomerge.value), + total_speedup=nomerge_total / max(merge_total, 1e-15), + build_speedup=nomerge.built.build_seconds / max(merge.built.build_seconds, 1e-15), + tensor_reduction=( + len(nomerge.built.quimb_circuit.psi.tensor_map) + / max(len(merge.built.quimb_circuit.psi.tensor_map), 1) + ), + ) + + +def time_quimb_contract_implementations( + expectation_tn, + tree, + *, + dtype="complex128", + implementations=("autoray", "cotengra"), + sort_options=(False, True), + repeats=3, +): + timings = [] + for sort_indices in sort_options: + work_tree = sorted_tree(tree, sort_indices) + for implementation in implementations: + value = None + samples = [] + for _ in range(repeats): + start = time.perf_counter() + value = contract_tn( + expectation_tn, + work_tree, + dtype=dtype, + implementation=implementation, + ) + samples.append(time.perf_counter() - start) + timings.append( + QuimbContractTiming( + implementation=implementation, + sort_indices=bool(sort_indices), + value=complex(value), + best_seconds=min(samples), + mean_seconds=sum(samples) / len(samples), + ) + ) + return tuple(timings) + + +def quimb_torch_parallel_opts( + *, + target_slices=None, + target_size=None, + search_workers=None, + torch_threads=1, + search_repeats=128, + search_time=60.0, + search_seed=0, + merge_gates=True, + search_backend="processpool", + dask_address=None, + dask_expected_workers=None, + dask_close_workers=False, + debug_trials=False, + search_only=False, + save_tree_path=None, + load_tree_path=None, + print_stats=False, +): + slicing_opts = {} + if target_slices is not None: + slicing_opts["target_slices"] = target_slices + if target_size is not None: + slicing_opts["target_size"] = target_size + + opts = { + "slicing_opts": slicing_opts or None, + "search_workers": search_workers or torch_threads, + "max_repeats": search_repeats, + "max_time": search_time, + "search_seed": search_seed, + "merge_1q": merge_gates, + "merge_2q": merge_gates, + "print_stats": print_stats, + } + if search_backend is not None: + opts["search_backend"] = search_backend + if dask_address is not None: + opts["dask_address"] = dask_address + if dask_expected_workers is not None: + opts["dask_expected_workers"] = dask_expected_workers + if dask_close_workers: + opts["dask_close_workers"] = True + if debug_trials: + opts["debug_trials"] = True + if search_only: + opts["search_only"] = True + opts["save_tree_path"] = save_tree_path + elif load_tree_path is not None: + opts["load_tree_path"] = load_tree_path + return opts + + +def load_custom_case_module(path): + """Load a user-provided Python module with circuit/observable builders.""" + path = Path(path).resolve() + spec = importlib.util.spec_from_file_location(path.stem, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"Cannot import case module from {path}.") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +def _call_builder(fn, **kwargs): + sig = inspect.signature(fn) + if any(p.kind == p.VAR_KEYWORD for p in sig.parameters.values()): + return fn(**kwargs) + return fn(**{name: value for name, value in kwargs.items() if name in sig.parameters}) + + +def load_custom_observable( + module, + *, + nqubits, + nlayers=0, + seed=42, + pauli_pattern=None, + observable_json=None, +): + """Load an observable from a custom module, JSON file, or Pauli pattern.""" + if pauli_pattern: + return {"pauli_string_pattern": pauli_pattern} + if observable_json: + with Path(observable_json).open(encoding="utf-8") as f: + return json.load(f) + if hasattr(module, "build_observable"): + return _call_builder( + module.build_observable, + nqubits=nqubits, + nlayers=nlayers, + seed=seed, + ) + if hasattr(module, "OBSERVABLE"): + return module.OBSERVABLE + raise ValueError( + "No observable supplied. Define build_observable/OBSERVABLE in the case " + "module, or pass pauli_pattern / observable_json." + ) + + +def run_custom_tn_expectation( + case_module, + *, + nqubits, + nlayers=0, + seed=42, + observable=None, + pauli_pattern=None, + observable_json=None, + mpi=False, + exact=False, + exact_max_qubits=24, + bond=1024, + cut_ratio=1e-12, + torch_threads=8, + quimb_backend="torch", + dtype="complex128", + parallel_opts=None, +): + """Run a quimb+torch TN expectation for a custom circuit module.""" + from qibotn.expectation_runner import ( + ExpectationConfig, + exact_for_observable, + run_cpu_expectation, + ) + + module = load_custom_case_module(case_module) + if not hasattr(module, "build_circuit"): + raise ValueError("case_module must define build_circuit.") + + circuit = _call_builder( + module.build_circuit, + nqubits=nqubits, + nlayers=nlayers, + seed=seed, + ) + if observable is None: + observable = load_custom_observable( + module, + nqubits=nqubits, + nlayers=nlayers, + seed=seed, + pauli_pattern=pauli_pattern, + observable_json=observable_json, + ) + + rank = 0 + if mpi: + from mpi4py import MPI + + rank = MPI.COMM_WORLD.Get_rank() + + exact_value = None + if exact and rank == 0: + if nqubits > exact_max_qubits: + raise ValueError(f"exact reference is limited to {exact_max_qubits} qubits.") + exact_value = exact_for_observable(circuit, observable, nqubits) + + config = ExpectationConfig( + ansatz="tn", + mpi=mpi, + bond=bond, + cut_ratio=cut_ratio, + tensor_module="torch", + quimb_backend=quimb_backend, + dtype=dtype, + torch_threads=torch_threads, + parallel_opts=parallel_opts or {}, + ) + result = run_cpu_expectation(circuit, observable, config) + if mpi and result.rank != 0: + return None + return { + "circuit": circuit, + "observable": observable, + "exact": exact_value, + "result": result, + "abs_error": None if exact_value is None else abs(result.value - exact_value), + "rel_error": ( + None + if exact_value is None + else abs(result.value - exact_value) / max(abs(exact_value), 1e-15) + ), + } + + +def run_contest_tn_case( + case_name, + obs_name, + *, + mode="contract", + tree_dir="trees/contest_tn", + nqubits=None, + nlayers=None, + seed=None, + mpi=False, + exact=False, + exact_max_qubits=24, + bond=1024, + cut_ratio=1e-12, + torch_threads=8, + quimb_backend="torch", + dtype="complex64", + target_slices=None, + target_size=2**34, + search_workers=None, + search_repeats=2048, + search_time=300.0, + search_seed=0, + merge_gates=True, + search_backend="dask", + dask_address=None, + dask_expected_workers=None, + dask_close_workers=False, + debug_trials=False, +): + """Run one shared contest-style quimb+torch TN search/contract case.""" + from qibotn.contest_cases import CASES, build_contest_circuit, build_contest_observable, tree_path + from qibotn.expectation_runner import ( + ExpectationConfig, + exact_for_observable, + run_cpu_expectation, + ) + + case = CASES[case_name] + nqubits = case.nqubits if nqubits is None else nqubits + nlayers = case.nlayers if nlayers is None else nlayers + seed = case.seed if seed is None else seed + target_slices = case.target_slices if target_slices is None else target_slices + + circuit = build_contest_circuit(case.circuit_kind, nqubits, nlayers, seed) + observable = build_contest_observable(obs_name, nqubits, seed) + path = tree_path(tree_dir, case_name, obs_name, nqubits, nlayers, target_slices, merge_gates) + path.parent.mkdir(parents=True, exist_ok=True) + if mode == "contract" and not path.exists(): + raise FileNotFoundError(f"Missing tree file: {path}. Run search first.") + + rank = 0 + if mpi: + from mpi4py import MPI + + rank = MPI.COMM_WORLD.Get_rank() + + exact_value = None + if exact and rank == 0 and mode != "search": + if nqubits > exact_max_qubits: + raise ValueError(f"exact reference is limited to {exact_max_qubits} qubits.") + exact_value = exact_for_observable(circuit, observable, nqubits) + + config = ExpectationConfig( + ansatz="tn", + mpi=mpi, + bond=bond, + cut_ratio=cut_ratio, + tensor_module="torch", + quimb_backend=quimb_backend, + dtype=dtype, + torch_threads=torch_threads, + parallel_opts=quimb_torch_parallel_opts( + target_slices=target_slices, + target_size=target_size, + search_workers=search_workers, + torch_threads=torch_threads, + search_repeats=search_repeats, + search_time=search_time, + search_seed=search_seed, + merge_gates=merge_gates, + search_backend=search_backend, + dask_address=dask_address, + dask_expected_workers=dask_expected_workers, + dask_close_workers=dask_close_workers, + debug_trials=debug_trials, + search_only=(mode == "search"), + save_tree_path=str(path), + load_tree_path=str(path), + print_stats=False, + ), + ) + result = run_cpu_expectation(circuit, observable, config) + if mpi and result.rank != 0: + return None + return { + "case": case, + "tree_path": path, + "circuit": circuit, + "observable": observable, + "exact": exact_value, + "result": result, + "abs_error": None if exact_value is None else abs(result.value - exact_value), + "rel_error": ( + None + if exact_value is None + else abs(result.value - exact_value) / max(abs(exact_value), 1e-15) + ), + } diff --git a/src/qibotn/backends/vidal.py b/src/qibotn/backends/vidal.py index 8fbd4d5..70b57b8 100644 --- a/src/qibotn/backends/vidal.py +++ b/src/qibotn/backends/vidal.py @@ -9,6 +9,7 @@ usable while the fast path is expanded. from __future__ import annotations import re +import time from dataclasses import dataclass import numpy as np @@ -475,3 +476,511 @@ class VidalBackend(QibotnBackend, NumpyBackend): return_array=return_array, **prob_kwargs, ) + + +@dataclass(frozen=True) +class VidalExpectationResult: + value: float + seconds: float + backend: object + + +@dataclass(frozen=True) +class VidalBackendComparisonResult: + circuit: object + observable: object + exact: float | None + qmatchatea: VidalExpectationResult | None + vidal: VidalExpectationResult + qmatchatea_error: float | None + vidal_error: float | None + + +@dataclass(frozen=True) +class VidalProfileResult: + value: float + trace_path: object + table_path: object + table: str + + +def make_vidal_backend( + *, + bond=10, + cut_ratio=1e-9, + tensor_module="torch", + compile_circuit=False, + mpi_approach="SR", + mpi_num_procs=1, + mpi_where_barriers=-1, + mpi_isometrization=-1, + mpi_term_batch_size=None, + fallback=True, +): + backend = VidalBackend() + backend.configure_tn_simulation( + max_bond_dimension=bond, + cut_ratio=cut_ratio, + tensor_module=tensor_module, + compile_circuit=compile_circuit, + mpi_approach=mpi_approach, + mpi_num_procs=mpi_num_procs, + mpi_where_barriers=mpi_where_barriers, + mpi_isometrization=mpi_isometrization, + mpi_term_batch_size=mpi_term_batch_size, + fallback=fallback, + ) + return backend + + +def run_vidal_expectation( + circuit, + observable, + *, + bond=10, + cut_ratio=1e-9, + tensor_module="torch", + compile_circuit=False, + preprocess=True, + mpi_approach="SR", + mpi_num_procs=1, + mpi_where_barriers=-1, + mpi_isometrization=-1, + mpi_term_batch_size=None, + fallback=True, +): + backend = make_vidal_backend( + bond=bond, + cut_ratio=cut_ratio, + tensor_module=tensor_module, + compile_circuit=compile_circuit, + mpi_approach=mpi_approach, + mpi_num_procs=mpi_num_procs, + mpi_where_barriers=mpi_where_barriers, + mpi_isometrization=mpi_isometrization, + mpi_term_batch_size=mpi_term_batch_size, + fallback=fallback, + ) + start = time.perf_counter() + value = backend.expectation( + circuit, + observable, + preprocess=preprocess, + compile_circuit=compile_circuit, + ) + return VidalExpectationResult( + value=float(np.real(value)), + seconds=time.perf_counter() - start, + backend=backend, + ) + + +def run_segmented_vidal_ring_xz( + circuit, + *, + max_bond=10, + cut_ratio=1e-9, + tensor_module="torch", + comm, +): + from qibotn.backends.vidal_mpi_segment import run_segment_vidal_mpi_ring_xz + + start = time.perf_counter() + value, timings = run_segment_vidal_mpi_ring_xz( + circuit, + max_bond=max_bond, + cut_ratio=cut_ratio, + tensor_module=tensor_module, + comm=comm, + ) + return VidalExpectationResult( + value=float(np.real(value)), + seconds=time.perf_counter() - start, + backend=timings, + ) + + +def compare_vidal_backend_qmatchatea( + circuit, + observable, + *, + bond=512, + cut_ratio=1e-12, + tensor_module="torch", + exact=None, + skip_qmatchatea=False, + qmatchatea_compile_circuit=True, + qmatchatea_svd_control="E!", + vidal_compile_circuit=True, + vidal_fallback=True, +): + qmatchatea_result = None + if not skip_qmatchatea: + qmatchatea_backend = QMatchaTeaBackend() + qmatchatea_backend.configure_tn_simulation( + ansatz="MPS", + max_bond_dimension=bond, + cut_ratio=cut_ratio, + svd_control=qmatchatea_svd_control, + tensor_module=tensor_module, + compile_circuit=qmatchatea_compile_circuit, + track_memory=False, + ) + start = time.perf_counter() + qmatchatea_value = qmatchatea_backend.expectation( + circuit, + observable, + preprocess=False, + compile_circuit=qmatchatea_compile_circuit, + ) + qmatchatea_result = VidalExpectationResult( + value=float(np.real(qmatchatea_value)), + seconds=time.perf_counter() - start, + backend=qmatchatea_backend, + ) + + vidal_backend = VidalBackend() + vidal_backend.configure_tn_simulation( + ansatz="MPS", + max_bond_dimension=bond, + cut_ratio=cut_ratio, + tensor_module=tensor_module, + compile_circuit=vidal_compile_circuit, + fallback=vidal_fallback, + ) + start = time.perf_counter() + vidal_value = vidal_backend.expectation( + circuit, + observable, + preprocess=False, + compile_circuit=vidal_compile_circuit, + ) + vidal_result = VidalExpectationResult( + value=float(np.real(vidal_value)), + seconds=time.perf_counter() - start, + backend=vidal_backend, + ) + + qmatchatea_error = None + vidal_error = None + if exact is not None: + if qmatchatea_result is not None: + qmatchatea_error = abs(qmatchatea_result.value - exact) + vidal_error = abs(vidal_result.value - exact) + + return VidalBackendComparisonResult( + circuit=circuit, + observable=observable, + exact=exact, + qmatchatea=qmatchatea_result, + vidal=vidal_result, + qmatchatea_error=qmatchatea_error, + vidal_error=vidal_error, + ) + + +def profile_vidal_expectation( + circuit, + observable, + *, + bond=512, + cut_ratio=1e-12, + torch_threads=32, + trace_path, + table_path, + profile_memory=False, + rows=60, +): + import torch + from torch.profiler import ProfilerActivity, profile + + from qibotn.expectation_runner import ExpectationConfig, run_cpu_expectation + + torch.set_num_threads(torch_threads) + config = ExpectationConfig( + ansatz="mps", + bond=bond, + cut_ratio=cut_ratio, + tensor_module="torch", + torch_threads=torch_threads, + ) + + with profile( + activities=[ProfilerActivity.CPU], + record_shapes=profile_memory, + profile_memory=profile_memory, + with_stack=profile_memory, + ) as prof: + result = run_cpu_expectation(circuit, observable, config) + + table = ( + f"expval={result.value:.16e}\n\n" + f"# sorted by self_cpu_time_total\n" + f"{prof.key_averages().table(sort_by='self_cpu_time_total', row_limit=rows)}\n\n" + f"# sorted by cpu_time_total\n" + f"{prof.key_averages().table(sort_by='cpu_time_total', row_limit=rows)}\n" + ) + table_path.parent.mkdir(parents=True, exist_ok=True) + table_path.write_text(table, encoding="utf-8") + prof.export_chrome_trace(str(trace_path)) + return VidalProfileResult( + value=result.value, + trace_path=trace_path, + table_path=table_path, + table=table, + ) + + +CONTEST_MPS_BONDS = {"main1": 512, "main2": 1024, "strong": 2048} +CONTEST_VIDAL_OBSERVABLES = ( + "boundary_ZZ_q1", + "boundary_ZZ_q2", + "boundary_ZZ_q3", + "long_Z_5_sites", + "mixed_XZYZX", + "ring_xz", + "open_zz", + "range2_xx", + "complex_iZ0", + "dense2_mid", + "dense3_spread", +) + + +def run_contest_mps_case( + case_name="main1", + *, + observables=None, + obs_filter="", + nqubits=None, + nlayers=None, + bond="case-default", + cut_ratio=1e-12, + seed=None, + torch_threads=8, + exact=False, + exact_max_qubits=24, +): + """Run a shared contest-style Vidal/MPS expectation case.""" + from qibotn.contest_cases import CASES, build_contest_circuit, build_contest_observable + from qibotn.expectation_runner import exact_for_observable + from qibotn.torch_utils import set_torch_threads + + from mpi4py import MPI + + set_torch_threads(torch_threads) + comm = MPI.COMM_WORLD + rank = comm.Get_rank() + size = comm.Get_size() + case = CASES[case_name] + nqubits = case.nqubits if nqubits is None else nqubits + nlayers = case.nlayers if nlayers is None else nlayers + seed = case.seed if seed is None else seed + if bond == "case-default": + bond = CONTEST_MPS_BONDS.get(case_name, 1024) + if observables is None: + observables = tuple(x.strip() for x in obs_filter.split(",") if x.strip()) or case.observables + + circuit = build_contest_circuit(case.circuit_kind, nqubits, nlayers, seed) + records = [] + for obs_name in observables: + observable = build_contest_observable(obs_name, nqubits, seed) + exact_value = None + if exact and rank == 0: + if nqubits > exact_max_qubits: + raise ValueError(f"exact reference is limited to {exact_max_qubits} qubits.") + exact_value = exact_for_observable(circuit, observable, nqubits) + + backend = VidalBackend() + backend.configure_tn_simulation( + max_bond_dimension=bond, + cut_ratio=cut_ratio, + tensor_module="torch", + mpi_approach="CT", + mpi_num_procs=size, + fallback=False, + ) + + comm.Barrier() + start = time.perf_counter() + value = backend.expectation( + circuit, + observable, + preprocess=True, + compile_circuit=False, + ) + seconds = time.perf_counter() - start + if rank == 0: + records.append( + { + "case": case, + "observable": obs_name, + "value": value, + "seconds": seconds, + "exact": exact_value, + "abs_error": None if exact_value is None else abs(value - exact_value), + "rel_error": ( + None + if exact_value is None + else abs(value - exact_value) / max(abs(exact_value), 1e-15) + ), + "truncation_error": backend.last_truncation_error, + "max_truncation_error": backend.last_max_truncation_error, + } + ) + return records + + +def run_vidal_mpi_contest_case( + *, + label, + kind, + nqubits, + nlayers, + bond, + cut_ratio, + seed, + torch_threads, + obs_filter="", +): + """Run the direct Vidal MPI contest observable sweep.""" + from qibotn.contest_cases import build_contest_circuit, build_contest_observable + from qibotn.torch_utils import set_torch_threads + + from mpi4py import MPI + + del label + set_torch_threads(torch_threads) + comm = MPI.COMM_WORLD + rank = comm.Get_rank() + size = comm.Get_size() + circuit = build_contest_circuit(kind, nqubits, nlayers, seed) + names = CONTEST_VIDAL_OBSERVABLES + if obs_filter: + wanted = set(obs_filter.split(",")) + names = tuple(name for name in names if name in wanted) + if not names: + raise ValueError(f"obs_filter matched no observables: {obs_filter!r}") + + records = [] + for obs_name in names: + observable = build_contest_observable(obs_name, nqubits, seed) + backend = VidalBackend() + backend.configure_tn_simulation( + max_bond_dimension=bond, + cut_ratio=cut_ratio, + tensor_module="torch", + mpi_approach="CT", + mpi_num_procs=size, + fallback=False, + ) + comm.Barrier() + start = time.perf_counter() + value = backend.expectation( + circuit, + observable, + preprocess=True, + compile_circuit=False, + ) + seconds = time.perf_counter() - start + if rank == 0: + records.append( + { + "observable": obs_name, + "value": value, + "seconds": seconds, + "truncation_error": backend.last_truncation_error, + "max_truncation_error": backend.last_max_truncation_error, + } + ) + return records + + +def build_vidal_validation_circuit(kind, nqubits, nlayers, seed): + """Build the circuit family used by Vidal correctness checks.""" + from qibotn.benchmark_cases import build_circuit + + aliases = {"brickwall": "brickwall_cnot"} + return build_circuit(aliases.get(kind, kind), nqubits, nlayers, seed) + + +def run_vidal_validation_cases( + *, + nqubits=16, + nlayers=6, + bond=512, + seed=42, + tensor_module="torch", + torch_threads=32, + mpi=False, + circuits=("brickwall", "reversed_cnot", "rx_ry_cz"), + observables=("ring_xz", "open_zz", "mixed_local"), +): + """Run Vidal/TEBD correctness checks against dense statevector references.""" + from qibotn.benchmark_cases import exact_pauli_sum, observable_terms + from qibotn.backends.vidal_tebd import VidalTEBDExecutor + from qibotn.torch_utils import set_torch_threads + + set_torch_threads(torch_threads) + comm = None + rank = 0 + if mpi: + from mpi4py import MPI + + from qibotn.backends.vidal_mpi_segment import SegmentVidalMPIExecutor + + comm = MPI.COMM_WORLD + rank = comm.Get_rank() + else: + SegmentVidalMPIExecutor = None + + records = [] + for circuit_kind in circuits: + circuit = build_vidal_validation_circuit(circuit_kind, nqubits, nlayers, seed) + if rank == 0: + exact_values = { + obs: exact_pauli_sum(circuit, observable_terms(obs, nqubits), nqubits) + for obs in observables + } + else: + exact_values = None + if comm is not None: + exact_values = comm.bcast(exact_values, root=0) + + for obs_kind in observables: + terms = observable_terms(obs_kind, nqubits) + start = time.perf_counter() + if mpi: + executor = SegmentVidalMPIExecutor( + nqubits=nqubits, + max_bond=bond, + cut_ratio=1e-12, + tensor_module=tensor_module, + comm=comm, + ) + executor.run_circuit(circuit) + value = executor.expectation_pauli_sum_root(terms) + else: + executor = VidalTEBDExecutor( + nqubits=nqubits, + max_bond=bond, + cut_ratio=1e-12, + tensor_module=tensor_module, + ) + executor.run_circuit(circuit) + value = float(executor.expectation_pauli_sum(terms)) + if rank != 0: + continue + seconds = time.perf_counter() - start + exact = exact_values[obs_kind] + records.append( + { + "circuit": circuit_kind, + "observable": obs_kind, + "exact": exact, + "value": value, + "abs_error": abs(value - exact), + "seconds": seconds, + } + ) + return records diff --git a/src/qibotn/benchmark_cases.py b/src/qibotn/benchmark_cases.py index cee08dc..e3c3d25 100644 --- a/src/qibotn/benchmark_cases.py +++ b/src/qibotn/benchmark_cases.py @@ -12,6 +12,7 @@ CIRCUITS = ( "brickwall_cnot", "reversed_cnot", "shifted_cz", + "rx_ry_cz", "rxx_rzz", "swap_scramble", "ghz_ladder", @@ -49,14 +50,14 @@ def build_circuit(kind, nqubits, nlayers, seed): for qubit in range(nqubits): circuit.add(gates.RY(qubit, theta=rng.uniform(-math.pi, math.pi))) circuit.add(gates.RZ(qubit, theta=rng.uniform(-math.pi, math.pi))) - if kind in ("rxx_rzz", "swap_scramble"): + if kind in ("rx_ry_cz", "rxx_rzz", "swap_scramble"): circuit.add(gates.RX(qubit, theta=rng.uniform(-math.pi, math.pi))) if kind == "brickwall_cnot": add_brickwall(circuit, nqubits, gates.CNOT, layer, reverse=False) elif kind == "reversed_cnot": add_brickwall(circuit, nqubits, gates.CNOT, layer, reverse=True) - elif kind == "shifted_cz": + elif kind in ("shifted_cz", "rx_ry_cz"): for qubit in range(layer % 2, nqubits - 1, 2): circuit.add(gates.CZ(qubit, qubit + 1)) elif kind == "rxx_rzz": @@ -149,3 +150,22 @@ def exact_pauli_sum(circuit, terms, nqubits): raise ValueError(f"Unsupported Pauli {name!r}.") value += coeff * np.vdot(state[flipped], phase * state) return float(value.real) + + +def ring_xz_statevector_expectation(state, nqubits, chunk_size=1 << 20): + """Compute ``0.5 * sum_i X_i Z_(i+1)`` from a dense state vector.""" + state = np.asarray(state).reshape(-1) + value = 0.0 + for qubit in range(nqubits): + next_qubit = (qubit + 1) % nqubits + x_flip = 1 << (nqubits - 1 - qubit) + z_shift = nqubits - 1 - next_qubit + term = 0.0 + for start in range(0, state.size, chunk_size): + stop = min(start + chunk_size, state.size) + indices = np.arange(start, stop, dtype=np.int64) + z_bit = (indices >> z_shift) & 1 + z_phase = 1 - 2 * z_bit + term += np.vdot(state[indices ^ x_flip], z_phase * state[start:stop]).real + value += 0.5 * term + return float(value) diff --git a/src/qibotn/circuit_convertor.py b/src/qibotn/circuit_convertor.py deleted file mode 100644 index 900cdf7..0000000 --- a/src/qibotn/circuit_convertor.py +++ /dev/null @@ -1,263 +0,0 @@ -import numpy as np - -try: - import cupy as cp -except ImportError: # pragma: no cover - exercised on CPU-only installations - cp = None - - -def _require_cupy(): - if cp is None: - raise ImportError( - "The cuQuantum circuit converter requires cupy. " - "Install the GPU dependencies or use the CPU backend." - ) - return cp - -# Reference: https://github.com/NVIDIA/cuQuantum/tree/main/python/samples/cutensornet/circuit_converter - - -class QiboCircuitToEinsum: - """Convert a circuit to a Tensor Network (TN) representation. - - The circuit is first processed to an intermediate form by grouping each gate matrix - with its corresponding qubit it is acting on to a list. It is then converted to an - equivalent TN expression through the class function state_vector_operands() - following the Einstein summation convention in the interleave format. - - See document for detail of the format: https://docs.nvidia.com/cuda/cuquantum/python/api/generated/cuquantum.contract.html - - The output is to be used by cuQuantum's contract() for computation of the - state vectors of the circuit. - """ - - def __init__(self, circuit, dtype="complex128"): - self.backend = _require_cupy() - self.dtype = getattr(self.backend, dtype) - self.init_basis_map(self.backend, dtype) - self.init_intermediate_circuit(circuit) - self.circuit = circuit - - def state_vector_operands(self): - """Create the operands for dense vector computation in the interleave - format. - - Returns: - Operands for the contraction in the interleave format. - """ - input_bitstring = "0" * len(self.active_qubits) - - input_operands = self._get_bitstring_tensors(input_bitstring) - - ( - mode_labels, - qubits_frontier, - next_frontier, - ) = self._init_mode_labels_from_qubits(self.active_qubits) - - gate_mode_labels, gate_operands = self._parse_gates_to_mode_labels_operands( - self.gate_tensors, qubits_frontier, next_frontier - ) - - operands = input_operands + gate_operands - mode_labels += gate_mode_labels - - out_list = [] - for key in qubits_frontier: - out_list.append(qubits_frontier[key]) - - operand_exp_interleave = [x for y in zip(operands, mode_labels) for x in y] - operand_exp_interleave.append(out_list) - return operand_exp_interleave - - def _init_mode_labels_from_qubits(self, qubits): - n = len(qubits) - frontier_dict = {q: i for i, q in enumerate(qubits)} - mode_labels = [[i] for i in range(n)] - return mode_labels, frontier_dict, n - - def _get_bitstring_tensors(self, bitstring): - return [self.basis_map[ibit] for ibit in bitstring] - - def _parse_gates_to_mode_labels_operands( - self, gates, qubits_frontier, next_frontier - ): - mode_labels = [] - operands = [] - - for tensor, gate_qubits in gates: - operands.append(tensor) - input_mode_labels = [] - output_mode_labels = [] - for q in gate_qubits: - input_mode_labels.append(qubits_frontier[q]) - output_mode_labels.append(next_frontier) - qubits_frontier[q] = next_frontier - next_frontier += 1 - mode_labels.append(output_mode_labels + input_mode_labels) - return mode_labels, operands - - def op_shape_from_qubits(self, nqubits): - """Modify tensor to cuQuantum shape. - - Parameters: - nqubits (int): The number of qubits in quantum circuit. - - Returns: - (qubit_states,input_output) * nqubits - """ - return (2, 2) * nqubits - - def init_intermediate_circuit(self, circuit): - """Initialize the intermediate circuit representation. - - This method initializes the intermediate circuit representation by extracting gate matrices and qubit IDs - from the given quantum circuit. - - Parameters: - circuit (object): The quantum circuit object. - """ - self.gate_tensors = [] - gates_qubits = [] - - for gate in circuit.queue: - gate_qubits = gate.control_qubits + gate.target_qubits - gates_qubits.extend(gate_qubits) - - # self.gate_tensors is to extract into a list the gate matrix together with the qubit id that it is acting on - # https://github.com/NVIDIA/cuQuantum/blob/6b6339358f859ea930907b79854b90b2db71ab92/python/cuquantum/cutensornet/_internal/circuit_parser_utils_cirq.py#L32 - required_shape = self.op_shape_from_qubits(len(gate_qubits)) - self.gate_tensors.append( - ( - self.backend.asarray(gate.matrix(), dtype=self.dtype).reshape( - required_shape - ), - gate_qubits, - ) - ) - - # self.active_qubits is to identify qubits with at least 1 gate acting on it in the whole circuit. - self.active_qubits = np.unique(gates_qubits) - - def init_basis_map(self, backend, dtype): - """Initialize the basis map for the quantum circuit. - - This method initializes a basis map for the quantum circuit, which maps binary - strings representing qubit states to their corresponding quantum state vectors. - - Parameters: - backend (object): The backend object providing the array conversion method. - dtype (object): The data type for the quantum state vectors. - """ - asarray = backend.asarray - state_0 = asarray([1, 0], dtype=dtype) - state_1 = asarray([0, 1], dtype=dtype) - - self.basis_map = {"0": state_0, "1": state_1} - - def init_inverse_circuit(self, circuit): - """Initialize the inverse circuit representation. - - This method initializes the inverse circuit representation by extracting gate matrices and qubit IDs - from the given quantum circuit. - - Parameters: - circuit (object): The quantum circuit object. - """ - self.gate_tensors_inverse = [] - gates_qubits_inverse = [] - - for gate in circuit.queue: - gate_qubits = gate.control_qubits + gate.target_qubits - gates_qubits_inverse.extend(gate_qubits) - - # self.gate_tensors is to extract into a list the gate matrix together with the qubit id that it is acting on - # https://github.com/NVIDIA/cuQuantum/blob/6b6339358f859ea930907b79854b90b2db71ab92/python/cuquantum/cutensornet/_internal/circuit_parser_utils_cirq.py#L32 - required_shape = self.op_shape_from_qubits(len(gate_qubits)) - self.gate_tensors_inverse.append( - ( - self.backend.asarray(gate.matrix()).reshape(required_shape), - gate_qubits, - ) - ) - - # self.active_qubits is to identify qubits with at least 1 gate acting on it in the whole circuit. - self.active_qubits_inverse = np.unique(gates_qubits_inverse) - - def get_pauli_gates(self, pauli_map, dtype="complex128", backend=None): - """Populate the gates for all pauli operators. - - Parameters: - pauli_map: A dictionary mapping qubits to pauli operators. - dtype: Data type for the tensor operands. - backend: The package the tensor operands belong to. - - Returns: - A sequence of pauli gates. - """ - if backend is None: - backend = _require_cupy() - asarray = backend.asarray - pauli_i = asarray([[1, 0], [0, 1]], dtype=dtype) - pauli_x = asarray([[0, 1], [1, 0]], dtype=dtype) - pauli_y = asarray([[0, -1j], [1j, 0]], dtype=dtype) - pauli_z = asarray([[1, 0], [0, -1]], dtype=dtype) - - operand_map = {"I": pauli_i, "X": pauli_x, "Y": pauli_y, "Z": pauli_z} - gates = [] - for qubit, pauli_char in pauli_map.items(): - operand = operand_map.get(pauli_char) - if operand is None: - raise ValueError("pauli string character must be one of I/X/Y/Z") - gates.append((operand, (qubit,))) - return gates - - def expectation_operands(self, ham_gates): - """Create the operands for pauli string expectation computation in the - interleave format. - - Parameters: - ham_gates: A list of gates derived from Qibo hamiltonian object. - - Returns: - Operands for the contraction in the interleave format. - """ - input_bitstring = "0" * self.circuit.nqubits - - input_operands = self._get_bitstring_tensors(input_bitstring) - - ( - mode_labels, - qubits_frontier, - next_frontier, - ) = self._init_mode_labels_from_qubits(range(self.circuit.nqubits)) - - gate_mode_labels, gate_operands = self._parse_gates_to_mode_labels_operands( - self.gate_tensors, qubits_frontier, next_frontier - ) - - operands = input_operands + gate_operands - mode_labels += gate_mode_labels - - self.init_inverse_circuit(self.circuit.invert()) - - next_frontier = max(qubits_frontier.values()) + 1 - - gates_inverse = ham_gates + self.gate_tensors_inverse - - ( - gate_mode_labels_inverse, - gate_operands_inverse, - ) = self._parse_gates_to_mode_labels_operands( - gates_inverse, qubits_frontier, next_frontier - ) - mode_labels = ( - mode_labels - + gate_mode_labels_inverse - + [[qubits_frontier[ix]] for ix in range(self.circuit.nqubits)] - ) - operands = operands + gate_operands_inverse + operands[: self.circuit.nqubits] - - operand_exp_interleave = [x for y in zip(operands, mode_labels) for x in y] - - return operand_exp_interleave diff --git a/src/qibotn/circuit_to_mps.py b/src/qibotn/circuit_to_mps.py deleted file mode 100644 index 48cf55d..0000000 --- a/src/qibotn/circuit_to_mps.py +++ /dev/null @@ -1,63 +0,0 @@ -import numpy as np - -from qibotn.circuit_convertor import QiboCircuitToEinsum -from qibotn.mps_utils import apply_gate, initial - -try: - import cupy as cp - import cuquantum.bindings.cutensornet as cutn -except ImportError: # pragma: no cover - exercised on CPU-only installations - cp = None - cutn = None - - -def _require_cuquantum(): - if cp is None or cutn is None: - raise ImportError( - "The cuQuantum MPS converter requires cupy and cuquantum. " - "Install the GPU dependencies or use the CPU backend." - ) - - -class QiboCircuitToMPS: - """A helper class to convert Qibo circuit to MPS. - - Parameters: - circ_qibo: The quantum circuit object. - gate_algo(dict): Dictionary for SVD and QR settings. - datatype (str): Either single ("complex64") or double (complex128) precision. - rand_seed(int): Seed for random number generator. - """ - - def __init__( - self, - circ_qibo, - gate_algo, - dtype="complex128", - rand_seed=0, - ): - _require_cuquantum() - np.random.seed(rand_seed) - cp.random.seed(rand_seed) - - self.num_qubits = circ_qibo.nqubits - self.handle = cutn.create() - self.dtype = dtype - self.mps_tensors = initial(self.num_qubits, dtype=dtype) - circuitconvertor = QiboCircuitToEinsum(circ_qibo, dtype=dtype) - - for gate, qubits in circuitconvertor.gate_tensors: - # mapping from qubits to qubit indices - # apply the gate in-place - apply_gate( - self.mps_tensors, - gate, - qubits, - algorithm=gate_algo, - options={"handle": self.handle}, - ) - - def __del__(self): - handle = getattr(self, "handle", None) - if cutn is not None and handle is not None: - cutn.destroy(handle) diff --git a/src/qibotn/contest_cases.py b/src/qibotn/contest_cases.py new file mode 100644 index 0000000..dfb7962 --- /dev/null +++ b/src/qibotn/contest_cases.py @@ -0,0 +1,241 @@ +"""Shared contest-style circuits and observables for qibotn tools.""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from pathlib import Path + +import numpy as np +from qibo import Circuit, gates, hamiltonians +from qibo.symbols import X, Y, Z +from qibotn.backends.quimb import quimb_torch_parallel_opts + + +@dataclass(frozen=True) +class CaseSpec: + circuit_kind: str + observables: tuple[str, ...] + nqubits: int + nlayers: int + seed: int + target_slices: int | None = None + + +CASES = { + "main1": CaseSpec( + circuit_kind="rxx_rzz_chain", + observables=("ring_xz",), + nqubits=37, + nlayers=20, + seed=31001, + target_slices=None, + ), + "main2": CaseSpec( + circuit_kind="scramble_chain", + observables=("open_zz", "range2_xx"), + nqubits=36, + nlayers=18, + seed=31002, + target_slices=None, + ), + "strong": CaseSpec( + circuit_kind="reversed_cnot", + observables=("ring_xz", "long_z_string"), + nqubits=40, + nlayers=24, + seed=41001, + target_slices=None, + ), +} + + +def _add_single_qubit_layer(circuit, nqubits, rng, include_rx=False): + for qubit in range(nqubits): + circuit.add(gates.RY(qubit, theta=rng.uniform(-math.pi, math.pi))) + circuit.add(gates.RZ(qubit, theta=rng.uniform(-math.pi, math.pi))) + if include_rx: + circuit.add(gates.RX(qubit, theta=rng.uniform(-math.pi, math.pi))) + + +def _add_brickwall(circuit, nqubits, gate, layer, reverse=False): + for qubit in range(0, nqubits - 1, 2): + if reverse and layer % 2: + circuit.add(gate(qubit + 1, qubit)) + else: + circuit.add(gate(qubit, qubit + 1)) + for qubit in range(1, nqubits - 1, 2): + if reverse and not layer % 2: + circuit.add(gate(qubit + 1, qubit)) + else: + circuit.add(gate(qubit, qubit + 1)) + + +def build_contest_circuit(kind, nqubits, nlayers, seed): + """Build one of the contest-style benchmark circuits.""" + rng = np.random.default_rng(seed) + circuit = Circuit(nqubits) + + if kind == "ghz_ladder": + circuit.add(gates.H(0)) + for qubit in range(nqubits - 1): + circuit.add(gates.CNOT(qubit, qubit + 1)) + return circuit + + for layer in range(nlayers): + if kind in {"brickwall_cnot", "reversed_cnot", "shifted_cz"}: + _add_single_qubit_layer(circuit, nqubits, rng) + elif kind in {"rxx_rzz", "swap_scramble"}: + _add_single_qubit_layer(circuit, nqubits, rng, include_rx=True) + elif kind in {"rxx_rzz_chain", "scramble_chain", "scramble"}: + _add_single_qubit_layer(circuit, nqubits, rng, include_rx=True) + else: + raise ValueError(f"Unknown circuit kind {kind!r}.") + + if kind == "brickwall_cnot": + _add_brickwall(circuit, nqubits, gates.CNOT, layer, reverse=False) + elif kind == "reversed_cnot": + _add_brickwall(circuit, nqubits, gates.CNOT, layer, reverse=True) + elif kind == "shifted_cz": + for qubit in range(layer % 2, nqubits - 1, 2): + circuit.add(gates.CZ(qubit, qubit + 1)) + elif kind == "rxx_rzz": + for qubit in range(layer % 2, nqubits - 1, 2): + circuit.add(gates.RXX(qubit, qubit + 1, theta=rng.uniform(-0.7, 0.7))) + circuit.add(gates.RZZ(qubit, qubit + 1, theta=rng.uniform(-0.7, 0.7))) + elif kind == "swap_scramble": + for qubit in range(layer % 2, nqubits - 1, 2): + circuit.add(gates.CZ(qubit, qubit + 1)) + if layer % 4 == 3: + circuit.add(gates.SWAP(qubit, qubit + 1)) + elif kind == "rxx_rzz_chain": + for qubit in range(layer % 2, nqubits - 1, 2): + circuit.add(gates.RXX(qubit, qubit + 1, theta=rng.uniform(-0.9, 0.9))) + circuit.add(gates.RZZ(qubit, qubit + 1, theta=rng.uniform(-0.9, 0.9))) + elif kind == "scramble_chain": + for qubit in range(layer % 2, nqubits - 1, 2): + circuit.add(gates.RXX(qubit, qubit + 1, theta=rng.uniform(-0.8, 0.8))) + circuit.add(gates.RZZ(qubit, qubit + 1, theta=rng.uniform(-0.8, 0.8))) + if layer % 5 == 4: + circuit.add(gates.SWAP(qubit, qubit + 1)) + elif kind == "scramble": + for qubit in range(layer % 2, nqubits - 1, 2): + circuit.add(gates.RXX(qubit, qubit + 1, theta=rng.uniform(-0.8, 0.8))) + circuit.add(gates.RZZ(qubit, qubit + 1, theta=rng.uniform(-0.8, 0.8))) + if layer % 5 == 4: + circuit.add(gates.SWAP(qubit, qubit + 1)) + + return circuit + + +def _dense_observable(nqubits, qubits, seed, dim): + del nqubits + rng = np.random.default_rng(seed) + raw = rng.normal(size=(dim, dim)) + 1j * rng.normal(size=(dim, dim)) + matrix = (raw + raw.conj().T) / 2.0 + matrix = matrix / np.linalg.norm(matrix) + return {"matrix": matrix, "qubits": list(qubits)} + + +def build_contest_observable(kind, nqubits, seed=0): + """Build one of the shared contest observables.""" + q1 = nqubits // 4 + q2 = nqubits // 2 + q3 = (3 * nqubits) // 4 + last = nqubits - 1 + + if kind == "ring_xz": + form = 0 + for qubit in range(nqubits): + form += 0.5 * X(qubit) * Z((qubit + 1) % nqubits) + return hamiltonians.SymbolicHamiltonian(form=form) + if kind == "open_zz": + form = 0 + for qubit in range(nqubits - 1): + form += (1.0 / max(1, nqubits - 1)) * Z(qubit) * Z(qubit + 1) + return hamiltonians.SymbolicHamiltonian(form=form) + if kind == "range2_xx": + form = 0 + for qubit in range(nqubits - 2): + form += (1.0 / max(1, nqubits - 2)) * X(qubit) * X(qubit + 2) + return hamiltonians.SymbolicHamiltonian(form=form) + if kind == "mixed_local": + form = 0.25 * X(0) - 0.5 * Z(last) + 0.125 * X(q1) * Z(q2) * Y(q3) + return hamiltonians.SymbolicHamiltonian(form=form) + if kind == "long_z_string": + stride = max(1, nqubits // 16) + form = None + for qubit in range(0, nqubits, stride): + form = Z(qubit) if form is None else form * Z(qubit) + return hamiltonians.SymbolicHamiltonian(form=form) + if kind == "boundary_ZZ_q1": + return hamiltonians.SymbolicHamiltonian(form=Z(q1 - 1) * Z(q1)) + if kind == "boundary_ZZ_q2": + return hamiltonians.SymbolicHamiltonian(form=Z(q2 - 1) * Z(q2)) + if kind == "boundary_ZZ_q3": + return hamiltonians.SymbolicHamiltonian(form=Z(q3 - 1) * Z(q3)) + if kind == "long_Z_5_sites": + return hamiltonians.SymbolicHamiltonian( + form=Z(0) * Z(q1) * Z(q2) * Z(q3) * Z(last) + ) + if kind == "mixed_XZYZX": + return hamiltonians.SymbolicHamiltonian(form=X(0) * Z(q1) * Y(q2) * Z(q3) * X(last)) + if kind == "complex_iZ0": + return hamiltonians.SymbolicHamiltonian(form=1.0j * Z(0)) + if kind == "dense2_mid": + return _dense_observable(nqubits, (q2 - 1, q2), seed + 101, 4) + if kind == "dense3_spread": + return _dense_observable(nqubits, (q1, q2, q3), seed + 202, 8) + raise ValueError(f"Unknown observable kind {kind!r}.") + + +def tree_path(tree_dir, case_name, obs_name, nqubits, nlayers, target_slices, merge_gates=True): + slice_label = "auto" if target_slices is None else f"s{target_slices}" + merge_label = "merge" if merge_gates else "nomerge" + return ( + Path(tree_dir) + / f"{case_name}_{obs_name}_{nqubits}q{nlayers}l_{slice_label}_{merge_label}.pkl" + ) + + +def selected_observables(args, case): + if args.observables: + return tuple(args.observables) + if args.obs_filter: + return tuple(x.strip() for x in args.obs_filter.split(",") if x.strip()) + return case.observables + + +def apply_case_defaults(args): + case = CASES[args.case] + if args.nqubits is None: + args.nqubits = case.nqubits + if args.nlayers is None: + args.nlayers = case.nlayers + if args.seed is None: + args.seed = case.seed + if args.tn_target_slices is None: + args.tn_target_slices = case.target_slices + args.observables = selected_observables(args, case) + + +def build_parallel_opts(args, tree_file=None, search_only=False): + return quimb_torch_parallel_opts( + target_slices=args.tn_target_slices, + target_size=args.tn_target_size, + search_workers=args.tn_search_workers, + torch_threads=args.torch_threads, + search_repeats=args.tn_search_repeats, + search_time=args.tn_search_time, + search_seed=args.tn_search_seed, + merge_gates=args.merge_gates, + search_backend=args.tn_search_backend, + dask_address=args.dask_address, + dask_expected_workers=args.dask_expected_workers, + dask_close_workers=args.dask_close_workers, + debug_trials=args.tn_debug_trials, + search_only=search_only, + save_tree_path=str(tree_file) if tree_file is not None else None, + load_tree_path=str(tree_file) if tree_file is not None else None, + print_stats=False, + ) diff --git a/src/qibotn/eval.py b/src/qibotn/eval.py index f2fbf71..144e1f8 100644 --- a/src/qibotn/eval.py +++ b/src/qibotn/eval.py @@ -1,8 +1,10 @@ from mpi4py import MPI -from qibotn.circuit_convertor import QiboCircuitToEinsum -from qibotn.circuit_to_mps import QiboCircuitToMPS -from qibotn.mps_contraction_helper import MPSContractionHelper +from qibotn.backends.cutensornet_helpers import ( + MPSContractionHelper, + QiboCircuitToEinsum, + QiboCircuitToMPS, +) from qibotn.observables import ( build_observable, check_observable, diff --git a/src/qibotn/expectation_runner.py b/src/qibotn/expectation_runner.py index 9592974..59ef1b7 100644 --- a/src/qibotn/expectation_runner.py +++ b/src/qibotn/expectation_runner.py @@ -8,7 +8,15 @@ from dataclasses import dataclass import numpy as np from qibo.backends import construct_backend -from qibotn.benchmark_cases import exact_pauli_sum +from qibotn.benchmark_cases import ( + CIRCUITS, + OBSERVABLES, + build_circuit, + exact_pauli_sum, + observable_terms, + parse_names, + terms_to_dict, +) from qibotn.observables import check_observable @@ -77,6 +85,18 @@ class ExpectationResult: parallel_stats: list | None = None +@dataclass +class BenchmarkExpectationRecord: + circuit: str + observable: str + value: float + seconds: float + exact: float | None = None + abs_error: float | None = None + rel_error: float | None = None + parallel_stats: list | None = None + + def _config_from_kwargs(**kwargs): fields = ExpectationConfig.__dataclass_fields__ config_kwargs = {name: kwargs.pop(name) for name in list(kwargs) if name in fields} @@ -155,3 +175,148 @@ def mps_expectation(circuit, observable=None, *, return_result=False, **kwargs): return_result=return_result, **kwargs, ) + + +def cpu_benchmark_parallel_opts( + *, + target_slices=None, + target_size=2**32, + search_workers=None, + torch_threads=8, + search_repeats=128, + search_time=60.0, + search_backend="dask", + dask_address=None, + dask_close_workers=False, + save_tree_path=None, + load_tree_path=None, + search_only=False, + debug_trials=False, + contract_implementation=None, + print_stats=True, +): + """Build parallel TN options for the CPU expectation backend.""" + slicing_opts = {} + if target_slices is not None: + slicing_opts["target_slices"] = target_slices + if target_size is not None: + slicing_opts["target_size"] = target_size + + opts = { + "slicing_opts": slicing_opts or None, + "search_workers": search_workers or torch_threads, + "max_repeats": search_repeats, + "max_time": search_time, + "print_stats": print_stats, + } + if search_backend is not None: + opts["search_backend"] = search_backend + if dask_address is not None: + opts["dask_address"] = dask_address + if save_tree_path is not None: + opts["save_tree_path"] = save_tree_path + if load_tree_path is not None: + opts["load_tree_path"] = load_tree_path + if search_only: + opts["search_only"] = True + if debug_trials: + opts["debug_trials"] = True + if contract_implementation is not None: + opts["contract_implementation"] = contract_implementation + if dask_close_workers: + opts["dask_close_workers"] = True + return opts + + +def run_cpu_benchmark_cases( + *, + nqubits=40, + nlayers=30, + bond=1024, + cut_ratio=1e-12, + seed=42, + torch_threads=8, + quimb_backend="torch", + dtype="complex128", + ansatz="tn", + mpi=False, + exact=False, + exact_max_qubits=24, + circuits=("brickwall_cnot",), + observables=("ring_xz",), + pauli_pattern=None, + parallel_opts=None, +): + """Run the reusable CPU TN/MPS benchmark cases. + + This is the importable library entrypoint for reusable CPU benchmark cases. + """ + selected_circuits = parse_names(list(circuits), CIRCUITS, "circuits") + selected_observables = ( + [] + if pauli_pattern + else parse_names(list(observables), OBSERVABLES, "observables") + ) + + rank = 0 + if mpi: + from mpi4py import MPI + + rank = MPI.COMM_WORLD.Get_rank() + + config = ExpectationConfig( + ansatz=ansatz, + mpi=mpi, + bond=bond, + cut_ratio=cut_ratio, + tensor_module="torch", + quimb_backend=quimb_backend, + dtype=dtype, + torch_threads=torch_threads, + parallel_opts=parallel_opts or {}, + ) + + records = [] + for circuit_kind in selected_circuits: + circuit = build_circuit(circuit_kind, nqubits, nlayers, seed) + named_observables = ( + [(f"pattern:{pauli_pattern}", {"pauli_string_pattern": pauli_pattern})] + if pauli_pattern + else [ + (obs_kind, terms_to_dict(observable_terms(obs_kind, nqubits))) + for obs_kind in selected_observables + ] + ) + + for obs_name, observable in named_observables: + exact_value = None + if exact and rank == 0: + if nqubits > exact_max_qubits: + raise ValueError( + f"exact reference is limited to {exact_max_qubits} qubits." + ) + exact_value = exact_for_observable(circuit, observable, nqubits) + + result = run_cpu_expectation(circuit, observable, config) + if mpi and result.rank != 0: + continue + + abs_error = None if exact_value is None else abs(result.value - exact_value) + rel_error = ( + None + if exact_value is None + else abs_error / max(abs(exact_value), 1e-15) + ) + records.append( + BenchmarkExpectationRecord( + circuit=circuit_kind, + observable=obs_name, + value=result.value, + seconds=result.seconds, + exact=exact_value, + abs_error=abs_error, + rel_error=rel_error, + parallel_stats=result.parallel_stats, + ) + ) + return records diff --git a/src/qibotn/mps_contraction_helper.py b/src/qibotn/mps_contraction_helper.py deleted file mode 100644 index b44cfb2..0000000 --- a/src/qibotn/mps_contraction_helper.py +++ /dev/null @@ -1,131 +0,0 @@ -try: - from cuquantum.tensornet import contract, contract_path -except ImportError: # pragma: no cover - exercised on CPU-only installations - contract = None - contract_path = None - - -def _require_cuquantum(): - if contract is None or contract_path is None: - raise ImportError( - "The cuQuantum MPS contraction helper requires cuquantum. " - "Install the GPU dependencies or use the CPU backend." - ) - -# Reference: https://github.com/NVIDIA/cuQuantum/blob/main/python/samples/cutensornet/tn_algorithms/mps_algorithms.ipynb - - -class MPSContractionHelper: - """A helper class to compute various quantities for a given MPS. - - Interleaved format is used to construct the input args for `cuquantum.contract`. - - Reference: https://github.com/NVIDIA/cuQuantum/blob/main/python/samples/cutensornet/tn_algorithms/mps_algorithms.ipynb - - The following compute quantities are supported: - - - the norm of the MPS. - - the equivalent state vector from the MPS. - - the expectation value for a given operator. - - the equivalent state vector after multiplying an MPO to an MPS. - - Parameters: - num_qubits: The number of qubits for the MPS. - """ - - def __init__(self, num_qubits): - self.num_qubits = num_qubits - self.bra_modes = [(2 * i, 2 * i + 1, 2 * i + 2) for i in range(num_qubits)] - offset = 2 * num_qubits + 1 - self.ket_modes = [ - (i + offset, 2 * i + 1, i + 1 + offset) for i in range(num_qubits) - ] - - def contract_norm(self, mps_tensors, options=None): - """Contract the corresponding tensor network to form the norm of the - MPS. - - Parameters: - mps_tensors: A list of rank-3 ndarray-like tensor objects. - The indices of the ith tensor are expected to be bonding index to the i-1 tensor, - the physical mode, and then the bonding index to the i+1th tensor. - options: Specify the contract and decompose options. - - Returns: - The norm of the MPS. - """ - interleaved_inputs = [] - for i, o in enumerate(mps_tensors): - interleaved_inputs.extend( - [o, self.bra_modes[i], o.conj(), self.ket_modes[i]] - ) - interleaved_inputs.append([]) # output - return self._contract(interleaved_inputs, options=options).real - - def contract_state_vector(self, mps_tensors, options=None): - """Contract the corresponding tensor network to form the state vector - representation of the MPS. - - Parameters: - mps_tensors: A list of rank-3 ndarray-like tensor objects. - The indices of the ith tensor are expected to be bonding index to the i-1 tensor, - the physical mode, and then the bonding index to the i+1th tensor. - options: Specify the contract and decompose options. - - Returns: - An ndarray-like object as the state vector. - """ - interleaved_inputs = [] - for i, o in enumerate(mps_tensors): - interleaved_inputs.extend([o, self.bra_modes[i]]) - output_modes = tuple([bra_modes[1] for bra_modes in self.bra_modes]) - interleaved_inputs.append(output_modes) # output - return self._contract(interleaved_inputs, options=options) - - def contract_expectation( - self, mps_tensors, operator, qubits, options=None, normalize=False - ): - """Contract the corresponding tensor network to form the expectation of - the MPS. - - Parameters: - mps_tensors: A list of rank-3 ndarray-like tensor objects. - The indices of the ith tensor are expected to be bonding index to the i-1 tensor, - the physical mode, and then the bonding index to the i+1th tensor. - operator: A ndarray-like tensor object. - The modes of the operator are expected to be output qubits followed by input qubits, e.g, - ``A, B, a, b`` where `a, b` denotes the inputs and `A, B'` denotes the outputs. - qubits: A sequence of integers specifying the qubits that the operator is acting on. - options: Specify the contract and decompose options. - normalize: Whether to scale the expectation value by the normalization factor. - - Returns: - An ndarray-like object as the state vector. - """ - - interleaved_inputs = [] - extra_mode = 3 * self.num_qubits + 2 - operator_modes = [None] * len(qubits) + [self.bra_modes[q][1] for q in qubits] - qubits = list(qubits) - for i, o in enumerate(mps_tensors): - interleaved_inputs.extend([o, self.bra_modes[i]]) - k_modes = self.ket_modes[i] - if i in qubits: - k_modes = (k_modes[0], extra_mode, k_modes[2]) - q = qubits.index(i) - operator_modes[q] = extra_mode # output modes - extra_mode += 1 - interleaved_inputs.extend([o.conj(), k_modes]) - interleaved_inputs.extend([operator, tuple(operator_modes)]) - interleaved_inputs.append([]) # output - if normalize: - norm = self.contract_norm(mps_tensors, options=options) - else: - norm = 1 - return self._contract(interleaved_inputs, options=options) / norm - - def _contract(self, interleaved_inputs, options=None): - _require_cuquantum() - path = contract_path(*interleaved_inputs, options=options)[0] - - return contract(*interleaved_inputs, options=options, optimize={"path": path}) diff --git a/src/qibotn/mps_utils.py b/src/qibotn/mps_utils.py deleted file mode 100644 index ff3d010..0000000 --- a/src/qibotn/mps_utils.py +++ /dev/null @@ -1,111 +0,0 @@ -try: - import cupy as cp - from cuquantum.tensornet import contract - from cuquantum.tensornet.experimental import contract_decompose -except ImportError: # pragma: no cover - exercised on CPU-only installations - cp = None - contract = None - contract_decompose = None - - -def _require_cuquantum(): - if cp is None or contract is None or contract_decompose is None: - raise ImportError( - "The cuQuantum MPS helpers require cupy and cuquantum. " - "Install the GPU dependencies or use the CPU backend." - ) - - -def initial(num_qubits, dtype): - r"""Generate the MPS with an initial state of :math:`\ket{00...00}` - - Parameters: - num_qubits: Number of qubits in the Quantum Circuit. - dtype: Either single ("complex64") or double (complex128) precision. - - Returns: - The initial MPS tensors. - """ - _require_cuquantum() - state_tensor = cp.asarray([1, 0], dtype=dtype).reshape(1, 2, 1) - mps_tensors = [state_tensor] * num_qubits - return mps_tensors - - -def mps_site_right_swap(mps_tensors, i, **kwargs): - """Perform the swap operation between the ith and i+1th MPS tensors. - - Parameters: - mps_tensors: Tensors representing MPS - i (int): index of the tensor to swap - - Returns: - The updated MPS tensors. - """ - _require_cuquantum() - # contraction followed by QR decomposition - a, _, b = contract_decompose( - "ipj,jqk->iqj,jpk", - *mps_tensors[i : i + 2], - algorithm=kwargs.get("algorithm", None), - options=kwargs.get("options", None), - ) - mps_tensors[i : i + 2] = (a, b) - return mps_tensors - - -def apply_gate(mps_tensors, gate, qubits, **kwargs): - """Apply the gate operand to the MPS tensors in-place. - - # Reference: https://github.com/NVIDIA/cuQuantum/blob/main/python/samples/cutensornet/tn_algorithms/mps_algorithms.ipynb - - Parameters: - mps_tensors: A list of rank-3 ndarray-like tensor objects. - The indices of the ith tensor are expected to be the bonding index to the i-1 tensor, - the physical mode, and then the bonding index to the i+1th tensor. - gate: A ndarray-like tensor object representing the gate operand. - The modes of the gate is expected to be output qubits followed by input qubits, e.g, - ``A, B, a, b`` where ``a, b`` denotes the inputs and ``A, B`` denotes the outputs. - qubits: A sequence of integers denoting the qubits that the gate is applied onto. - algorithm: The contract and decompose algorithm to use for gate application. - Can be either a `dict` or a `ContractDecomposeAlgorithm`. - options: Specify the contract and decompose options. - - Returns: - The updated MPS tensors. - """ - - _require_cuquantum() - n_qubits = len(qubits) - if n_qubits == 1: - # single-qubit gate - i = qubits[0] - mps_tensors[i] = contract( - "ipj,qp->iqj", mps_tensors[i], gate, options=kwargs.get("options", None) - ) # in-place update - elif n_qubits == 2: - # two-qubit gate - i, j = qubits - if i > j: - # swap qubits order - return apply_gate(mps_tensors, gate.transpose(1, 0, 3, 2), (j, i), **kwargs) - elif i + 1 == j: - # two adjacent qubits - a, _, b = contract_decompose( - "ipj,jqk,rspq->irj,jsk", - *mps_tensors[i : i + 2], - gate, - algorithm=kwargs.get("algorithm", None), - options=kwargs.get("options", None), - ) - mps_tensors[i : i + 2] = (a, b) # in-place update - else: - # non-adjacent two-qubit gate - # step 1: swap i with i+1 - mps_site_right_swap(mps_tensors, i, **kwargs) - # step 2: apply gate to (i+1, j) pair. This amounts to a recursive swap until the two qubits are adjacent - apply_gate(mps_tensors, gate, (i + 1, j), **kwargs) - # step 3: swap back i and i+1 - mps_site_right_swap(mps_tensors, i, **kwargs) - else: - raise NotImplementedError("Only one- and two-qubit gates supported") diff --git a/src/qibotn/observables.py b/src/qibotn/observables.py index 7f3c242..b90a2da 100644 --- a/src/qibotn/observables.py +++ b/src/qibotn/observables.py @@ -35,7 +35,17 @@ def check_observable(observable, circuit_nqubit): if isinstance(observable, dict): return create_hamiltonian_from_dict(observable, circuit_nqubit) if isinstance(observable, hamiltonians.SymbolicHamiltonian): - return observable + if observable.nqubits == circuit_nqubit: + return observable + if observable.nqubits > circuit_nqubit: + raise ValueError( + "Observable has more qubits than the circuit: " + f"{observable.nqubits} > {circuit_nqubit}." + ) + return hamiltonians.SymbolicHamiltonian( + form=observable.form, + nqubits=circuit_nqubit, + ) try: return hamiltonians.SymbolicHamiltonian(form=observable) except Exception as exc: diff --git a/src/qibotn/parallel.py b/src/qibotn/parallel.py index 0fd577c..d7746b1 100644 --- a/src/qibotn/parallel.py +++ b/src/qibotn/parallel.py @@ -1,12 +1,16 @@ """Parallel path search and contraction utilities for tensor networks.""" +import importlib import os import pickle import signal import time -from math import log2, log10 -import numpy as np -from dataclasses import dataclass +from collections import Counter, defaultdict from concurrent.futures import ProcessPoolExecutor, TimeoutError, as_completed +from dataclasses import dataclass +from math import log2, log10 +from pathlib import Path + +import numpy as np try: from mpi4py import MPI @@ -40,6 +44,12 @@ def _optimizer_search_stats(opt): } +def _tree_search_stats(tree): + if tree is None: + return {} + return getattr(tree, "qibotn_search_stats", {}) or {} + + def _attach_search_stats(tree, opt): try: tree.qibotn_search_stats = _optimizer_search_stats(opt) @@ -48,6 +58,47 @@ def _attach_search_stats(tree, opt): return tree +def _search_seed_kwargs(optlib, seed): + if optlib == "random": + return {"seed": seed} + if optlib is None: + return {"sampler_opts": {"seed": seed}} + return {} + + +def _fallback_greedy_tree(tn, output_inds, slicing_opts=None, error=None): + import cotengra as ctg + + tree = tn.contraction_tree( + output_inds=output_inds, + optimize=ctg.GreedyOptimizer(), + ) + if slicing_opts: + target_size = slicing_opts.get("target_size") + target_slices = slicing_opts.get("target_slices") + if target_size is not None: + tree.slice_(target_size=target_size) + elif target_slices is not None: + tree.slice_(target_slices=target_slices) + try: + tree.qibotn_search_stats = { + "completed_trials": 0, + "finite_trials": 0, + "failed_trials": 0, + "requested_trials": 0, + "trial_seconds_sum": 0.0, + "best_score": float("nan"), + "best_flops": float("nan"), + "best_write": float("nan"), + "best_size": float("nan"), + "fallback": "greedy", + "fallback_error": repr(error) if error is not None else None, + } + except Exception: + pass + return tree + + def _dask_worker_slots(client): info = client.scheduler_info(n_workers=-1) workers = info.get("workers", {}) @@ -218,13 +269,18 @@ def _search_chunk( slicing_opts, optlib=None, ): - import random, cotengra as ctg + import random + import cotengra as ctg + + seed = int(seed) random.seed(seed) + np.random.seed(seed % (2**32)) tn = pickle.loads(tn_bytes) kwargs = {} if optlib is not None: kwargs["optlib"] = optlib + kwargs.update(_search_seed_kwargs(optlib, seed)) opt = ctg.HyperOptimizer( methods=SEARCH_METHODS, max_repeats=repeats, @@ -266,7 +322,15 @@ def _kill_pool(pool): pool.shutdown(wait=False) -def _serial_search(tn_bytes, output_inds, repeats, seed, max_time, slicing_opts=None, trial_timeout=None): +def _serial_search( + tn_bytes, + output_inds, + repeats, + seed, + max_time, + slicing_opts=None, + trial_timeout=None, +): import time if trial_timeout is None: @@ -287,7 +351,13 @@ def _serial_search(tn_bytes, output_inds, repeats, seed, max_time, slicing_opts= break timeout = min(trial_timeout, deadline - time.time()) pool = ProcessPoolExecutor(max_workers=1) - fut = pool.submit(_run_single_trial, tn_bytes, output_inds, seed * 10000 + i, slicing_opts) + fut = pool.submit( + _run_single_trial, + tn_bytes, + output_inds, + seed * 10000 + i, + slicing_opts, + ) try: cost, tree = fut.result(timeout=timeout) if cost < best_cost: @@ -304,15 +374,30 @@ def _split_repeats(total_repeats, n_workers): n_workers = max(1, int(n_workers)) total_repeats = max(1, int(total_repeats)) chunk, extra = divmod(total_repeats, n_workers) - return [chunk + (1 if i < extra else 0) for i in range(n_workers) if chunk + (1 if i < extra else 0) > 0] + return [ + chunk + (1 if i < extra else 0) + for i in range(n_workers) + if chunk + (1 if i < extra else 0) > 0 + ] -def _processpool_search(tn, output_inds, total_repeats, n_workers, max_time, slicing_opts=None, trial_timeout=None): +def _processpool_search( + tn, + output_inds, + total_repeats, + n_workers, + max_time, + slicing_opts=None, + trial_timeout=None, + search_seed=0, +): tn_bytes = pickle.dumps(tn) repeat_chunks = _split_repeats(total_repeats, n_workers) pool = ProcessPoolExecutor(max_workers=len(repeat_chunks)) futures = [] - for seed, repeats in enumerate(repeat_chunks): + errors = [] + for worker_id, repeats in enumerate(repeat_chunks): + seed = int(search_seed) + worker_id futures.append( pool.submit( _serial_search, @@ -334,14 +419,34 @@ def _processpool_search(tn, output_inds, total_repeats, n_workers, max_time, sli cost, tree = fut.result() if cost < best_cost: best_cost, best_tree = cost, tree - except Exception: - pass + except Exception as exc: + errors.append(repr(exc)) except TimeoutError: - pass + errors.append("TimeoutError()") finally: for fut in futures: fut.cancel() _kill_pool(pool) + if best_tree is None: + if errors: + print( + "qibotn_search_failed " + f"backend=processpool errors={errors[:3]} " + f"num_errors={len(errors)} fallback=greedy", + flush=True, + ) + else: + print( + "qibotn_search_failed " + "backend=processpool errors=[] fallback=greedy", + flush=True, + ) + return _fallback_greedy_tree( + tn, + output_inds, + slicing_opts=slicing_opts, + error=errors[:3], + ) return best_tree @@ -357,6 +462,7 @@ def _dask_search( debug_trials=False, close_workers=False, expected_workers=None, + search_seed=0, ): """Run one centralized cotengra hyper-optimizer over a dask pool. @@ -371,8 +477,14 @@ def _dask_search( "`pip install distributed` or the package extra that provides it." ) from exc + import random + import cotengra as ctg + search_seed = int(search_seed) + random.seed(search_seed) + np.random.seed(search_seed % (2**32)) + _patch_cotengra_dask_as_completed() _patch_cotengra_dask_submit(debug_trials=debug_trials) @@ -400,6 +512,7 @@ def _dask_search( kwargs = {} if optlib is not None: kwargs["optlib"] = optlib + kwargs.update(_search_seed_kwargs(optlib, search_seed)) retire_workers = [] try: @@ -470,10 +583,12 @@ def _mpi_search( dask_address=None, debug_trials=False, dask_close_workers=False, + search_seed=0, ): comm = MPI.COMM_WORLD rank, size = comm.Get_rank(), comm.Get_size() search_backend = search_backend or "processpool" + search_seed = int(search_seed) if search_backend == "dask": if not dask_address: @@ -496,6 +611,7 @@ def _mpi_search( n_workers=n_workers, debug_trials=debug_trials, close_workers=dask_close_workers, + search_seed=search_seed, ) payload = ("ok", tree) except Exception as exc: @@ -518,6 +634,7 @@ def _mpi_search( max_time, slicing_opts, trial_timeout, + search_seed=search_seed + rank * max(1, n_workers or 1), ) local_cost = local_tree.combo_cost(factor=256) if local_tree else float("inf") @@ -531,11 +648,22 @@ def _mpi_search( return comm.bcast(best_tree, root=0) -def parallel_path_search(tn, output_inds, method='processpool', total_repeats=1024, - max_time=300, n_workers=48, slicing_opts=None, - trial_timeout=None, search_backend=None, - dask_address=None, debug_trials=False, - dask_close_workers=False, expected_workers=None): +def parallel_path_search( + tn, + output_inds, + method="processpool", + total_repeats=1024, + max_time=300, + n_workers=48, + slicing_opts=None, + trial_timeout=None, + search_backend=None, + dask_address=None, + debug_trials=False, + dask_close_workers=False, + expected_workers=None, + search_seed=0, +): """Parallel contraction path search. Args: @@ -546,11 +674,32 @@ def parallel_path_search(tn, output_inds, method='processpool', total_repeats=10 slicing_opts: cotengra slicing options for memory control trial_timeout: Per-trial timeout (seconds); kills and skips hung trials """ - if method == 'serial': + if method == "serial": tn_bytes = pickle.dumps(tn) - _, tree = _serial_search(tn_bytes, output_inds, total_repeats, 0, max_time, slicing_opts, trial_timeout) + try: + _, tree = _serial_search( + tn_bytes, + output_inds, + total_repeats, + search_seed, + max_time, + slicing_opts, + trial_timeout, + ) + except Exception as exc: + print( + "qibotn_search_failed " + f"backend=serial error={exc!r} fallback=greedy", + flush=True, + ) + return _fallback_greedy_tree( + tn, + output_inds, + slicing_opts=slicing_opts, + error=exc, + ) return tree - elif method == 'mpi': + if method == "mpi": if not _HAVE_MPI: raise ImportError("mpi4py not available") return _mpi_search( @@ -565,10 +714,20 @@ def parallel_path_search(tn, output_inds, method='processpool', total_repeats=10 dask_address=dask_address, debug_trials=debug_trials, dask_close_workers=dask_close_workers, + search_seed=search_seed, ) - elif method == 'processpool': - return _processpool_search(tn, output_inds, total_repeats, n_workers, max_time, slicing_opts, trial_timeout) - elif method == 'dask': + if method == "processpool": + return _processpool_search( + tn, + output_inds, + total_repeats, + n_workers, + max_time, + slicing_opts, + trial_timeout, + search_seed=search_seed, + ) + if method == "dask": return _dask_search( tn, output_inds, @@ -580,9 +739,9 @@ def parallel_path_search(tn, output_inds, method='processpool', total_repeats=10 debug_trials=debug_trials, close_workers=dask_close_workers, expected_workers=expected_workers, + search_seed=search_seed, ) - else: - raise ValueError(f"Unknown method: {method}") + raise ValueError(f"Unknown method: {method}") def contraction_tree_costs(tree, dtype_bytes=16, combo_factor=256): @@ -615,6 +774,171 @@ def contraction_tree_costs(tree, dtype_bytes=16, combo_factor=256): } +def load_tree_payload(path, index=0): + with Path(path).open("rb") as f: + payload = pickle.load(f) + trees = payload["trees"] if isinstance(payload, dict) else payload + if not isinstance(trees, (list, tuple)): + trees = [trees] + return payload, trees[index] + + +def save_tree_payload(path, payload): + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as f: + pickle.dump(payload, f) + + +def slice_tree_payload(path, output_path, *, term=0, target_slices=2, max_repeats=64, seed=42): + payload, tree = load_tree_payload(path, index=term) + original_costs = contraction_tree_costs(tree) + sliced_tree = tree.slice( + target_slices=target_slices, + max_repeats=max_repeats, + seed=seed, + ) + sliced_costs = contraction_tree_costs(sliced_tree) + + if isinstance(payload, dict): + out_payload = dict(payload) + trees = payload["trees"] if isinstance(payload["trees"], (list, tuple)) else [payload["trees"]] + new_trees = list(trees) + new_trees[term] = sliced_tree + out_payload["trees"] = new_trees + out_payload["costs"] = [contraction_tree_costs(t) for t in new_trees] + out_payload["nterms"] = len(new_trees) + else: + trees = payload if isinstance(payload, (list, tuple)) else [payload] + new_trees = list(trees) + new_trees[term] = sliced_tree + out_payload = new_trees + + save_tree_payload(output_path, out_payload) + return TreePayloadSliceResult( + payload=payload, + tree=tree, + sliced_tree=sliced_tree, + original_costs=original_costs, + sliced_costs=sliced_costs, + ) + + +def _prod(values): + out = 1 + for value in values: + out *= int(value) + return out + + +def _broadcast_batch(a_batch, b_batch): + if a_batch == b_batch: + return _prod(a_batch) + if not a_batch: + return _prod(b_batch) + if not b_batch: + return _prod(a_batch) + ndim = max(len(a_batch), len(b_batch)) + a_batch = (1,) * (ndim - len(a_batch)) + tuple(a_batch) + b_batch = (1,) * (ndim - len(b_batch)) + tuple(b_batch) + return _prod(max(a, b) for a, b in zip(a_batch, b_batch)) + + +def analyze_contraction_tree(tree): + contract_mod = importlib.import_module("cotengra.contract") + contractions = contract_mod.extract_contractions(tree) + size_dict = tree.size_dict + ops = [] + counts = Counter() + + for op_index, (parent, left, right, tdot, arg, perm) in enumerate(contractions): + if left is None and right is None: + counts["preprocess"] += 1 + continue + + left_inds = tree.get_inds(left) + right_inds = tree.get_inds(right) + parent_inds = tree.get_inds(parent) + left_shape = tuple(size_dict[ix] for ix in left_inds) + right_shape = tuple(size_dict[ix] for ix in right_inds) + + if tdot: + parsed = contract_mod._parse_tensordot_axes_to_matmul( + arg, + left_shape, + right_shape, + ) + else: + parsed = contract_mod._parse_eq_to_batch_matmul( + arg, + left_shape, + right_shape, + ) + + ( + _eq_a, + _eq_b, + new_shape_a, + new_shape_b, + _new_shape_ab, + _perm_ab, + pure_multiplication, + ) = parsed + + matmul_shape = None + matmul_flops = 0 + if pure_multiplication: + kind = "mul" + else: + a_shape = tuple(new_shape_a or left_shape) + b_shape = tuple(new_shape_b or right_shape) + batch = _broadcast_batch(a_shape[:-2], b_shape[:-2]) + m, k, n = int(a_shape[-2]), int(a_shape[-1]), int(b_shape[-1]) + kind = "mm" if batch == 1 else "bmm" + matmul_shape = (batch, m, k, n) + matmul_flops = batch * m * k * n + + tree_flops = int(tree.get_flops(parent)) + out_size = int(tree.get_size(parent)) + ops.append( + ContractionOpInfo( + index=op_index, + kind=kind, + matmul_shape=matmul_shape, + matmul_flops=matmul_flops, + tree_flops=tree_flops, + out_size=out_size, + left_shape=left_shape, + right_shape=right_shape, + left_rank=len(left_inds), + right_rank=len(right_inds), + out_rank=len(parent_inds), + perm=perm, + ) + ) + counts[kind] += 1 + + nslices = int(getattr(tree, "multiplicity", 1)) + per_slice_flops = sum(op.tree_flops for op in ops) + per_slice_write = sum(op.out_size for op in ops) + max_out = max((op.out_size for op in ops), default=0) + dtype_bytes = 16 + return TreeInspectionResult( + tree=tree, + contractions=tuple(contractions), + operations=tuple(ops), + counts=dict(counts), + nslices=nslices, + per_slice_flops=per_slice_flops, + per_slice_write=per_slice_write, + max_output_size=max_out, + all_slice_flops=per_slice_flops * nslices, + all_slice_write=per_slice_write * nslices, + dtype_bytes=dtype_bytes, + max_output_gib=max_out * dtype_bytes / 1024**3, + ) + + @dataclass(frozen=True) class SlicePlan: """Slice ownership for one MPI rank.""" @@ -637,6 +961,49 @@ class SlicedContractStats: assignment: str +@dataclass(frozen=True) +class TreePayloadSliceResult: + """Result of slicing one tree stored in a tree payload.""" + + payload: object + tree: object + sliced_tree: object + original_costs: dict + sliced_costs: dict + + +@dataclass(frozen=True) +class ContractionOpInfo: + index: int + kind: str + matmul_shape: tuple | None + matmul_flops: int + tree_flops: int + out_size: int + left_shape: tuple + right_shape: tuple + left_rank: int + right_rank: int + out_rank: int + perm: object + + +@dataclass(frozen=True) +class TreeInspectionResult: + tree: object + contractions: tuple + operations: tuple + counts: dict + nslices: int + per_slice_flops: int + per_slice_write: int + max_output_size: int + all_slice_flops: int + all_slice_write: int + dtype_bytes: int + max_output_gib: float + + def mpi_slice_plan(nslices, rank, size, assignment="block"): """Return the contraction slice ids assigned to one MPI rank. diff --git a/src/qibotn/torch_utils.py b/src/qibotn/torch_utils.py new file mode 100644 index 0000000..98cd19c --- /dev/null +++ b/src/qibotn/torch_utils.py @@ -0,0 +1,90 @@ +"""Shared torch helpers for qibotn CPU tensor-network code.""" + +from __future__ import annotations + +import numpy as np + + +def torch_dtype(dtype): + """Return the torch dtype used by qibotn complex CPU contractions.""" + import torch + + if dtype in ("complex64", "single", np.complex64): + return torch.complex64 + return torch.complex128 + + +def numpy_dtype(dtype): + """Return the numpy dtype matching qibotn's complex dtype names.""" + if dtype in ("complex64", "single", np.complex64): + return np.complex64 + return np.complex128 + + +def torch_cpu_array(data, dtype=None): + """Convert array-like data to a contiguous CPU torch tensor. + + ``torch.from_numpy`` rejects negative strides and read-only arrays in common + quimb paths, so this helper normalizes both cases before handing data to + torch. + """ + import torch + + if isinstance(data, torch.Tensor): + tensor = data + else: + array = np.asarray(data) + if any(stride < 0 for stride in array.strides): + array = np.ascontiguousarray(array) + elif not array.flags.writeable: + array = array.copy() + tensor = torch.from_numpy(array) + + if tensor.device.type != "cpu": + tensor = tensor.cpu() + target_dtype = torch_dtype(dtype) if isinstance(dtype, str) else dtype + if target_dtype is not None and tensor.dtype != target_dtype: + tensor = tensor.to(target_dtype) + if not tensor.is_contiguous(): + tensor = tensor.contiguous() + return tensor + + +def arrays_to_torch(arrays, dtype="complex128"): + """Convert an iterable of arrays to CPU torch tensors.""" + target_dtype = torch_dtype(dtype) + return [torch_cpu_array(array, dtype=target_dtype) for array in arrays] + + +def arrays_to_numpy(arrays, dtype="complex128"): + """Convert an iterable of arrays to numpy arrays with qibotn dtype names.""" + target_dtype = numpy_dtype(dtype) + return [np.asarray(array, dtype=target_dtype) for array in arrays] + + +def arrays_to_backend(arrays, backend, engine=None, dtype="complex128"): + """Convert arrays to the backend representation used by quimb/cotengra.""" + if backend == "torch": + return arrays_to_torch(arrays, dtype=dtype) + if engine is not None: + return [engine.asarray(array, dtype=numpy_dtype(dtype)) for array in arrays] + return arrays_to_numpy(arrays, dtype=dtype) + + +def set_torch_threads(nthreads=None, interop_threads=None): + """Set torch CPU thread counts and return the active intra-op thread count.""" + import torch + + if nthreads is not None: + torch.set_num_threads(max(1, int(nthreads))) + if interop_threads is not None: + try: + torch.set_num_interop_threads(max(1, int(interop_threads))) + except RuntimeError: + pass + return torch.get_num_threads() + + +def is_torch_array(value): + """Return whether *value* looks like a torch tensor without importing torch.""" + return type(value).__module__.startswith("torch") diff --git a/tests/test_cpu_backend.py b/tests/test_cpu_backend.py index e5ea781..5041869 100644 --- a/tests/test_cpu_backend.py +++ b/tests/test_cpu_backend.py @@ -10,6 +10,11 @@ from qibotn.benchmark_cases import ( exact_pauli_sum, ) from qibotn import cpu_expectation, mps_expectation, pauli_pattern, pauli_sum +from qibotn.backends.quimb import ( + build_expectation_tn, + contract_tn, + search_contraction_tree, +) def build_circuit(nqubits=6): @@ -61,6 +66,31 @@ def test_public_cpu_expectation_api_matches_statevector(): assert math.isclose(value, exact, abs_tol=1e-12) +def test_public_quimb_torch_pipeline_matches_statevector(): + circuit = build_circuit(nqubits=4) + observable = hamiltonians.SymbolicHamiltonian(form=X(0) * Z(1)) + exact = exact_pauli_sum(circuit, [(1.0, (("X", 0), ("Z", 1)))], 4) + + built = build_expectation_tn( + circuit, + observable, + dtype="complex128", + merge_1q=True, + merge_2q=True, + ) + search = search_contraction_tree( + built.tn, + method="serial", + total_repeats=1, + max_time=30, + n_workers=1, + search_seed=0, + ) + value = built.coeff * complex(contract_tn(built.tn, search.tree)) + + assert math.isclose(value.real, exact, abs_tol=1e-12) + + def test_public_mps_expectation_api_accepts_pauli_pattern(): circuit = build_circuit() exact_hamiltonian = hamiltonians.SymbolicHamiltonian( diff --git a/tools/README.md b/tools/README.md deleted file mode 100644 index 284a712..0000000 --- a/tools/README.md +++ /dev/null @@ -1,19 +0,0 @@ -# Tools - -Auxiliary scripts for profiling, legacy comparisons, and scale probes. - -The main CPU expectation entrypoint is `../benchmark_cpu_expectation.py`. -For the current Vidal/MPS 1D-chain tests, prefer `../run_vidal_mps_cases.sh`. - -Files here are intentionally secondary: - -- `compare_vidal_backend_qmatchatea.py`: diagnostic comparison against QMatchaTea. -- `profile_vidal_chrome.py`: PyTorch CPU profiler for the Vidal path. -- `run_cpu_single_cases.sh`: single-node scale probes. -- `run_cpu_large_cases.sh`: two-node MPI scale probes. -- `run_vidal_segment_mpi_scan.sh`: rank/thread scaling scan for Vidal segmented MPI. -- `baseline_mps_expectation.py`: legacy MPS comparison CLI kept for old commands. -- `benchmark_tn_mpi.py`, `benchmark_search.py`, `benchmark_slice.py`, `benchmark_contract_sliced.py`, `check_tree.py`: old TN path-search/slicing experiments. -- `qibojit_reference_expectation.py`: state-vector reference helper. -- `validate_vidal_mpi_correctness.py`: focused Vidal MPI correctness helper. -- `mpi_torch_thread_probe.py`: MPI + torch OpenMP affinity and threading probe. diff --git a/tools/baseline_mps_expectation.py b/tools/baseline_mps_expectation.py deleted file mode 100644 index ef12ae3..0000000 --- a/tools/baseline_mps_expectation.py +++ /dev/null @@ -1,201 +0,0 @@ -"""MPS expectation benchmark for qmatchatea and Vidal backends.""" - -import argparse -import json -import logging -import os -import socket -import time - -import numpy as np - -from qibotn.benchmark_cases import ( - build_circuit as build_benchmark_circuit, - exact_pauli_sum, - observable_terms, - terms_to_dict, -) -from qibotn.backends.qmatchatea import QMatchaTeaBackend -from qibotn.backends.vidal_tebd import run_vidal_ring_xz - - -def optional_int(text): - if isinstance(text, str) and text.lower() in {"none", "null", "inf", "unlimited"}: - return None - return int(text) - - -def optional_float(text): - if isinstance(text, str) and text.lower() in {"none", "null", "inf", "unlimited"}: - return None - return float(text) - - -def format_optional(value, fmt="g"): - return "None" if value is None else format(value, fmt) - - -def build_circuit(nqubits, nlayers, seed): - return build_benchmark_circuit("brickwall_cnot", nqubits, nlayers, seed) - - -def build_observable(nqubits): - return terms_to_dict(observable_terms("ring_xz", nqubits)) - - -def exact_expectation(circuit, nqubits): - return exact_pauli_sum(circuit, observable_terms("ring_xz", nqubits), nqubits) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--nqubits", type=int, default=40) - parser.add_argument("--nlayers", type=int, default=30) - parser.add_argument("--bond", "--bonds", dest="bond", type=optional_int, default=512) - parser.add_argument("--cut-ratio", type=optional_float, default=1e-12) - parser.add_argument("--seed", type=int, default=42) - parser.add_argument("--tensor-module", choices=("numpy", "torch"), default="torch") - parser.add_argument("--torch-threads", type=int, default=32) - parser.add_argument( - "--executor", - choices=("qmatchatea", "vidal", "vidal-mpi"), - default="qmatchatea", - ) - parser.add_argument("--mpi-ct", action="store_true") - parser.add_argument("--mpi-barriers", type=int, default=-1) - parser.add_argument("--mpi-isometrization", type=int, default=-1) - parser.add_argument("--exact", action="store_true") - parser.add_argument("--exact-max-qubits", type=int, default=24) - parser.add_argument("--reference-file") - parser.add_argument( - "--mpi-rank-map", - action="store_true", - help="Print MPI rank, host, pid, and torch thread placement metadata.", - ) - args = parser.parse_args() - logging.getLogger("qibo.config").setLevel(logging.ERROR) - logging.getLogger("qtealeaves").setLevel(logging.ERROR) - import torch - - torch.set_num_threads(args.torch_threads) - rank = 0 - size = 1 - if args.mpi_ct: - from mpi4py import MPI - - rank = MPI.COMM_WORLD.Get_rank() - size = MPI.COMM_WORLD.Get_size() - if args.mpi_rank_map: - rank_info = { - "rank": rank, - "size": size, - "host": socket.gethostname(), - "pid": os.getpid(), - "torch_threads": args.torch_threads, - "omp_num_threads": os.environ.get("OMP_NUM_THREADS", ""), - "mkl_num_threads": os.environ.get("MKL_NUM_THREADS", ""), - } - rank_infos = MPI.COMM_WORLD.gather(rank_info, root=0) - if rank == 0: - print("mpi_rank_map") - for item in sorted(rank_infos, key=lambda row: row["rank"]): - print( - "rank={rank} size={size} host={host} pid={pid} " - "torch_threads={torch_threads} " - "OMP_NUM_THREADS={omp_num_threads} " - "MKL_NUM_THREADS={mkl_num_threads}".format(**item) - ) - - circuit = build_circuit(args.nqubits, args.nlayers, args.seed) - observable = build_observable(args.nqubits) - exact = None - if args.reference_file: - with open(args.reference_file, "r", encoding="utf-8") as f: - exact = float(json.load(f)["expectation"]) - elif args.exact: - if args.nqubits > args.exact_max_qubits: - raise ValueError( - f"--exact is limited to {args.exact_max_qubits} qubits by default." - ) - exact = exact_expectation(circuit, args.nqubits) - - if rank == 0: - if args.mpi_ct and args.executor in ("vidal", "vidal-mpi"): - mpi_label = f"VidalSegment/{size}" - else: - mpi_label = f"MPIMPS/{size}" if args.mpi_ct else "SR" - print( - f"nqubits={args.nqubits} nlayers={args.nlayers} " - f"bond={format_optional(args.bond)} " - f"cut_ratio={format_optional(args.cut_ratio)} seed={args.seed} " - f"tensor_module={args.tensor_module} svd_control=E! " - f"compile_circuit=True mpi={mpi_label} executor={args.executor}" - ) - if exact is not None: - print(f"exact={exact:.16e}") - print("expval abs_error rel_error seconds") - - start = time.perf_counter() - timings = None - if args.executor in ("vidal", "vidal-mpi"): - if args.executor == "vidal-mpi" and not args.mpi_ct: - raise ValueError("--executor vidal-mpi requires --mpi-ct.") - if args.mpi_ct: - from qibotn.backends.vidal_mpi_segment import run_segment_vidal_mpi_ring_xz - - value, timings = run_segment_vidal_mpi_ring_xz( - circuit, - max_bond=args.bond, - cut_ratio=args.cut_ratio, - tensor_module=args.tensor_module, - comm=MPI.COMM_WORLD, - ) - else: - value = run_vidal_ring_xz( - circuit, - max_bond=args.bond, - cut_ratio=args.cut_ratio, - tensor_module=args.tensor_module, - ) - else: - backend = QMatchaTeaBackend() - backend.configure_tn_simulation( - ansatz="MPS", - max_bond_dimension=args.bond, - cut_ratio=args.cut_ratio, - svd_control="E!", - tensor_module=args.tensor_module, - compile_circuit=True, - track_memory=False, - mpi_approach="CT" if args.mpi_ct else "SR", - mpi_num_procs=size, - mpi_where_barriers=args.mpi_barriers if args.mpi_ct else -1, - mpi_isometrization=args.mpi_isometrization, - ) - value = backend.expectation( - circuit, - observable, - preprocess=False, - compile_circuit=True, - ) - max_timings = None - if timings: - max_timings = { - key: MPI.COMM_WORLD.reduce(local_value, op=MPI.MAX, root=0) - for key, local_value in timings.items() - } - if rank != 0: - return - value = float(np.real(value)) - elapsed = time.perf_counter() - start - abs_error = float("nan") if exact is None else abs(value - exact) - rel_error = float("nan") if exact is None else abs_error / max(abs(exact), 1e-15) - print(f"{value:.16e} {abs_error:.6e} {rel_error:.6e} {elapsed:.3f}") - if max_timings: - print("timing_section max_seconds") - for key, max_value in max_timings.items(): - print(f"{key} {max_value:.6f}") - - -if __name__ == "__main__": - main() diff --git a/tools/benchmark_contract_sliced.py b/tools/benchmark_contract_sliced.py deleted file mode 100644 index a089546..0000000 --- a/tools/benchmark_contract_sliced.py +++ /dev/null @@ -1,56 +0,0 @@ -"""MPI parallel sliced contraction using pre-sliced tree.""" -import time, pickle, os -import numpy as np -from mpi4py import MPI - -NQUBITS, NLAYERS, NCORES = 25, 10, 48 - -comm = MPI.COMM_WORLD -rank, size = comm.Get_rank(), comm.Get_size() - -os.environ['OMP_NUM_THREADS'] = str(NCORES) -os.environ['MKL_NUM_THREADS'] = str(NCORES) - -import torch -import qibo, quimb as qu -from qibotn.observables import build_random_circuit - -torch.set_num_threads(NCORES) - -circuit = build_random_circuit(NQUBITS, NLAYERS) -qibo.set_backend("qibotn", platform="quimb") -backend = qibo.get_backend() -backend.configure_tn_simulation(ansatz="tn") -qc = backend._qibo_circuit_to_quimb(circuit, backend.circuit_ansatz) -tn = qc.local_expectation(qu.pauli('x') & qu.pauli('z'), (0, 1), rehearse='tn') - -if rank == 0: - with open(f"data/tree_q{NQUBITS}_l{NLAYERS}_sliced.pkl", 'rb') as f: - tree = pickle.load(f) -else: - tree = None -tree = comm.bcast(tree, root=0) - -arrays = [torch.from_numpy(np.asarray(t._data)) for t in tn.tensors] -n_slices = tree.multiplicity - -if rank == 0: - print(f"Slices: {n_slices}, Ranks: {size}, " - f"Peak: {tree.max_size() * 16 / 1e9:.2f} GB, " - f"Threads/rank: {NCORES}, Backend: torch") - -t0 = time.time() -result = None -for i in range(rank, n_slices, size): - val = tree.contract_slice(arrays, i, backend='torch') - val_np = val.cpu().numpy().reshape(-1) - result = val_np if result is None else result + val_np - -if result is None: - result = np.zeros(1, dtype=np.complex128) - -total = np.zeros_like(result) if rank == 0 else None -comm.Reduce(result, total, root=0) - -if rank == 0: - print(f"Contract: {time.time() - t0:.4f}s Expectation: {0.5 * total[0].real:.10f}") diff --git a/tools/benchmark_qredtea_svd_controls.py b/tools/benchmark_qredtea_svd_controls.py deleted file mode 100644 index 4111c48..0000000 --- a/tools/benchmark_qredtea_svd_controls.py +++ /dev/null @@ -1,157 +0,0 @@ -#!/usr/bin/env python -"""Benchmark qredtea/qtealeaves SVD control modes. - -This isolates the tensor split used by MPS updates: a rank-2 tensor is split -with singular values contracted either left or right, then reconstructed to -measure numerical error and timing. -""" - -from __future__ import annotations - -import argparse -import gc -import statistics -import time - -import torch - -import qmatchatea -from qredtea.torchapi import QteaTorchTensor - - -def _dtype(name: str): - return { - "complex64": torch.complex64, - "complex128": torch.complex128, - "float64": torch.float64, - "float32": torch.float32, - }[name] - - -def _random_matrix(shape, dtype, seed): - gen = torch.Generator(device="cpu") - gen.manual_seed(seed) - if dtype.is_complex: - real_dtype = torch.float32 if dtype == torch.complex64 else torch.float64 - real = torch.randn(shape, dtype=real_dtype, generator=gen) - imag = torch.randn(shape, dtype=real_dtype, generator=gen) - return torch.complex(real, imag).to(dtype) - return torch.randn(shape, dtype=dtype, generator=gen) - - -def _sync(): - if torch.cuda.is_available(): - torch.cuda.synchronize() - - -def run_one(matrix, ctrl, max_bond, contract_singvals, repeats): - conv = qmatchatea.QCConvergenceParameters( - max_bond_dimension=max_bond, - cut_ratio=0.0, - svd_ctrl=ctrl, - ) - qtensor = QteaTorchTensor.from_elem_array(matrix, dtype=matrix.dtype, device="cpu") - - times = [] - rel_error = None - kept = None - status = "ok" - error = "" - - for i in range(repeats): - gc.collect() - _sync() - t0 = time.perf_counter() - try: - left, right, singvals, _ = qtensor.split_svd( - [0], - [1], - contract_singvals=contract_singvals, - conv_params=conv, - ) - except Exception as exc: # noqa: BLE001 - benchmark should keep going - status = "error" - error = repr(exc) - break - _sync() - times.append(time.perf_counter() - t0) - - if i == repeats - 1: - left_matrix = left.elem.reshape(matrix.shape[0], -1) - right_matrix = right.elem.reshape(-1, matrix.shape[1]) - recon = left_matrix @ right_matrix - rel_error = ( - torch.linalg.vector_norm(matrix - recon) - / torch.linalg.vector_norm(matrix) - ).item() - kept = int(singvals.numel()) - - return { - "ctrl": ctrl, - "contract_singvals": contract_singvals, - "status": status, - "median_ms": float("nan") if not times else statistics.median(times) * 1000, - "min_ms": float("nan") if not times else min(times) * 1000, - "rel_error": rel_error, - "kept": kept, - "error": error, - } - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--shapes", nargs="+", default=("256x1024", "1024x256", "512x512")) - parser.add_argument("--max-bond", type=int, default=128) - parser.add_argument("--dtype", choices=("complex64", "complex128", "float32", "float64"), default="complex128") - parser.add_argument("--threads", type=int, default=8) - parser.add_argument("--repeats", type=int, default=3) - parser.add_argument( - "--controls", - nargs="+", - default=("A", "D", "V", "R", "E", "E!", "X", "X!"), - ) - args = parser.parse_args() - - torch.set_num_threads(args.threads) - dtype = _dtype(args.dtype) - - print( - "svd_benchmark " - f"dtype={args.dtype} threads={torch.get_num_threads()} " - f"max_bond={args.max_bond} repeats={args.repeats}", - flush=True, - ) - print( - "columns shape contract ctrl status median_ms min_ms kept rel_error error", - flush=True, - ) - - for shape_text in args.shapes: - m_text, n_text = shape_text.lower().split("x", 1) - shape = (int(m_text), int(n_text)) - matrix = _random_matrix(shape, dtype, seed=sum(shape)) - for contract_singvals in ("L", "R"): - for ctrl in args.controls: - result = run_one( - matrix, - ctrl=ctrl, - max_bond=args.max_bond, - contract_singvals=contract_singvals, - repeats=args.repeats, - ) - print( - f"row shape={shape_text} " - f"contract={contract_singvals} " - f"ctrl={ctrl} " - f"status={result['status']} " - f"median_ms={result['median_ms']:.3f} " - f"min_ms={result['min_ms']:.3f} " - f"kept={result['kept']} " - f"rel_error={result['rel_error']} " - f"error={result['error']}", - flush=True, - ) - - -if __name__ == "__main__": - main() diff --git a/tools/benchmark_search.py b/tools/benchmark_search.py deleted file mode 100644 index f0bc464..0000000 --- a/tools/benchmark_search.py +++ /dev/null @@ -1,34 +0,0 @@ -"""Search contraction path and save.""" -import time, os, pickle -from qibotn.parallel import parallel_path_search -from qibotn.observables import build_random_circuit -import qibo, quimb as qu - -from mpi4py import MPI - -NQUBITS, NLAYERS, WORKERS = 20, 10, 96 - -comm = MPI.COMM_WORLD -rank, size = comm.Get_rank(), comm.Get_size() -method = 'mpi' if size > 1 else 'processpool' - -circuit = build_random_circuit(NQUBITS, NLAYERS) -qibo.set_backend("qibotn", platform="quimb") -backend = qibo.get_backend() -backend.configure_tn_simulation(ansatz="tn") -qc = backend._qibo_circuit_to_quimb(circuit, backend.circuit_ansatz) -tn = qc.local_expectation(qu.pauli('x') & qu.pauli('z'), (0, 1), rehearse='tn') - -if rank == 0: - print(f"Searching {NQUBITS}q {NLAYERS}l, method={method}, ranks={size}, workers/rank={WORKERS}...") -t0 = time.time() -tree = parallel_path_search(tn, tn.outer_inds(), method=method, - total_repeats=1024, max_time=300, n_workers=WORKERS,trial_timeout=60) -t_search = time.time() - t0 - -if rank == 0: - os.makedirs('data', exist_ok=True) - path = f"data/tree_q{NQUBITS}_l{NLAYERS}.pkl" - with open(path, 'wb') as f: - pickle.dump(tree, f) - print(f"Search: {t_search:.2f}s Peak: {tree.max_size() * 16 / 1e9:.2f} GB Saved: {path}") diff --git a/tools/benchmark_slice.py b/tools/benchmark_slice.py deleted file mode 100644 index b398857..0000000 --- a/tools/benchmark_slice.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Slice saved tree and save.""" -import pickle - -NQUBITS, NLAYERS = 25, 10 - -with open(f"data/tree_q{NQUBITS}_l{NLAYERS}.pkl", 'rb') as f: - tree = pickle.load(f) - -print(f"Original peak: {tree.max_size() * 16 / 1e9:.2f} GB") - -tree_sliced = tree.slice_and_reconfigure(target_size=2**28) - -with open(f"data/tree_q{NQUBITS}_l{NLAYERS}_sliced.pkl", 'wb') as f: - pickle.dump(tree_sliced, f) - -print(f"Sliced peak: {tree_sliced.max_size() * 16 / 1e9:.2f} GB Slices: {tree_sliced.multiplicity}") diff --git a/tools/benchmark_tn_mpi.py b/tools/benchmark_tn_mpi.py deleted file mode 100644 index 8dc80d1..0000000 --- a/tools/benchmark_tn_mpi.py +++ /dev/null @@ -1,378 +0,0 @@ -"""MPI-parallel TN benchmark: path search + contraction via MPI.""" -import json -import pickle -import time -import argparse -import numpy as np -import cotengra as ctg -import qibo -from qibo import Circuit, gates -from mpi4py import MPI -from concurrent.futures import ProcessPoolExecutor, as_completed -from qibotn.observables import check_observable, extract_gates_and_qubits - - -def _load_observable(observable_file=None, observable_json=None): - if observable_file: - with open(observable_file, "r", encoding="utf8") as f: - return json.load(f) - if observable_json: - return json.loads(observable_json) - return None - - -def _term_to_quimb_operator(term): - """Convert one extracted Hamiltonian term to a quimb operator.""" - import quimb as qu - - coeff = complex(term[0][2]) if term else 1.0 - op = None - where = [] - - for qubit, gate_name, _ in term: - qubit = int(qubit) - gate_name = str(gate_name).upper() - if gate_name == "I": - continue - where.append(qubit) - op = qu.pauli(gate_name.lower()) if op is None else op & qu.pauli(gate_name.lower()) - - return complex(coeff), op, tuple(where) - - -def _run_serial_search(tn_bytes, output_inds, repeats, seed, num_slices, n_ranks, max_time): - import pickle, cotengra as ctg, random - random.seed(seed) - tn = pickle.loads(tn_bytes) - opt = ctg.HyperOptimizer( - methods=['kahypar', 'kahypar-agglom', 'spinglass'], - max_repeats=repeats, - parallel=False, - minimize='combo-256', - max_time=max_time, - optlib="random", - slicing_opts={'target_size': 2**29, 'allow_outer': True}, - progbar=False, - ) - tree = tn.contraction_tree(optimize=opt, output_inds=output_inds) - return tree.combo_cost(factor=256), tree - - -def parallel_search(tn, output_inds, total_repeats, n_workers, num_slices, n_ranks, - timeout): - import pickle, os, signal - from concurrent.futures import ProcessPoolExecutor, as_completed - tn_bytes = pickle.dumps(tn) - if n_workers <= 1: - return _run_serial_search( - tn_bytes, output_inds, total_repeats, 0, num_slices, n_ranks, timeout - )[1] - repeats_per = max(1, total_repeats // n_workers) - best_cost, best_tree = float('inf'), None - - pool = ProcessPoolExecutor(max_workers=n_workers) - futures = [ - pool.submit(_run_serial_search, tn_bytes, output_inds, - repeats_per, seed, num_slices, n_ranks, timeout) - for seed in range(n_workers) - ] - try: - for fut in as_completed(futures, timeout=timeout + 5): - try: - cost, tree = fut.result() - if cost < best_cost: - best_cost, best_tree = cost, tree - except Exception as e: - print(f" [worker failed] {e}") - except TimeoutError: - pass - finally: - for fut in futures: - fut.cancel() - for pid in list(pool._processes.keys()): - try: - os.kill(pid, signal.SIGKILL) - except ProcessLookupError: - pass - pool.shutdown(wait=False) - - return best_tree - - -def make_circuit(circuit_type, nqubits, nlayers=1): - c = Circuit(nqubits) - if circuit_type == "qft": - from qibo.models import QFT - return QFT(nqubits) - elif circuit_type == "variational": - for layer in range(nlayers): - for q in range(nqubits): - c.add(gates.RY(q, theta=np.random.uniform(0, 2 * np.pi))) - offset = layer % 2 - for q in range(offset, nqubits - 1, 2): - c.add(gates.CZ(q, q + 1)) - elif circuit_type == "ghz": - c.add(gates.H(0)) - for q in range(nqubits - 1): - c.add(gates.CNOT(q, q + 1)) - elif circuit_type == "brickwork": - for q in range(nqubits): - c.add(gates.H(q)) - for layer in range(nlayers): - offset = layer % 2 - for q in range(offset, nqubits - 1, 2): - c.add(gates.CNOT(q, q + 1)) - c.add(gates.RZ(q, theta=np.random.uniform(0, 2 * np.pi))) - c.add(gates.RZ(q + 1, theta=np.random.uniform(0, 2 * np.pi))) - else: - raise ValueError(f"Unknown circuit: {circuit_type}") - return c - - -def _contract_mpi(tree, arrays, comm, root=0): - rank = comm.Get_rank() - size = comm.Get_size() - is_torch = type(arrays[0]).__module__.startswith("torch") - - result_np = None - for i in range(rank, tree.multiplicity, size): - x = tree.contract_slice(arrays, i) - x_np = np.asfortranarray(x.detach().cpu().numpy() if is_torch else np.asarray(x)) - result_np = x_np if result_np is None else result_np + x_np - - if result_np is None: - result_np = np.zeros(1, dtype=np.complex128) - - result = np.zeros_like(result_np) if rank == root else None - comm.Reduce(result_np, result, root=root) - - if rank == root: - import torch - return torch.from_numpy(np.asarray(result)) if is_torch else result - return None - - -def run_mpi(circuit, nqubits, num_slices, total_repeats=1024, - load_path=None, save_path=None): - """Each MPI rank runs serial path search over total_repeats/size trials, - rank 0 picks the global best, then all ranks contract in parallel.""" - comm = MPI.COMM_WORLD - rank = comm.Get_rank() - size = comm.Get_size() - - qibo.set_backend("qibotn", platform="quimb") - b = qibo.get_backend() - b.configure_tn_simulation(ansatz="tn") - - import torch - qc = b._qibo_circuit_to_quimb(circuit, quimb_circuit_type=b.circuit_ansatz, - gate_opts={"max_bond": None, "cutoff": 1e-10}) - qc.to_backend = lambda x: torch.from_numpy(x).to(torch.complex128) - - # --- path search: each rank serial, gather best to rank 0 --- - if load_path: - if rank == 0: - with open(load_path, "rb") as f: - saved = pickle.load(f) - tree, psi, t_search = saved["tree"], saved["psi"], 0.0 - print(f" [path loaded] {load_path}") - else: - tree = psi = None - t_search = 0.0 - else: - rank_repeats = max(1, total_repeats // size) - t0 = time.time() - # get TN object first (no contraction), then run parallel search - psi_tn = qc.to_dense(rehearse="tn") - local_tree = parallel_search( - psi_tn, psi_tn.outer_inds(), rank_repeats, n_workers=48, - num_slices=num_slices, n_ranks=size, timeout=600, - ) - t_search = time.time() - t0 - local_psi = psi_tn - - all_results = comm.gather((local_tree.combo_cost(factor=256), local_tree, local_psi), root=0) - if rank == 0: - _, tree, psi = min(all_results, key=lambda x: x[0]) - print(f" [path search] {t_search:.3f}s " - f"flops~2^{tree.contraction_cost(log=2):.2f} " - f"size~2^{tree.contraction_width():.2f} " - f"slices={tree.multiplicity}") - if save_path: - with open(save_path, "wb") as f: - pickle.dump({"tree": tree, "psi": psi}, f) - print(f" [path saved] {save_path}") - else: - tree = psi = None - - if save_path: - t_search = comm.bcast(t_search, root=0) - return None, t_search - - tree = comm.bcast(tree, root=0) - psi = comm.bcast(psi, root=0) - t_search = comm.bcast(t_search, root=0) - - # --- contraction: all ranks work in parallel --- - import torch - torch.set_num_threads(max(1, 96 // size)) - arrays = [torch.from_numpy(np.asarray(a)).to(torch.complex128) for a in psi.arrays] - t0 = time.time() - sv = _contract_mpi(tree, arrays, comm, root=0) - t_contract = time.time() - t0 - - if rank == 0: - print(f" [contraction] {t_contract:.3f}s") - return np.array(sv).reshape(-1), t_search + t_contract - return None, t_search + t_contract - - -def run_mpi_expval( - circuit, - nqubits, - observable=None, - total_repeats=1024, - search_workers=1, - search_timeout=300, -): - """Compute a Hamiltonian expectation value directly from TN via MPI. - MPI parallelizes over Hamiltonian terms; ProcessPool optionally helps - path search for each term.""" - import torch - comm = MPI.COMM_WORLD - rank = comm.Get_rank() - size = comm.Get_size() - - qibo.set_backend("qibotn", platform="quimb") - b = qibo.get_backend() - b.configure_tn_simulation(ansatz="tn") - - observable = check_observable(observable, nqubits) - ham_gate_map = extract_gates_and_qubits(observable) - - qc = b._qibo_circuit_to_quimb(circuit, quimb_circuit_type=b.circuit_ansatz, - gate_opts={"max_bond": None, "cutoff": 1e-10}) - - my_terms = ham_gate_map[rank::size] - torch.set_num_threads(max(1, 96 // size)) - t0 = time.time() - - my_exp = 0.0 + 0.0j - for term in my_terms: - coeff, op, where = _term_to_quimb_operator(term) - if op is None: - my_exp += coeff - continue - tn = qc.local_expectation_tn(op, where=where) - if len(tn.outer_inds()) == 0: - val = complex(tn.contract()) - else: - tree = parallel_search( - tn, - tn.outer_inds(), - total_repeats, - n_workers=search_workers, - num_slices=1, - n_ranks=size, - timeout=search_timeout, - ) - if tree is None: - raise RuntimeError("Failed to find a contraction tree for expectation TN.") - arrays = [torch.from_numpy(np.asarray(a)).to(torch.complex128) for a in tn.arrays] - acc = sum(tree.contract_slice(arrays, i) for i in range(tree.multiplicity)) - val = complex(acc.item() if hasattr(acc, 'item') else acc) - my_exp += coeff * val - - t_total = time.time() - t0 - - all_results = comm.gather(my_exp, root=0) - if rank == 0: - total_exp = sum(all_results) - print(f"\n[TN expval] time={t_total:.4f}s expval={total_exp.real:.12f}") - return np.real_if_close(total_exp), t_total - return None, t_total - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--nqubits", type=int, default=30) - parser.add_argument("--circuit", type=str, default="qft", - choices=["qft", "variational", "ghz", "brickwork"]) - parser.add_argument("--nlayers", type=int, default=3) - parser.add_argument("--num-slices", type=int, default=1) - parser.add_argument("--total-repeats", type=int, default=1024) - parser.add_argument("--search-workers", type=int, default=1) - parser.add_argument("--search-timeout", type=int, default=300) - parser.add_argument("--observable-file", type=str, default=None) - parser.add_argument("--observable-json", type=str, default=None) - parser.add_argument("--save-path", type=str, default=None) - parser.add_argument("--load-path", type=str, default=None) - parser.add_argument("--no-compare", action="store_true") - parser.add_argument("--mode", type=str, default="sv", choices=["sv", "expval"]) - args = parser.parse_args() - - comm = MPI.COMM_WORLD - rank = comm.Get_rank() - - if rank == 0: - print(f"Circuit: {args.circuit}, nqubits={args.nqubits}, " - f"nlayers={args.nlayers}, ranks={comm.Get_size()}") - - np.random.seed(42) - circuit = make_circuit(args.circuit, args.nqubits, args.nlayers) - observable = _load_observable(args.observable_file, args.observable_json) - - if args.mode == "expval": - try: - expval, t_total = run_mpi_expval( - circuit, - args.nqubits, - observable=observable, - total_repeats=args.total_repeats, - search_workers=args.search_workers, - search_timeout=args.search_timeout, - ) - except Exception as e: - if rank == 0: - print(f"[FAILED] {e}") - raise - if rank == 0: - np.save(f"data/expval_tn_{args.circuit}{args.nqubits}.npy", np.asarray(expval)) - if not args.no_compare: - print("No built-in reference comparison for arbitrary observables.") - return - - try: - sv, t_total = run_mpi(circuit, args.nqubits, args.num_slices, - total_repeats=args.total_repeats, - load_path=args.load_path, save_path=args.save_path) - except Exception as e: - if rank == 0: - print(f"[FAILED] {e}") - raise - - if rank == 0 and sv is not None: - print(f"\n[quimb TN MPI] time={t_total:.4f}s shape={sv.shape}") - np.save(f"data/sv_tn_{args.circuit}{args.nqubits}_mpi.npy", sv) - - if not args.no_compare: - from qibotn.bak.benchmark_tn import run_qibojit - import gc - np.random.seed(42) - circuit_ref = make_circuit(args.circuit, args.nqubits, args.nlayers) - sv_ref, t_ref = run_qibojit(circuit_ref) - np.save(f"data/sv_qibojit_{args.circuit}{args.nqubits}.npy", sv_ref) - print(f"[qibojit] time={t_ref:.4f}s") - # free memory before loading via mmap for expval comparison - del sv, sv_ref - gc.collect() - from compare_jit_tn_quimb import check_results - ref_path = f"data/sv_qibojit_{args.circuit}{args.nqubits}.npy" - tn_path = f"data/sv_tn_{args.circuit}{args.nqubits}_mpi.npy" - check_results(ref_path, tn_path, args.nqubits) - if t_total > 0: - print(f"Speedup : {t_ref/t_total:.2f}x") - - -if __name__ == "__main__": - main() diff --git a/tools/check_tree.py b/tools/check_tree.py deleted file mode 100644 index 935f952..0000000 --- a/tools/check_tree.py +++ /dev/null @@ -1,25 +0,0 @@ -"""Check contraction tree statistics.""" -import pickle, sys - -path = sys.argv[1] if len(sys.argv) > 1 else "data/tree_q25_l10.pkl" -with open(path, 'rb') as f: - tree = pickle.load(f) - -# Intel 8558P: 96 cores, 2.1GHz, AVX-512 (16 FP64/cycle), FMA x2 -# complex128 multiply-add = 6 real FLOPs -CORES = 96 -FREQ = 2.1e9 -AVX512_FP64 = 16 -TFLOPS = CORES * FREQ * AVX512_FP64 * 2 / 1e12 # ~6.45 TFLOPS real FP64 -COMPLEX_FLOPS = TFLOPS / 6 # complex128 effective - -flops = tree.total_flops() -slices = tree.multiplicity -est_seconds = flops * slices / (COMPLEX_FLOPS * 1e12) - -print(f"File: {path}") -print(f"Peak memory (GB): {tree.max_size() * 16 / 1e9:.2f}") -print(f"Total FLOPs: {flops:.2e} x{slices} slices = {flops*slices:.2e}") -print(f"Contraction width: {tree.contraction_width()}") -print(f"Multiplicity (slices): {slices}") -print(f"Estimated time (96 cores): {est_seconds:.1f}s ({est_seconds/3600:.2f}h)") diff --git a/tools/compare_vidal_backend_qmatchatea.py b/tools/compare_vidal_backend_qmatchatea.py deleted file mode 100644 index b5050cf..0000000 --- a/tools/compare_vidal_backend_qmatchatea.py +++ /dev/null @@ -1,137 +0,0 @@ -"""Compare QMatchaTeaBackend with the VidalBackend fast path.""" - -from __future__ import annotations - -import argparse -import json -import math -import time - -import numpy as np -import torch -from qibo import Circuit, gates, hamiltonians -from qibo.symbols import X, Y, Z - -from qibotn.backends.qmatchatea import QMatchaTeaBackend -from qibotn.backends.vidal import VidalBackend - - -def build_circuit(nqubits, nlayers, seed, kind): - rng = np.random.default_rng(seed) - circuit = Circuit(nqubits) - for layer in range(nlayers): - for q in range(nqubits): - circuit.add(gates.RY(q, theta=rng.uniform(-math.pi, math.pi))) - circuit.add(gates.RZ(q, theta=rng.uniform(-math.pi, math.pi))) - if kind == "brickwall": - for q in range(0, nqubits - 1, 2): - circuit.add(gates.CNOT(q, q + 1)) - for q in range(1, nqubits - 1, 2): - circuit.add(gates.CNOT(q, q + 1)) - elif kind == "shifted-cz": - for q in range(layer % 2, nqubits - 1, 2): - circuit.add(gates.CZ(q, q + 1)) - elif kind == "reversed-cnot": - for q in range(0, nqubits - 1, 2): - circuit.add(gates.CNOT(q + 1, q)) - for q in range(1, nqubits - 1, 2): - circuit.add(gates.CNOT(q, q + 1)) - else: - raise ValueError(f"Unknown circuit kind {kind!r}.") - return circuit - - -def build_observable(nqubits, kind): - form = 0 - if kind == "ring-xz": - for q in range(nqubits): - form += 0.5 * X(q) * Z((q + 1) % nqubits) - elif kind == "open-zz": - for q in range(nqubits - 1): - form += Z(q) * Z(q + 1) / (nqubits - 1) - elif kind == "mixed": - form += 0.25 * X(0) - 0.5 * Z(nqubits - 1) - for q in range(0, nqubits - 1, 3): - form += 0.125 * Y(q) * Y(q + 1) - else: - raise ValueError(f"Unknown observable kind {kind!r}.") - return hamiltonians.SymbolicHamiltonian(form=form) - - -def run_backend(backend, circuit, observable): - start = time.perf_counter() - value = backend.expectation(circuit, observable, preprocess=False, compile_circuit=True) - return float(np.real(value)), time.perf_counter() - start - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--nqubits", type=int, default=34) - parser.add_argument("--nlayers", type=int, default=20) - parser.add_argument("--bond", "--bonds", dest="bond", type=int, default=512) - parser.add_argument("--seed", type=int, default=42) - parser.add_argument("--tensor-module", choices=("torch", "numpy"), default="torch") - parser.add_argument("--torch-threads", type=int, default=32) - parser.add_argument( - "--circuit-kind", - choices=("brickwall", "shifted-cz", "reversed-cnot"), - default="brickwall", - ) - parser.add_argument( - "--observable-kind", - choices=("ring-xz", "open-zz", "mixed"), - default="ring-xz", - ) - parser.add_argument("--reference-file") - parser.add_argument("--skip-qmatchatea", action="store_true") - args = parser.parse_args() - - torch.set_num_threads(args.torch_threads) - circuit = build_circuit(args.nqubits, args.nlayers, args.seed, args.circuit_kind) - observable = build_observable(args.nqubits, args.observable_kind) - - exact = None - if args.reference_file: - with open(args.reference_file, "r", encoding="utf-8") as f: - exact = float(json.load(f)["expectation"]) - - print( - f"nqubits={args.nqubits} nlayers={args.nlayers} bond={args.bond} " - f"circuit={args.circuit_kind} observable={args.observable_kind} " - f"tensor_module={args.tensor_module} torch_threads={args.torch_threads}" - ) - if exact is not None: - print(f"exact={exact:.16e}") - print("backend value abs_error seconds") - - if not args.skip_qmatchatea: - qmt = QMatchaTeaBackend() - qmt.configure_tn_simulation( - ansatz="MPS", - max_bond_dimension=args.bond, - cut_ratio=1e-12, - svd_control="E!", - tensor_module=args.tensor_module, - compile_circuit=True, - track_memory=False, - ) - value, seconds = run_backend(qmt, circuit, observable) - error = float("nan") if exact is None else abs(value - exact) - print(f"qmatchatea {value:.16e} {error:.6e} {seconds:.3f}") - - vidal = VidalBackend() - vidal.configure_tn_simulation( - ansatz="MPS", - max_bond_dimension=args.bond, - cut_ratio=1e-12, - tensor_module=args.tensor_module, - compile_circuit=True, - fallback=True, - ) - value, seconds = run_backend(vidal, circuit, observable) - error = float("nan") if exact is None else abs(value - exact) - print(f"vidal {value:.16e} {error:.6e} {seconds:.3f}") - - -if __name__ == "__main__": - main() diff --git a/tools/example_tn_case.py b/tools/example_tn_case.py deleted file mode 100644 index c35f057..0000000 --- a/tools/example_tn_case.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Example custom case for tools/run_tn_custom.py.""" - -from __future__ import annotations - -import math - -import numpy as np -from qibo import Circuit, gates - - -def build_circuit(nqubits, nlayers, seed): - rng = np.random.default_rng(seed) - circuit = Circuit(nqubits) - for layer in range(nlayers): - for qubit in range(nqubits): - circuit.add(gates.RY(qubit, theta=rng.uniform(-math.pi, math.pi))) - circuit.add(gates.RZ(qubit, theta=rng.uniform(-math.pi, math.pi))) - for qubit in range(layer % 2, nqubits - 1, 2): - circuit.add(gates.RXX(qubit, qubit + 1, theta=rng.uniform(-0.7, 0.7))) - circuit.add(gates.RZZ(qubit, qubit + 1, theta=rng.uniform(-0.7, 0.7))) - return circuit - - -def build_observable(nqubits, seed): - return { - "terms": [ - { - "coefficient": 1.0 / max(1, nqubits - 1), - "operators": [("Z", site), ("Z", site + 1)], - } - for site in range(nqubits - 1) - ] - } diff --git a/tools/inspect_contraction_tree.py b/tools/inspect_contraction_tree.py deleted file mode 100644 index a6422ba..0000000 --- a/tools/inspect_contraction_tree.py +++ /dev/null @@ -1,208 +0,0 @@ -"""Inspect cotengra contraction trees for dominant torch matmul shapes.""" - -from __future__ import annotations - -import argparse -import importlib -import math -import pickle -from collections import Counter, defaultdict -from pathlib import Path - - -def _prod(values): - out = 1 - for value in values: - out *= int(value) - return out - - -def _broadcast_batch(a_batch, b_batch): - if a_batch == b_batch: - return _prod(a_batch) - if not a_batch: - return _prod(b_batch) - if not b_batch: - return _prod(a_batch) - - ndim = max(len(a_batch), len(b_batch)) - a_batch = (1,) * (ndim - len(a_batch)) + tuple(a_batch) - b_batch = (1,) * (ndim - len(b_batch)) + tuple(b_batch) - return _prod(max(a, b) for a, b in zip(a_batch, b_batch)) - - -def _load_tree(path, index): - with Path(path).open("rb") as f: - payload = pickle.load(f) - trees = payload["trees"] if isinstance(payload, dict) else payload - if not isinstance(trees, (list, tuple)): - trees = [trees] - return trees[index] - - -def _analyze_tree(tree): - contract_mod = importlib.import_module("cotengra.contract") - contractions = contract_mod.extract_contractions(tree) - size_dict = tree.size_dict - ops = [] - counts = Counter() - - for op_index, (parent, left, right, tdot, arg, perm) in enumerate(contractions): - if left is None and right is None: - counts["preprocess"] += 1 - continue - - left_inds = tree.get_inds(left) - right_inds = tree.get_inds(right) - parent_inds = tree.get_inds(parent) - left_shape = tuple(size_dict[ix] for ix in left_inds) - right_shape = tuple(size_dict[ix] for ix in right_inds) - - if tdot: - parsed = contract_mod._parse_tensordot_axes_to_matmul( - arg, - left_shape, - right_shape, - ) - else: - parsed = contract_mod._parse_eq_to_batch_matmul( - arg, - left_shape, - right_shape, - ) - - ( - _eq_a, - _eq_b, - new_shape_a, - new_shape_b, - _new_shape_ab, - _perm_ab, - pure_multiplication, - ) = parsed - - matmul_shape = None - matmul_flops = 0 - if pure_multiplication: - kind = "mul" - else: - a_shape = tuple(new_shape_a or left_shape) - b_shape = tuple(new_shape_b or right_shape) - batch = _broadcast_batch(a_shape[:-2], b_shape[:-2]) - m, k, n = int(a_shape[-2]), int(a_shape[-1]), int(b_shape[-1]) - kind = "mm" if batch == 1 else "bmm" - matmul_shape = (batch, m, k, n) - matmul_flops = batch * m * k * n - - tree_flops = int(tree.get_flops(parent)) - out_size = int(tree.get_size(parent)) - ops.append( - { - "index": op_index, - "kind": kind, - "matmul_shape": matmul_shape, - "matmul_flops": matmul_flops, - "tree_flops": tree_flops, - "out_size": out_size, - "left_shape": left_shape, - "right_shape": right_shape, - "left_rank": len(left_inds), - "right_rank": len(right_inds), - "out_rank": len(parent_inds), - "perm": perm, - } - ) - counts[kind] += 1 - - return contractions, ops, counts - - -def _format_log(value, base): - return "-inf" if value <= 0 else f"{math.log(value, base):.3f}" - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("tree", help="Pickle file containing one tree or {'trees': [...]}.") - parser.add_argument("--index", type=int, default=0, help="Tree index in the file.") - parser.add_argument("--top", type=int, default=20, help="Number of top ops to print.") - parser.add_argument( - "--dtype-bytes", - type=int, - default=8, - help="Bytes per element for memory estimates, for example 8 for complex64.", - ) - args = parser.parse_args() - - tree = _load_tree(args.tree, args.index) - contractions, ops, counts = _analyze_tree(tree) - nslices = int(getattr(tree, "multiplicity", 1)) - per_slice_flops = sum(op["tree_flops"] for op in ops) - per_slice_write = sum(op["out_size"] for op in ops) - max_out = max((op["out_size"] for op in ops), default=0) - all_flops = per_slice_flops * nslices - all_write = per_slice_write * nslices - - print(f"tree={args.tree} index={args.index}") - print( - "summary " - f"slices={nslices} contractions={len(contractions)} " - f"counts={dict(counts)}" - ) - print( - "per_slice " - f"log10_flops={_format_log(per_slice_flops, 10)} " - f"log10_write={_format_log(per_slice_write, 10)} " - f"log2_max_output={_format_log(max_out, 2)} " - f"max_output_gib={max_out * args.dtype_bytes / 1024**3:.6g}" - ) - print( - "all_slices " - f"log10_flops={_format_log(all_flops, 10)} " - f"log10_write={_format_log(all_write, 10)}" - ) - - print(f"\ntop_{args.top}_ops_by_flops") - for op in sorted(ops, key=lambda item: item["tree_flops"], reverse=True)[: args.top]: - print( - f"op={op['index']} kind={op['kind']} " - f"flops={op['tree_flops']:.6e} out={op['out_size']:.6e} " - f"matmul={op['matmul_shape']} " - f"ranks=({op['left_rank']},{op['right_rank']}->{op['out_rank']}) " - f"lhs={op['left_shape']} rhs={op['right_shape']}" - ) - - by_shape = defaultdict(lambda: [0, 0, 0]) - for op in ops: - shape = op["matmul_shape"] - if shape is None: - continue - by_shape[shape][0] += 1 - by_shape[shape][1] += op["tree_flops"] - by_shape[shape][2] += op["out_size"] - - print(f"\ntop_{args.top}_matmul_shapes_by_flops") - for shape, (count, flops, out_size) in sorted( - by_shape.items(), - key=lambda item: item[1][1], - reverse=True, - )[: args.top]: - print( - f"shape={shape} count={count} " - f"flops={flops:.6e} output={out_size:.6e}" - ) - - print(f"\ntop_{args.top}_matmul_shapes_by_count") - for shape, (count, flops, out_size) in sorted( - by_shape.items(), - key=lambda item: item[1][0], - reverse=True, - )[: args.top]: - print( - f"shape={shape} count={count} " - f"flops={flops:.6e} output={out_size:.6e}" - ) - - -if __name__ == "__main__": - main() diff --git a/tools/manage_tn_dask_cluster.sh b/tools/manage_tn_dask_cluster.sh deleted file mode 100755 index 20c4e01..0000000 --- a/tools/manage_tn_dask_cluster.sh +++ /dev/null @@ -1,223 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Manage the dask cluster used by TN path search. -# -# Defaults target two servers: -# scheduler: 10.20.1.103:8786 -# workers: 10.20.1.103, 10.20.6.101 -# -# Usage: -# tools/manage_tn_dask_cluster.sh start -# tools/manage_tn_dask_cluster.sh status -# tools/manage_tn_dask_cluster.sh stop -# -# Common overrides: -# SCHEDULER_HOST=10.20.1.103 -# WORKER_HOSTS="10.20.1.103 10.20.6.101" -# NWORKERS=48 -# NTHREADS=1 -# ROOT_DIR=/home/qibo/qibotn -# PYTHON_BIN=.venv/bin/python - -ROOT_DIR="${ROOT_DIR:-/home/qibo/qibotn}" -PYTHON_BIN="${PYTHON_BIN:-.venv/bin/python}" -SCHEDULER_HOST="${SCHEDULER_HOST:-10.20.1.103}" -SCHEDULER_PORT="${SCHEDULER_PORT:-8786}" -DASHBOARD_ADDRESS="${DASHBOARD_ADDRESS:-:8787}" -WORKER_HOSTS="${WORKER_HOSTS:-10.20.1.103 10.20.6.101}" -NWORKERS="${NWORKERS:-84}" -NTHREADS="${NTHREADS:-1}" -MEMORY_LIMIT="${MEMORY_LIMIT:-0}" -LOCAL_DIRECTORY="${LOCAL_DIRECTORY:-/tmp/qibotn-dask}" -LOG_DIR="${LOG_DIR:-$ROOT_DIR/logs/dask}" -SSH_BIN="${SSH_BIN:-ssh}" -DASK_WORKER_TTL="${DASK_WORKER_TTL:-24 hours}" -DASK_TICK_LIMIT="${DASK_TICK_LIMIT:-30 minutes}" -DASK_LOST_WORKER_TIMEOUT="${DASK_LOST_WORKER_TIMEOUT:-30 minutes}" - -SCHEDULER_ADDR="tcp://${SCHEDULER_HOST}:${SCHEDULER_PORT}" - -is_local_host() { - local host="$1" - [[ "$host" == "localhost" || "$host" == "127.0.0.1" ]] && return 0 - [[ "$host" == "$(hostname)" ]] && return 0 - [[ "$host" == "$(hostname -f 2>/dev/null || true)" ]] && return 0 - hostname -I 2>/dev/null | tr ' ' '\n' | grep -qx "$host" -} - -run_on_host() { - local host="$1" - shift - local cmd="$*" - if is_local_host "$host"; then - bash -lc "$cmd" - else - "$SSH_BIN" "$host" "bash -lc $(printf '%q' "$cmd")" - fi -} - -start_scheduler() { - local host="$SCHEDULER_HOST" - local log="$LOG_DIR/scheduler_${SCHEDULER_HOST}_${SCHEDULER_PORT}.log" - local pid_file="$LOG_DIR/scheduler_${SCHEDULER_HOST}_${SCHEDULER_PORT}.pid" - run_on_host "$host" " - set -euo pipefail - cd '$ROOT_DIR' - mkdir -p '$LOG_DIR' - if [[ -s '$pid_file' ]]; then - pid=\$(cat '$pid_file') - if kill -0 \"\$pid\" 2>/dev/null; then - echo \"scheduler already running on $host pid=\$pid\" - exit 0 - fi - fi - DASK_DISTRIBUTED__SCHEDULER__WORKER_TTL='$DASK_WORKER_TTL' \ - DASK_DISTRIBUTED__ADMIN__TICK__LIMIT='$DASK_TICK_LIMIT' \ - DASK_DISTRIBUTED__DEPLOY__LOST_WORKER_TIMEOUT='$DASK_LOST_WORKER_TIMEOUT' \ - setsid '$PYTHON_BIN' -m distributed.cli.dask_scheduler \ - --host '$SCHEDULER_HOST' \ - --port '$SCHEDULER_PORT' \ - --dashboard-address '$DASHBOARD_ADDRESS' \ - > '$log' 2>&1 < /dev/null & - pid=\$! - echo \"\$pid\" > '$pid_file' - echo \"scheduler host=$host pid=\$pid addr=$SCHEDULER_ADDR log=$log\" - " -} - -start_worker() { - local host="$1" - local log="$LOG_DIR/worker_${host}.log" - local pid_file="$LOG_DIR/worker_${host}.pid" - run_on_host "$host" " - set -euo pipefail - cd '$ROOT_DIR' - mkdir -p '$LOG_DIR' '$LOCAL_DIRECTORY' - if [[ -s '$pid_file' ]]; then - pid=\$(cat '$pid_file') - if kill -0 \"\$pid\" 2>/dev/null; then - echo \"worker already running on $host pid=\$pid\" - exit 0 - fi - fi - TCM_ENABLE=1 \ - DASK_DISTRIBUTED__SCHEDULER__WORKER_TTL='$DASK_WORKER_TTL' \ - DASK_DISTRIBUTED__ADMIN__TICK__LIMIT='$DASK_TICK_LIMIT' \ - DASK_DISTRIBUTED__DEPLOY__LOST_WORKER_TIMEOUT='$DASK_LOST_WORKER_TIMEOUT' \ - setsid '$PYTHON_BIN' -m distributed.cli.dask_worker \ - '$SCHEDULER_ADDR' \ - --host '$host' \ - --nworkers '$NWORKERS' \ - --nthreads '$NTHREADS' \ - --memory-limit '$MEMORY_LIMIT' \ - --local-directory '$LOCAL_DIRECTORY' \ - > '$log' 2>&1 < /dev/null & - pid=\$! - echo \"\$pid\" > '$pid_file' - echo \"worker host=$host pid=\$pid scheduler=$SCHEDULER_ADDR log=$log\" - " -} - -stop_host() { - local host="$1" - local scheduler_pid_file="$LOG_DIR/scheduler_${SCHEDULER_HOST}_${SCHEDULER_PORT}.pid" - local worker_pid_file="$LOG_DIR/worker_${host}.pid" - run_on_host "$host" " - set +e - for pid_file in '$worker_pid_file' '$scheduler_pid_file'; do - [[ -f \"\$pid_file\" ]] || continue - if [[ \"\$pid_file\" == '$scheduler_pid_file' && '$host' != '$SCHEDULER_HOST' ]]; then - continue - fi - pid=\$(cat \"\$pid_file\") - kill \"\$pid\" 2>/dev/null || true - rm -f \"\$pid_file\" - done - pkill -f '[d]istributed.cli.dask_worker.*$SCHEDULER_ADDR' - pkill -f '[d]istributed.cli.dask_scheduler.*--port $SCHEDULER_PORT' - true - " -} - -status_host() { - local host="$1" - local scheduler_pid_file="$LOG_DIR/scheduler_${SCHEDULER_HOST}_${SCHEDULER_PORT}.pid" - local worker_pid_file="$LOG_DIR/worker_${host}.pid" - echo "--------------------------------------------------------------------------------" - echo "host=$host" - run_on_host "$host" " - set +e - for pid_file in '$worker_pid_file' '$scheduler_pid_file'; do - [[ -f \"\$pid_file\" ]] || continue - if [[ \"\$pid_file\" == '$scheduler_pid_file' && '$host' != '$SCHEDULER_HOST' ]]; then - continue - fi - pid=\$(cat \"\$pid_file\") - if kill -0 \"\$pid\" 2>/dev/null; then - ps -p \"\$pid\" -o pid,ppid,stat,etime,cmd --no-headers - else - echo \"stale pid_file=\$pid_file pid=\$pid\" - fi - done - pgrep -af '[d]istributed.cli.dask' || true - " -} - -case "${1:-help}" in - start) - start_scheduler - sleep 2 - for host in $WORKER_HOSTS; do - start_worker "$host" - done - echo - echo "Dask scheduler: $SCHEDULER_ADDR" - echo "Dashboard: http://$SCHEDULER_HOST$DASHBOARD_ADDRESS" - ;; - stop) - for host in $WORKER_HOSTS; do - stop_host "$host" - done - stop_host "$SCHEDULER_HOST" - ;; - status) - status_host "$SCHEDULER_HOST" - for host in $WORKER_HOSTS; do - [[ "$host" == "$SCHEDULER_HOST" ]] && continue - status_host "$host" - done - ;; - restart) - "$0" stop - sleep 2 - "$0" start - ;; - help|*) - cat < 0 else float("inf") - print( - f"{label} wall={wall:.3f}s cpu={cpu:.3f}s cpu_over_wall={ratio:.2f} " - f"checksum={checksum:.6e}", - flush=True, - ) - - -def _visible_numa_nodes(): - nodes = [] - for path in sorted(Path("/sys/devices/system/node").glob("node[0-9]*")): - cpulist = path / "cpulist" - if cpulist.exists(): - nodes.append(f"{path.name}:{cpulist.read_text(encoding='utf-8').strip()}") - return ",".join(nodes) if nodes else "unknown" - - -def _dtype_nbytes(name): - return { - "float32": 4, - "float64": 8, - "complex64": 8, - "complex128": 16, - }[name] - - -def _format_gib(nbytes): - return f"{nbytes / (1024 ** 3):.2f}GiB" - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--threads", type=int, default=48) - parser.add_argument("--n", type=int, default=4096) - parser.add_argument("--iters", type=int, default=4) - parser.add_argument("--dtype", choices=("float32", "float64", "complex64", "complex128"), default="float32") - parser.add_argument("--op", choices=("matmul", "tensordot", "both"), default="both") - parser.add_argument( - "--affinity-only", - action="store_true", - help="Print MPI/torch placement diagnostics without allocating tensors.", - ) - args = parser.parse_args() - - os.environ.setdefault("OMP_NUM_THREADS", str(args.threads)) - os.environ.setdefault("MKL_NUM_THREADS", str(args.threads)) - os.environ.setdefault("OMP_PROC_BIND", "close") - os.environ.setdefault("OMP_PLACES", "cores") - - import torch - - comm = MPI.COMM_WORLD - rank = comm.Get_rank() - size = comm.Get_size() - - torch.set_num_threads(args.threads) - try: - torch.set_num_interop_threads(1) - except Exception: - pass - - dtype = _dtype_from_name(args.dtype) - affinity = sorted(os.sched_getaffinity(0)) - allowed_list = "" - try: - with open("/proc/self/status", encoding="utf-8") as f: - for line in f: - if line.startswith("Cpus_allowed_list:"): - allowed_list = line.split(":", 1)[1].strip() - break - except OSError: - pass - - print( - f"rank={rank}/{size} host={socket.gethostname()} pid={os.getpid()} " - f"affinity_len={len(affinity)} allowed={allowed_list} " - f"torch_threads={torch.get_num_threads()} " - f"torch_interop={torch.get_num_interop_threads()} " - f"OMP_NUM_THREADS={os.environ.get('OMP_NUM_THREADS')} " - f"MKL_NUM_THREADS={os.environ.get('MKL_NUM_THREADS')} " - f"OMP_PROC_BIND={os.environ.get('OMP_PROC_BIND')} " - f"OMP_PLACES={os.environ.get('OMP_PLACES')} " - f"visible_numa={_visible_numa_nodes()}", - flush=True, - ) - - if rank == 0: - print(torch.__config__.parallel_info(), flush=True) - input_bytes = args.n * args.n * _dtype_nbytes(args.dtype) - min_live_bytes = 3 * input_bytes - print( - f"matrix_n={args.n} dtype={args.dtype} " - f"one_matrix={_format_gib(input_bytes)} " - f"approx_min_live_per_rank={_format_gib(min_live_bytes)} " - f"approx_min_live_all_ranks={_format_gib(min_live_bytes * size)}", - flush=True, - ) - comm.Barrier() - if args.affinity_only: - return - - a = _make_tensor((args.n, args.n), dtype) - b = _make_tensor((args.n, args.n), dtype) - - def run_matmul(): - value = (a @ b).sum() - return value.real.item() if value.is_complex() else value.item() - - def run_tensordot(): - value = torch.tensordot(a, b, dims=1) - value = value.sum() - return value.real.item() if value.is_complex() else value.item() - - if args.op in ("matmul", "both"): - _bench("matmul", run_matmul, args.iters) - if args.op in ("tensordot", "both"): - _bench("tensordot", run_tensordot, args.iters) - - -if __name__ == "__main__": - main() diff --git a/tools/mps_contest_runner.py b/tools/mps_contest_runner.py deleted file mode 100644 index 353cc3e..0000000 --- a/tools/mps_contest_runner.py +++ /dev/null @@ -1,313 +0,0 @@ -#!/usr/bin/env python -"""Contest-style multi-node Vidal/MPS expectation runner.""" - -from __future__ import annotations - -import argparse -import math -import sys -import time -from dataclasses import dataclass -from pathlib import Path - -import numpy as np -from mpi4py import MPI -from qibo import Circuit, gates, hamiltonians -from qibo.symbols import X, Y, Z - -ROOT = Path(__file__).resolve().parents[1] -SRC = ROOT / "src" -if str(SRC) not in sys.path: - sys.path.insert(0, str(SRC)) - -from qibotn.backends.vidal import VidalBackend # noqa: E402 -from qibotn.expectation_runner import exact_for_observable # noqa: E402 - - -@dataclass(frozen=True) -class CaseSpec: - circuit_kind: str - observables: tuple[str, ...] - nqubits: int - nlayers: int - bond: int | None - seed: int - - -CASES = { - "main1": CaseSpec( - circuit_kind="reversed_cnot", - observables=("ring_xz",), - nqubits=128, - nlayers=24, - bond=512, - seed=31001, - ), - "main2": CaseSpec( - circuit_kind="rxx_rzz", - observables=("open_zz", "range2_xx", "mixed_local"), - nqubits=128, - nlayers=32, - bond=1024, - seed=31002, - ), - "strong": CaseSpec( - circuit_kind="scramble", - observables=("ring_xz", "long_z_string", "dense3_spread"), - nqubits=256, - nlayers=48, - bond=2048, - seed=41001, - ), -} - - -def optional_int(text): - if isinstance(text, str) and text.lower() in {"none", "null", "inf", "unlimited"}: - return None - return int(text) - - -def optional_float(text): - if isinstance(text, str) and text.lower() in {"none", "null", "inf", "unlimited"}: - return None - return float(text) - - -def format_optional(value, fmt="g"): - return "None" if value is None else format(value, fmt) - - -def set_torch_threads(nthreads): - try: - import torch - - torch.set_num_threads(nthreads) - except Exception: - pass - - -def add_single_qubit_layer(circuit, nqubits, rng, include_rx=False): - for qubit in range(nqubits): - circuit.add(gates.RY(qubit, theta=rng.uniform(-math.pi, math.pi))) - circuit.add(gates.RZ(qubit, theta=rng.uniform(-math.pi, math.pi))) - if include_rx: - circuit.add(gates.RX(qubit, theta=rng.uniform(-math.pi, math.pi))) - - -def build_circuit(kind, nqubits, nlayers, seed): - rng = np.random.default_rng(seed) - circuit = Circuit(nqubits) - - for layer in range(nlayers): - if kind == "reversed_cnot": - add_single_qubit_layer(circuit, nqubits, rng) - for qubit in range(0, nqubits - 1, 2): - gate = gates.CNOT(qubit + 1, qubit) if layer % 2 else gates.CNOT(qubit, qubit + 1) - circuit.add(gate) - for qubit in range(1, nqubits - 1, 2): - gate = gates.CNOT(qubit + 1, qubit) if layer % 2 == 0 else gates.CNOT(qubit, qubit + 1) - circuit.add(gate) - - elif kind == "rxx_rzz": - add_single_qubit_layer(circuit, nqubits, rng, include_rx=True) - for qubit in range(layer % 2, nqubits - 1, 2): - circuit.add(gates.RXX(qubit, qubit + 1, theta=rng.uniform(-0.9, 0.9))) - circuit.add(gates.RZZ(qubit, qubit + 1, theta=rng.uniform(-0.9, 0.9))) - - elif kind == "scramble": - add_single_qubit_layer(circuit, nqubits, rng, include_rx=True) - for qubit in range(layer % 2, nqubits - 1, 2): - circuit.add(gates.RXX(qubit, qubit + 1, theta=rng.uniform(-0.8, 0.8))) - circuit.add(gates.RZZ(qubit, qubit + 1, theta=rng.uniform(-0.8, 0.8))) - if layer % 5 == 4: - circuit.add(gates.SWAP(qubit, qubit + 1)) - - else: - raise ValueError(f"Unknown circuit kind {kind!r}.") - - return circuit - - -def dense_observable(nqubits, qubits, seed, dim): - del nqubits - rng = np.random.default_rng(seed) - raw = rng.normal(size=(dim, dim)) + 1j * rng.normal(size=(dim, dim)) - matrix = (raw + raw.conj().T) / 2.0 - matrix = matrix / np.linalg.norm(matrix) - return {"matrix": matrix, "qubits": list(qubits)} - - -def observable(kind, nqubits, seed): - q1 = nqubits // 4 - q2 = nqubits // 2 - q3 = (3 * nqubits) // 4 - last = nqubits - 1 - - if kind == "boundary_ZZ_q1": - return hamiltonians.SymbolicHamiltonian(form=Z(q1 - 1) * Z(q1)) - if kind == "boundary_ZZ_q2": - return hamiltonians.SymbolicHamiltonian(form=Z(q2 - 1) * Z(q2)) - if kind == "boundary_ZZ_q3": - return hamiltonians.SymbolicHamiltonian(form=Z(q3 - 1) * Z(q3)) - if kind == "long_Z_5_sites": - return hamiltonians.SymbolicHamiltonian(form=Z(0) * Z(q1) * Z(q2) * Z(q3) * Z(last)) - if kind == "mixed_XZYZX": - return hamiltonians.SymbolicHamiltonian(form=X(0) * Z(q1) * Y(q2) * Z(q3) * X(last)) - if kind == "ring_xz": - form = 0 - for qubit in range(nqubits): - form += 0.5 * X(qubit) * Z((qubit + 1) % nqubits) - return hamiltonians.SymbolicHamiltonian(form=form) - if kind == "open_zz": - form = 0 - for qubit in range(nqubits - 1): - form += (1.0 / max(1, nqubits - 1)) * Z(qubit) * Z(qubit + 1) - return hamiltonians.SymbolicHamiltonian(form=form) - if kind == "range2_xx": - form = 0 - for qubit in range(nqubits - 2): - form += (1.0 / max(1, nqubits - 2)) * X(qubit) * X(qubit + 2) - return hamiltonians.SymbolicHamiltonian(form=form) - if kind == "mixed_local": - form = 0.25 * X(0) - 0.5 * Z(last) + 0.125 * X(q1) * Z(q2) * Y(q3) - return hamiltonians.SymbolicHamiltonian(form=form) - if kind == "complex_iZ0": - return hamiltonians.SymbolicHamiltonian(form=1.0j * Z(0)) - if kind == "dense2_mid": - return dense_observable(nqubits, (q2 - 1, q2), seed + 101, 4) - if kind == "dense3_spread": - return dense_observable(nqubits, (q1, q2, q3), seed + 202, 8) - raise ValueError(f"Unknown observable kind {kind!r}.") - - -def selected_observables(args, case): - if args.observables: - return tuple(args.observables) - if args.obs_filter: - return tuple(x.strip() for x in args.obs_filter.split(",") if x.strip()) - return case.observables - - -def apply_case_defaults(args): - case = CASES[args.case] - if args.nqubits is None: - args.nqubits = case.nqubits - if args.nlayers is None: - args.nlayers = case.nlayers - if args.bond == "case-default": - args.bond = case.bond - if args.seed is None: - args.seed = case.seed - args.observables = selected_observables(args, case) - - -def run_case(args): - set_torch_threads(args.torch_threads) - comm = MPI.COMM_WORLD - rank = comm.Get_rank() - size = comm.Get_size() - - case = CASES[args.case] - circuit = build_circuit(case.circuit_kind, args.nqubits, args.nlayers, args.seed) - - if rank == 0: - print("=" * 88, flush=True) - print( - "backend=vidal_mps " - f"case={args.case} circuit={case.circuit_kind} ranks={size} " - f"nqubits={args.nqubits} nlayers={args.nlayers} gates={len(circuit.queue)} " - f"bond={format_optional(args.bond)} cut_ratio={format_optional(args.cut_ratio)} " - f"torch_threads={args.torch_threads} seed={args.seed} " - f"observables={','.join(args.observables)}", - flush=True, - ) - print("observable exact value abs_error rel_error seconds trunc_sum trunc_max status", flush=True) - - for obs_name in args.observables: - obs = observable(obs_name, args.nqubits, args.seed) - exact = None - if args.exact and rank == 0: - if args.nqubits > args.exact_max_qubits: - raise ValueError( - f"--exact is limited to {args.exact_max_qubits} qubits by default." - ) - exact = exact_for_observable(circuit, obs, args.nqubits) - - backend = VidalBackend() - backend.configure_tn_simulation( - max_bond_dimension=args.bond, - cut_ratio=args.cut_ratio, - tensor_module="torch", - mpi_approach="CT", - mpi_num_procs=size, - fallback=False, - ) - - comm.Barrier() - start = time.perf_counter() - try: - value = backend.expectation( - circuit, - obs, - preprocess=True, - compile_circuit=False, - ) - status = "ok" - except Exception as exc: - value = np.nan - status = type(exc).__name__ + ":" + str(exc).split("\n", 1)[0] - seconds = time.perf_counter() - start - - if rank == 0: - abs_error = float("nan") if exact is None else abs(value - exact) - rel_error = float("nan") if exact is None else abs_error / max(abs(exact), 1e-15) - exact_text = "nan" if exact is None else f"{exact:.16e}" - print( - f"{obs_name} {exact_text} {value!r} " - f"{abs_error:.6e} {rel_error:.6e} {seconds:.3f} " - f"{backend.last_truncation_error:.6e} " - f"{backend.last_max_truncation_error:.6e} {status}", - flush=True, - ) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("mode", choices=("run", "validate", "list")) - parser.add_argument("--case", choices=sorted(CASES), default="main1") - parser.add_argument("--observables", nargs="+") - parser.add_argument("--obs-filter", default="") - parser.add_argument("--nqubits", type=int) - parser.add_argument("--nlayers", type=int) - parser.add_argument("--bond", "--bonds", dest="bond", default="case-default") - parser.add_argument("--cut-ratio", type=optional_float, default=1e-12) - parser.add_argument("--seed", type=int) - parser.add_argument("--torch-threads", type=int, default=8) - parser.add_argument("--exact", action="store_true") - parser.add_argument("--exact-max-qubits", type=int, default=24) - args = parser.parse_args() - - if args.mode == "list": - for name, case in CASES.items(): - print( - f"{name}: circuit={case.circuit_kind} " - f"observables={','.join(case.observables)} " - f"nqubits={case.nqubits} nlayers={case.nlayers} " - f"bond={case.bond} seed={case.seed}" - ) - return - - apply_case_defaults(args) - if isinstance(args.bond, str): - args.bond = optional_int(args.bond) - - if args.mode == "validate": - args.exact = True - args.nqubits = min(args.nqubits, args.exact_max_qubits) - - run_case(args) - - -if __name__ == "__main__": - main() diff --git a/tools/profile_vidal_chrome.py b/tools/profile_vidal_chrome.py deleted file mode 100644 index bf22276..0000000 --- a/tools/profile_vidal_chrome.py +++ /dev/null @@ -1,72 +0,0 @@ -"""Chrome trace profiler for the VidalBackend fast path.""" - -from __future__ import annotations - -import argparse -from pathlib import Path - -import torch -from torch.profiler import ProfilerActivity, profile - -from qibotn.benchmark_cases import build_circuit, terms_to_dict, observable_terms -from qibotn.expectation_runner import ExpectationConfig, run_cpu_expectation - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--nqubits", type=int, default=34) - parser.add_argument("--nlayers", type=int, default=20) - parser.add_argument("--bond", type=int, default=512) - parser.add_argument("--seed", type=int, default=42) - parser.add_argument("--torch-threads", type=int, default=32) - parser.add_argument("--cut-ratio", type=float, default=1e-12) - parser.add_argument("--profile-memory", action="store_true") - parser.add_argument("--rows", type=int, default=60) - args = parser.parse_args() - - torch.set_num_threads(args.torch_threads) - - prefix = f"profiles/vidal_n{args.nqubits}_l{args.nlayers}_b{args.bond}_t{args.torch_threads}" - trace_path = Path(f"{prefix}.json") - table_path = Path(f"{prefix}.txt") - trace_path.parent.mkdir(parents=True, exist_ok=True) - - circuit = build_circuit("brickwall_cnot", args.nqubits, args.nlayers, args.seed) - observable = terms_to_dict(observable_terms("ring_xz", args.nqubits)) - config = ExpectationConfig( - ansatz="mps", - bond=args.bond, - cut_ratio=args.cut_ratio, - tensor_module="torch", - torch_threads=args.torch_threads, - ) - - print( - f"profile vidal nqubits={args.nqubits} nlayers={args.nlayers} " - f"bond={args.bond} threads={args.torch_threads}" - ) - - with profile( - activities=[ProfilerActivity.CPU], - record_shapes=args.profile_memory, - profile_memory=args.profile_memory, - with_stack=args.profile_memory, - ) as prof: - result = run_cpu_expectation(circuit, observable, config) - - table = ( - f"expval={result.value:.16e}\n\n" - f"# sorted by self_cpu_time_total\n" - f"{prof.key_averages().table(sort_by='self_cpu_time_total', row_limit=args.rows)}\n\n" - f"# sorted by cpu_time_total\n" - f"{prof.key_averages().table(sort_by='cpu_time_total', row_limit=args.rows)}\n" - ) - - print(table, end="") - table_path.write_text(table, encoding="utf-8") - prof.export_chrome_trace(str(trace_path)) - print(f"trace={trace_path}\ntable={table_path}") - - -if __name__ == "__main__": - main() diff --git a/tools/qibojit_reference_expectation.py b/tools/qibojit_reference_expectation.py deleted file mode 100644 index 429855a..0000000 --- a/tools/qibojit_reference_expectation.py +++ /dev/null @@ -1,109 +0,0 @@ -"""Compute and cache a qibojit state-vector reference for the ring-XZ observable.""" - -import argparse -import json -import math -import time -from pathlib import Path - -import numpy as np -import qibo -from qibo import Circuit, gates - - -def build_circuit(nqubits, nlayers, seed): - rng = np.random.default_rng(seed) - circuit = Circuit(nqubits) - for _ in range(nlayers): - for qubit in range(nqubits): - circuit.add(gates.RY(qubit, theta=rng.uniform(-math.pi, math.pi))) - circuit.add(gates.RZ(qubit, theta=rng.uniform(-math.pi, math.pi))) - for qubit in range(0, nqubits - 1, 2): - circuit.add(gates.CNOT(qubit, qubit + 1)) - for qubit in range(1, nqubits - 1, 2): - circuit.add(gates.CNOT(qubit, qubit + 1)) - return circuit - - -def ring_xz_expectation(state, nqubits, chunk_size): - value = 0.0 - for qubit in range(nqubits): - next_qubit = (qubit + 1) % nqubits - x_flip = 1 << (nqubits - 1 - qubit) - z_shift = nqubits - 1 - next_qubit - term = 0.0 - for start in range(0, state.size, chunk_size): - stop = min(start + chunk_size, state.size) - indices = np.arange(start, stop, dtype=np.int64) - z_bit = (indices >> z_shift) & 1 - z_phase = 1 - 2 * z_bit - term += np.vdot(state[indices ^ x_flip], z_phase * state[start:stop]).real - value += 0.5 * term - return float(value) - - -def default_output_path(nqubits, nlayers, seed): - return Path("references") / ( - f"qibojit_ring_xz_n{nqubits}_l{nlayers}_seed{seed}.json" - ) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--nqubits", type=int, default=32) - parser.add_argument("--nlayers", type=int, default=3) - parser.add_argument("--seed", type=int, default=42) - parser.add_argument("--output") - parser.add_argument("--force", action="store_true") - parser.add_argument("--allow-large", action="store_true") - parser.add_argument("--max-state-gb", type=float, default=32.0) - parser.add_argument("--chunk-size", type=int, default=1 << 20) - args = parser.parse_args() - - output = Path(args.output) if args.output else default_output_path( - args.nqubits, args.nlayers, args.seed - ) - if output.exists() and not args.force: - with open(output, "r", encoding="utf-8") as f: - data = json.load(f) - print(f"loaded {output}") - print(f"expectation={float(data['expectation']):.16e}") - return - - state_gb = (2**args.nqubits) * np.dtype(np.complex128).itemsize / (1024**3) - if state_gb > args.max_state_gb and not args.allow_large: - raise MemoryError( - f"Estimated state vector alone is {state_gb:.1f} GiB. " - "Pass --allow-large after confirming the node has enough memory." - ) - - qibo.set_backend("qibojit") - circuit = build_circuit(args.nqubits, args.nlayers, args.seed) - - start = time.perf_counter() - state = circuit().state(numpy=True).reshape(-1) - expectation = ring_xz_expectation(state, args.nqubits, args.chunk_size) - elapsed = time.perf_counter() - start - - data = { - "backend": "qibojit", - "observable": "0.5 * sum_i X_i Z_((i+1) mod n)", - "nqubits": args.nqubits, - "nlayers": args.nlayers, - "seed": args.seed, - "expectation": expectation, - "seconds": elapsed, - "state_vector_gib_estimate": state_gb, - } - output.parent.mkdir(parents=True, exist_ok=True) - with open(output, "w", encoding="utf-8") as f: - json.dump(data, f, indent=2, sort_keys=True) - f.write("\n") - - print(f"saved {output}") - print(f"expectation={expectation:.16e}") - print(f"seconds={elapsed:.3f}") - - -if __name__ == "__main__": - main() diff --git a/tools/qibotn_torch_mt_env.sh b/tools/qibotn_torch_mt_env.sh deleted file mode 100644 index 838cdef..0000000 --- a/tools/qibotn_torch_mt_env.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env bash -# Shared runtime setup for CPU torch TN/MPS runs. -# -# This makes AOCL BLIS use the multithreaded library when available, which is -# required for complex64 tensordot/cgemm to actually use all cores on this host. - -QIBOTN_BLIS_MT="${QIBOTN_BLIS_MT:-/home/aocc/aocl/5.2.0/aocc/lib_LP64/libblis-mt.so.5}" - -export BLIS_NUM_THREADS="${BLIS_NUM_THREADS:-${OMP_NUM_THREADS:-1}}" - -if [[ -f "$QIBOTN_BLIS_MT" ]]; then - case ":${LD_PRELOAD:-}:" in - *":$QIBOTN_BLIS_MT:"*) - ;; - *) - export LD_PRELOAD="${LD_PRELOAD:+$LD_PRELOAD:}$QIBOTN_BLIS_MT" - ;; - esac -fi - -export OMP_PROC_BIND="${OMP_PROC_BIND:-close}" -export OMP_PLACES="${OMP_PLACES:-cores}" diff --git a/tools/run_cpu_large_cases.sh b/tools/run_cpu_large_cases.sh deleted file mode 100755 index 59be311..0000000 --- a/tools/run_cpu_large_cases.sh +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Large CPU expectation benchmarks for two-server runs. -# -# Defaults assume two Intel Xeon Platinum 8558P servers with about 500 GiB RAM -# each. Override HOSTFILE, PYTHON_BIN, MPIEXEC, or the per-case knobs below as -# needed. - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -PYTHON_BIN="${PYTHON_BIN:-.venv/bin/python}" -MPIEXEC="${MPIEXEC:-mpiexec}" -HOSTFILE="${HOSTFILE:-hostfile}" - -MPS_RANKS="${MPS_RANKS:-8}" -MPS_THREADS="${MPS_THREADS:-12}" -TN_RANKS="${TN_RANKS:-12}" -TN_THREADS="${TN_THREADS:-8}" - -export OMP_NUM_THREADS="${OMP_NUM_THREADS:-1}" -export MKL_NUM_THREADS="${MKL_NUM_THREADS:-1}" -source "$ROOT_DIR/tools/qibotn_torch_mt_env.sh" - -run_mpi() { - local ranks="$1" - shift - "$MPIEXEC" -hostfile "$HOSTFILE" -n "$ranks" "$PYTHON_BIN" "$@" -} - -run_case() { - local title="$1" - shift - echo - echo "================================================================================" - echo "$title" - echo "================================================================================" - echo "HOSTFILE=$HOSTFILE PYTHON_BIN=$PYTHON_BIN MPIEXEC=$MPIEXEC" - echo "OMP_NUM_THREADS=$OMP_NUM_THREADS MKL_NUM_THREADS=$MKL_NUM_THREADS" - echo "$*" - "$@" -} - -case "${1:-help}" in - smoke) - run_case "MPS MPI smoke: n=40 layers=30 bond=2048" \ - run_mpi "$MPS_RANKS" benchmark_cpu_expectation.py \ - --mpi --mps \ - --nqubits "${MPS_SMOKE_NQ:-40}" \ - --nlayers "${MPS_SMOKE_LAYERS:-30}" \ - --bond "${MPS_SMOKE_BOND:-2048}" \ - --torch-threads "$MPS_THREADS" \ - --circuits brickwall_cnot reversed_cnot shifted_cz \ - --observables ring_xz open_zz range2_xx - - run_case "TN MPI smoke: n=32 layers=16 target_slices=12" \ - run_mpi "$TN_RANKS" benchmark_cpu_expectation.py \ - --mpi \ - --nqubits "${TN_SMOKE_NQ:-32}" \ - --nlayers "${TN_SMOKE_LAYERS:-16}" \ - --torch-threads "$TN_THREADS" \ - --circuits brickwall_cnot shifted_cz rxx_rzz \ - --observables ring_xz open_zz range2_xx \ - --tn-target-slices "${TN_SMOKE_SLICES:-12}" - ;; - - mps-long) - run_case "MPS MPI long: n=64 layers=48 bond=4096" \ - run_mpi "$MPS_RANKS" benchmark_cpu_expectation.py \ - --mpi --mps \ - --nqubits "${MPS_LONG_NQ:-64}" \ - --nlayers "${MPS_LONG_LAYERS:-48}" \ - --bond "${MPS_LONG_BOND:-4096}" \ - --torch-threads "$MPS_THREADS" \ - --circuits brickwall_cnot reversed_cnot shifted_cz rxx_rzz \ - --observables ring_xz open_zz mixed_local range2_xx - ;; - - mps-pressure) - run_case "MPS MPI pressure: n=80 layers=64 bond=4096" \ - run_mpi "$MPS_RANKS" benchmark_cpu_expectation.py \ - --mpi --mps \ - --nqubits "${MPS_PRESSURE_NQ:-80}" \ - --nlayers "${MPS_PRESSURE_LAYERS:-64}" \ - --bond "${MPS_PRESSURE_BOND:-4096}" \ - --torch-threads "$MPS_THREADS" \ - --circuits brickwall_cnot reversed_cnot shifted_cz rxx_rzz swap_scramble \ - --observables ring_xz open_zz mixed_local range2_xx long_z_string - ;; - - tn-long) - run_case "TN MPI long: n=36 layers=20 target_slices=24" \ - run_mpi "$TN_RANKS" benchmark_cpu_expectation.py \ - --mpi \ - --nqubits "${TN_LONG_NQ:-36}" \ - --nlayers "${TN_LONG_LAYERS:-20}" \ - --torch-threads "$TN_THREADS" \ - --circuits brickwall_cnot shifted_cz rxx_rzz \ - --observables ring_xz open_zz range2_xx \ - --tn-target-slices "${TN_LONG_SLICES:-24}" - ;; - - all) - "$0" smoke - "$0" mps-long - "$0" tn-long - ;; - - help|*) - cat >&2 <<'EOF' -Usage: tools/run_cpu_large_cases.sh [smoke|mps-long|mps-pressure|tn-long|all] - -Common overrides: - HOSTFILE=hostfile - PYTHON_BIN=.venv/bin/python - MPIEXEC=mpiexec - MPS_RANKS=8 MPS_THREADS=12 - TN_RANKS=12 TN_THREADS=8 - -Scale overrides: - MPS_LONG_NQ=64 MPS_LONG_LAYERS=48 MPS_LONG_BOND=4096 - MPS_PRESSURE_NQ=80 MPS_PRESSURE_LAYERS=64 MPS_PRESSURE_BOND=4096 - TN_LONG_NQ=36 TN_LONG_LAYERS=20 TN_LONG_SLICES=24 -EOF - exit 2 - ;; -esac diff --git a/tools/run_cpu_single_cases.sh b/tools/run_cpu_single_cases.sh deleted file mode 100755 index b7f23e7..0000000 --- a/tools/run_cpu_single_cases.sh +++ /dev/null @@ -1,149 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Single-node CPU scale probes for expectation benchmarks. -# -# Intended for one 96-core / ~500 GiB RAM node. The default "probe" mode runs -# moderate MPS and TN cases first. Larger modes are available after checking -# runtime and memory from the probe output. - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -PYTHON_BIN="${PYTHON_BIN:-.venv/bin/python}" -PYTHON_FLAGS="${PYTHON_FLAGS:--u}" -MPIEXEC="${MPIEXEC:-mpiexec}" -TIME_BIN="${TIME_BIN:-/usr/bin/time}" - -MPS_RANKS="${MPS_RANKS:-8}" -MPS_THREADS="${MPS_THREADS:-12}" -TN_RANKS="${TN_RANKS:-8}" -TN_THREADS="${TN_THREADS:-12}" - -export OMP_NUM_THREADS="${OMP_NUM_THREADS:-1}" -export MKL_NUM_THREADS="${MKL_NUM_THREADS:-1}" -source "$ROOT_DIR/tools/qibotn_torch_mt_env.sh" - -estimate_mps_memory() { - local nqubits="$1" - local bond="$2" - "$PYTHON_BIN" - "$nqubits" "$bond" "$MPS_RANKS" <<'PY' -import sys -n = int(sys.argv[1]) -chi = int(sys.argv[2]) -ranks = int(sys.argv[3]) -resident = n * 2 * chi * chi * 16 -per_rank = resident / ranks -print( - "MPS rough resident memory: " - f"total={resident / 1024**3:.1f} GiB " - f"per_rank={per_rank / 1024**3:.1f} GiB " - "(temporary eig/SVD workspaces are additional)" -) -PY -} - -run_timed() { - echo - echo "--------------------------------------------------------------------------------" - echo "$*" - echo "--------------------------------------------------------------------------------" - "$TIME_BIN" -v "$@" -} - -run_mps_case() { - local label="$1" - local nqubits="$2" - local nlayers="$3" - local bond="$4" - shift 4 - echo - echo "================================================================================" - echo "$label" - echo "================================================================================" - echo "PYTHON_BIN=$PYTHON_BIN MPIEXEC=$MPIEXEC" - echo "MPS_RANKS=$MPS_RANKS MPS_THREADS=$MPS_THREADS" - echo "OMP_NUM_THREADS=$OMP_NUM_THREADS MKL_NUM_THREADS=$MKL_NUM_THREADS" - estimate_mps_memory "$nqubits" "$bond" - run_timed "$MPIEXEC" -n "$MPS_RANKS" "$PYTHON_BIN" $PYTHON_FLAGS benchmark_cpu_expectation.py \ - --mpi --mps \ - --nqubits "$nqubits" \ - --nlayers "$nlayers" \ - --bond "$bond" \ - --torch-threads "$MPS_THREADS" \ - "$@" -} - -run_tn_case() { - local label="$1" - local nqubits="$2" - local nlayers="$3" - shift 3 - echo - echo "================================================================================" - echo "$label" - echo "================================================================================" - echo "PYTHON_BIN=$PYTHON_BIN MPIEXEC=$MPIEXEC" - echo "TN_RANKS=$TN_RANKS TN_THREADS=$TN_THREADS" - echo "OMP_NUM_THREADS=$OMP_NUM_THREADS MKL_NUM_THREADS=$MKL_NUM_THREADS" - echo "TN memory is contraction-tree dependent; increase --tn-target-slices if RSS is high." - run_timed "$MPIEXEC" -n "$TN_RANKS" "$PYTHON_BIN" $PYTHON_FLAGS benchmark_cpu_expectation.py \ - --mpi \ - --nqubits "$nqubits" \ - --nlayers "$nlayers" \ - --torch-threads "$TN_THREADS" \ - "$@" -} - -case "${1:-help}" in - probe) - run_mps_case "MPS probe: n=40 layers=30 bond=2048" 40 30 2048 \ - --circuits brickwall_cnot \ - --observables ring_xz - - run_tn_case "TN probe: n=28 layers=12 target_slices=8" 28 12 \ - --circuits brickwall_cnot \ - --observables ring_xz \ - --tn-target-slices 8 - ;; - - mps-medium) - run_mps_case "MPS medium: n=56 layers=40 bond=3072" 56 40 3072 \ - --circuits brickwall_cnot reversed_cnot shifted_cz rxx_rzz \ - --observables ring_xz open_zz mixed_local range2_xx - ;; - - mps-long) - run_mps_case "MPS long: n=64 layers=48 bond=4096" 64 48 4096 \ - --circuits brickwall_cnot reversed_cnot shifted_cz rxx_rzz \ - --observables ring_xz open_zz mixed_local range2_xx - ;; - - tn-medium) - run_tn_case "TN medium: n=32 layers=16 target_slices=16" 32 16 \ - --circuits brickwall_cnot shifted_cz rxx_rzz \ - --observables ring_xz open_zz range2_xx \ - --tn-target-slices 16 - ;; - - tn-long) - run_tn_case "TN long: n=36 layers=20 target_slices=32" 36 20 \ - --circuits brickwall_cnot shifted_cz rxx_rzz \ - --observables ring_xz open_zz range2_xx \ - --tn-target-slices 32 - ;; - - help|*) - cat >&2 <<'EOF' -Usage: tools/run_cpu_single_cases.sh [probe|mps-medium|mps-long|tn-medium|tn-long] - -Common overrides: - PYTHON_BIN=.venv/bin/python - MPIEXEC=mpiexec - MPS_RANKS=8 MPS_THREADS=12 - TN_RANKS=8 TN_THREADS=12 - OMP_NUM_THREADS=1 MKL_NUM_THREADS=1 -EOF - exit 2 - ;; -esac diff --git a/tools/run_tn_custom.py b/tools/run_tn_custom.py deleted file mode 100644 index 049ebed..0000000 --- a/tools/run_tn_custom.py +++ /dev/null @@ -1,243 +0,0 @@ -#!/usr/bin/env python -"""Run TN expectation for a user-provided circuit and observable. - -The case module should define: - - def build_circuit(nqubits, nlayers, seed): ... - def build_observable(nqubits, seed): ... - -``build_observable`` may return a Qibo SymbolicHamiltonian/form or the qibotn -dict form: - - {"terms": [ - {"coefficient": 1.0, "operators": [("X", 0), ("Z", 1)]}, - ]} - -For a single repeated Pauli string, pass ``--pauli-pattern`` instead of -defining ``build_observable``. -""" - -from __future__ import annotations - -import argparse -import importlib.util -import inspect -import json -import sys -from pathlib import Path - -ROOT = Path(__file__).resolve().parents[1] -SRC = ROOT / "src" -if str(SRC) not in sys.path: - sys.path.insert(0, str(SRC)) - -from qibotn.expectation_runner import ( # noqa: E402 - ExpectationConfig, - exact_for_observable, - run_cpu_expectation, -) - - -def optional_int(text): - if isinstance(text, str) and text.lower() in {"none", "null", "inf", "unlimited"}: - return None - return int(text) - - -def optional_float(text): - if isinstance(text, str) and text.lower() in {"none", "null", "inf", "unlimited"}: - return None - return float(text) - - -def load_module(path): - path = Path(path).resolve() - spec = importlib.util.spec_from_file_location(path.stem, path) - if spec is None or spec.loader is None: - raise RuntimeError(f"Cannot import case module from {path}.") - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - return module - - -def call_builder(fn, **kwargs): - sig = inspect.signature(fn) - if any(p.kind == p.VAR_KEYWORD for p in sig.parameters.values()): - return fn(**kwargs) - accepted = { - name: value - for name, value in kwargs.items() - if name in sig.parameters - } - return fn(**accepted) - - -def load_observable(args, module): - if args.pauli_pattern: - return {"pauli_string_pattern": args.pauli_pattern} - if args.observable_json: - with Path(args.observable_json).open() as f: - return json.load(f) - if hasattr(module, "build_observable"): - return call_builder( - module.build_observable, - nqubits=args.nqubits, - nlayers=args.nlayers, - seed=args.seed, - ) - if hasattr(module, "OBSERVABLE"): - return module.OBSERVABLE - raise ValueError( - "No observable supplied. Define build_observable/OBSERVABLE in the case " - "module, or pass --pauli-pattern / --observable-json." - ) - - -def build_parallel_opts(args): - slicing_opts = {} - if args.tn_target_slices is not None: - slicing_opts["target_slices"] = args.tn_target_slices - if args.tn_target_size is not None: - slicing_opts["target_size"] = args.tn_target_size - - opts = { - "slicing_opts": slicing_opts or None, - "search_workers": args.tn_search_workers or args.torch_threads, - "max_repeats": args.tn_search_repeats, - "max_time": args.tn_search_time, - "print_stats": not args.no_tn_stats, - } - if args.tn_search_backend is not None: - opts["search_backend"] = args.tn_search_backend - if args.dask_address is not None: - opts["dask_address"] = args.dask_address - if args.dask_close_workers: - opts["dask_close_workers"] = True - if args.tn_save_tree is not None: - opts["save_tree_path"] = args.tn_save_tree - if args.tn_load_tree is not None: - opts["load_tree_path"] = args.tn_load_tree - if args.tn_search_only: - opts["search_only"] = True - return opts - - -def main(): - parser = argparse.ArgumentParser( - description="Run CPU TN expectation for a custom qibo circuit module." - ) - parser.add_argument("case_module", help="Python file defining build_circuit.") - parser.add_argument("--nqubits", type=int, required=True) - parser.add_argument("--nlayers", type=int, default=0) - parser.add_argument("--seed", type=int, default=42) - parser.add_argument("--mpi", action="store_true") - parser.add_argument("--exact", action="store_true") - parser.add_argument("--exact-max-qubits", type=int, default=24) - parser.add_argument("--bond", "--bonds", dest="bond", type=optional_int, default=1024) - parser.add_argument("--cut-ratio", type=optional_float, default=1e-12) - parser.add_argument("--torch-threads", type=int, default=8) - parser.add_argument("--quimb-backend", choices=("numpy", "torch"), default="torch") - parser.add_argument("--dtype", choices=("complex128", "complex64"), default="complex128") - parser.add_argument("--pauli-pattern") - parser.add_argument("--observable-json") - parser.add_argument("--tn-target-slices", type=int) - parser.add_argument("--tn-target-size", type=int, default=2**32) - parser.add_argument("--tn-search-workers", type=int) - parser.add_argument("--tn-search-repeats", type=int, default=128) - parser.add_argument("--tn-search-time", type=float, default=60.0) - parser.add_argument("--tn-search-backend", choices=("processpool", "dask")) - parser.add_argument("--dask-address") - parser.add_argument("--dask-close-workers", action="store_true") - parser.add_argument("--tn-save-tree") - parser.add_argument("--tn-load-tree") - parser.add_argument("--tn-search-only", action="store_true") - parser.add_argument("--no-tn-stats", action="store_true") - args = parser.parse_args() - - rank = 0 - if args.mpi: - from mpi4py import MPI - - rank = MPI.COMM_WORLD.Get_rank() - - module = load_module(args.case_module) - if not hasattr(module, "build_circuit"): - raise ValueError("case_module must define build_circuit.") - - circuit = call_builder( - module.build_circuit, - nqubits=args.nqubits, - nlayers=args.nlayers, - seed=args.seed, - ) - observable = load_observable(args, module) - - config = ExpectationConfig( - ansatz="tn", - mpi=args.mpi, - bond=args.bond, - cut_ratio=args.cut_ratio, - tensor_module="torch", - quimb_backend=args.quimb_backend, - dtype=args.dtype, - torch_threads=args.torch_threads, - parallel_opts=build_parallel_opts(args), - ) - - if rank == 0: - mode = "MPI" if args.mpi else "serial" - print( - f"backend=cpu ansatz=TN mode={mode} case={Path(args.case_module).name} " - f"nqubits={args.nqubits} nlayers={args.nlayers} seed={args.seed} " - f"quimb_backend={args.quimb_backend} dtype={args.dtype} " - f"torch_threads={args.torch_threads}", - flush=True, - ) - print("observable exact value abs_error rel_error seconds", flush=True) - - exact = None - if args.exact and rank == 0: - if args.nqubits > args.exact_max_qubits: - raise ValueError( - f"--exact is limited to {args.exact_max_qubits} qubits by default." - ) - exact = exact_for_observable(circuit, observable, args.nqubits) - - result = run_cpu_expectation(circuit, observable, config) - if args.mpi and result.rank != 0: - return - - abs_error = float("nan") if exact is None else abs(result.value - exact) - rel_error = float("nan") if exact is None else abs_error / max(abs(exact), 1e-15) - exact_text = "nan" if exact is None else f"{exact:.16e}" - print( - f"custom {exact_text} {result.value:.16e} " - f"{abs_error:.6e} {rel_error:.6e} {result.seconds:.3f}", - flush=True, - ) - - for stat in result.parallel_stats or (): - cost = stat["path_cost"] - search_stats = stat.get("search_stats", {}) - print( - "tn_term_summary " - f"term={stat.get('term_index', 0)} " - f"search_seconds={stat.get('search_seconds', float('nan')):.3f} " - f"contract_seconds={stat.get('contract_seconds', float('nan')):.3f} " - f"completed_trials={search_stats.get('completed_trials', 'na')} " - f"finite_trials={search_stats.get('finite_trials', 'na')} " - f"failed_trials={search_stats.get('failed_trials', 'na')} " - f"requested_trials={search_stats.get('requested_trials', 'na')} " - f"best_score={search_stats.get('best_score', float('nan')):.6g} " - f"slices={cost.get('slices')} " - f"log10_flops={cost.get('log10_flops', float('nan')):.3f} " - f"log10_write={cost.get('log10_write', float('nan')):.3f} " - f"log2_size={cost.get('log2_size', float('nan')):.3f} " - f"peak_memory_gib={cost.get('peak_memory_gib', float('nan')):.3g} " - f"rank_slices={stat.get('rank_slices')}", - flush=True, - ) - - -if __name__ == "__main__": - main() diff --git a/tools/run_tn_dask_mpi_all.sh b/tools/run_tn_dask_mpi_all.sh deleted file mode 100755 index b4ba0d1..0000000 --- a/tools/run_tn_dask_mpi_all.sh +++ /dev/null @@ -1,260 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -CASE="${CASE:-main1}" -OBSERVABLES="${OBSERVABLES:-long_z_string}" -NQUBITS="${NQUBITS:-34}" -NLAYERS="${NLAYERS:-20}" -TORCH_THREADS="${TORCH_THREADS:-48}" -SEARCH_REPEATS="${SEARCH_REPEATS:-2048}" -SEARCH_TIME="${SEARCH_TIME:-300}" -TN_TARGET_SIZE="${TN_TARGET_SIZE:-17179869184}" -TN_TARGET_SLICES="${TN_TARGET_SLICES:-}" - -PYTHON_BIN="${PYTHON_BIN:-.venv/bin/python}" -DTYPE="${DTYPE:-complex64}" -TREE_DIR="${TREE_DIR:-trees/contest_tn}" -DASK_ADDRESS="${DASK_ADDRESS:-tcp://10.20.1.103:8786}" -DASK_EXPECTED_WORKERS="${DASK_EXPECTED_WORKERS:-}" -DASK_WAIT_FOR_WORKERS="${DASK_WAIT_FOR_WORKERS:-1}" -DASK_WAIT_TIMEOUT="${DASK_WAIT_TIMEOUT:-600}" -TN_DEBUG_TRIALS="${TN_DEBUG_TRIALS:-0}" -MPIEXEC="${MPIEXEC:-mpirun}" -MPIEXEC_FULL="${MPIEXEC_FULL:-}" -MPI_HOSTS="${MPI_HOSTS:-}" -MPI_HOSTFILE="${MPI_HOSTFILE:-${HOSTFILE:-}}" -MPI_RANKS="${MPI_RANKS:-}" -MPI_PE="${MPI_PE:-$TORCH_THREADS}" -MPI_MAP_BY="${MPI_MAP_BY:-ppr:1:numa:PE=$MPI_PE}" -MPI_BIND_TO="${MPI_BIND_TO:-core}" -MPI_REPORT_BINDINGS="${MPI_REPORT_BINDINGS:-0}" -MPI_EXPORT_ENV="${MPI_EXPORT_ENV:-1}" -TN_CONTRACT_ENV_CHECK="${TN_CONTRACT_ENV_CHECK:-1}" -SYNC_TREES="${SYNC_TREES:-1}" -SYNC_HOSTS="${SYNC_HOSTS:-${WORKER_HOSTS:-}}" -SSH_BIN="${SSH_BIN:-ssh}" -DASK_CLUSTER_MANAGED="${DASK_CLUSTER_MANAGED:-0}" - -export TCM_ENABLE="${TCM_ENABLE:-1}" -export OMP_NUM_THREADS="${OMP_NUM_THREADS:-$TORCH_THREADS}" -export MKL_NUM_THREADS="${MKL_NUM_THREADS:-$TORCH_THREADS}" -source "$ROOT_DIR/tools/qibotn_torch_mt_env.sh" - -tn_slice_args=(--tn-target-size "$TN_TARGET_SIZE") -if [[ -n "$TN_TARGET_SLICES" ]]; then - tn_slice_args+=(--tn-target-slices "$TN_TARGET_SLICES") -fi - -cleanup_dask_cluster() { - local status=$? - if [[ "$DASK_CLUSTER_MANAGED" == "1" ]]; then - set +e - tools/manage_tn_dask_cluster.sh stop >/dev/null 2>&1 || true - fi - exit "$status" -} - -trap cleanup_dask_cluster EXIT INT TERM HUP - -sum_host_slots() { - local hosts="$1" - local total=0 - local item slots - IFS=',' read -r -a host_items <<< "$hosts" - for item in "${host_items[@]}"; do - if [[ "$item" == *:* ]]; then - slots="${item##*:}" - else - slots=1 - fi - total=$((total + slots)) - done - echo "$total" -} - -count_hosts() { - local hosts="$1" - local count=0 - local item - IFS=' ' read -r -a host_items <<< "$hosts" - for item in "${host_items[@]}"; do - [[ -n "$item" ]] && count=$((count + 1)) - done - echo "$count" -} - -wait_for_dask_workers() { - [[ "$DASK_WAIT_FOR_WORKERS" == "1" ]] || return 0 - local expected="$DASK_EXPECTED_WORKERS" - if [[ -z "$expected" && -n "$WORKER_HOSTS" ]]; then - expected=$(( $(count_hosts "$WORKER_HOSTS") * NWORKERS )) - fi - if [[ -z "$expected" || "$expected" -le 0 ]]; then - return 0 - fi - - echo "Waiting for Dask workers: expected=$expected timeout=${DASK_WAIT_TIMEOUT}s" - "$PYTHON_BIN" - "$DASK_ADDRESS" "$expected" "$DASK_WAIT_TIMEOUT" <<'PY' -import sys -import time -from distributed import Client - -address, expected, timeout = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) -deadline = time.time() + timeout -client = Client(address) -try: - while True: - info = client.scheduler_info(n_workers=-1) - workers = info.get("workers", {}) - count = len(workers) - if count >= expected: - print(f"dask_workers_ready count={count} expected={expected}", flush=True) - break - if time.time() >= deadline: - print( - f"dask_workers_wait_timeout count={count} expected={expected}", - flush=True, - ) - break - time.sleep(2) -finally: - client.close() -PY -} - -append_mpi_env_args() { - [[ "$MPI_EXPORT_ENV" == "1" ]] || return 0 - mpi_prefix+=( - -x "LD_PRELOAD=${LD_PRELOAD:-}" - -x "BLIS_NUM_THREADS=$BLIS_NUM_THREADS" - -x "OMP_NUM_THREADS=$OMP_NUM_THREADS" - -x "MKL_NUM_THREADS=$MKL_NUM_THREADS" - -x "OMP_PROC_BIND=$OMP_PROC_BIND" - -x "OMP_PLACES=$OMP_PLACES" - ) -} - -build_mpi_prefix() { - if [[ -n "$MPIEXEC_FULL" ]]; then - # shellcheck disable=SC2206 - mpi_prefix=($MPIEXEC_FULL) - append_mpi_env_args - return - fi - - local ranks="$MPI_RANKS" - if [[ -z "$ranks" && -n "$MPI_HOSTS" ]]; then - ranks="$(sum_host_slots "$MPI_HOSTS")" - fi - if [[ -z "$ranks" ]]; then - ranks=2 - fi - - mpi_prefix=( - "$MPIEXEC" - --map-by "$MPI_MAP_BY" - --bind-to "$MPI_BIND_TO" - -np "$ranks" - ) - if [[ "$MPI_REPORT_BINDINGS" == "1" ]]; then - mpi_prefix+=(--report-bindings) - fi - append_mpi_env_args - if [[ -n "$MPI_HOSTS" ]]; then - mpi_prefix+=(-host "$MPI_HOSTS") - elif [[ -n "$MPI_HOSTFILE" ]]; then - mpi_prefix+=(-hostfile "$MPI_HOSTFILE") - fi -} - -is_local_host() { - local host="$1" - [[ "$host" == "localhost" || "$host" == "127.0.0.1" ]] && return 0 - [[ "$host" == "$(hostname)" ]] && return 0 - [[ "$host" == "$(hostname -f 2>/dev/null || true)" ]] && return 0 - hostname -I 2>/dev/null | tr ' ' '\n' | grep -qx "$host" -} - -sync_trees_to_hosts() { - [[ "$SYNC_TREES" == "1" ]] || return 0 - [[ -n "$SYNC_HOSTS" ]] || return 0 - - local src_dir="$TREE_DIR" - local dst_dir="$TREE_DIR" - if [[ "$TREE_DIR" != /* ]]; then - src_dir="$ROOT_DIR/$TREE_DIR" - dst_dir="$ROOT_DIR/$TREE_DIR" - fi - - for host in $SYNC_HOSTS; do - is_local_host "$host" && continue - echo "Sync tree dir to $host:$dst_dir" - "$SSH_BIN" "$host" "mkdir -p $(printf '%q' "$dst_dir")" - if command -v rsync >/dev/null 2>&1; then - rsync -a "$src_dir/" "$host:$dst_dir/" - else - scp -q "$src_dir"/*.pkl "$host:$dst_dir/" - fi - done -} - -tools/manage_tn_dask_cluster.sh start -DASK_CLUSTER_MANAGED=1 -wait_for_dask_workers - -echo "Search with dask: $DASK_ADDRESS" -search_args=( - --case "$CASE" - --nqubits "$NQUBITS" - --nlayers "$NLAYERS" - --observables $OBSERVABLES - --tree-dir "$TREE_DIR" - --dask-address "$DASK_ADDRESS" - --torch-threads "$TORCH_THREADS" - --dtype "$DTYPE" - --tn-search-repeats "$SEARCH_REPEATS" - --tn-search-time "$SEARCH_TIME" - "${tn_slice_args[@]}" -) -if [[ -n "$DASK_EXPECTED_WORKERS" ]]; then - search_args+=(--dask-expected-workers "$DASK_EXPECTED_WORKERS") -fi -if [[ "$TN_DEBUG_TRIALS" == "1" ]]; then - search_args+=(--tn-debug-trials) -fi -"$PYTHON_BIN" -u tools/tn_contest_runner.py search "${search_args[@]}" - -sync_trees_to_hosts - -build_mpi_prefix -echo "Contract with MPI: ${mpi_prefix[*]}" -if [[ "$TN_CONTRACT_ENV_CHECK" == "1" ]]; then - "${mpi_prefix[@]}" "$PYTHON_BIN" -c "from mpi4py import MPI; import os; \ -import torch; \ -rank = MPI.COMM_WORLD.Get_rank(); \ -blis = []; \ -[blis.append(line.strip().split()[-1]) for line in open('/proc/self/maps') if 'libblis' in line and line.strip().split()[-1] not in blis]; \ -print('tn_contract_env ' + \ - f'rank={rank} ' + \ - f'LD_PRELOAD={os.environ.get(\"LD_PRELOAD\", \"\")} ' + \ - f'BLIS_NUM_THREADS={os.environ.get(\"BLIS_NUM_THREADS\", \"\")} ' + \ - f'OMP_NUM_THREADS={os.environ.get(\"OMP_NUM_THREADS\", \"\")} ' + \ - f'MKL_NUM_THREADS={os.environ.get(\"MKL_NUM_THREADS\", \"\")} ' + \ - f'OMP_PROC_BIND={os.environ.get(\"OMP_PROC_BIND\", \"\")} ' + \ - f'OMP_PLACES={os.environ.get(\"OMP_PLACES\", \"\")} ' + \ - f'torch_threads={torch.get_num_threads()} ' + \ - f'blis={\";\".join(blis) if blis else \"missing\"}', flush=True)" -fi -"${mpi_prefix[@]}" "$PYTHON_BIN" -u tools/tn_contest_runner.py contract \ - --mpi \ - --case "$CASE" \ - --nqubits "$NQUBITS" \ - --nlayers "$NLAYERS" \ - --observables $OBSERVABLES \ - --tree-dir "$TREE_DIR" \ - --torch-threads "$TORCH_THREADS" \ - --dtype "$DTYPE" \ - "${tn_slice_args[@]}" diff --git a/tools/run_vidal_mpi_contest_cases.sh b/tools/run_vidal_mpi_contest_cases.sh deleted file mode 100755 index cee84a4..0000000 --- a/tools/run_vidal_mpi_contest_cases.sh +++ /dev/null @@ -1,414 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Contest-style Vidal/MPI MPS cases. -# -# Usage: -# tools/run_vidal_mpi_contest_cases.sh main1 -# tools/run_vidal_mpi_contest_cases.sh main2 -# tools/run_vidal_mpi_contest_cases.sh strong -# tools/run_vidal_mpi_contest_cases.sh all -# -# Common overrides: -# PYTHON_BIN=.venv/bin/python -# MPIEXEC=mpirun -# MPI_HOSTS="node-1:2,node-2:2,node-3:2,node-0:2" -# MPI_RANKS=8 -# MPI_PE=128 -# MPI_MAP_BY=ppr:1:numa:PE=128 -# MPI_BIND_TO=core -# MPIEXEC_FULL="mpirun --map-by ppr:1:numa:PE=128 --bind-to core -np 8 -host node-1:2,node-2:2,node-3:2,node-0:2" -# HOSTFILE=hostfile # optional; used only if the file exists -# RANKS=8 # fallback if MPI_RANKS is not set -# TORCH_THREADS=8 -# CUT_RATIO=1e-12 -# OBS_FILTER="boundary_ZZ_q2 ring_xz dense3_spread complex_iZ0" -# -# Per-case overrides: -# MAIN1_NQ=128 MAIN1_LAYERS=50 MAIN1_BOND=1024 MAIN1_SEED=31001 -# MAIN2_NQ=128 MAIN2_LAYERS=64 MAIN2_BOND=2048 MAIN2_SEED=31002 -# STRONG_NQ=256 STRONG_LAYERS=64 STRONG_BOND=2048 STRONG_SEED=41001 - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -PYTHON_BIN="${PYTHON_BIN:-.venv/bin/python}" -MPIEXEC="${MPIEXEC:-mpirun}" -MPIEXEC_FULL="${MPIEXEC_FULL:-}" -MPI_HOSTS="${MPI_HOSTS:-}" -MPI_HOSTFILE="${MPI_HOSTFILE:-${HOSTFILE:-}}" -MPI_RANKS="${MPI_RANKS:-${RANKS:-}}" -RANKS="${RANKS:-4}" -TORCH_THREADS="${TORCH_THREADS:-1}" -MPI_PE="${MPI_PE:-$TORCH_THREADS}" -MPI_MAP_BY="${MPI_MAP_BY:-ppr:1:numa:PE=$MPI_PE}" -MPI_BIND_TO="${MPI_BIND_TO:-core}" -MPI_REPORT_BINDINGS="${MPI_REPORT_BINDINGS:-0}" -MPI_EXPORT_ENV="${MPI_EXPORT_ENV:-1}" -CUT_RATIO="${CUT_RATIO:-1e-12}" -OBS_FILTER="${OBS_FILTER:-}" -export OMP_NUM_THREADS="${OMP_NUM_THREADS:-$TORCH_THREADS}" -export MKL_NUM_THREADS="${MKL_NUM_THREADS:-$TORCH_THREADS}" -source "$ROOT_DIR/tools/qibotn_torch_mt_env.sh" - -RUNNER_DIR="$ROOT_DIR/.tmp" -mkdir -p "$RUNNER_DIR" -RUNNER="$(mktemp "$RUNNER_DIR/qibotn_vidal_contest.XXXXXX.py")" -cleanup() { - rm -f "$RUNNER" -} -trap cleanup EXIT - -cat > "$RUNNER" <<'PY' -from __future__ import annotations - -import argparse -import math -import time - -import numpy as np -from mpi4py import MPI -from qibo import Circuit, gates, hamiltonians -from qibo.symbols import X, Y, Z - -from qibotn.backends.vidal import VidalBackend - - -def set_torch_threads(nthreads): - try: - import torch - - torch.set_num_threads(nthreads) - except Exception: - pass - - -def build_circuit(kind, nqubits, nlayers, seed): - rng = np.random.default_rng(seed) - circuit = Circuit(nqubits) - - for layer in range(nlayers): - for q in range(nqubits): - circuit.add(gates.RY(q, theta=rng.uniform(-math.pi, math.pi))) - circuit.add(gates.RZ(q, theta=rng.uniform(-math.pi, math.pi))) - if kind in ("rxx_rzz", "scramble"): - circuit.add(gates.RX(q, theta=rng.uniform(-math.pi, math.pi))) - - if kind == "reversed_cnot": - for q in range(0, nqubits - 1, 2): - circuit.add(gates.CNOT(q + 1, q) if layer % 2 else gates.CNOT(q, q + 1)) - for q in range(1, nqubits - 1, 2): - circuit.add(gates.CNOT(q + 1, q) if layer % 2 == 0 else gates.CNOT(q, q + 1)) - elif kind == "rxx_rzz": - for q in range(layer % 2, nqubits - 1, 2): - circuit.add(gates.RXX(q, q + 1, theta=rng.uniform(-0.9, 0.9))) - circuit.add(gates.RZZ(q, q + 1, theta=rng.uniform(-0.9, 0.9))) - elif kind == "scramble": - for q in range(layer % 2, nqubits - 1, 2): - circuit.add(gates.RXX(q, q + 1, theta=rng.uniform(-0.8, 0.8))) - circuit.add(gates.RZZ(q, q + 1, theta=rng.uniform(-0.8, 0.8))) - if layer % 5 == 4: - circuit.add(gates.SWAP(q, q + 1)) - else: - raise ValueError(f"Unknown circuit kind {kind!r}.") - - return circuit - - -def ring_xz(nqubits): - form = 0 - for q in range(nqubits): - form += 0.5 * X(q) * Z((q + 1) % nqubits) - return hamiltonians.SymbolicHamiltonian(form=form) - - -def open_zz(nqubits): - form = 0 - for q in range(nqubits - 1): - form += (1.0 / (nqubits - 1)) * Z(q) * Z(q + 1) - return hamiltonians.SymbolicHamiltonian(form=form) - - -def range2_xx(nqubits): - form = 0 - for q in range(nqubits - 2): - form += (1.0 / (nqubits - 2)) * X(q) * X(q + 2) - return hamiltonians.SymbolicHamiltonian(form=form) - - -def dense_observable(nqubits, qubits, seed, dim): - rng = np.random.default_rng(seed) - raw = rng.normal(size=(dim, dim)) + 1j * rng.normal(size=(dim, dim)) - matrix = (raw + raw.conj().T) / 2.0 - matrix = matrix / np.linalg.norm(matrix) - return {"matrix": matrix, "qubits": list(qubits)} - - -def observables_for_case(nqubits, seed): - q1 = nqubits // 4 - q2 = nqubits // 2 - q3 = (3 * nqubits) // 4 - last = nqubits - 1 - - return [ - ("boundary_ZZ_q1", hamiltonians.SymbolicHamiltonian(form=Z(q1 - 1) * Z(q1))), - ("boundary_ZZ_q2", hamiltonians.SymbolicHamiltonian(form=Z(q2 - 1) * Z(q2))), - ("boundary_ZZ_q3", hamiltonians.SymbolicHamiltonian(form=Z(q3 - 1) * Z(q3))), - ( - "long_Z_5_sites", - hamiltonians.SymbolicHamiltonian(form=Z(0) * Z(q1) * Z(q2) * Z(q3) * Z(last)), - ), - ( - "mixed_XZYZX", - hamiltonians.SymbolicHamiltonian(form=X(0) * Z(q1) * Y(q2) * Z(q3) * X(last)), - ), - ("ring_xz", ring_xz(nqubits)), - ("open_zz", open_zz(nqubits)), - ("range2_xx", range2_xx(nqubits)), - ("complex_iZ0", hamiltonians.SymbolicHamiltonian(form=1.0j * Z(0))), - ("dense2_mid", dense_observable(nqubits, (q2 - 1, q2), seed + 101, 4)), - ("dense3_spread", dense_observable(nqubits, (q1, q2, q3), seed + 202, 8)), - ] - - -def run_case(args): - set_torch_threads(args.torch_threads) - comm = MPI.COMM_WORLD - rank = comm.Get_rank() - size = comm.Get_size() - - circuit = build_circuit(args.kind, args.nqubits, args.nlayers, args.seed) - observables = observables_for_case(args.nqubits, args.seed) - if args.obs_filter: - wanted = set(args.obs_filter.split(",")) - observables = [(name, obs) for name, obs in observables if name in wanted] - if not observables: - raise ValueError(f"OBS_FILTER matched no observables: {args.obs_filter!r}") - - if rank == 0: - print("=" * 88, flush=True) - print( - "case " - f"label={args.label} kind={args.kind} ranks={size} " - f"nqubits={args.nqubits} nlayers={args.nlayers} gates={len(circuit.queue)} " - f"bond={args.bond} cut_ratio={args.cut_ratio:g} " - f"torch_threads={args.torch_threads} seed={args.seed} " - f"obs_filter={args.obs_filter or 'all'}", - flush=True, - ) - print( - "observable value seconds trunc_sum trunc_max status", - flush=True, - ) - - for obs_name, observable in observables: - backend = VidalBackend() - backend.configure_tn_simulation( - max_bond_dimension=args.bond, - cut_ratio=args.cut_ratio, - tensor_module="torch", - mpi_approach="CT", - mpi_num_procs=size, - fallback=False, - ) - - comm.Barrier() - start = time.perf_counter() - try: - value = backend.expectation( - circuit, - observable, - preprocess=True, - compile_circuit=False, - ) - status = "ok" - except Exception as exc: # pragma: no cover - printed for manual runs - value = np.nan - status = type(exc).__name__ + ":" + str(exc).split("\n", 1)[0] - seconds = time.perf_counter() - start - - if rank == 0: - print( - f"{obs_name} {value!r} {seconds:.3f} " - f"{backend.last_truncation_error:.6e} " - f"{backend.last_max_truncation_error:.6e} {status}", - flush=True, - ) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--label", required=True) - parser.add_argument("--kind", choices=("reversed_cnot", "rxx_rzz", "scramble"), required=True) - parser.add_argument("--nqubits", type=int, required=True) - parser.add_argument("--nlayers", type=int, required=True) - parser.add_argument("--bond", type=int, required=True) - parser.add_argument("--cut-ratio", type=float, required=True) - parser.add_argument("--seed", type=int, required=True) - parser.add_argument("--torch-threads", type=int, required=True) - parser.add_argument("--obs-filter", default="") - run_case(parser.parse_args()) - - -if __name__ == "__main__": - main() -PY - -sum_host_slots() { - local hosts="$1" - local total=0 - local item slots - IFS=',' read -r -a host_items <<< "$hosts" - for item in "${host_items[@]}"; do - if [[ "$item" == *:* ]]; then - slots="${item##*:}" - else - slots=1 - fi - total=$((total + slots)) - done - echo "$total" -} - -append_mpi_env_args() { - [[ "$MPI_EXPORT_ENV" == "1" ]] || return 0 - mpi_prefix+=( - -x "LD_PRELOAD=${LD_PRELOAD:-}" - -x "BLIS_NUM_THREADS=$BLIS_NUM_THREADS" - -x "OMP_NUM_THREADS=$OMP_NUM_THREADS" - -x "MKL_NUM_THREADS=$MKL_NUM_THREADS" - -x "OMP_PROC_BIND=$OMP_PROC_BIND" - -x "OMP_PLACES=$OMP_PLACES" - ) -} - -build_mpi_prefix() { - if [[ -n "$MPIEXEC_FULL" ]]; then - # shellcheck disable=SC2206 - mpi_prefix=($MPIEXEC_FULL) - append_mpi_env_args - return - fi - - local ranks="$MPI_RANKS" - if [[ -z "$ranks" && -n "$MPI_HOSTS" ]]; then - ranks="$(sum_host_slots "$MPI_HOSTS")" - fi - if [[ -z "$ranks" ]]; then - ranks="$RANKS" - fi - - mpi_prefix=( - "$MPIEXEC" - --map-by "$MPI_MAP_BY" - --bind-to "$MPI_BIND_TO" - -np "$ranks" - ) - if [[ "$MPI_REPORT_BINDINGS" == "1" ]]; then - mpi_prefix+=(--report-bindings) - fi - append_mpi_env_args - if [[ -n "$MPI_HOSTS" ]]; then - mpi_prefix+=(-host "$MPI_HOSTS") - elif [[ -n "$MPI_HOSTFILE" ]]; then - mpi_prefix+=(-hostfile "$MPI_HOSTFILE") - fi -} - -build_mpi_prefix - -run_case() { - local label="$1" - local kind="$2" - local nq="$3" - local layers="$4" - local bond="$5" - local seed="$6" - - echo - echo "Running $label: kind=$kind nqubits=$nq layers=$layers bond=$bond seed=$seed" - echo "MPI: ${mpi_prefix[*]}" - "${mpi_prefix[@]}" "$PYTHON_BIN" -u "$ROOT_DIR/tools/vidal_mpi_contest_runner.py" \ - --label "$label" \ - --kind "$kind" \ - --nqubits "$nq" \ - --nlayers "$layers" \ - --bond "$bond" \ - --cut-ratio "$CUT_RATIO" \ - --seed "$seed" \ - --torch-threads "$TORCH_THREADS" \ - --obs-filter "$(tr ' ' ',' <<< "$OBS_FILTER")" -} - -case "${1:-help}" in - main1) - run_case \ - "main1-reversed-cnot" \ - "reversed_cnot" \ - "${MAIN1_NQ:-128}" \ - "${MAIN1_LAYERS:-50}" \ - "${MAIN1_BOND:-1024}" \ - "${MAIN1_SEED:-31001}" - ;; - main2) - run_case \ - "main2-rxx-rzz" \ - "rxx_rzz" \ - "${MAIN2_NQ:-128}" \ - "${MAIN2_LAYERS:-64}" \ - "${MAIN2_BOND:-2048}" \ - "${MAIN2_SEED:-31002}" - ;; - strong) - run_case \ - "strong-scramble" \ - "scramble" \ - "${STRONG_NQ:-256}" \ - "${STRONG_LAYERS:-64}" \ - "${STRONG_BOND:-2048}" \ - "${STRONG_SEED:-41001}" - ;; - all) - "$0" main1 - "$0" main2 - "$0" strong - ;; - smoke) - MAIN1_NQ="${MAIN1_NQ:-32}" \ - MAIN1_LAYERS="${MAIN1_LAYERS:-6}" \ - MAIN1_BOND="${MAIN1_BOND:-128}" \ - "$0" main1 - ;; - help|*) - cat >&2 <<'EOF' -Usage: tools/run_vidal_mpi_contest_cases.sh [main1|main2|strong|all|smoke] - -Cases: - main1 128 qubits, 50 layers, reversed-CNOT brickwall, chi=1024 - main2 128 qubits, 64 layers, RXX/RZZ brickwall, chi=2048 - strong 256 qubits, 64 layers, RXX/RZZ + periodic SWAP scramble, chi=2048 - smoke Small syntax/runtime check of main1 - -Common overrides: - PYTHON_BIN=.venv/bin/python - MPIEXEC=mpiexec - MPI_HOSTS="node-1:2,node-2:2,node-3:2,node-0:2" - MPI_RANKS=8 - MPI_PE=128 - MPI_MAP_BY=ppr:1:numa:PE=128 - MPI_BIND_TO=core - MPIEXEC_FULL="mpirun --map-by ppr:1:numa:PE=128 --bind-to core -np 8 -host node-1:2,node-2:2,node-3:2,node-0:2" - HOSTFILE=hostfile - RANKS=8 - TORCH_THREADS=8 - CUT_RATIO=1e-12 - OBS_FILTER="boundary_ZZ_q2 ring_xz dense3_spread complex_iZ0" - -Per-case overrides: - MAIN1_NQ=128 MAIN1_LAYERS=50 MAIN1_BOND=1024 MAIN1_SEED=31001 - MAIN2_NQ=128 MAIN2_LAYERS=64 MAIN2_BOND=2048 MAIN2_SEED=31002 - STRONG_NQ=256 STRONG_LAYERS=64 STRONG_BOND=2048 STRONG_SEED=41001 -EOF - exit 2 - ;; -esac diff --git a/tools/run_vidal_segment_mpi_scan.sh b/tools/run_vidal_segment_mpi_scan.sh deleted file mode 100755 index 49dc138..0000000 --- a/tools/run_vidal_segment_mpi_scan.sh +++ /dev/null @@ -1,70 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -NQ="${NQ:-34}" -LAYERS="${LAYERS:-20}" -BOND="${BOND:-512}" -SEED="${SEED:-42}" -RANKS="${RANKS:-1 2 4}" -THREADS="${THREADS:-32 32 16}" -PYTHON_BIN="${PYTHON_BIN:-.venv/bin/python}" -MPIEXEC="${MPIEXEC:-mpiexec}" -CIRCUIT="${CIRCUIT:-brickwall_cnot}" -OBSERVABLE="${OBSERVABLE:-ring_xz}" -EXACT="${EXACT:-0}" - -ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -cd "$ROOT_DIR" - -if [[ "${1:-help}" != "run" ]]; then - cat >&2 <<'EOF' -Usage: tools/run_vidal_segment_mpi_scan.sh run - -Overrides: - NQ=34 LAYERS=20 BOND=512 SEED=42 - RANKS="1 2 4" THREADS="32 32 16" - CIRCUIT=brickwall_cnot OBSERVABLE=ring_xz - EXACT=1 - PYTHON_BIN=.venv/bin/python MPIEXEC=mpiexec -EOF - if [[ "${1:-help}" == "help" ]]; then - exit 0 - fi - exit 2 -fi - -read -r -a ranks <<< "$RANKS" -read -r -a threads <<< "$THREADS" - -if [[ "${#ranks[@]}" != "${#threads[@]}" ]]; then - echo "RANKS and THREADS must have the same number of entries." >&2 - exit 2 -fi - -common=( - --nqubits "$NQ" - --nlayers "$LAYERS" - --bond "$BOND" - --seed "$SEED" - --mps - --circuits "$CIRCUIT" - --observables "$OBSERVABLE" -) - -if [[ "$EXACT" == "1" ]]; then - common+=(--exact) -fi - -for idx in "${!ranks[@]}"; do - nrank="${ranks[$idx]}" - nthr="${threads[$idx]}" - if [[ "$nrank" == "1" ]]; then - echo "== Vidal serial ranks=1 torch_threads=$nthr ==" - "$PYTHON_BIN" -u benchmark_cpu_expectation.py \ - "${common[@]}" --torch-threads "$nthr" - else - echo "== Vidal segmented MPI ranks=$nrank torch_threads=$nthr ==" - "$MPIEXEC" -n "$nrank" "$PYTHON_BIN" -u benchmark_cpu_expectation.py \ - "${common[@]}" --torch-threads "$nthr" --mpi - fi -done diff --git a/tools/slice_existing_tree.py b/tools/slice_existing_tree.py deleted file mode 100644 index 4e94e9c..0000000 --- a/tools/slice_existing_tree.py +++ /dev/null @@ -1,59 +0,0 @@ -"""Slice an existing saved cotengra tree without re-running path search.""" - -from __future__ import annotations - -import argparse -import pickle -from pathlib import Path - -from qibotn.parallel import contraction_tree_costs - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("input", help="Input pickle saved by --tn-save-tree.") - parser.add_argument("output", help="Output pickle path.") - parser.add_argument("--term", type=int, default=0) - parser.add_argument("--target-slices", type=int, default=2) - parser.add_argument("--max-repeats", type=int, default=64) - parser.add_argument("--seed", type=int, default=42) - args = parser.parse_args() - - input_path = Path(args.input) - output_path = Path(args.output) - with input_path.open("rb") as f: - payload = pickle.load(f) - - trees = payload["trees"] if isinstance(payload, dict) else payload - if not isinstance(trees, (list, tuple)): - trees = [trees] - tree = trees[args.term] - - print("original", contraction_tree_costs(tree), flush=True) - sliced = tree.slice( - target_slices=args.target_slices, - max_repeats=args.max_repeats, - seed=args.seed, - ) - print("sliced", contraction_tree_costs(sliced), flush=True) - print(f"sliced_inds={sliced.sliced_inds}", flush=True) - - new_trees = list(trees) - new_trees[args.term] = sliced - - if isinstance(payload, dict): - out_payload = dict(payload) - out_payload["trees"] = new_trees - out_payload["costs"] = [contraction_tree_costs(t) for t in new_trees] - out_payload["nterms"] = len(new_trees) - else: - out_payload = new_trees - - output_path.parent.mkdir(parents=True, exist_ok=True) - with output_path.open("wb") as f: - pickle.dump(out_payload, f) - print(f"saved {output_path}", flush=True) - - -if __name__ == "__main__": - main() diff --git a/tools/tn_contest_runner.py b/tools/tn_contest_runner.py deleted file mode 100644 index 06ff913..0000000 --- a/tools/tn_contest_runner.py +++ /dev/null @@ -1,443 +0,0 @@ -#!/usr/bin/env python -"""Contest-style CPU TN path search and contraction runner. - -This file is intentionally self-contained: define contest circuits and -observables here, run path search once, then load the saved trees for repeated -MPI contractions. -""" - -from __future__ import annotations - -import argparse -import math -import os -import subprocess -import sys -from dataclasses import dataclass -from pathlib import Path -from urllib.parse import urlparse - -import numpy as np -from qibo import Circuit, gates, hamiltonians -from qibo.symbols import X, Y, Z - -ROOT = Path(__file__).resolve().parents[1] -SRC = ROOT / "src" -if str(SRC) not in sys.path: - sys.path.insert(0, str(SRC)) - -from qibotn.expectation_runner import ( # noqa: E402 - ExpectationConfig, - exact_for_observable, - run_cpu_expectation, -) - - -@dataclass(frozen=True) -class CaseSpec: - circuit_kind: str - observables: tuple[str, ...] - nqubits: int - nlayers: int - seed: int - target_slices: int | None = None - - -CASES = { - "main1": CaseSpec( - circuit_kind="rxx_rzz_chain", - observables=("ring_xz",), - nqubits=37, - nlayers=20, - seed=31001, - target_slices=None, - ), - "main2": CaseSpec( - circuit_kind="scramble_chain", - observables=("open_zz", "range2_xx"), - nqubits=36, - nlayers=18, - seed=31002, - target_slices=None, - ), - "strong": CaseSpec( - circuit_kind="reversed_cnot", - observables=("ring_xz", "long_z_string"), - nqubits=40, - nlayers=24, - seed=41001, - target_slices=None, - ), -} - - -def optional_int(text): - if isinstance(text, str) and text.lower() in {"none", "null", "inf", "unlimited"}: - return None - return int(text) - - -def optional_float(text): - if isinstance(text, str) and text.lower() in {"none", "null", "inf", "unlimited"}: - return None - return float(text) - - -def set_torch_threads(nthreads): - try: - import torch - - torch.set_num_threads(nthreads) - except Exception: - pass - - -def add_single_qubit_layer(circuit, nqubits, rng, include_rx=False): - for qubit in range(nqubits): - circuit.add(gates.RY(qubit, theta=rng.uniform(-math.pi, math.pi))) - circuit.add(gates.RZ(qubit, theta=rng.uniform(-math.pi, math.pi))) - if include_rx: - circuit.add(gates.RX(qubit, theta=rng.uniform(-math.pi, math.pi))) - - -def build_circuit(kind, nqubits, nlayers, seed): - """Define contest circuits here.""" - rng = np.random.default_rng(seed) - circuit = Circuit(nqubits) - - for layer in range(nlayers): - if kind == "rxx_rzz_chain": - add_single_qubit_layer(circuit, nqubits, rng, include_rx=True) - for qubit in range(layer % 2, nqubits - 1, 2): - circuit.add(gates.RXX(qubit, qubit + 1, theta=rng.uniform(-0.9, 0.9))) - circuit.add(gates.RZZ(qubit, qubit + 1, theta=rng.uniform(-0.9, 0.9))) - - elif kind == "scramble_chain": - add_single_qubit_layer(circuit, nqubits, rng, include_rx=True) - for qubit in range(layer % 2, nqubits - 1, 2): - circuit.add(gates.RXX(qubit, qubit + 1, theta=rng.uniform(-0.8, 0.8))) - circuit.add(gates.RZZ(qubit, qubit + 1, theta=rng.uniform(-0.8, 0.8))) - if layer % 5 == 4: - circuit.add(gates.SWAP(qubit, qubit + 1)) - - elif kind == "reversed_cnot": - add_single_qubit_layer(circuit, nqubits, rng) - for qubit in range(0, nqubits - 1, 2): - gate = gates.CNOT(qubit + 1, qubit) if layer % 2 else gates.CNOT(qubit, qubit + 1) - circuit.add(gate) - for qubit in range(1, nqubits - 1, 2): - gate = gates.CNOT(qubit + 1, qubit) if layer % 2 == 0 else gates.CNOT(qubit, qubit + 1) - circuit.add(gate) - - else: - raise ValueError(f"Unknown circuit kind {kind!r}.") - - return circuit - - -def pauli_sum_observable(kind, nqubits, seed): - """Define contest observables here. - - TN path currently expects Pauli products / SymbolicHamiltonian terms. - Keep production contest observables Hermitian unless complex output is - explicitly required by the scoring rule. - """ - del seed - if kind == "ring_xz": - form = 0 - for qubit in range(nqubits): - form += 0.5 * X(qubit) * Z((qubit + 1) % nqubits) - return hamiltonians.SymbolicHamiltonian(form=form) - - if kind == "open_zz": - form = 0 - for qubit in range(nqubits - 1): - form += (1.0 / max(1, nqubits - 1)) * Z(qubit) * Z(qubit + 1) - return hamiltonians.SymbolicHamiltonian(form=form) - - if kind == "range2_xx": - form = 0 - for qubit in range(nqubits - 2): - form += (1.0 / max(1, nqubits - 2)) * X(qubit) * X(qubit + 2) - return hamiltonians.SymbolicHamiltonian(form=form) - - if kind == "long_z_string": - stride = max(1, nqubits // 16) - form = None - for qubit in range(0, nqubits, stride): - form = Z(qubit) if form is None else form * Z(qubit) - return hamiltonians.SymbolicHamiltonian(form=form) - - if kind == "mixed_local": - q1 = nqubits // 4 - q2 = nqubits // 2 - q3 = (3 * nqubits) // 4 - form = 0.25 * X(0) - 0.5 * Z(nqubits - 1) - form += 0.125 * X(q1) * Z(q2) * Y(q3) - return hamiltonians.SymbolicHamiltonian(form=form) - - raise ValueError(f"Unknown observable kind {kind!r}.") - - -def tree_path(tree_dir, case_name, obs_name, nqubits, nlayers, target_slices): - slice_label = "auto" if target_slices is None else f"s{target_slices}" - return ( - Path(tree_dir) - / f"{case_name}_{obs_name}_{nqubits}q{nlayers}l_{slice_label}.pkl" - ) - - -def build_parallel_opts(args, tree_file=None, search_only=False): - slicing_opts = {} - if args.tn_target_slices is not None: - slicing_opts["target_slices"] = args.tn_target_slices - if args.tn_target_size is not None: - slicing_opts["target_size"] = args.tn_target_size - - opts = { - "slicing_opts": slicing_opts or None, - "search_workers": args.tn_search_workers or args.torch_threads, - "max_repeats": args.tn_search_repeats, - "max_time": args.tn_search_time, - "print_stats": False, - } - if args.tn_search_backend is not None: - opts["search_backend"] = args.tn_search_backend - if args.dask_address is not None: - opts["dask_address"] = args.dask_address - if args.dask_expected_workers is not None: - opts["dask_expected_workers"] = args.dask_expected_workers - if args.dask_close_workers: - opts["dask_close_workers"] = True - if args.tn_debug_trials: - opts["debug_trials"] = True - if search_only: - opts["search_only"] = True - opts["save_tree_path"] = str(tree_file) - elif tree_file is not None: - opts["load_tree_path"] = str(tree_file) - return opts - - -def run_one(args, case_name, obs_name, mode): - case = CASES[case_name] - circuit = build_circuit(case.circuit_kind, args.nqubits, args.nlayers, args.seed) - observable = pauli_sum_observable(obs_name, args.nqubits, args.seed) - path = tree_path( - args.tree_dir, - case_name, - obs_name, - args.nqubits, - args.nlayers, - args.tn_target_slices, - ) - path.parent.mkdir(parents=True, exist_ok=True) - - rank = 0 - if args.mpi: - from mpi4py import MPI - - rank = MPI.COMM_WORLD.Get_rank() - - if rank == 0: - print("=" * 88, flush=True) - print( - f"mode={mode} case={case_name} circuit={case.circuit_kind} " - f"observable={obs_name} nqubits={args.nqubits} nlayers={args.nlayers} " - f"seed={args.seed} gates={len(circuit.queue)} tree={path}", - flush=True, - ) - - if mode == "contract" and not path.exists(): - raise FileNotFoundError(f"Missing tree file: {path}. Run search first.") - - exact = None - if args.exact and rank == 0 and mode != "search": - if args.nqubits > args.exact_max_qubits: - raise ValueError( - f"--exact is limited to {args.exact_max_qubits} qubits by default." - ) - exact = exact_for_observable(circuit, observable, args.nqubits) - - config = ExpectationConfig( - ansatz="tn", - mpi=args.mpi, - bond=args.bond, - cut_ratio=args.cut_ratio, - tensor_module="torch", - quimb_backend=args.quimb_backend, - dtype=args.dtype, - torch_threads=args.torch_threads, - parallel_opts=build_parallel_opts( - args, - tree_file=path, - search_only=(mode == "search"), - ), - ) - result = run_cpu_expectation(circuit, observable, config) - if args.mpi and result.rank != 0: - return - - if mode == "search": - print(f"searched observable={obs_name} tree={path}", flush=True) - else: - abs_error = float("nan") if exact is None else abs(result.value - exact) - rel_error = float("nan") if exact is None else abs_error / max(abs(exact), 1e-15) - exact_text = "nan" if exact is None else f"{exact:.16e}" - print( - f"result observable={obs_name} exact={exact_text} " - f"value={result.value:.16e} abs_error={abs_error:.6e} " - f"rel_error={rel_error:.6e} seconds={result.seconds:.3f}", - flush=True, - ) - - for stat in result.parallel_stats or (): - cost = stat["path_cost"] - search_stats = stat.get("search_stats", {}) - print( - "tn_term_summary " - f"observable={obs_name} " - f"term={stat.get('term_index', 0)} " - f"search_seconds={stat.get('search_seconds', float('nan')):.3f} " - f"contract_seconds={stat.get('contract_seconds', float('nan')):.3f} " - f"completed_trials={search_stats.get('completed_trials', 'na')} " - f"finite_trials={search_stats.get('finite_trials', 'na')} " - f"failed_trials={search_stats.get('failed_trials', 'na')} " - f"requested_trials={search_stats.get('requested_trials', 'na')} " - f"best_score={search_stats.get('best_score', float('nan')):.6g} " - f"slices={cost.get('nslices')} " - f"log10_flops={cost.get('log10_flops', float('nan')):.3f} " - f"log10_write={cost.get('log10_write', float('nan')):.3f} " - f"log2_size={cost.get('log2_size', float('nan')):.3f} " - f"peak_memory_gib={cost.get('peak_memory_gib', float('nan')):.3g} " - f"rank_slices={stat.get('rank_slices')}", - flush=True, - ) - - -def selected_observables(args, case): - if args.observables: - return tuple(args.observables) - if args.obs_filter: - return tuple(x.strip() for x in args.obs_filter.split(",") if x.strip()) - return case.observables - - -def apply_case_defaults(args): - case = CASES[args.case] - if args.nqubits is None: - args.nqubits = case.nqubits - if args.nlayers is None: - args.nlayers = case.nlayers - if args.seed is None: - args.seed = case.seed - if args.tn_target_slices is None: - args.tn_target_slices = case.target_slices - args.observables = selected_observables(args, case) - - -def stop_dask_cluster(args): - if args.keep_dask or args.tn_search_backend != "dask" or not args.dask_address: - return - if args.mpi: - from mpi4py import MPI - - if MPI.COMM_WORLD.Get_rank() != 0: - return - script = ROOT / "tools" / "manage_tn_dask_cluster.sh" - if not script.exists(): - print(f"dask_stop_skipped reason=missing_script path={script}", flush=True) - return - - env = os.environ.copy() - parsed = urlparse(args.dask_address) - if parsed.hostname: - env.setdefault("SCHEDULER_HOST", parsed.hostname) - if parsed.port: - env.setdefault("SCHEDULER_PORT", str(parsed.port)) - - print("dask_stop_after_search start", flush=True) - subprocess.run([str(script), "stop"], cwd=str(ROOT), env=env, check=False) - print("dask_stop_after_search done", flush=True) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("mode", choices=("search", "contract", "all", "validate", "list")) - parser.add_argument("--case", choices=sorted(CASES), default="main1") - parser.add_argument("--observables", nargs="+") - parser.add_argument("--obs-filter", default="") - parser.add_argument("--tree-dir", default="trees/contest_tn") - parser.add_argument("--nqubits", type=int) - parser.add_argument("--nlayers", type=int) - parser.add_argument("--seed", type=int) - parser.add_argument("--mpi", action="store_true") - parser.add_argument("--exact", action="store_true") - parser.add_argument("--exact-max-qubits", type=int, default=24) - parser.add_argument("--bond", "--bonds", dest="bond", type=optional_int, default=1024) - parser.add_argument("--cut-ratio", type=optional_float, default=1e-12) - parser.add_argument("--torch-threads", type=int, default=8) - parser.add_argument("--quimb-backend", choices=("numpy", "torch"), default="torch") - parser.add_argument("--dtype", choices=("complex128", "complex64"), default="complex64") - parser.add_argument("--tn-target-slices", type=int) - parser.add_argument("--tn-target-size", type=int, default=2**34) - parser.add_argument("--tn-search-workers", type=int) - parser.add_argument("--tn-search-repeats", type=int, default=2048) - parser.add_argument("--tn-search-time", type=float, default=300.0) - parser.add_argument( - "--tn-search-backend", - choices=("processpool", "dask"), - default="dask", - help=( - "Path-search backend. Defaults to dask. Without --dask-address, " - "non-MPI search starts a local dask cluster." - ), - ) - parser.add_argument("--dask-address") - parser.add_argument("--dask-expected-workers", type=int) - parser.add_argument("--dask-close-workers", action="store_true") - parser.add_argument( - "--keep-dask", - action="store_true", - help=( - "Keep an external dask cluster running after search. By default, " - "tools/manage_tn_dask_cluster.sh stop is called after search when " - "--dask-address is used." - ), - ) - parser.add_argument( - "--tn-debug-trials", - action="store_true", - help="Print dask worker summary and per-trial start/done logs.", - ) - parser.add_argument("--no-tn-stats", action="store_true") - args = parser.parse_args() - - if args.mode == "list": - for name, case in CASES.items(): - print( - f"{name}: circuit={case.circuit_kind} " - f"observables={','.join(case.observables)} " - f"nqubits={case.nqubits} nlayers={case.nlayers} " - f"seed={case.seed} target_slices={case.target_slices}" - ) - return - - apply_case_defaults(args) - set_torch_threads(args.torch_threads) - - modes = ("search", "contract") if args.mode == "all" else (args.mode,) - if args.mode == "validate": - args.exact = True - args.nqubits = min(args.nqubits, args.exact_max_qubits) - modes = ("search", "contract") - - for mode in modes: - for obs_name in args.observables: - run_one(args, args.case, obs_name, mode) - if mode == "search": - stop_dask_cluster(args) - - -if __name__ == "__main__": - main() diff --git a/tools/torch_profile_tn_complex64.py b/tools/torch_profile_tn_complex64.py deleted file mode 100644 index b7392f9..0000000 --- a/tools/torch_profile_tn_complex64.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Run the 34q/20L TN complex64 benchmark under torch.profiler briefly.""" - -from __future__ import annotations - -import argparse -import os -import signal -import sys -from pathlib import Path - -from mpi4py import MPI - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--seconds", type=float, default=30.0) - parser.add_argument("--out-dir", default="torch_profiles/tn_complex64") - parser.add_argument("--torch-threads", type=int, default=48) - args = parser.parse_args() - - repo_root = Path(__file__).resolve().parents[1] - os.chdir(repo_root) - sys.path.insert(0, str(repo_root)) - - import torch - from torch.profiler import ProfilerActivity, profile - - comm = MPI.COMM_WORLD - rank = comm.Get_rank() - size = comm.Get_size() - out_dir = Path(args.out_dir) - if rank == 0: - out_dir.mkdir(parents=True, exist_ok=True) - comm.Barrier() - - torch.set_num_threads(args.torch_threads) - - def run_benchmark(): - import benchmark_cpu_expectation - - sys.argv = [ - "benchmark_cpu_expectation.py", - "--mpi", - "--ansatz", - "tn", - "--nqubits", - "34", - "--nlayers", - "20", - "--circuits", - "rxx_rzz", - "--pauli-pattern", - "XZ", - "--tn-load-tree", - "trees/rxx_rzz_34q20l_s4.pkl", - "--quimb-backend", - "torch", - "--torch-threads", - str(args.torch_threads), - "--dtype", - "complex64", - ] - benchmark_cpu_expectation.main() - - trace_path = out_dir / f"rank{rank}_trace.json" - stacks_path = out_dir / f"rank{rank}_stacks.txt" - summary_path = out_dir / f"rank{rank}_summary.txt" - - prof = profile( - activities=[ProfilerActivity.CPU], - record_shapes=True, - profile_memory=True, - with_stack=True, - ) - - class ProfileTimeout(Exception): - pass - - def alarm_handler(signum, frame): - raise ProfileTimeout() - - old_handler = signal.signal(signal.SIGALRM, alarm_handler) - signal.setitimer(signal.ITIMER_REAL, args.seconds) - try: - with prof: - try: - run_benchmark() - except ProfileTimeout: - pass - finally: - signal.setitimer(signal.ITIMER_REAL, 0) - signal.signal(signal.SIGALRM, old_handler) - - prof.export_chrome_trace(str(trace_path)) - try: - prof.export_stacks(str(stacks_path), "self_cpu_time_total") - except Exception as exc: # pragma: no cover - diagnostic only - stacks_path.write_text(f"export_stacks failed: {exc}\n", encoding="utf-8") - - summary = prof.key_averages(group_by_stack_n=5).table( - sort_by="self_cpu_time_total", - row_limit=40, - ) - summary_path.write_text(summary, encoding="utf-8") - - print( - f"torch_profile_done rank={rank}/{size} " - f"trace={trace_path} summary={summary_path}", - flush=True, - ) - - -if __name__ == "__main__": - main() diff --git a/tools/validate_vidal_mpi_correctness.py b/tools/validate_vidal_mpi_correctness.py deleted file mode 100644 index bce8e2d..0000000 --- a/tools/validate_vidal_mpi_correctness.py +++ /dev/null @@ -1,202 +0,0 @@ -"""Correctness checks for the Vidal/TEBD MPS fast path. - -The cases here intentionally cover more than the benchmark ring-XZ observable: -different nearest-neighbor gate orientations and several Pauli-sum observables. -Run serially to compare qibojit/statevector vs Vidal, or under MPI to compare -the segmented Vidal executor. -""" - -from __future__ import annotations - -import argparse -import math -import time - -import numpy as np -import torch -from qibo import Circuit, gates - -from qibotn.backends.vidal_mpi_segment import SegmentVidalMPIExecutor -from qibotn.backends.vidal_tebd import VidalTEBDExecutor - - -def build_circuit(kind, nqubits, nlayers, seed): - rng = np.random.default_rng(seed) - circuit = Circuit(nqubits) - for layer in range(nlayers): - for q in range(nqubits): - circuit.add(gates.RY(q, theta=rng.uniform(-math.pi, math.pi))) - circuit.add(gates.RZ(q, theta=rng.uniform(-math.pi, math.pi))) - if kind == "rx_ry_cz": - circuit.add(gates.RX(q, theta=rng.uniform(-math.pi, math.pi))) - - if kind in ("brickwall", "reversed_cnot"): - for q in range(0, nqubits - 1, 2): - if kind == "reversed_cnot" and (layer % 2): - circuit.add(gates.CNOT(q + 1, q)) - else: - circuit.add(gates.CNOT(q, q + 1)) - for q in range(1, nqubits - 1, 2): - if kind == "reversed_cnot" and not (layer % 2): - circuit.add(gates.CNOT(q + 1, q)) - else: - circuit.add(gates.CNOT(q, q + 1)) - elif kind == "rx_ry_cz": - for q in range(layer % 2, nqubits - 1, 2): - circuit.add(gates.CZ(q, q + 1)) - else: - raise ValueError(f"Unknown circuit kind {kind!r}.") - return circuit - - -def observable_terms(kind, nqubits): - if kind == "ring_xz": - return [ - (0.5, (("X", site), ("Z", (site + 1) % nqubits))) - for site in range(nqubits) - ] - if kind == "open_zz": - return [ - (1.0 / (nqubits - 1), (("Z", site), ("Z", site + 1))) - for site in range(nqubits - 1) - ] - if kind == "mixed_local": - terms = [(0.25, (("X", 0),)), (-0.5, (("Z", nqubits - 1),))] - terms += [ - (0.125, (("Y", site), ("Y", site + 1))) - for site in range(0, nqubits - 1, 3) - ] - return terms - raise ValueError(f"Unknown observable kind {kind!r}.") - - -def exact_pauli_sum(circuit, terms, nqubits): - state = circuit().state(numpy=True).reshape(-1) - indices = np.arange(state.size, dtype=np.int64) - value = 0.0 + 0.0j - for coeff, ops in terms: - flipped = indices.copy() - phase = np.ones(state.size, dtype=np.complex128) - for name, site in ops: - shift = nqubits - 1 - site - bit = (indices >> shift) & 1 - name = name.upper() - if name == "X": - flipped ^= 1 << shift - elif name == "Y": - flipped ^= 1 << shift - phase *= 1j * (1 - 2 * bit) - elif name == "Z": - phase *= 1 - 2 * bit - elif name != "I": - raise ValueError(f"Unsupported Pauli {name!r}.") - value += coeff * np.vdot(state[flipped], phase * state) - return float(value.real) - - -def run_vidal(circuit, terms, nqubits, bond, tensor_module): - executor = VidalTEBDExecutor( - nqubits=nqubits, - max_bond=bond, - cut_ratio=1e-12, - tensor_module=tensor_module, - ) - executor.run_circuit(circuit) - return float(executor.expectation_pauli_sum(terms)) - - -def run_segment_mpi(circuit, terms, nqubits, bond, tensor_module, comm): - executor = SegmentVidalMPIExecutor( - nqubits=nqubits, - max_bond=bond, - cut_ratio=1e-12, - tensor_module=tensor_module, - comm=comm, - ) - executor.run_circuit(circuit) - return executor.expectation_pauli_sum_root(terms) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--nqubits", type=int, default=16) - parser.add_argument("--nlayers", type=int, default=6) - parser.add_argument("--bond", "--bonds", dest="bond", type=int, default=512) - parser.add_argument("--seed", type=int, default=42) - parser.add_argument("--tensor-module", choices=("torch", "numpy"), default="torch") - parser.add_argument("--torch-threads", type=int, default=32) - parser.add_argument("--mpi", action="store_true") - parser.add_argument( - "--circuits", - nargs="+", - default=("brickwall", "reversed_cnot", "rx_ry_cz"), - ) - parser.add_argument( - "--observables", - nargs="+", - default=("ring_xz", "open_zz", "mixed_local"), - ) - args = parser.parse_args() - - torch.set_num_threads(args.torch_threads) - comm = None - rank = 0 - size = 1 - if args.mpi: - from mpi4py import MPI - - comm = MPI.COMM_WORLD - rank = comm.Get_rank() - size = comm.Get_size() - - if rank == 0: - mode = f"vidal-segment-mpi/{size}" if args.mpi else "vidal" - print( - f"mode={mode} nqubits={args.nqubits} nlayers={args.nlayers} " - f"bond={args.bond} tensor_module={args.tensor_module}" - ) - print("circuit observable exact value abs_error seconds") - - for circuit_kind in args.circuits: - circuit = build_circuit(circuit_kind, args.nqubits, args.nlayers, args.seed) - exact = None - if rank == 0: - exact_values = { - obs: exact_pauli_sum( - circuit, observable_terms(obs, args.nqubits), args.nqubits - ) - for obs in args.observables - } - else: - exact_values = None - if comm is not None: - exact_values = comm.bcast(exact_values, root=0) - - for obs_kind in args.observables: - terms = observable_terms(obs_kind, args.nqubits) - start = time.perf_counter() - if args.mpi: - value = run_segment_mpi( - circuit, - terms, - args.nqubits, - args.bond, - args.tensor_module, - comm, - ) - else: - value = run_vidal( - circuit, terms, args.nqubits, args.bond, args.tensor_module - ) - if rank != 0: - continue - elapsed = time.perf_counter() - start - exact = exact_values[obs_kind] - print( - f"{circuit_kind} {obs_kind} {exact:.16e} {value:.16e} " - f"{abs(value - exact):.6e} {elapsed:.3f}" - ) - - -if __name__ == "__main__": - main() diff --git a/tools/vidal_mpi_contest_runner.py b/tools/vidal_mpi_contest_runner.py deleted file mode 100644 index 405f47c..0000000 --- a/tools/vidal_mpi_contest_runner.py +++ /dev/null @@ -1,209 +0,0 @@ -from __future__ import annotations - -import argparse -import math -import time - -import numpy as np -from mpi4py import MPI -from qibo import Circuit, gates, hamiltonians -from qibo.symbols import X, Y, Z - -from qibotn.backends.vidal import VidalBackend - - -def optional_int(text): - if isinstance(text, str) and text.lower() in {"none", "null", "inf", "unlimited"}: - return None - return int(text) - - -def optional_float(text): - if isinstance(text, str) and text.lower() in {"none", "null", "inf", "unlimited"}: - return None - return float(text) - - -def format_optional(value, fmt="g"): - return "None" if value is None else format(value, fmt) - - -def set_torch_threads(nthreads): - try: - import torch - - torch.set_num_threads(nthreads) - except Exception: - pass - - -def build_circuit(kind, nqubits, nlayers, seed): - rng = np.random.default_rng(seed) - circuit = Circuit(nqubits) - - for layer in range(nlayers): - for q in range(nqubits): - circuit.add(gates.RY(q, theta=rng.uniform(-math.pi, math.pi))) - circuit.add(gates.RZ(q, theta=rng.uniform(-math.pi, math.pi))) - if kind in ("rxx_rzz", "scramble"): - circuit.add(gates.RX(q, theta=rng.uniform(-math.pi, math.pi))) - - if kind == "reversed_cnot": - for q in range(0, nqubits - 1, 2): - circuit.add(gates.CNOT(q + 1, q) if layer % 2 else gates.CNOT(q, q + 1)) - for q in range(1, nqubits - 1, 2): - circuit.add(gates.CNOT(q + 1, q) if layer % 2 == 0 else gates.CNOT(q, q + 1)) - elif kind == "rxx_rzz": - for q in range(layer % 2, nqubits - 1, 2): - circuit.add(gates.RXX(q, q + 1, theta=rng.uniform(-0.9, 0.9))) - circuit.add(gates.RZZ(q, q + 1, theta=rng.uniform(-0.9, 0.9))) - elif kind == "scramble": - for q in range(layer % 2, nqubits - 1, 2): - circuit.add(gates.RXX(q, q + 1, theta=rng.uniform(-0.8, 0.8))) - circuit.add(gates.RZZ(q, q + 1, theta=rng.uniform(-0.8, 0.8))) - if layer % 5 == 4: - circuit.add(gates.SWAP(q, q + 1)) - else: - raise ValueError(f"Unknown circuit kind {kind!r}.") - - return circuit - - -def ring_xz(nqubits): - form = 0 - for q in range(nqubits): - form += 0.5 * X(q) * Z((q + 1) % nqubits) - return hamiltonians.SymbolicHamiltonian(form=form) - - -def open_zz(nqubits): - form = 0 - for q in range(nqubits - 1): - form += (1.0 / (nqubits - 1)) * Z(q) * Z(q + 1) - return hamiltonians.SymbolicHamiltonian(form=form) - - -def range2_xx(nqubits): - form = 0 - for q in range(nqubits - 2): - form += (1.0 / (nqubits - 2)) * X(q) * X(q + 2) - return hamiltonians.SymbolicHamiltonian(form=form) - - -def dense_observable(nqubits, qubits, seed, dim): - rng = np.random.default_rng(seed) - raw = rng.normal(size=(dim, dim)) + 1j * rng.normal(size=(dim, dim)) - matrix = (raw + raw.conj().T) / 2.0 - matrix = matrix / np.linalg.norm(matrix) - return {"matrix": matrix, "qubits": list(qubits)} - - -def observables_for_case(nqubits, seed): - q1 = nqubits // 4 - q2 = nqubits // 2 - q3 = (3 * nqubits) // 4 - last = nqubits - 1 - - return [ - ("boundary_ZZ_q1", hamiltonians.SymbolicHamiltonian(form=Z(q1 - 1) * Z(q1))), - ("boundary_ZZ_q2", hamiltonians.SymbolicHamiltonian(form=Z(q2 - 1) * Z(q2))), - ("boundary_ZZ_q3", hamiltonians.SymbolicHamiltonian(form=Z(q3 - 1) * Z(q3))), - ( - "long_Z_5_sites", - hamiltonians.SymbolicHamiltonian(form=Z(0) * Z(q1) * Z(q2) * Z(q3) * Z(last)), - ), - ( - "mixed_XZYZX", - hamiltonians.SymbolicHamiltonian(form=X(0) * Z(q1) * Y(q2) * Z(q3) * X(last)), - ), - ("ring_xz", ring_xz(nqubits)), - ("open_zz", open_zz(nqubits)), - ("range2_xx", range2_xx(nqubits)), - ("complex_iZ0", hamiltonians.SymbolicHamiltonian(form=1.0j * Z(0))), - ("dense2_mid", dense_observable(nqubits, (q2 - 1, q2), seed + 101, 4)), - ("dense3_spread", dense_observable(nqubits, (q1, q2, q3), seed + 202, 8)), - ] - - -def run_case(args): - set_torch_threads(args.torch_threads) - comm = MPI.COMM_WORLD - rank = comm.Get_rank() - size = comm.Get_size() - - circuit = build_circuit(args.kind, args.nqubits, args.nlayers, args.seed) - observables = observables_for_case(args.nqubits, args.seed) - if args.obs_filter: - wanted = set(args.obs_filter.split(",")) - observables = [(name, obs) for name, obs in observables if name in wanted] - if not observables: - raise ValueError(f"OBS_FILTER matched no observables: {args.obs_filter!r}") - - if rank == 0: - print("=" * 88, flush=True) - print( - "case " - f"label={args.label} kind={args.kind} ranks={size} " - f"nqubits={args.nqubits} nlayers={args.nlayers} gates={len(circuit.queue)} " - f"bond={format_optional(args.bond)} " - f"cut_ratio={format_optional(args.cut_ratio)} " - f"torch_threads={args.torch_threads} seed={args.seed} " - f"obs_filter={args.obs_filter or 'all'}", - flush=True, - ) - print( - "observable value seconds trunc_sum trunc_max status", - flush=True, - ) - - for obs_name, observable in observables: - backend = VidalBackend() - backend.configure_tn_simulation( - max_bond_dimension=args.bond, - cut_ratio=args.cut_ratio, - tensor_module="torch", - mpi_approach="CT", - mpi_num_procs=size, - fallback=False, - ) - - comm.Barrier() - start = time.perf_counter() - try: - value = backend.expectation( - circuit, - observable, - preprocess=True, - compile_circuit=False, - ) - status = "ok" - except Exception as exc: # pragma: no cover - printed for manual runs - value = np.nan - status = type(exc).__name__ + ":" + str(exc).split("\n", 1)[0] - seconds = time.perf_counter() - start - - if rank == 0: - print( - f"{obs_name} {value!r} {seconds:.3f} " - f"{backend.last_truncation_error:.6e} " - f"{backend.last_max_truncation_error:.6e} {status}", - flush=True, - ) - - -def main(): - parser = argparse.ArgumentParser() - parser.add_argument("--label", required=True) - parser.add_argument("--kind", choices=("reversed_cnot", "rxx_rzz", "scramble"), required=True) - parser.add_argument("--nqubits", type=int, required=True) - parser.add_argument("--nlayers", type=int, required=True) - parser.add_argument("--bond", type=optional_int, required=True) - parser.add_argument("--cut-ratio", type=optional_float, required=True) - parser.add_argument("--seed", type=int, required=True) - parser.add_argument("--torch-threads", type=int, required=True) - parser.add_argument("--obs-filter", default="") - run_case(parser.parse_args()) - - -if __name__ == "__main__": - main() diff --git a/trees/contest_tn/main1_long_z_string_34q20l_auto.pkl b/trees/contest_tn/main1_long_z_string_34q20l_auto.pkl deleted file mode 100644 index e41f1f53f7e677a46e70864c28a05184b65e849e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 451239 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⋘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
H|h1RtpXk$05&yY+;?(3 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