From 57b6d131cd0211652a6ef4df018d64b5244fb3ca Mon Sep 17 00:00:00 2001 From: Vlad Stirbu Date: Tue, 30 Jun 2026 06:07:30 +0300 Subject: [PATCH 1/8] patch pass manager --- .gitignore | 8 + pyproject.toml | 37 +++++ src/q8s/runtime/__init__.py | 3 + src/q8s/runtime/matplotlib/backend.py | 44 +++++ src/q8s/runtime/mlflow/qiskit/__init__.py | 1 + src/q8s/runtime/mlflow/qiskit/autologging.py | 110 +++++++++++++ src/q8s/runtime/qprov/record.py | 94 +++++++++++ src/q8s/runtime/tracking.py | 164 +++++++++++++++++++ test.py | 28 ++++ 9 files changed, 489 insertions(+) create mode 100644 .gitignore create mode 100644 pyproject.toml create mode 100644 src/q8s/runtime/__init__.py create mode 100644 src/q8s/runtime/matplotlib/backend.py create mode 100644 src/q8s/runtime/mlflow/qiskit/__init__.py create mode 100644 src/q8s/runtime/mlflow/qiskit/autologging.py create mode 100644 src/q8s/runtime/qprov/record.py create mode 100644 src/q8s/runtime/tracking.py create mode 100644 test.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e64ac02 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +__pycache__/ +*.egg-info/ +.venv/ + +mlartifacts/ +mlflow.db + +out.txt \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..1b2a816 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "q8s.runtime" +version = "0.1.0" +authors = [{ name = "Vlad Stirbu", email = "vstirbu@gmail.com" }] +description = "A runtime library for q8s workloads" +requires-python = ">=3.10" +classifiers = [ + "Development Status :: 4 - Beta", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Natural Language :: English", + "Operating System :: MacOS", + "Operating System :: Microsoft :: Windows", + "Operating System :: POSIX :: Linux", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Typing :: Typed", +] + +dependencies = ["mlflow-skinny", "dataclasses-json"] + +[project.optional-dependencies] +matplotlib = ["matplotlib"] + +test = ["mqt.bench", "qiskit<2.2", "mlflow", "iqm-client[qiskit]"] +[build-system] +requires = ["setuptools >= 61.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools.packages.find] +where = ["src"] diff --git a/src/q8s/runtime/__init__.py b/src/q8s/runtime/__init__.py new file mode 100644 index 0000000..387d5ae --- /dev/null +++ b/src/q8s/runtime/__init__.py @@ -0,0 +1,3 @@ +from importlib.metadata import version + +__version__ = version("q8s.runtime") diff --git a/src/q8s/runtime/matplotlib/backend.py b/src/q8s/runtime/matplotlib/backend.py new file mode 100644 index 0000000..beb4163 --- /dev/null +++ b/src/q8s/runtime/matplotlib/backend.py @@ -0,0 +1,44 @@ +import base64 +import io + +import matplotlib.backend_bases +from matplotlib.backends.backend_agg import FigureCanvasAgg + + +class Q8SLoggerBackend(FigureCanvasAgg): + """Custom Matplotlib backend that logs images as base64-encoded strings.""" + + def print_png(self, filename_or_obj, *args, **kwargs): + """Override the PNG printing to log Base64 instead of saving to a file.""" + buf = io.BytesIO() + super().print_png(buf, *args, **kwargs) + + # Convert to base64 + buf.seek(0) + encoded_image = base64.b64encode(buf.getvalue()).decode("utf-8") + + # Log to console + print(f"data:image/png;base64,{encoded_image}\n") + + +FigureCanvas = Q8SLoggerBackend +# Register the backend + + +# Fix: Override `show()` to manually trigger rendering +def show(): + """Manually process all figures and log their base64 images.""" + import matplotlib._pylab_helpers + + # Get all active figures + figures = matplotlib._pylab_helpers.Gcf.get_all_fig_managers() + + for manager in figures: + figure = manager.canvas.figure + canvas = Q8SLoggerBackend(figure) + canvas.draw() # Render the figure + canvas.print_png(None) # Trigger our Base64 logging + + +# Register the custom show function +matplotlib.backend_bases.show = show \ No newline at end of file diff --git a/src/q8s/runtime/mlflow/qiskit/__init__.py b/src/q8s/runtime/mlflow/qiskit/__init__.py new file mode 100644 index 0000000..4cd53ad --- /dev/null +++ b/src/q8s/runtime/mlflow/qiskit/__init__.py @@ -0,0 +1 @@ +from q8s.runtime.mlflow.qiskit.autologging import autolog diff --git a/src/q8s/runtime/mlflow/qiskit/autologging.py b/src/q8s/runtime/mlflow/qiskit/autologging.py new file mode 100644 index 0000000..4338224 --- /dev/null +++ b/src/q8s/runtime/mlflow/qiskit/autologging.py @@ -0,0 +1,110 @@ +import contextvars +import time + +import qiskit +from qiskit.circuit import QuantumCircuit +from qiskit.transpiler import StagedPassManager + +from mlflow.utils.autologging_utils import autologging_integration, safe_patch + +from q8s.runtime.qprov.record import ( + CompilationProvenance, + QProvRecord, + QuantumCircuitProvenance, +) + +CONTEXT_NAME = "qiskit_mlflow_autologging_context" + +_current_context = contextvars.ContextVar(CONTEXT_NAME, default={}) + + +def get_context() -> QProvRecord: + ctx = _current_context.get() + if ctx is None: + raise RuntimeError("No active autolog context") + return ctx + + +@autologging_integration("qiskit") +def autolog( + log_passes=True, experiment_name=str, disable=False, silent=False, extra_tags=None +) -> None: + """ + Enables the Qiskit autologging integration. + + This function is idempotent and can be called multiple times. The integration will only be enabled once. + """ + + def _patched_run( + original: StagedPassManager.run, # type: ignore[no-untyped-def] + instance: StagedPassManager, + *args, + **kwargs, + ): + print("Qiskit autologging integration is enabled.") + + start = time.perf_counter() + + circuit: QuantumCircuit = args[0] + + ctx = _current_context.get() + + if ctx is None or not isinstance(ctx, QProvRecord): + _initialize_context(circuit) + ctx = _current_context.get() + + if disable: + return original(*args, **kwargs) + + if not silent: + print("Logging Qiskit transpilation run to MLflow.") + + kwargs["callback"] = callback + + history = original(instance, *args, **kwargs) + + ctx.compilation.duration_s = time.perf_counter() - start + + return history + + safe_patch( + "qiskit", + qiskit.transpiler.StagedPassManager, + "run", + _patched_run, + manage_run=True, + extra_tags=extra_tags, + ) + + +def callback(pass_, dag, time, property_set, count): + name = pass_.__class__.__name__ + print( + f"Pass {count:03d}: {name}, Time: {time:.6f}s, Depth: {dag.depth()}, Size: {dag.size()}" + ) + + +def _initialize_context(circuit: QuantumCircuit) -> None: + """ + Initializes the autologging context for a Qiskit transpilation run. + + This function is called at the beginning of a transpilation run to set up the context for logging. + """ + print("Initializing Qiskit autologging context.") + + record = QProvRecord( + circuit=QuantumCircuitProvenance( + circuit_id=str(id(circuit)), + name=circuit.name, + num_qubits=circuit.num_qubits, + depth=circuit.depth(), + width=circuit.width(), + size=circuit.size(), + gate_counts=dict(circuit.count_ops()), + ), + compilation=CompilationProvenance( + compiler="qiskit", compiler_version=qiskit.version.VERSION + ), + ) + + _current_context.set(record) diff --git a/src/q8s/runtime/qprov/record.py b/src/q8s/runtime/qprov/record.py new file mode 100644 index 0000000..5c3ce2e --- /dev/null +++ b/src/q8s/runtime/qprov/record.py @@ -0,0 +1,94 @@ +from __future__ import annotations + +from dataclasses import dataclass, field, asdict +from datetime import datetime, timezone +from typing import Any +from uuid import uuid4 + +from dataclasses_json import dataclass_json + + +@dataclass +class QuantumCircuitProvenance: + circuit_id: str + name: str | None = None + num_qubits: int | None = None + depth: int | None = None + width: int | None = None + size: int | None = None + gate_counts: dict[str, int] = field(default_factory=dict) + qasm: str | None = None + source: str | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class QuantumComputerProvenance: + provider: str + backend_name: str + backend_version: str | None = None + num_qubits: int | None = None + basis_gates: list[str] = field(default_factory=list) + coupling_map: list[tuple[int, int]] = field(default_factory=list) + simulator: bool | None = None + calibration_timestamp: datetime | None = None + qubit_properties: dict[str, Any] = field(default_factory=dict) + gate_errors: dict[str, Any] = field(default_factory=dict) + readout_errors: dict[str, Any] = field(default_factory=dict) + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass +class CompilationProvenance: + compiler: str = "qiskit" + compiler_version: str | None = None + optimization_level: int | None = None + initial_layout: Any | None = None + final_layout: Any | None = None + routing_method: str | None = None + translation_method: str | None = None + scheduling_method: str | None = None + seed_transpiler: int | None = None + input_depth: int | None = None + output_depth: int | None = None + input_gate_counts: dict[str, int] = field(default_factory=dict) + output_gate_counts: dict[str, int] = field(default_factory=dict) + duration_s: float | None = None + metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass_json +@dataclass +class ExecutionProvenance: + execution_id: str = field(default_factory=lambda: str(uuid4())) + job_id: str | None = None + shots: int | None = None + status: str | None = None + submit_time: datetime | None = None + start_time: datetime | None = None + end_time: datetime | None = None + queue_time_s: float | None = None + execution_time_s: float | None = None + counts: dict[str, int] = field(default_factory=dict) + result_metadata: dict[str, Any] = field(default_factory=dict) + + +@dataclass_json +@dataclass +class QProvRecord: + circuit: QuantumCircuitProvenance + quantum_computer: QuantumComputerProvenance | None = None + compilation: CompilationProvenance | None = None + execution: ExecutionProvenance | None = None + created_at: datetime = field( + default_factory=lambda: datetime.now(timezone.utc), + metadata={"dataclasses_json": {"encoder": lambda dt: dt.isoformat()}}, + ) + record_id: str = field(default_factory=lambda: str(uuid4())) + metadata: dict[str, Any] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return asdict(self) + + def to_json(self, **kwargs) -> str: + return dataclass_json.dumps(self, **kwargs) diff --git a/src/q8s/runtime/tracking.py b/src/q8s/runtime/tracking.py new file mode 100644 index 0000000..20c510e --- /dev/null +++ b/src/q8s/runtime/tracking.py @@ -0,0 +1,164 @@ +import time +import json +import tempfile +from pathlib import Path +from dataclasses import dataclass +from typing import Any + +import mlflow +from qiskit import QuantumCircuit +import qiskit +from qiskit.transpiler import StagedPassManager, generate_preset_pass_manager +from qiskit.transpiler.passes import TrivialLayout + +from q8s.runtime.mlflow.qiskit.autologging import get_context + + +@dataclass +class MLflowTranspilationManager: + tracking_uri: str + experiment_name: str = "qiskit-transpilation" + optimization_level: int = 1 + backend: Any | None = None + basis_gates: list[str] | None = None + coupling_map: Any | None = None + + def __post_init__(self): + mlflow.set_tracking_uri(self.tracking_uri) + mlflow.set_experiment(self.experiment_name) + + def transpile( + self, circuit: QuantumCircuit, run_name: str | None = None + ) -> QuantumCircuit: + with mlflow.start_run(run_name=run_name): + self._log_input(circuit) + + mlflow.log_params( + { + "optimization_level": self.optimization_level, + "backend": getattr(self.backend, "name", None) or str(self.backend), + "basis_gates": json.dumps(self.basis_gates), + "num_qubits_input": circuit.num_qubits, + } + ) + + pass_manager: StagedPassManager = generate_preset_pass_manager( + optimization_level=self.optimization_level, + # layout_method=TrivialLayout, + backend=self.backend, + basis_gates=self.basis_gates, + coupling_map=self.coupling_map, + ) + + pass_times = {} + + def callback(pass_, dag, time, property_set, count): + name = pass_.__class__.__name__ + pass_times[f"{count:03d}_{name}"] = time + mlflow.log_metric(f"pass_time_{name}", time, step=count) + mlflow.log_metric(f"pass_depth_{name}", dag.depth(), step=count) + mlflow.log_metric(f"pass_size_{name}", dag.size(), step=count) + + start = time.perf_counter() + transpiled = pass_manager.run(circuit) + elapsed = time.perf_counter() - start + + self._log_output(circuit, transpiled, elapsed) + self._log_artifacts(circuit, transpiled) + + return transpiled + + def _log_input(self, circuit: QuantumCircuit): + mlflow.log_metrics( + { + "input_depth": circuit.depth(), + "input_size": circuit.size(), + "input_width": circuit.width(), + "input_num_qubits": circuit.num_qubits, + "input_num_clbits": circuit.num_clbits, + } + ) + + for gate, count in circuit.count_ops().items(): + mlflow.log_metric(f"input_gate_{gate}", count) + + def _log_output( + self, + original: QuantumCircuit, + transpiled: QuantumCircuit, + elapsed: float, + ): + mlflow.log_metrics( + { + "transpilation_time_s": elapsed, + "output_depth": transpiled.depth(), + "output_size": transpiled.size(), + "output_width": transpiled.width(), + "output_num_qubits": transpiled.num_qubits, + "output_num_clbits": transpiled.num_clbits, + "depth_delta": transpiled.depth() - original.depth(), + "size_delta": transpiled.size() - original.size(), + } + ) + + for gate, count in transpiled.count_ops().items(): + mlflow.log_metric(f"output_gate_{gate}", count) + + def _log_artifacts(self, original: QuantumCircuit, transpiled: QuantumCircuit): + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + + input_qasm = tmp / "input_circuit.qasm" + output_qasm = tmp / "transpiled_circuit.qasm" + summary = tmp / "transpilation_summary.json" + qprov = tmp / "qprov_record.json" + + try: + input_qasm.write_text(original.qasm()) + output_qasm.write_text(transpiled.qasm()) + except AttributeError as e: + from qiskit.qasm3 import dumps + + input_qasm.write_text(dumps(original)) + output_qasm.write_text(dumps(transpiled)) + + summary.write_text( + json.dumps( + { + "input": { + "depth": original.depth(), + "size": original.size(), + "count_ops": dict(original.count_ops()), + }, + "output": { + "depth": transpiled.depth(), + "size": transpiled.size(), + "count_ops": dict(transpiled.count_ops()), + }, + }, + indent=2, + ) + ) + + # record = QProvRecord( + # circuit=QuantumCircuitProvenance( + # circuit_id=str(id(original)), + # name=original.name, + # num_qubits=original.num_qubits, + # depth=original.depth(), + # width=original.width(), + # size=original.size(), + # gate_counts=dict(original.count_ops()), + # ), + # compilation=CompilationProvenance( + # compiler="qiskit", + # compiler_version=qiskit.version.VERSION, + # optimization_level=self.optimization_level, + # ), + # ) + + ctx = get_context() + + qprov.write_text(ctx.to_json(indent=2, sort_keys=True)) + + mlflow.log_artifacts(tmp) diff --git a/test.py b/test.py new file mode 100644 index 0000000..2967ddc --- /dev/null +++ b/test.py @@ -0,0 +1,28 @@ +import q8s.runtime +from mqt.bench import BenchmarkLevel, get_benchmark + +from iqm.qiskit_iqm.fake_backends.fake_aphrodite import IQMFakeAphrodite + +from q8s.runtime.mlflow.qiskit import autolog +from q8s.runtime.tracking import MLflowTranspilationManager + +autolog() + +print("q8s.runtime version:", q8s.runtime.__version__) + +qc = get_benchmark( + benchmark="ghz", + level=BenchmarkLevel.ALG, + circuit_size=12, +) + +backend = IQMFakeAphrodite() + +manager = MLflowTranspilationManager( + tracking_uri="http://127.0.0.1:5000", + experiment_name="qubernetes-transpilation", + optimization_level=3, + backend=backend, +) + +tqc = manager.transpile(qc, run_name="qft-8-opt3") From 142938f35aeea0927fa1d3c0f27ab498305052c9 Mon Sep 17 00:00:00 2001 From: Vlad Stirbu Date: Tue, 30 Jun 2026 11:41:38 +0300 Subject: [PATCH 2/8] init logging context, and update backend and circuit info --- src/q8s/runtime/mlflow/qiskit/autologging.py | 93 +++++++++++++------- src/q8s/runtime/qprov/record.py | 2 +- src/q8s/runtime/tracking.py | 29 ------ test.py | 5 +- 4 files changed, 66 insertions(+), 63 deletions(-) diff --git a/src/q8s/runtime/mlflow/qiskit/autologging.py b/src/q8s/runtime/mlflow/qiskit/autologging.py index 4338224..1346080 100644 --- a/src/q8s/runtime/mlflow/qiskit/autologging.py +++ b/src/q8s/runtime/mlflow/qiskit/autologging.py @@ -11,6 +11,7 @@ CompilationProvenance, QProvRecord, QuantumCircuitProvenance, + QuantumComputerProvenance, ) CONTEXT_NAME = "qiskit_mlflow_autologging_context" @@ -35,12 +36,23 @@ def autolog( This function is idempotent and can be called multiple times. The integration will only be enabled once. """ + record = QProvRecord( + compilation=CompilationProvenance( + compiler="qiskit", compiler_version=qiskit.version.VERSION + ) + ) + + _current_context.set(record) + def _patched_run( original: StagedPassManager.run, # type: ignore[no-untyped-def] instance: StagedPassManager, *args, **kwargs, ): + if disable: + return original(*args, **kwargs) + print("Qiskit autologging integration is enabled.") start = time.perf_counter() @@ -49,12 +61,20 @@ def _patched_run( ctx = _current_context.get() - if ctx is None or not isinstance(ctx, QProvRecord): - _initialize_context(circuit) - ctx = _current_context.get() + ctx.circuit = QuantumCircuitProvenance( + circuit_id=str(id(circuit)), + name=circuit.name, + num_qubits=circuit.num_qubits, + depth=circuit.depth(), + width=circuit.width(), + size=circuit.size(), + gate_counts=dict(circuit.count_ops()), + ) - if disable: - return original(*args, **kwargs) + if ctx is None or not isinstance(ctx, QProvRecord): + raise RuntimeError( + "No active autolog context. Please call transpile() first." + ) if not silent: print("Logging Qiskit transpilation run to MLflow.") @@ -67,6 +87,34 @@ def _patched_run( return history + def _patched_generate_preset_pass_manager( + original, # type: ignore[no-untyped-def] + *args, + **kwargs, + ): + print( + "Qiskit autologging integration is enabled for generate_preset_pass_manager." + ) + + backend = kwargs.get("backend", None) + + print(f"Backend: {backend.name if backend else 'None'}") + + if backend is not None: + ctx = _current_context.get() + + if ctx is None or not isinstance(ctx, QProvRecord): + raise RuntimeError( + "No active autolog context. Please call transpile() first." + ) + + ctx.quantum_computer = QuantumComputerProvenance( + provider="IQM", + backend_name=backend.name, + ) + + return original(*args, **kwargs) + safe_patch( "qiskit", qiskit.transpiler.StagedPassManager, @@ -76,35 +124,18 @@ def _patched_run( extra_tags=extra_tags, ) + safe_patch( + "qiskit", + qiskit.transpiler, + "generate_preset_pass_manager", + _patched_generate_preset_pass_manager, + manage_run=True, + extra_tags=extra_tags, + ) + def callback(pass_, dag, time, property_set, count): name = pass_.__class__.__name__ print( f"Pass {count:03d}: {name}, Time: {time:.6f}s, Depth: {dag.depth()}, Size: {dag.size()}" ) - - -def _initialize_context(circuit: QuantumCircuit) -> None: - """ - Initializes the autologging context for a Qiskit transpilation run. - - This function is called at the beginning of a transpilation run to set up the context for logging. - """ - print("Initializing Qiskit autologging context.") - - record = QProvRecord( - circuit=QuantumCircuitProvenance( - circuit_id=str(id(circuit)), - name=circuit.name, - num_qubits=circuit.num_qubits, - depth=circuit.depth(), - width=circuit.width(), - size=circuit.size(), - gate_counts=dict(circuit.count_ops()), - ), - compilation=CompilationProvenance( - compiler="qiskit", compiler_version=qiskit.version.VERSION - ), - ) - - _current_context.set(record) diff --git a/src/q8s/runtime/qprov/record.py b/src/q8s/runtime/qprov/record.py index 5c3ce2e..da3dbb4 100644 --- a/src/q8s/runtime/qprov/record.py +++ b/src/q8s/runtime/qprov/record.py @@ -76,7 +76,7 @@ class ExecutionProvenance: @dataclass_json @dataclass class QProvRecord: - circuit: QuantumCircuitProvenance + circuit: QuantumCircuitProvenance | None = None quantum_computer: QuantumComputerProvenance | None = None compilation: CompilationProvenance | None = None execution: ExecutionProvenance | None = None diff --git a/src/q8s/runtime/tracking.py b/src/q8s/runtime/tracking.py index 20c510e..5a1f9df 100644 --- a/src/q8s/runtime/tracking.py +++ b/src/q8s/runtime/tracking.py @@ -7,9 +7,7 @@ import mlflow from qiskit import QuantumCircuit -import qiskit from qiskit.transpiler import StagedPassManager, generate_preset_pass_manager -from qiskit.transpiler.passes import TrivialLayout from q8s.runtime.mlflow.qiskit.autologging import get_context @@ -44,21 +42,11 @@ def transpile( pass_manager: StagedPassManager = generate_preset_pass_manager( optimization_level=self.optimization_level, - # layout_method=TrivialLayout, backend=self.backend, basis_gates=self.basis_gates, coupling_map=self.coupling_map, ) - pass_times = {} - - def callback(pass_, dag, time, property_set, count): - name = pass_.__class__.__name__ - pass_times[f"{count:03d}_{name}"] = time - mlflow.log_metric(f"pass_time_{name}", time, step=count) - mlflow.log_metric(f"pass_depth_{name}", dag.depth(), step=count) - mlflow.log_metric(f"pass_size_{name}", dag.size(), step=count) - start = time.perf_counter() transpiled = pass_manager.run(circuit) elapsed = time.perf_counter() - start @@ -140,23 +128,6 @@ def _log_artifacts(self, original: QuantumCircuit, transpiled: QuantumCircuit): ) ) - # record = QProvRecord( - # circuit=QuantumCircuitProvenance( - # circuit_id=str(id(original)), - # name=original.name, - # num_qubits=original.num_qubits, - # depth=original.depth(), - # width=original.width(), - # size=original.size(), - # gate_counts=dict(original.count_ops()), - # ), - # compilation=CompilationProvenance( - # compiler="qiskit", - # compiler_version=qiskit.version.VERSION, - # optimization_level=self.optimization_level, - # ), - # ) - ctx = get_context() qprov.write_text(ctx.to_json(indent=2, sort_keys=True)) diff --git a/test.py b/test.py index 2967ddc..86b421e 100644 --- a/test.py +++ b/test.py @@ -4,10 +4,11 @@ from iqm.qiskit_iqm.fake_backends.fake_aphrodite import IQMFakeAphrodite from q8s.runtime.mlflow.qiskit import autolog -from q8s.runtime.tracking import MLflowTranspilationManager autolog() +from q8s.runtime.tracking import MLflowTranspilationManager + print("q8s.runtime version:", q8s.runtime.__version__) qc = get_benchmark( @@ -21,7 +22,7 @@ manager = MLflowTranspilationManager( tracking_uri="http://127.0.0.1:5000", experiment_name="qubernetes-transpilation", - optimization_level=3, + optimization_level=1, backend=backend, ) From a7da400fd7cd5408e68caf2ca6494b291668a26f Mon Sep 17 00:00:00 2001 From: Vlad Stirbu Date: Tue, 30 Jun 2026 11:56:40 +0300 Subject: [PATCH 3/8] Add pass provenance tracking to autologging and enhance callback functionality --- src/q8s/runtime/mlflow/qiskit/autologging.py | 21 ++++++++++++-- src/q8s/runtime/qprov/record.py | 30 ++++++++++++++++++++ 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/src/q8s/runtime/mlflow/qiskit/autologging.py b/src/q8s/runtime/mlflow/qiskit/autologging.py index 1346080..2db060b 100644 --- a/src/q8s/runtime/mlflow/qiskit/autologging.py +++ b/src/q8s/runtime/mlflow/qiskit/autologging.py @@ -135,7 +135,24 @@ def _patched_generate_preset_pass_manager( def callback(pass_, dag, time, property_set, count): + """ + Callback function for logging pass information during transpilation. + """ name = pass_.__class__.__name__ - print( - f"Pass {count:03d}: {name}, Time: {time:.6f}s, Depth: {dag.depth()}, Size: {dag.size()}" + + ctx = _current_context.get() + + if ctx is None or not isinstance(ctx, QProvRecord): + raise RuntimeError("No active autolog context. Please call transpile() first.") + + pass_metadata = { + "depth": dag.depth(), + "size": dag.size(), + } + + ctx.compilation.add_pass( + pass_name=name, + pass_index=count, + pass_duration_s=time, + pass_metadata=pass_metadata, ) diff --git a/src/q8s/runtime/qprov/record.py b/src/q8s/runtime/qprov/record.py index da3dbb4..1da37f5 100644 --- a/src/q8s/runtime/qprov/record.py +++ b/src/q8s/runtime/qprov/record.py @@ -38,6 +38,15 @@ class QuantumComputerProvenance: metadata: dict[str, Any] = field(default_factory=dict) +@dataclass +class PassProvenance: + pass_name: str + pass_index: int + pass_type: str | None = None + pass_duration_s: float | None = None + pass_metadata: dict[str, Any] = field(default_factory=dict) + + @dataclass class CompilationProvenance: compiler: str = "qiskit" @@ -54,8 +63,29 @@ class CompilationProvenance: input_gate_counts: dict[str, int] = field(default_factory=dict) output_gate_counts: dict[str, int] = field(default_factory=dict) duration_s: float | None = None + passes: list[PassProvenance] = field(default_factory=list) metadata: dict[str, Any] = field(default_factory=dict) + def add_pass( + self, + pass_name: str, + pass_index: int, + pass_type: str | None = None, + pass_duration_s: float | None = None, + pass_metadata: dict[str, Any] | None = None, + ): + if pass_metadata is None: + pass_metadata = {} + self.passes.append( + PassProvenance( + pass_name=pass_name, + pass_index=pass_index, + pass_type=pass_type, + pass_duration_s=pass_duration_s, + pass_metadata=pass_metadata, + ) + ) + @dataclass_json @dataclass From 6a0b0c16c13233f2bf78f9895757fd5653ec2024 Mon Sep 17 00:00:00 2001 From: Vlad Stirbu Date: Tue, 30 Jun 2026 17:57:34 +0300 Subject: [PATCH 4/8] Enhance Qiskit autologging with context management and logging features; add transpilation timeline plotting --- src/q8s/runtime/mlflow/qiskit/autologging.py | 138 ++++++++++++++++--- src/q8s/runtime/qprov/graphs.py | 85 ++++++++++++ src/q8s/runtime/tracking.py | 2 - test-mlflow.py | 33 +++++ test.py | 1 - 5 files changed, 235 insertions(+), 24 deletions(-) create mode 100644 src/q8s/runtime/qprov/graphs.py create mode 100644 test-mlflow.py diff --git a/src/q8s/runtime/mlflow/qiskit/autologging.py b/src/q8s/runtime/mlflow/qiskit/autologging.py index 2db060b..2607cc8 100644 --- a/src/q8s/runtime/mlflow/qiskit/autologging.py +++ b/src/q8s/runtime/mlflow/qiskit/autologging.py @@ -1,12 +1,17 @@ import contextvars +import tempfile import time +from pathlib import Path +from mlflow.tracking.fluent import ActiveRun import qiskit from qiskit.circuit import QuantumCircuit from qiskit.transpiler import StagedPassManager +from qiskit_aer import AerSimulator from mlflow.utils.autologging_utils import autologging_integration, safe_patch +from q8s.runtime.qprov.graphs import plot_transpilation_timeline from q8s.runtime.qprov.record import ( CompilationProvenance, QProvRecord, @@ -16,7 +21,14 @@ CONTEXT_NAME = "qiskit_mlflow_autologging_context" -_current_context = contextvars.ContextVar(CONTEXT_NAME, default={}) +_current_context: contextvars.ContextVar[QProvRecord] = contextvars.ContextVar( + CONTEXT_NAME, + default=QProvRecord( + compilation=CompilationProvenance( + compiler="qiskit", compiler_version=qiskit.version.VERSION + ) + ), +) def get_context() -> QProvRecord: @@ -28,7 +40,10 @@ def get_context() -> QProvRecord: @autologging_integration("qiskit") def autolog( - log_passes=True, experiment_name=str, disable=False, silent=False, extra_tags=None + # log_passes=True, + disable=False, + silent=False, + extra_tags=None, ) -> None: """ Enables the Qiskit autologging integration. @@ -36,15 +51,7 @@ def autolog( This function is idempotent and can be called multiple times. The integration will only be enabled once. """ - record = QProvRecord( - compilation=CompilationProvenance( - compiler="qiskit", compiler_version=qiskit.version.VERSION - ) - ) - - _current_context.set(record) - - def _patched_run( + def patched_run( original: StagedPassManager.run, # type: ignore[no-untyped-def] instance: StagedPassManager, *args, @@ -53,7 +60,7 @@ def _patched_run( if disable: return original(*args, **kwargs) - print("Qiskit autologging integration is enabled.") + print("Qiskit autologging integration is enabled for StagedPassManager.run.") start = time.perf_counter() @@ -87,11 +94,23 @@ def _patched_run( return history - def _patched_generate_preset_pass_manager( + safe_patch( + "qiskit", + qiskit.transpiler.StagedPassManager, + "run", + patched_run, + manage_run=False, + extra_tags=extra_tags, + ) + + def patched_generate_preset_pass_manager( original, # type: ignore[no-untyped-def] *args, **kwargs, ): + """ + Patch the generate_preset_pass_manager function to log the backend information to the QProvRecord. + """ print( "Qiskit autologging integration is enabled for generate_preset_pass_manager." ) @@ -111,25 +130,75 @@ def _patched_generate_preset_pass_manager( ctx.quantum_computer = QuantumComputerProvenance( provider="IQM", backend_name=backend.name, + num_qubits=getattr(backend, "num_qubits", None), ) return original(*args, **kwargs) safe_patch( "qiskit", - qiskit.transpiler.StagedPassManager, - "run", - _patched_run, - manage_run=True, + qiskit.transpiler, + "generate_preset_pass_manager", + patched_generate_preset_pass_manager, + manage_run=False, extra_tags=extra_tags, ) + def patched_backend_run(original, self, *args, **kwargs): + job = original(self, *args, **kwargs) + + job_cls = job.__class__ + + def patched_result(original_result, job_self, *r_args, **r_kwargs): + result = original_result(job_self, *r_args, **r_kwargs) + + # log result here + try: + counts = result.get_counts() + print("counts:", counts) + # mlflow.log_dict(counts, "qiskit/result_counts.json") + except Exception: + pass + + return result + + safe_patch( + "qiskit", + job_cls, + "result", + patched_result, + ) + + return job + safe_patch( "qiskit", - qiskit.transpiler, - "generate_preset_pass_manager", - _patched_generate_preset_pass_manager, - manage_run=True, + AerSimulator, + "run", + patched_backend_run, + ) + + def patched_activerun_exit(original, *args, **kwargs): + """ + Patch the __exit__ method of ActiveRun to log the QProvRecord to MLflow when the run ends. + """ + ctx = _current_context.get() + + if ctx is None or not isinstance(ctx, QProvRecord): + raise RuntimeError( + "No active autolog context. Please call transpile() first." + ) + + log_to_mlflow(ctx) + + return original(*args, **kwargs) + + safe_patch( + "qiskit", + ActiveRun, + "__exit__", + patched_activerun_exit, + manage_run=False, extra_tags=extra_tags, ) @@ -156,3 +225,30 @@ def callback(pass_, dag, time, property_set, count): pass_duration_s=time, pass_metadata=pass_metadata, ) + + print( + f"Pass {count:03d}: {name}, Time: {time:.6f}s, Depth: {dag.depth()}, Size: {dag.size()}" + ) + + +def log_to_mlflow(record: QProvRecord): + """ + Logs the QProvRecord to MLflow. + """ + import mlflow + + with tempfile.TemporaryDirectory() as tmpdir: + tmp = Path(tmpdir) + transpilation_timeline = tmp / "transpilation_timeline.png" + + qprov_file = tmp / "qprov_record.json" + qprov_file.write_text(record.to_json(indent=2, sort_keys=True)) + + fig = plot_transpilation_timeline(list(record.compilation.passes)) + fig.savefig(transpilation_timeline, bbox_inches="tight") + + run = mlflow.active_run() + if run is None: + raise RuntimeError("No active MLflow run. Please start a run first.") + + mlflow.log_artifacts(tmp) diff --git a/src/q8s/runtime/qprov/graphs.py b/src/q8s/runtime/qprov/graphs.py new file mode 100644 index 0000000..5715109 --- /dev/null +++ b/src/q8s/runtime/qprov/graphs.py @@ -0,0 +1,85 @@ +import matplotlib.pyplot as plt + + +def plot_transpilation_timeline(passes, figsize=(16, 6)): + """ + Plot a transpilation timeline. + + Parameters + ---------- + passes : list[dict] + List of pass execution records, e.g. + + { + "pass_duration_s": 0.000285, + "pass_index": 2, + "pass_metadata": { + "depth": 14, + "size": 25 + }, + "pass_name": "HighLevelSynthesis", + "pass_type": None + } + """ + + passes = sorted(passes, key=lambda p: p.pass_index) + + labels = [f"{p.pass_index:03d}: {p.pass_name}" for p in passes] + + times = [p.pass_duration_s * 1000 for p in passes] + + depths = [p.pass_metadata.get("depth", None) for p in passes] + + sizes = [p.pass_metadata.get("size", None) for p in passes] + + x = range(len(passes)) + + fig, ax_time = plt.subplots(figsize=figsize) + + # Runtime bars + bars = ax_time.bar( + x, + times, + alpha=0.7, + label="Runtime", + ) + + ax_time.set_ylabel("Runtime (ms)") + ax_time.set_xlabel("Transpiler pass") + ax_time.set_xticks(x) + ax_time.set_xticklabels(labels, rotation=60, ha="right", fontsize=8) + ax_time.grid(axis="y", linestyle="--", alpha=0.4) + + # Depth + ax_depth = ax_time.twinx() + depth_line = ax_depth.plot( + x, + depths, + marker="o", + linewidth=2, + label="Depth", + ) + ax_depth.set_ylabel("Circuit depth") + + # Size + ax_size = ax_time.twinx() + ax_size.spines["right"].set_position(("outward", 60)) + + size_line = ax_size.plot( + x, + sizes, + marker="s", + linewidth=2, + label="Size", + ) + ax_size.set_ylabel("Circuit size") + + # Combined legend + handles = [bars] + depth_line + size_line + labels = ["Runtime (ms)", "Depth", "Size"] + ax_time.legend(handles, labels, loc="upper left") + + plt.title("Transpilation Pass Timeline") + plt.tight_layout() + + return fig diff --git a/src/q8s/runtime/tracking.py b/src/q8s/runtime/tracking.py index 5a1f9df..6df66df 100644 --- a/src/q8s/runtime/tracking.py +++ b/src/q8s/runtime/tracking.py @@ -14,7 +14,6 @@ @dataclass class MLflowTranspilationManager: - tracking_uri: str experiment_name: str = "qiskit-transpilation" optimization_level: int = 1 backend: Any | None = None @@ -22,7 +21,6 @@ class MLflowTranspilationManager: coupling_map: Any | None = None def __post_init__(self): - mlflow.set_tracking_uri(self.tracking_uri) mlflow.set_experiment(self.experiment_name) def transpile( diff --git a/test-mlflow.py b/test-mlflow.py new file mode 100644 index 0000000..fbf5c8e --- /dev/null +++ b/test-mlflow.py @@ -0,0 +1,33 @@ +import mlflow +from mqt.bench import BenchmarkLevel, get_benchmark +from q8s.runtime.mlflow.qiskit import autolog + +autolog() + +from iqm.qiskit_iqm.fake_backends.fake_aphrodite import IQMFakeAphrodite +from qiskit.transpiler import generate_preset_pass_manager + +mlflow.set_experiment("qubernetes-transpilation") + +qc = get_benchmark( + benchmark="qft", + level=BenchmarkLevel.ALG, + circuit_size=10, +) + +backend = IQMFakeAphrodite() + +manager = generate_preset_pass_manager( + optimization_level=3, + backend=backend, +) + +with mlflow.start_run(): + + tqc = manager.run(qc) + + job = backend.run(tqc, shots=1024, memory=True) + + result = job.result() + + result.get_counts() diff --git a/test.py b/test.py index 86b421e..f4d3ce8 100644 --- a/test.py +++ b/test.py @@ -20,7 +20,6 @@ backend = IQMFakeAphrodite() manager = MLflowTranspilationManager( - tracking_uri="http://127.0.0.1:5000", experiment_name="qubernetes-transpilation", optimization_level=1, backend=backend, From 28a34de9fd0ac4872085f35b64c66788737dabab Mon Sep 17 00:00:00 2001 From: Vlad Stirbu Date: Thu, 2 Jul 2026 14:08:05 +0300 Subject: [PATCH 5/8] Refactor autologging to enhance pass tracking with stage information --- .gitignore | 1 + src/q8s/runtime/mlflow/qiskit/autologging.py | 21 +++-- src/q8s/runtime/mlflow/qiskit/transpiler.py | 70 +++++++++++++++++ src/q8s/runtime/qprov/graphs.py | 80 ++++++++++++++++---- 4 files changed, 150 insertions(+), 22 deletions(-) create mode 100644 src/q8s/runtime/mlflow/qiskit/transpiler.py diff --git a/.gitignore b/.gitignore index e64ac02..dfa239a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ __pycache__/ mlartifacts/ mlflow.db +.envrc out.txt \ No newline at end of file diff --git a/src/q8s/runtime/mlflow/qiskit/autologging.py b/src/q8s/runtime/mlflow/qiskit/autologging.py index 2607cc8..c984311 100644 --- a/src/q8s/runtime/mlflow/qiskit/autologging.py +++ b/src/q8s/runtime/mlflow/qiskit/autologging.py @@ -11,6 +11,10 @@ from mlflow.utils.autologging_utils import autologging_integration, safe_patch +from q8s.runtime.mlflow.qiskit.transpiler import ( + find_stage_by_id, + process_staged_pass_manager, +) from q8s.runtime.qprov.graphs import plot_transpilation_timeline from q8s.runtime.qprov.record import ( CompilationProvenance, @@ -51,7 +55,7 @@ def autolog( This function is idempotent and can be called multiple times. The integration will only be enabled once. """ - def patched_run( + def patched_staged_pass_manager_run( original: StagedPassManager.run, # type: ignore[no-untyped-def] instance: StagedPassManager, *args, @@ -88,6 +92,10 @@ def patched_run( kwargs["callback"] = callback + ctx.compilation.metadata["stages_pass_info"] = process_staged_pass_manager( + instance + ) + history = original(instance, *args, **kwargs) ctx.compilation.duration_s = time.perf_counter() - start @@ -98,7 +106,7 @@ def patched_run( "qiskit", qiskit.transpiler.StagedPassManager, "run", - patched_run, + patched_staged_pass_manager_run, manage_run=False, extra_tags=extra_tags, ) @@ -155,7 +163,7 @@ def patched_result(original_result, job_self, *r_args, **r_kwargs): # log result here try: counts = result.get_counts() - print("counts:", counts) + # print("counts:", counts) # mlflow.log_dict(counts, "qiskit/result_counts.json") except Exception: pass @@ -217,6 +225,9 @@ def callback(pass_, dag, time, property_set, count): pass_metadata = { "depth": dag.depth(), "size": dag.size(), + "stage": find_stage_by_id( + ctx.compilation.metadata["stages_pass_info"], id(pass_) + ), } ctx.compilation.add_pass( @@ -226,10 +237,6 @@ def callback(pass_, dag, time, property_set, count): pass_metadata=pass_metadata, ) - print( - f"Pass {count:03d}: {name}, Time: {time:.6f}s, Depth: {dag.depth()}, Size: {dag.size()}" - ) - def log_to_mlflow(record: QProvRecord): """ diff --git a/src/q8s/runtime/mlflow/qiskit/transpiler.py b/src/q8s/runtime/mlflow/qiskit/transpiler.py new file mode 100644 index 0000000..b6d07ce --- /dev/null +++ b/src/q8s/runtime/mlflow/qiskit/transpiler.py @@ -0,0 +1,70 @@ +from qiskit.transpiler import ( + ConditionalController, + DoWhileController, + StagedPassManager, +) +from qiskit.passmanager import FlowControllerLinear, GenericPass + + +def process_pass( + task: ( + GenericPass | ConditionalController | DoWhileController | FlowControllerLinear + ), + stage_info: list[int], +): + """ + Recursively process a pass or controller and collect the IDs of all passes in the stage_info list. + Args: + task: The pass or controller to process. + stage_info: A list to collect the IDs of all passes. + Returns: + The updated stage_info list containing the IDs of all passes.""" + if ( + isinstance(task, ConditionalController) + or isinstance(task, DoWhileController) + or isinstance(task, FlowControllerLinear) + ): + for sub_pass in task.tasks: + process_pass(sub_pass, stage_info) + elif isinstance(task, GenericPass): + if id(task) not in stage_info: + stage_info.append(id(task)) + else: + raise ValueError(f"Unknown task type: {type(task)}") + + return stage_info + + +def process_staged_pass_manager(manager: StagedPassManager) -> dict[str, list[int]]: + """ + Process a StagedPassManager and return a dictionary containing the stage names and their corresponding pass IDs. + Args: + manager (StagedPassManager): The StagedPassManager to process. + Returns: + dict: A dictionary mapping stage names to lists of pass IDs. + """ + stages_pass_info = {} + + for stage in manager.expanded_stages: + stage_pm = getattr(manager, stage, None) + if stage_pm is None: + continue + + stages_pass_info[stage] = process_pass(stage_pm.to_flow_controller(), []) + + return stages_pass_info + + +def find_stage_by_id(stage_pass_info: dict[str, list[int]], pass_id: int) -> str | None: + """ + Find the stage name corresponding to a given pass ID in the staged pass manager. + Args: + stage_pass_info (dict): A dictionary mapping stage names to lists of pass IDs. + pass_id (int): The ID of the pass to find. + Returns: + str | None: The name of the stage containing the pass ID, or None if not found. + """ + for stage, pass_ids in stage_pass_info.items(): + if pass_id in pass_ids: + return stage + return None diff --git a/src/q8s/runtime/qprov/graphs.py b/src/q8s/runtime/qprov/graphs.py index 5715109..8e81ad4 100644 --- a/src/q8s/runtime/qprov/graphs.py +++ b/src/q8s/runtime/qprov/graphs.py @@ -1,7 +1,8 @@ import matplotlib.pyplot as plt +from matplotlib import colormaps -def plot_transpilation_timeline(passes, figsize=(16, 6)): +def plot_transpilation_timeline(passes, figsize=(16, 8)): """ Plot a transpilation timeline. @@ -15,7 +16,8 @@ def plot_transpilation_timeline(passes, figsize=(16, 6)): "pass_index": 2, "pass_metadata": { "depth": 14, - "size": 25 + "size": 25, + "stage": "init" }, "pass_name": "HighLevelSynthesis", "pass_type": None @@ -25,39 +27,85 @@ def plot_transpilation_timeline(passes, figsize=(16, 6)): passes = sorted(passes, key=lambda p: p.pass_index) labels = [f"{p.pass_index:03d}: {p.pass_name}" for p in passes] - times = [p.pass_duration_s * 1000 for p in passes] - - depths = [p.pass_metadata.get("depth", None) for p in passes] - - sizes = [p.pass_metadata.get("size", None) for p in passes] + depths = [p.pass_metadata.get("depth") for p in passes] + sizes = [p.pass_metadata.get("size") for p in passes] + stages = [p.pass_metadata.get("stage", "unknown") for p in passes] x = range(len(passes)) fig, ax_time = plt.subplots(figsize=figsize) + # Stage background bands + unique_stages = list(dict.fromkeys(stages)) + + cmap = colormaps["Pastel1"].resampled(max(len(unique_stages), 1)) + + stage_colors = {stage: cmap(i) for i, stage in enumerate(unique_stages)} + + if stages: + stage_start = 0 + current_stage = stages[0] + + for i, stage in enumerate(stages + [None]): + if stage != current_stage: + stage_end = i - 1 + + ax_time.axvspan( + stage_start - 0.5, + stage_end + 0.5, + color=stage_colors[current_stage], + alpha=0.35, + zorder=0, + ) + + ax_time.axvline( + stage_start - 0.5, + color="gray", + linestyle="--", + linewidth=0.8, + alpha=0.6, + zorder=1, + ) + + ax_time.text( + (stage_start + stage_end) / 2, + 1.02, + current_stage, + transform=ax_time.get_xaxis_transform(), + ha="center", + va="bottom", + fontsize=9, + fontweight="bold", + ) + + stage_start = i + current_stage = stage + # Runtime bars bars = ax_time.bar( x, times, alpha=0.7, label="Runtime", + zorder=2, ) ax_time.set_ylabel("Runtime (ms)") ax_time.set_xlabel("Transpiler pass") - ax_time.set_xticks(x) + ax_time.set_xticks(list(x)) ax_time.set_xticklabels(labels, rotation=60, ha="right", fontsize=8) - ax_time.grid(axis="y", linestyle="--", alpha=0.4) + ax_time.grid(axis="y", linestyle="--", alpha=0.4, zorder=1) # Depth ax_depth = ax_time.twinx() depth_line = ax_depth.plot( - x, + list(x), depths, marker="o", linewidth=2, label="Depth", + zorder=3, ) ax_depth.set_ylabel("Circuit depth") @@ -66,20 +114,22 @@ def plot_transpilation_timeline(passes, figsize=(16, 6)): ax_size.spines["right"].set_position(("outward", 60)) size_line = ax_size.plot( - x, + list(x), sizes, marker="s", linewidth=2, label="Size", + zorder=3, ) ax_size.set_ylabel("Circuit size") # Combined legend handles = [bars] + depth_line + size_line - labels = ["Runtime (ms)", "Depth", "Size"] - ax_time.legend(handles, labels, loc="upper left") + legend_labels = ["Runtime (ms)", "Depth", "Size"] + ax_time.legend(handles, legend_labels, loc="upper left") + + ax_time.set_title("Transpilation Pass Timeline", pad=20) - plt.title("Transpilation Pass Timeline") - plt.tight_layout() + plt.tight_layout(rect=(0, 0.12, 1, 0.95)) return fig From fe65ee8510d10e111fc94810d33a6b0cb33cef59 Mon Sep 17 00:00:00 2001 From: Vlad Stirbu Date: Thu, 9 Jul 2026 17:56:46 +0300 Subject: [PATCH 6/8] Enhance Qiskit autologging with context management and parameter logging for optimization level and seed transpiler --- .github/workflows/publish.yaml | 161 +++++++++++++++++++ src/q8s/runtime/mlflow/qiskit/autologging.py | 45 +++++- 2 files changed, 198 insertions(+), 8 deletions(-) create mode 100644 .github/workflows/publish.yaml diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml new file mode 100644 index 0000000..0fec11f --- /dev/null +++ b/.github/workflows/publish.yaml @@ -0,0 +1,161 @@ +name: Publish Python ๐Ÿ distribution ๐Ÿ“ฆ to PyPI and TestPyPI + +on: push + +jobs: + test: + name: Run tests ๐Ÿงช + runs-on: [ubuntu-latest] + permissions: + contents: read + id-token: write + + strategy: + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + - "3.13" + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + # - name: Install dependencies + # run: >- + # python3 -m + # pip install + # .[development] + # - name: Run tests with coverage + # run: >- + # python3 -m + # pytest + # --cov=q8s + # --cov-report=xml + # - name: Upload coverage to Codecov + # uses: codecov/codecov-action@v5 + # if: matrix.python-version == '3.10' + # with: + # use_oidc: true + # files: ./coverage.xml + # fail_ci_if_error: true + + build: + name: Build distribution ๐Ÿ“ฆ + needs: + - test + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + - name: Install pypa/build + run: >- + python3 -m + pip install + build + --user + - name: Build a binary wheel and a source tarball + run: python3 -m build + - name: Store the distribution packages + uses: actions/upload-artifact@v4 + with: + name: python-package-distributions + path: dist/ + + publish-to-pypi: + name: >- + Publish Python ๐Ÿ distribution ๐Ÿ“ฆ to PyPI + if: startsWith(github.ref, 'refs/tags/') # only publish to PyPI on tag pushes + needs: + - build + runs-on: ubuntu-latest + environment: + name: pypi + url: https://pypi.org/p/q8s.runtime # Replace with your PyPI project name + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution ๐Ÿ“ฆ to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + + github-release: + name: >- + Sign the Python ๐Ÿ distribution ๐Ÿ“ฆ with Sigstore + and upload them to GitHub Release + needs: + - publish-to-pypi + runs-on: ubuntu-latest + + permissions: + contents: write # IMPORTANT: mandatory for making GitHub Releases + id-token: write # IMPORTANT: mandatory for sigstore + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Sign the dists with Sigstore + uses: sigstore/gh-action-sigstore-python@v3.0.0 + with: + inputs: >- + ./dist/*.tar.gz + ./dist/*.whl + - name: Create GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + run: >- + gh release create + '${{ github.ref_name }}' + --repo '${{ github.repository }}' + --notes "" + - name: Upload artifact signatures to GitHub Release + env: + GITHUB_TOKEN: ${{ github.token }} + # Upload to GitHub Release using the `gh` CLI. + # `dist/` contains the built packages, and the + # sigstore-produced signatures and certificates. + run: >- + gh release upload + '${{ github.ref_name }}' dist/** + --repo '${{ github.repository }}' + + publish-to-testpypi: + name: Publish Python ๐Ÿ distribution ๐Ÿ“ฆ to TestPyPI + needs: + - build + runs-on: ubuntu-latest + + environment: + name: testpypi + url: https://test.pypi.org/p/q8s.runtime + + permissions: + id-token: write # IMPORTANT: mandatory for trusted publishing + + steps: + - name: Download all the dists + uses: actions/download-artifact@v4 + with: + name: python-package-distributions + path: dist/ + - name: Publish distribution ๐Ÿ“ฆ to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + skip-existing: true + verbose: true \ No newline at end of file diff --git a/src/q8s/runtime/mlflow/qiskit/autologging.py b/src/q8s/runtime/mlflow/qiskit/autologging.py index c984311..4e90818 100644 --- a/src/q8s/runtime/mlflow/qiskit/autologging.py +++ b/src/q8s/runtime/mlflow/qiskit/autologging.py @@ -123,23 +123,34 @@ def patched_generate_preset_pass_manager( "Qiskit autologging integration is enabled for generate_preset_pass_manager." ) - backend = kwargs.get("backend", None) + ctx = _current_context.get() + + if ctx is None or not isinstance(ctx, QProvRecord): + raise RuntimeError( + "No active autolog context. Please call transpile() first." + ) - print(f"Backend: {backend.name if backend else 'None'}") + optimization_level = kwargs.get("optimization_level", None) - if backend is not None: - ctx = _current_context.get() + if optimization_level is not None: + ctx.compilation.optimization_level = optimization_level + mlflow.log_param("optimization_level", optimization_level) + + seed_transpiler = kwargs.get("seed_transpiler", None) - if ctx is None or not isinstance(ctx, QProvRecord): - raise RuntimeError( - "No active autolog context. Please call transpile() first." - ) + if seed_transpiler is not None: + ctx.compilation.seed_transpiler = seed_transpiler + mlflow.log_param("seed_transpiler", seed_transpiler) + backend = kwargs.get("backend", None) + + if backend is not None: ctx.quantum_computer = QuantumComputerProvenance( provider="IQM", backend_name=backend.name, num_qubits=getattr(backend, "num_qubits", None), ) + mlflow.log_param("backend_name", backend.name) return original(*args, **kwargs) @@ -258,4 +269,22 @@ def log_to_mlflow(record: QProvRecord): if run is None: raise RuntimeError("No active MLflow run. Please start a run first.") + ctx = _current_context.get() + + if ctx is None or not isinstance(ctx, QProvRecord): + raise RuntimeError( + "No active autolog context. Please call transpile() first." + ) + + passes = sorted(ctx.compilation.passes, key=lambda p: p.pass_index) + + mlflow.log_metric("passes_count", len(passes)) + mlflow.log_metric("transpilation_duration", ctx.compilation.duration_s) + mlflow.log_metric( + "circuit_depth", passes[-1].pass_metadata.get("depth", 0) if passes else 0 + ) + mlflow.log_metric( + "circuit_size", passes[-1].pass_metadata.get("size", 0) if passes else 0 + ) + mlflow.log_artifacts(tmp) From 5eb89448cf3a0df9f75a0641601966d7a2706920 Mon Sep 17 00:00:00 2001 From: Vlad Stirbu Date: Thu, 9 Jul 2026 18:13:05 +0300 Subject: [PATCH 7/8] Add Apache License 2.0 to the repository --- LICENSE | 201 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..ab9198b --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [2026] [Qubernetes contributors] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. From 6c576199eb614c8bb61d74d2578594fe364a6c08 Mon Sep 17 00:00:00 2001 From: Vlad Stirbu Date: Thu, 9 Jul 2026 18:22:34 +0300 Subject: [PATCH 8/8] Update README.md to enhance documentation for Qiskit autologging and installation instructions --- README.md | 49 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 584b952..01a08ab 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,50 @@ # q8s.runtime -A runtime library for q8s workloads +A runtime library for q8s workloads. + +## Installation + +You can install the q8s.runtime library using pip: + +```bash +pip install q8s.runtime +``` + +## Features + +### Qiskit autologging to MLflow + +```python +import mlflow +from mqt.bench import BenchmarkLevel, get_benchmark +from q8s.runtime.mlflow.qiskit import autolog + +autolog() + +from iqm.qiskit_iqm.fake_backends.fake_aphrodite import IQMFakeAphrodite +from qiskit.transpiler import generate_preset_pass_manager + +mlflow.set_experiment("qiskit-transpilation") + + +with mlflow.start_run(): + qc = get_benchmark( + benchmark="qft", + level=BenchmarkLevel.ALG, + circuit_size=30, + ) + + backend = IQMFakeAphrodite() + + manager = generate_preset_pass_manager( + optimization_level=3, backend=backend, seed_transpiler=42 + ) + + tqc = manager.run(qc) + + job = backend.run(tqc, shots=1024, memory=True) + + result = job.result() + + result.get_counts() +```