From 7f43876455a307efc01e4d674213ee764beb2757 Mon Sep 17 00:00:00 2001 From: Jake Arkinstall <65358059+jake-arkinstall@users.noreply.github.com> Date: Tue, 7 Jul 2026 15:41:50 +0100 Subject: [PATCH 1/3] Add customisable kv-pair metadata to gate definitions --- hatch_build.py | 2 + selene-core/c/include/selene/gatewire.h | 40 ++ selene-core/cbindgen/core_types.toml | 11 + selene-core/cbindgen/error_model.toml | 11 + selene-core/cbindgen/runtime.toml | 11 + selene-core/cbindgen/simulator.toml | 11 + selene-core/pyproject.toml | 2 + selene-core/python/selene_core/__init__.py | 9 + selene-core/python/selene_core/gatewire.py | 175 +++++- selene-core/python/selene_core/trace.py | 13 + .../python/selene_core/trace_passes.py | 521 ++++++++++++++++++ selene-core/rust/gatewire.rs | 15 +- selene-core/rust/gatewire/cbindgen.toml | 11 + selene-core/rust/gatewire/ffi/convert.rs | 107 +++- selene-core/rust/gatewire/ffi/functions.rs | 57 +- selene-core/rust/gatewire/ffi/types.rs | 54 ++ selene-core/rust/gatewire/instance.rs | 38 +- selene-core/rust/gatewire/metadata.rs | 96 ++++ selene-core/rust/gatewire/tests.rs | 47 +- selene-core/rust/gatewire/typed.rs | 1 + selene-core/rust/gatewire/wire.rs | 78 ++- selene-core/rust/operation.rs | 7 + .../interfaces/base_qis/c/CMakeLists.txt | 10 + .../c/include/base_qis/gate_metadata.h | 42 ++ .../interfaces/base_qis/c/src/gate_metadata.c | 116 ++++ .../interfaces/helios_qis/c/src/helios_ops.c | 17 +- selene-ext/interfaces/sol_qis/c/src/sol_ops.c | 17 +- selene-ext/runtimes/simple/rust/lib.rs | 14 +- .../selene_sim/event_hooks/instruction_log.py | 10 +- .../python/tests/test_gate_metadata_events.py | 36 ++ selene-sim/python/tests/test_interactive.py | 48 +- selene-sim/python/tests/test_qis.py | 255 +++++++++ uv.lock | 19 + 33 files changed, 1857 insertions(+), 44 deletions(-) create mode 100644 selene-core/python/selene_core/trace_passes.py create mode 100644 selene-core/rust/gatewire/metadata.rs create mode 100644 selene-ext/interfaces/base_qis/c/include/base_qis/gate_metadata.h create mode 100644 selene-ext/interfaces/base_qis/c/src/gate_metadata.c create mode 100644 selene-sim/python/tests/test_gate_metadata_events.py diff --git a/hatch_build.py b/hatch_build.py index 8584c112..7658c3b8 100644 --- a/hatch_build.py +++ b/hatch_build.py @@ -492,6 +492,7 @@ def build_base_qis(self): cmake_build_dir.mkdir(parents=True, exist_ok=True) dist_dir = base_qis_dir / "python/selene_base_qis_plugin/_dist" dist_dir.mkdir(parents=True, exist_ok=True) + selene_core_include_dir = Path(self.root) / "selene-core/c/include" selene_sim_dist_dir = Path(self.root) / "selene-sim/python/selene_sim/_dist" local_dist_lib_dir = dist_dir / "lib" local_selene_dist_lib_dir = selene_sim_dist_dir / "lib" @@ -509,6 +510,7 @@ def build_base_qis(self): f"-DCMAKE_INSTALL_PREFIX={dist_dir}", *self.install_rpath_arg([local_rpath, installed_rpath]), "-DCMAKE_BUILD_TYPE=Release", + f"-DSELENE_CORE_INCLUDE_DIR={selene_core_include_dir}", f"-DCMAKE_PREFIX_PATH={selene_sim_dist_dir}", f"{cmake_source_dir}", ] diff --git a/selene-core/c/include/selene/gatewire.h b/selene-core/c/include/selene/gatewire.h index 2b1ffc0c..6f4cbc0d 100644 --- a/selene-core/c/include/selene/gatewire.h +++ b/selene-core/c/include/selene/gatewire.h @@ -9,6 +9,18 @@ #include #include +#define GW_METADATA_VALUE_KIND_BOOL 1 + +#define GW_METADATA_VALUE_KIND_I64 2 + +#define GW_METADATA_VALUE_KIND_U64 3 + +#define GW_METADATA_VALUE_KIND_F64 4 + +#define GW_METADATA_VALUE_KIND_STRING 5 + +#define GW_METADATA_VALUE_KIND_BYTES 6 + #define GW_OPERAND_KIND_QUBIT 1 #define GW_OPERAND_KIND_F64 2 @@ -81,11 +93,30 @@ typedef struct { size_t bytes_len; } GwGateValue; +typedef union { + uint8_t bool_value; + int64_t i64_value; + uint64_t u64_value; + double f64_value; +} GwMetadataValueData; + +typedef struct { + size_t abi_size; + const char *key_ptr; + size_t key_len; + uint32_t value_kind; + GwMetadataValueData data; + const uint8_t *bytes_ptr; + size_t bytes_len; +} GwGateMetadata; + typedef struct { size_t abi_size; GwSemanticId semantic_id; const GwGateValue *values_ptr; size_t values_len; + const GwGateMetadata *metadata_ptr; + size_t metadata_len; } GwGateInstanceView; typedef struct { @@ -181,6 +212,15 @@ GwStatus gw_decoded_gate_value_count(const GwDecodedGate *gate, size_t *out); GwStatus gw_decoded_gate_value_at(const GwDecodedGate *gate, size_t index, GwGateValue *out); +GwStatus gw_decoded_gate_metadata_count(const GwDecodedGate *gate, size_t *out); + +GwStatus gw_decoded_gate_metadata_at(const GwDecodedGate *gate, size_t index, GwGateMetadata *out); + +GwStatus gw_decoded_gate_metadata_find(const GwDecodedGate *gate, + const char *key_ptr, + size_t key_len, + GwGateMetadata *out); + GwStatus gw_decoded_gate_qubit_operand_count(const GwDecodedGate *gate, size_t *out); GwStatus gw_decoded_gate_qubit_operand_at(const GwDecodedGate *gate, diff --git a/selene-core/cbindgen/core_types.toml b/selene-core/cbindgen/core_types.toml index 83bb35fb..7ff0b629 100644 --- a/selene-core/cbindgen/core_types.toml +++ b/selene-core/cbindgen/core_types.toml @@ -38,6 +38,12 @@ exclude = [ "GW_OPERAND_KIND_I64", "GW_OPERAND_KIND_U8", "GW_OPERAND_KIND_BOOL", + "GW_METADATA_VALUE_KIND_BOOL", + "GW_METADATA_VALUE_KIND_I64", + "GW_METADATA_VALUE_KIND_U64", + "GW_METADATA_VALUE_KIND_F64", + "GW_METADATA_VALUE_KIND_STRING", + "GW_METADATA_VALUE_KIND_BYTES", "GwSemanticId", "GwGateSet", "GwOperandDeclView", @@ -46,6 +52,8 @@ exclude = [ "GwOperandDeclInfo", "GwGateValueData", "GwGateValue", + "GwMetadataValueData", + "GwGateMetadata", "GwGateInstanceView", "GwDecodedGate", "gw_status_message", @@ -77,6 +85,9 @@ exclude = [ "gw_decoded_gate_semantic_id", "gw_decoded_gate_value_count", "gw_decoded_gate_value_at", + "gw_decoded_gate_metadata_count", + "gw_decoded_gate_metadata_at", + "gw_decoded_gate_metadata_find", "gw_gateset_validate_decoded", ] item_types = ["functions", "structs", "opaque", "enums", "typedefs"] diff --git a/selene-core/cbindgen/error_model.toml b/selene-core/cbindgen/error_model.toml index 50259a70..77129a07 100644 --- a/selene-core/cbindgen/error_model.toml +++ b/selene-core/cbindgen/error_model.toml @@ -49,6 +49,12 @@ exclude = [ "GW_OPERAND_KIND_I64", "GW_OPERAND_KIND_U8", "GW_OPERAND_KIND_BOOL", + "GW_METADATA_VALUE_KIND_BOOL", + "GW_METADATA_VALUE_KIND_I64", + "GW_METADATA_VALUE_KIND_U64", + "GW_METADATA_VALUE_KIND_F64", + "GW_METADATA_VALUE_KIND_STRING", + "GW_METADATA_VALUE_KIND_BYTES", "GwSemanticId", "GwGateSet", "GwOperandDeclView", @@ -57,6 +63,8 @@ exclude = [ "GwOperandDeclInfo", "GwGateValueData", "GwGateValue", + "GwMetadataValueData", + "GwGateMetadata", "GwGateInstanceView", "GwDecodedGate", "gw_status_message", @@ -88,6 +96,9 @@ exclude = [ "gw_decoded_gate_semantic_id", "gw_decoded_gate_value_count", "gw_decoded_gate_value_at", + "gw_decoded_gate_metadata_count", + "gw_decoded_gate_metadata_at", + "gw_decoded_gate_metadata_find", "gw_gateset_validate_decoded", ] item_types = ["functions", "structs", "opaque", "enums", "typedefs"] diff --git a/selene-core/cbindgen/runtime.toml b/selene-core/cbindgen/runtime.toml index 098dc478..8c21e82a 100644 --- a/selene-core/cbindgen/runtime.toml +++ b/selene-core/cbindgen/runtime.toml @@ -45,6 +45,12 @@ exclude = [ "GW_OPERAND_KIND_I64", "GW_OPERAND_KIND_U8", "GW_OPERAND_KIND_BOOL", + "GW_METADATA_VALUE_KIND_BOOL", + "GW_METADATA_VALUE_KIND_I64", + "GW_METADATA_VALUE_KIND_U64", + "GW_METADATA_VALUE_KIND_F64", + "GW_METADATA_VALUE_KIND_STRING", + "GW_METADATA_VALUE_KIND_BYTES", "GwSemanticId", "GwGateSet", "GwOperandDeclView", @@ -53,6 +59,8 @@ exclude = [ "GwOperandDeclInfo", "GwGateValueData", "GwGateValue", + "GwMetadataValueData", + "GwGateMetadata", "GwGateInstanceView", "GwDecodedGate", "gw_status_message", @@ -84,6 +92,9 @@ exclude = [ "gw_decoded_gate_semantic_id", "gw_decoded_gate_value_count", "gw_decoded_gate_value_at", + "gw_decoded_gate_metadata_count", + "gw_decoded_gate_metadata_at", + "gw_decoded_gate_metadata_find", "gw_gateset_validate_decoded", ] item_types = ["functions", "structs", "opaque", "enums", "typedefs"] diff --git a/selene-core/cbindgen/simulator.toml b/selene-core/cbindgen/simulator.toml index 74b68e4d..b937cc99 100644 --- a/selene-core/cbindgen/simulator.toml +++ b/selene-core/cbindgen/simulator.toml @@ -42,6 +42,12 @@ exclude = [ "GW_OPERAND_KIND_I64", "GW_OPERAND_KIND_U8", "GW_OPERAND_KIND_BOOL", + "GW_METADATA_VALUE_KIND_BOOL", + "GW_METADATA_VALUE_KIND_I64", + "GW_METADATA_VALUE_KIND_U64", + "GW_METADATA_VALUE_KIND_F64", + "GW_METADATA_VALUE_KIND_STRING", + "GW_METADATA_VALUE_KIND_BYTES", "GwSemanticId", "GwGateSet", "GwOperandDeclView", @@ -50,6 +56,8 @@ exclude = [ "GwOperandDeclInfo", "GwGateValueData", "GwGateValue", + "GwMetadataValueData", + "GwGateMetadata", "GwGateInstanceView", "GwDecodedGate", "gw_status_message", @@ -81,6 +89,9 @@ exclude = [ "gw_decoded_gate_semantic_id", "gw_decoded_gate_value_count", "gw_decoded_gate_value_at", + "gw_decoded_gate_metadata_count", + "gw_decoded_gate_metadata_at", + "gw_decoded_gate_metadata_find", "gw_gateset_validate_decoded", ] item_types = ["functions", "structs", "opaque", "enums", "typedefs"] diff --git a/selene-core/pyproject.toml b/selene-core/pyproject.toml index b83e2f6e..1e9530f5 100644 --- a/selene-core/pyproject.toml +++ b/selene-core/pyproject.toml @@ -7,6 +7,7 @@ readme = "python/selene_core/README.md" dependencies = [ "blake3>=1.0.0", "cffi>=1.17.1", + "filebytes>=0.10.2", "hugr>=0.13.0", # required for inspecting object files to find defined and declared symbols "lief>=0.16.5", @@ -17,6 +18,7 @@ dependencies = [ "networkx>=2.6,<4", "pydantic>=2.12.5", "pydot>=4.0.0", + "pyelftools>=0.33", "pyyaml~=6.0", "typing_extensions>=4", "ziglang~=0.13", diff --git a/selene-core/python/selene_core/__init__.py b/selene-core/python/selene_core/__init__.py index c92c2a9e..6036802c 100644 --- a/selene-core/python/selene_core/__init__.py +++ b/selene-core/python/selene_core/__init__.py @@ -12,6 +12,7 @@ DEFAULT_BUILD_PLANNER, ) from .headers import get_include_directory +from .trace_passes import QisCallSiteSymbolizer, symbolize_qis_call_sites from .gatewire import ( BOOL, F64, @@ -22,9 +23,12 @@ BoundGate, Gate, GateDefinition, + GateMetadata, GateValue, Gateset, HeliosGateSet, + MetadataValue, + MetadataValueKind, OperandDefinition, OperandKind, PhasedX, @@ -54,6 +58,8 @@ "BuildCtx", "DEFAULT_BUILD_PLANNER", "get_include_directory", + "QisCallSiteSymbolizer", + "symbolize_qis_call_sites", "BOOL", "F64", "I64", @@ -63,9 +69,12 @@ "BoundGate", "Gate", "GateDefinition", + "GateMetadata", "GateValue", "Gateset", "HeliosGateSet", + "MetadataValue", + "MetadataValueKind", "OperandDefinition", "OperandKind", "PhasedX", diff --git a/selene-core/python/selene_core/gatewire.py b/selene-core/python/selene_core/gatewire.py index 35257b38..ed2b6feb 100644 --- a/selene-core/python/selene_core/gatewire.py +++ b/selene-core/python/selene_core/gatewire.py @@ -3,7 +3,7 @@ from dataclasses import dataclass from enum import IntEnum import struct -from typing import Iterable +from typing import Iterable, Mapping import blake3 @@ -17,6 +17,15 @@ class OperandKind(IntEnum): BOOL = 6 +class MetadataValueKind(IntEnum): + BOOL = 1 + I64 = 2 + U64 = 3 + F64 = 4 + STRING = 5 + BYTES = 6 + + @dataclass(frozen=True) class OperandDefinition: name: str @@ -81,12 +90,61 @@ def bool(value: bool) -> GateValue: return GateValue(OperandKind.BOOL, bool(value)) +@dataclass(frozen=True) +class MetadataValue: + kind: MetadataValueKind + value: bool | int | float | str | bytes + + @staticmethod + def bool(value: bool) -> MetadataValue: + return MetadataValue(MetadataValueKind.BOOL, bool(value)) + + @staticmethod + def i64(value: int) -> MetadataValue: + return MetadataValue(MetadataValueKind.I64, int(value)) + + @staticmethod + def u64(value: int) -> MetadataValue: + return MetadataValue(MetadataValueKind.U64, int(value)) + + @staticmethod + def f64(value: float) -> MetadataValue: + return MetadataValue(MetadataValueKind.F64, float(value)) + + @staticmethod + def string(value: str) -> MetadataValue: + return MetadataValue(MetadataValueKind.STRING, str(value)) + + @staticmethod + def bytes(value: bytes | bytearray | memoryview) -> MetadataValue: + return MetadataValue(MetadataValueKind.BYTES, bytes(value)) + + +@dataclass(frozen=True) +class GateMetadata: + key: str + value: MetadataValue + + @dataclass(frozen=True) class Gate: semantic_id: bytes operands: tuple[GateValue, ...] + metadata: tuple[GateMetadata, ...] - def __init__(self, semantic_id: str | bytes, operands: Iterable[GateValue]): + def __init__( + self, + semantic_id: str | bytes, + operands: Iterable[GateValue], + metadata: ( + Mapping[str, MetadataValue | bool | int | float | str | bytes] + | Iterable[ + GateMetadata + | tuple[str, MetadataValue | bool | int | float | str | bytes] + ] + | None + ) = None, + ): object.__setattr__( self, "semantic_id", @@ -97,11 +155,26 @@ def __init__(self, semantic_id: str | bytes, operands: Iterable[GateValue]): if len(self.semantic_id) != 16: raise ValueError("semantic_id must be 16 bytes") object.__setattr__(self, "operands", tuple(operands)) + object.__setattr__(self, "metadata", _coerce_metadata(metadata)) + + def with_metadata( + self, key: str, value: MetadataValue | bool | int | float | str | bytes + ) -> Gate: + metadata = {entry.key: entry.value for entry in self.metadata} + metadata[str(key)] = _coerce_metadata_value(value) + return Gate(self.semantic_id, self.operands, metadata) + + def metadata_value(self, key: str) -> MetadataValue | None: + for entry in self.metadata: + if entry.key == key: + return entry.value + return None def serialize(self) -> bytes: out = bytearray() out.extend(b"GWG1") - out.extend(struct.pack(" bytes: out.extend(struct.pack(" Gate: if cursor.read(4) != b"GWG1": raise ValueError("bad gate magic") version, _reserved = struct.unpack(" Gate: (raw,) = struct.unpack("= 2: + (metadata_count,) = struct.unpack(" None: out.extend(data) +def _write_bytes(out: bytearray, value: bytes) -> None: + out.extend(struct.pack(" tuple[GateMetadata, ...]: + if metadata is None: + return () + raw_items = metadata.items() if isinstance(metadata, Mapping) else metadata + entries: dict[str, MetadataValue] = {} + for item in raw_items: + if isinstance(item, GateMetadata): + key = item.key + value = item.value + else: + key, raw_value = item + value = _coerce_metadata_value(raw_value) + entries[str(key)] = value + return tuple(GateMetadata(key, value) for key, value in entries.items()) + + def _instantiate_gate( definition: GateDefinition, values: Iterable[int | float | bool | GateValue] ) -> Gate: @@ -389,6 +550,10 @@ def read_string(self) -> str: (length,) = struct.unpack(" bytes: + (length,) = struct.unpack(" None: if self._offset != len(self._data): raise ValueError("trailing gatewire data") diff --git a/selene-core/python/selene_core/trace.py b/selene-core/python/selene_core/trace.py index 9ccb4d6f..0507a51f 100644 --- a/selene-core/python/selene_core/trace.py +++ b/selene-core/python/selene_core/trace.py @@ -7,6 +7,13 @@ class PredicateResult(BaseModel): result: bool +class DebugStackFrame(BaseModel): + function: str | None = None + file: str | None = None + line: int | None = None + column: int | None = None + + class UserProgramSource(BaseModel): kind: Literal["UserProgram"] = "UserProgram" index: int @@ -43,6 +50,12 @@ class GateEvent(AbstractEvent): qubits: list[int] = Field(default_factory=list) gate_name: str params: list[float | int | bool] = Field(default_factory=list) + metadata: dict[str, str | int | float | bool | bytes] = Field( + default_factory=dict, exclude_if=lambda value: not value + ) + debug_stack: list[DebugStackFrame] = Field( + default_factory=list, exclude_if=lambda value: not value + ) predicates: list[PredicateResult] = Field(default_factory=list) diff --git a/selene-core/python/selene_core/trace_passes.py b/selene-core/python/selene_core/trace_passes.py new file mode 100644 index 00000000..3b7a5278 --- /dev/null +++ b/selene-core/python/selene_core/trace_passes.py @@ -0,0 +1,521 @@ +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum +from io import BytesIO +from pathlib import Path +from typing import BinaryIO + +from .trace import DebugStackFrame, GateEvent, Trace + +QIS_MODULE_METADATA = "qis.call_site.module" +QIS_MODULE_OFFSET_METADATA = "qis.call_site.module_offset" +QIS_RETURN_ADDRESS_METADATA = "qis.call_site.return_address" +ELF_MAGIC = b"\x7fELF" +MACHO_MAGICS = { + b"\xfe\xed\xfa\xce", + b"\xfe\xed\xfa\xcf", + b"\xce\xfa\xed\xfe", + b"\xcf\xfa\xed\xfe", + b"\xca\xfe\xba\xbe", +} +PE_MAGIC = b"MZ" + + +class _ObjectFormat(Enum): + ELF = "elf" + MACHO = "mach-o" + PE = "pe" + UNKNOWN = "unknown" + + +@dataclass(frozen=True) +class _LineEntry: + start: int + end: int + file: str | None + line: int | None + column: int | None + + +@dataclass(frozen=True) +class _FunctionRange: + start: int + end: int + name: str + + +class _DwarfDebugInfo: + """DWARF symbolizer. + + This backend is intentionally in-process and does not shell out to tools like + addr2line. ELF is read directly through pyelftools. Mach-O and PE files are + parsed with filebytes to expose their embedded DWARF sections to pyelftools. + """ + + def __init__( + self, + path: Path, + dwarf, + min_load_address: int | None, + file: BinaryIO | None = None, + ): + self.path = path + self._file = file + self._dwarf = dwarf + self._lines = self._collect_lines() + self._functions = self._collect_functions() + self._cache: dict[tuple[int | None, int | None], list[DebugStackFrame]] = {} + self._min_load_address = min_load_address + + @classmethod + def from_elf(cls, path: Path) -> "_DwarfDebugInfo": + from elftools.elf.elffile import ELFFile + + file = path.open("rb") + try: + elf = ELFFile(file) + dwarf = elf.get_dwarf_info() if elf.has_dwarf_info() else None + min_load_address = _elf_min_load_address(elf) + return cls(path, dwarf, min_load_address, file=file) + except Exception: + file.close() + raise + + @classmethod + def from_macho(cls, path: Path) -> "_DwarfDebugInfo": + from filebytes.mach_o import MachO + + macho = MachO(str(path)) + if macho.isFat: + for thin in macho.fatArches: + section_map = _macho_dwarf_sections(thin) + if section_map: + return cls( + path, + _dwarf_info_from_sections( + section_map, + little_endian=_macho_is_little_endian(thin), + address_size=_macho_address_size(thin), + machine_arch="x64", + ), + max(thin.imageBase, 0), + ) + return cls(path, None, None) + + section_map = _macho_dwarf_sections(macho) + return cls( + path, + _dwarf_info_from_sections( + section_map, + little_endian=_macho_is_little_endian(macho), + address_size=_macho_address_size(macho), + machine_arch="x64", + ), + max(macho.imageBase, 0), + ) + + @classmethod + def from_pe(cls, path: Path) -> "_DwarfDebugInfo": + from filebytes.pe import PE + + pe = PE(str(path)) + section_map = _pe_dwarf_sections(pe) + return cls( + path, + _dwarf_info_from_sections( + section_map, + little_endian=True, + address_size=8 if pe.imageBase > 0xFFFFFFFF else 4, + machine_arch="x64", + ), + pe.imageBase, + ) + + def close(self) -> None: + if self._file is not None: + self._file.close() + + def symbolize( + self, module_offset: int | None, return_address: int | None + ) -> list[DebugStackFrame]: + key = (module_offset, return_address) + if key in self._cache: + return self._cache[key] + + for address in self._address_candidates(module_offset, return_address): + frame = self._symbolize_address(address) + if frame is not None: + self._cache[key] = [frame] + return [frame] + + self._cache[key] = [] + return [] + + def _address_candidates( + self, module_offset: int | None, return_address: int | None + ) -> list[int]: + candidates = [] + raw_candidates = [ + return_address, + module_offset, + ( + self._min_load_address + module_offset + if self._min_load_address is not None and module_offset is not None + else None + ), + ] + for address in raw_candidates: + if address is not None and address > 0 and address - 1 not in candidates: + candidates.append(address - 1) + for address in raw_candidates: + if address is not None and address not in candidates: + candidates.append(address) + return candidates + + def _symbolize_address(self, address: int) -> DebugStackFrame | None: + line = self._find_line(address) + function = self._find_function(address) + if line is None and function is None: + return None + return DebugStackFrame( + function=function.name if function is not None else None, + file=line.file if line is not None else None, + line=line.line if line is not None else None, + column=line.column if line is not None else None, + ) + + def _find_line(self, address: int) -> _LineEntry | None: + for line in self._lines: + if line.start <= address < line.end: + return line + return None + + def _find_function(self, address: int) -> _FunctionRange | None: + matches = [ + function + for function in self._functions + if function.start <= address < function.end + ] + if not matches: + return None + return min(matches, key=lambda function: function.end - function.start) + + def _collect_lines(self) -> list[_LineEntry]: + if self._dwarf is None: + return [] + result = [] + for cu in self._dwarf.iter_CUs(): + line_program = self._dwarf.line_program_for_CU(cu) + if line_program is None: + continue + previous_state = None + for entry in line_program.get_entries(): + state = entry.state + if state is None: + continue + if previous_state is not None and not previous_state.end_sequence: + if previous_state.address <= state.address: + result.append( + _LineEntry( + start=previous_state.address, + end=state.address, + file=self._resolve_line_file( + cu, line_program, previous_state.file + ), + line=previous_state.line, + column=previous_state.column, + ) + ) + previous_state = None if state.end_sequence else state + return result + + def _collect_functions(self) -> list[_FunctionRange]: + if self._dwarf is None: + return [] + result = [] + for cu in self._dwarf.iter_CUs(): + for die in cu.iter_DIEs(): + if die.tag not in {"DW_TAG_subprogram", "DW_TAG_inlined_subroutine"}: + continue + name = self._die_name(die) + bounds = self._die_bounds(die) + if name is not None and bounds is not None: + result.append(_FunctionRange(bounds[0], bounds[1], name)) + return result + + def _die_name(self, die) -> str | None: + name_attr = die.attributes.get("DW_AT_name") + if name_attr is not None: + return self._decode(name_attr.value) + return None + + def _die_bounds(self, die) -> tuple[int, int] | None: + from elftools.dwarf.descriptions import describe_form_class + + low_pc = die.attributes.get("DW_AT_low_pc") + high_pc = die.attributes.get("DW_AT_high_pc") + if low_pc is None or high_pc is None: + return None + start = int(low_pc.value) + if describe_form_class(high_pc.form) == "address": + end = int(high_pc.value) + else: + end = start + int(high_pc.value) + if end <= start: + return None + return start, end + + def _resolve_line_file(self, cu, line_program, file_index: int) -> str | None: + if file_index <= 0: + return None + files = line_program.header.get("file_entry") or line_program.header.get( + "file_names" + ) + if not files or file_index > len(files): + return None + file_entry = files[file_index - 1] + name = self._decode(file_entry.name) + if not name: + return None + path = Path(name) + if path.is_absolute(): + return str(path) + + directory = self._line_file_directory(cu, line_program, file_entry) + if directory: + return str(Path(directory) / path) + return str(path) + + def _line_file_directory(self, cu, line_program, file_entry) -> str | None: + dir_index = getattr(file_entry, "dir_index", 0) + if dir_index: + directories = ( + line_program.header.get("include_directory") + or line_program.header.get("directories") + or [] + ) + if 0 < dir_index <= len(directories): + return self._decode(directories[dir_index - 1]) + + top_die = cu.get_top_DIE() + comp_dir = top_die.attributes.get("DW_AT_comp_dir") + if comp_dir is not None: + return self._decode(comp_dir.value) + return None + + @staticmethod + def _decode(value) -> str: + if isinstance(value, bytes): + return value.decode("utf-8", errors="replace") + return str(value) + + +class QisCallSiteSymbolizer: + def __init__(self) -> None: + self._modules: dict[Path, _DwarfDebugInfo | None] = {} + + def close(self) -> None: + for module in self._modules.values(): + if module is not None: + module.close() + self._modules.clear() + + def symbolize_event(self, event: GateEvent) -> list[DebugStackFrame]: + module_path = event.metadata.get(QIS_MODULE_METADATA) + if not isinstance(module_path, str): + return [] + + module_offset = self._metadata_int(event, QIS_MODULE_OFFSET_METADATA) + return_address = self._metadata_int(event, QIS_RETURN_ADDRESS_METADATA) + if module_offset is None and return_address is None: + return [] + + module = self._module(Path(module_path)) + if module is None: + return [] + return module.symbolize(module_offset, return_address) + + def _module(self, path: Path) -> _DwarfDebugInfo | None: + if path in self._modules: + return self._modules[path] + object_format = _detect_object_format(path) + module: _DwarfDebugInfo | None + try: + match object_format: + case _ObjectFormat.ELF: + module = _DwarfDebugInfo.from_elf(path) + case _ObjectFormat.MACHO: + module = _DwarfDebugInfo.from_macho(path) + case _ObjectFormat.PE: + module = _DwarfDebugInfo.from_pe(path) + case _: + module = None + except Exception: + module = None + self._modules[path] = module + return module + + @staticmethod + def _metadata_int(event: GateEvent, key: str) -> int | None: + value = event.metadata.get(key) + if isinstance(value, bool): + return None + if isinstance(value, int): + return value + return None + + +def _detect_object_format(path: Path) -> _ObjectFormat: + try: + with path.open("rb") as fh: + magic = fh.read(4) + except OSError: + return _ObjectFormat.UNKNOWN + + if magic == ELF_MAGIC: + return _ObjectFormat.ELF + if magic[:2] == PE_MAGIC: + return _ObjectFormat.PE + if magic in MACHO_MAGICS: + return _ObjectFormat.MACHO + return _ObjectFormat.UNKNOWN + + +@dataclass(frozen=True) +class _DebugSection: + data: bytes + offset: int + address: int + + +def _elf_min_load_address(elf) -> int | None: + addresses = [] + for segment in elf.iter_segments(): + if segment.header.p_type == "PT_LOAD": + addresses.append(int(segment.header.p_vaddr)) + return min(addresses) if addresses else None + + +def _macho_dwarf_sections(macho) -> dict[str, _DebugSection]: + sections = {} + for command in macho.loadCommands: + if not hasattr(command, "sections"): + continue + for section in command.sections: + if section.name.startswith("__debug_"): + name = "." + section.name[2:] + elif section.name == "__eh_frame": + name = ".eh_frame" + else: + continue + sections[name] = _DebugSection( + data=bytes(section.raw)[: section.header.size], + offset=int(section.header.offset), + address=int(section.header.addr), + ) + return sections + + +def _macho_is_little_endian(macho) -> bool: + magic = int(macho.machHeader.header.magic) + return magic in {0xFEEDFACE, 0xFEEDFACF} + + +def _macho_address_size(macho) -> int: + magic = int(macho.machHeader.header.magic) + return 8 if magic in {0xFEEDFACF, 0xCFFAEDFE} else 4 + + +def _pe_dwarf_sections(pe) -> dict[str, _DebugSection]: + sections = {} + for section in pe.sections: + if not section.name.startswith(".debug_"): + continue + size = int(section.header.PhysicalAddress_or_VirtualSize) + sections[section.name] = _DebugSection( + data=bytes(section.raw)[:size], + offset=int(section.header.PointerToRawData), + address=int(pe.imageBase + section.header.VirtualAddress), + ) + return sections + + +def _dwarf_info_from_sections( + sections: dict[str, _DebugSection], + *, + little_endian: bool, + address_size: int, + machine_arch: str, +): + if ".debug_info" not in sections or ".debug_abbrev" not in sections: + return None + + from elftools.dwarf.dwarfinfo import ( + DWARFInfo, + DebugSectionDescriptor, + DwarfConfig, + ) + + def descriptor(name: str): + section = sections.get(name) + if section is None: + return None + return DebugSectionDescriptor( + BytesIO(section.data), + name, + section.offset, + len(section.data), + section.address, + ) + + return DWARFInfo( + config=DwarfConfig( + little_endian=little_endian, + machine_arch=machine_arch, + default_address_size=address_size, + ), + debug_info_sec=descriptor(".debug_info"), + debug_aranges_sec=descriptor(".debug_aranges"), + debug_abbrev_sec=descriptor(".debug_abbrev"), + debug_frame_sec=descriptor(".debug_frame"), + eh_frame_sec=descriptor(".eh_frame"), + debug_str_sec=descriptor(".debug_str"), + debug_loc_sec=descriptor(".debug_loc"), + debug_ranges_sec=descriptor(".debug_ranges"), + debug_line_sec=descriptor(".debug_line"), + debug_pubtypes_sec=descriptor(".debug_pubtypes"), + debug_pubnames_sec=descriptor(".debug_pubnames"), + debug_addr_sec=descriptor(".debug_addr"), + debug_str_offsets_sec=descriptor(".debug_str_offsets"), + debug_line_str_sec=descriptor(".debug_line_str"), + debug_loclists_sec=descriptor(".debug_loclists"), + debug_rnglists_sec=descriptor(".debug_rnglists"), + debug_sup_sec=descriptor(".debug_sup"), + gnu_debugaltlink_sec=descriptor(".gnu_debugaltlink"), + debug_types_sec=descriptor(".debug_types"), + ) + + +def symbolize_qis_call_sites( + trace: Trace, symbolizer: QisCallSiteSymbolizer | None = None +) -> Trace: + """Return a copy of ``trace`` with QIS PC metadata expanded to debug frames. + + The current implementation supports ELF, Mach-O, and PE binaries with DWARF + debug info. Native Windows PDB symbolization is not supported. + """ + + owns_symbolizer = symbolizer is None + symbolizer = symbolizer or QisCallSiteSymbolizer() + try: + enriched = trace.model_copy(deep=True) + for record in enriched.events: + if isinstance(record.event, GateEvent): + stack = symbolizer.symbolize_event(record.event) + if stack: + record.event.debug_stack = stack + return enriched + finally: + if owns_symbolizer: + symbolizer.close() diff --git a/selene-core/rust/gatewire.rs b/selene-core/rust/gatewire.rs index 61c9f4ee..af17ded1 100644 --- a/selene-core/rust/gatewire.rs +++ b/selene-core/rust/gatewire.rs @@ -13,6 +13,7 @@ mod dynamic; mod error; mod id; mod instance; +mod metadata; mod operand; mod typed; mod wire; @@ -21,11 +22,17 @@ pub use decl::{GateDecl, OperandSpec, SmallOperandSpecs}; pub use dynamic::{DynamicGateSet, LocalGateId}; pub use error::GateError; pub use ffi::{ - GwDecodedGate, GwGateDeclInfo, GwGateDeclView, GwGateInstanceView, GwGateSet, GwGateValue, - GwGateValueData, GwOperandDeclInfo, GwOperandDeclView, GwSemanticId, GwStatus, + GwDecodedGate, GwGateDeclInfo, GwGateDeclView, GwGateInstanceView, GwGateMetadata, GwGateSet, + GwGateValue, GwGateValueData, GwMetadataValueData, GwOperandDeclInfo, GwOperandDeclView, + GwSemanticId, GwStatus, }; pub use id::GateSemanticId; pub use instance::OwnedGateInstance; +pub use metadata::{ + GW_METADATA_VALUE_KIND_BOOL, GW_METADATA_VALUE_KIND_BYTES, GW_METADATA_VALUE_KIND_F64, + GW_METADATA_VALUE_KIND_I64, GW_METADATA_VALUE_KIND_STRING, GW_METADATA_VALUE_KIND_U64, + GateMetadata, MetadataValue, SmallGateMetadata, +}; pub use operand::{ Angle, GW_OPERAND_KIND_BOOL, GW_OPERAND_KIND_F64, GW_OPERAND_KIND_I64, GW_OPERAND_KIND_QUBIT, GW_OPERAND_KIND_U8, GW_OPERAND_KIND_U64, GateOperand, GateValue, OperandKind, Qubit, @@ -37,8 +44,8 @@ pub mod prelude { pub use crate::gatewire::builtin; pub use crate::gatewire::{ Angle, DynamicGateSet, GateDecl, GateError, GateOperand, GateSemanticId, GateSet, - GateSetSpec, GateSpec, GateValue, GateView, OperandKind, OperandSpec, OwnedGateInstance, - Qubit, TryDecode, + GateSetSpec, GateSpec, GateValue, GateView, MetadataValue, OperandKind, OperandSpec, + OwnedGateInstance, Qubit, TryDecode, }; } diff --git a/selene-core/rust/gatewire/cbindgen.toml b/selene-core/rust/gatewire/cbindgen.toml index 4456fb68..9fe312c8 100644 --- a/selene-core/rust/gatewire/cbindgen.toml +++ b/selene-core/rust/gatewire/cbindgen.toml @@ -23,6 +23,12 @@ include = [ "GW_OPERAND_KIND_I64", "GW_OPERAND_KIND_U8", "GW_OPERAND_KIND_BOOL", + "GW_METADATA_VALUE_KIND_BOOL", + "GW_METADATA_VALUE_KIND_I64", + "GW_METADATA_VALUE_KIND_U64", + "GW_METADATA_VALUE_KIND_F64", + "GW_METADATA_VALUE_KIND_STRING", + "GW_METADATA_VALUE_KIND_BYTES", "GwSemanticId", "GwGateSet", "GwOperandDeclView", @@ -31,6 +37,8 @@ include = [ "GwOperandDeclInfo", "GwGateValueData", "GwGateValue", + "GwMetadataValueData", + "GwGateMetadata", "GwGateInstanceView", "GwDecodedGate", "gw_status_message", @@ -62,6 +70,9 @@ include = [ "gw_decoded_gate_semantic_id", "gw_decoded_gate_value_count", "gw_decoded_gate_value_at", + "gw_decoded_gate_metadata_count", + "gw_decoded_gate_metadata_at", + "gw_decoded_gate_metadata_find", "gw_gateset_validate_decoded", ] exclude = [ diff --git a/selene-core/rust/gatewire/ffi/convert.rs b/selene-core/rust/gatewire/ffi/convert.rs index d56bfa8f..474ae1fd 100644 --- a/selene-core/rust/gatewire/ffi/convert.rs +++ b/selene-core/rust/gatewire/ffi/convert.rs @@ -1,7 +1,11 @@ use crate::gatewire::ffi::types::*; use crate::gatewire::{ - DynamicGateSet, GateDecl, GateError, GateSemanticId, GateValue, OperandKind, OperandSpec, - OwnedGateInstance, + DynamicGateSet, GateDecl, GateError, GateMetadata, GateSemanticId, GateValue, MetadataValue, + OperandKind, OperandSpec, OwnedGateInstance, + metadata::{ + GW_METADATA_VALUE_KIND_BOOL, GW_METADATA_VALUE_KIND_BYTES, GW_METADATA_VALUE_KIND_F64, + GW_METADATA_VALUE_KIND_I64, GW_METADATA_VALUE_KIND_STRING, GW_METADATA_VALUE_KIND_U64, + }, }; use std::ffi::c_char; use std::{mem, ptr, slice, str}; @@ -87,29 +91,59 @@ pub(crate) unsafe fn gate_decl_from_view(view: &GwGateDeclView) -> Result Result { - check_abi_size(view.abi_size, mem::size_of::())?; unsafe { - let values_raw = if view.values_len == 0 { + if view.is_null() { + return Err(GateError::NullPointer); + } + let abi_size = ptr::addr_of!((*view).abi_size).read(); + check_abi_size(abi_size, legacy_gate_instance_view_size())?; + + let semantic_id = ptr::addr_of!((*view).semantic_id).read(); + let values_ptr = ptr::addr_of!((*view).values_ptr).read(); + let values_len = ptr::addr_of!((*view).values_len).read(); + + let values_raw = if values_len == 0 { &[] } else { - if view.values_ptr.is_null() { + if values_ptr.is_null() { return Err(GateError::NullPointer); } - slice::from_raw_parts(view.values_ptr, view.values_len) + slice::from_raw_parts(values_ptr, values_len) }; let mut values = Vec::with_capacity(values_raw.len()); for raw in values_raw { values.push(gate_value_from_view(raw)?); } - Ok(OwnedGateInstance::new( - GateSemanticId::from(view.semantic_id), - values, - )) + + let mut instance = OwnedGateInstance::new(GateSemanticId::from(semantic_id), values); + if abi_size >= mem::size_of::() { + let metadata_ptr = ptr::addr_of!((*view).metadata_ptr).read(); + let metadata_len = ptr::addr_of!((*view).metadata_len).read(); + let metadata_raw = if metadata_len == 0 { + &[] + } else { + if metadata_ptr.is_null() { + return Err(GateError::NullPointer); + } + slice::from_raw_parts(metadata_ptr, metadata_len) + }; + for raw in metadata_raw { + instance.metadata.push(gate_metadata_from_view(raw)?); + } + } + Ok(instance) } } +const fn legacy_gate_instance_view_size() -> usize { + mem::size_of::() + + mem::size_of::() + + mem::size_of::<*const GwGateValue>() + + mem::size_of::() +} + unsafe fn gate_value_from_view(view: &GwGateValue) -> Result { check_abi_size(view.abi_size, mem::size_of::())?; unsafe { @@ -128,6 +162,31 @@ unsafe fn gate_value_from_view(view: &GwGateValue) -> Result Result { + check_abi_size(view.abi_size, mem::size_of::())?; + unsafe { + let key = read_str(view.key_ptr, view.key_len)?; + let value = match view.value_kind { + GW_METADATA_VALUE_KIND_BOOL => match view.data.bool_value { + 0 => MetadataValue::Bool(false), + 1 => MetadataValue::Bool(true), + _ => return Err(GateError::Decode("invalid bool metadata value")), + }, + GW_METADATA_VALUE_KIND_I64 => MetadataValue::I64(view.data.i64_value), + GW_METADATA_VALUE_KIND_U64 => MetadataValue::U64(view.data.u64_value), + GW_METADATA_VALUE_KIND_F64 => MetadataValue::F64(view.data.f64_value), + GW_METADATA_VALUE_KIND_STRING => MetadataValue::String( + read_text(view.bytes_ptr.cast::(), view.bytes_len)?.to_owned(), + ), + GW_METADATA_VALUE_KIND_BYTES => { + MetadataValue::Bytes(read_bytes(view.bytes_ptr, view.bytes_len)?.to_vec()) + } + _ => return Err(GateError::Decode("invalid metadata value kind")), + }; + Ok(GateMetadata { key, value }) + } +} + pub(crate) fn value_to_view(value: &GateValue) -> GwGateValue { let data = match value { GateValue::Qubit(v) => GwGateValueData { qubit: *v }, @@ -146,6 +205,32 @@ pub(crate) fn value_to_view(value: &GateValue) -> GwGateValue { } } +pub(crate) fn metadata_to_view(metadata: &GateMetadata) -> GwGateMetadata { + let (data, bytes_ptr, bytes_len) = match &metadata.value { + MetadataValue::Bool(v) => ( + GwMetadataValueData { + bool_value: u8::from(*v), + }, + ptr::null(), + 0, + ), + MetadataValue::I64(v) => (GwMetadataValueData { i64_value: *v }, ptr::null(), 0), + MetadataValue::U64(v) => (GwMetadataValueData { u64_value: *v }, ptr::null(), 0), + MetadataValue::F64(v) => (GwMetadataValueData { f64_value: *v }, ptr::null(), 0), + MetadataValue::String(v) => (GwMetadataValueData::default(), v.as_ptr(), v.len()), + MetadataValue::Bytes(v) => (GwMetadataValueData::default(), v.as_ptr(), v.len()), + }; + GwGateMetadata { + key_ptr: metadata.key.as_ptr().cast::(), + key_len: metadata.key.len(), + value_kind: metadata.value.kind(), + data, + bytes_ptr, + bytes_len, + ..Default::default() + } +} + pub(crate) fn write_to_out( bytes: &[u8], buffer: *mut u8, diff --git a/selene-core/rust/gatewire/ffi/functions.rs b/selene-core/rust/gatewire/ffi/functions.rs index fbaf2eca..bdd52ac0 100644 --- a/selene-core/rust/gatewire/ffi/functions.rs +++ b/selene-core/rust/gatewire/ffi/functions.rs @@ -271,7 +271,7 @@ pub unsafe extern "C" fn gw_gate_serialized_len( if view.is_null() || out.is_null() { return Err(GateError::NullPointer); } - *out = gate_instance_from_view(&*view)?.serialize().len(); + *out = gate_instance_from_view(view)?.serialize().len(); Ok(()) }) } @@ -287,7 +287,7 @@ pub unsafe extern "C" fn gw_gate_serialize( if view.is_null() { return Err(GateError::NullPointer); } - let bytes = gate_instance_from_view(&*view)?.serialize(); + let bytes = gate_instance_from_view(view)?.serialize(); write_to_out(&bytes, buffer, buffer_len, written) }) } @@ -362,6 +362,59 @@ pub unsafe extern "C" fn gw_decoded_gate_value_at( }) } +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gw_decoded_gate_metadata_count( + gate: *const GwDecodedGate, + out: *mut usize, +) -> GwStatus { + ffi_result(|| unsafe { + if out.is_null() { + return Err(GateError::NullPointer); + } + *out = as_decoded(gate)?.metadata.len(); + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gw_decoded_gate_metadata_at( + gate: *const GwDecodedGate, + index: usize, + out: *mut GwGateMetadata, +) -> GwStatus { + ffi_result(|| unsafe { + if out.is_null() { + return Err(GateError::NullPointer); + } + let gate = as_decoded(gate)?; + let metadata = gate.metadata.get(index).ok_or(GateError::UnknownGate)?; + *out = metadata_to_view(metadata); + Ok(()) + }) +} + +#[unsafe(no_mangle)] +pub unsafe extern "C" fn gw_decoded_gate_metadata_find( + gate: *const GwDecodedGate, + key_ptr: *const c_char, + key_len: usize, + out: *mut GwGateMetadata, +) -> GwStatus { + ffi_result(|| unsafe { + if out.is_null() { + return Err(GateError::NullPointer); + } + let key = read_text(key_ptr, key_len)?; + let metadata = as_decoded(gate)? + .metadata + .iter() + .find(|entry| entry.key == key) + .ok_or(GateError::UnknownGate)?; + *out = metadata_to_view(metadata); + Ok(()) + }) +} + #[unsafe(no_mangle)] pub unsafe extern "C" fn gw_decoded_gate_qubit_operand_count( gate: *const GwDecodedGate, diff --git a/selene-core/rust/gatewire/ffi/types.rs b/selene-core/rust/gatewire/ffi/types.rs index 2bc71fbb..43342798 100644 --- a/selene-core/rust/gatewire/ffi/types.rs +++ b/selene-core/rust/gatewire/ffi/types.rs @@ -1,4 +1,8 @@ use crate::gatewire::GateSemanticId; +use crate::gatewire::metadata::{ + GW_METADATA_VALUE_KIND_BOOL, GW_METADATA_VALUE_KIND_BYTES, GW_METADATA_VALUE_KIND_F64, + GW_METADATA_VALUE_KIND_I64, GW_METADATA_VALUE_KIND_STRING, GW_METADATA_VALUE_KIND_U64, +}; use std::ffi::c_char; use std::{mem, ptr}; @@ -119,6 +123,49 @@ pub struct GwGateInstanceView { pub semantic_id: GwSemanticId, pub values_ptr: *const GwGateValue, pub values_len: usize, + pub metadata_ptr: *const GwGateMetadata, + pub metadata_len: usize, +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub union GwMetadataValueData { + pub bool_value: u8, + pub i64_value: i64, + pub u64_value: u64, + pub f64_value: f64, +} + +impl Default for GwMetadataValueData { + fn default() -> Self { + Self { u64_value: 0 } + } +} + +#[repr(C)] +#[derive(Clone, Copy)] +pub struct GwGateMetadata { + pub abi_size: usize, + pub key_ptr: *const c_char, + pub key_len: usize, + pub value_kind: u32, + pub data: GwMetadataValueData, + pub bytes_ptr: *const u8, + pub bytes_len: usize, +} + +impl Default for GwGateMetadata { + fn default() -> Self { + Self { + abi_size: mem::size_of::(), + key_ptr: ptr::null(), + key_len: 0, + value_kind: 0, + data: GwMetadataValueData::default(), + bytes_ptr: ptr::null(), + bytes_len: 0, + } + } } #[repr(C)] @@ -134,3 +181,10 @@ pub struct GwDecodedGate { static_assertions::const_assert_eq!(std::mem::size_of::(), 16); static_assertions::assert_impl_all!(GwSemanticId: Copy, Clone); static_assertions::assert_impl_all!(GwGateValue: Copy, Clone); +static_assertions::assert_impl_all!(GwGateMetadata: Copy, Clone); +static_assertions::const_assert_eq!(GW_METADATA_VALUE_KIND_BOOL, 1); +static_assertions::const_assert_eq!(GW_METADATA_VALUE_KIND_I64, 2); +static_assertions::const_assert_eq!(GW_METADATA_VALUE_KIND_U64, 3); +static_assertions::const_assert_eq!(GW_METADATA_VALUE_KIND_F64, 4); +static_assertions::const_assert_eq!(GW_METADATA_VALUE_KIND_STRING, 5); +static_assertions::const_assert_eq!(GW_METADATA_VALUE_KIND_BYTES, 6); diff --git a/selene-core/rust/gatewire/instance.rs b/selene-core/rust/gatewire/instance.rs index 2b452a59..7e7d9bc5 100644 --- a/selene-core/rust/gatewire/instance.rs +++ b/selene-core/rust/gatewire/instance.rs @@ -1,9 +1,13 @@ -use crate::gatewire::{GateError, GateSemanticId, GateValue, SmallGateValues, wire}; +use crate::gatewire::{ + GateError, GateMetadata, GateSemanticId, GateValue, MetadataValue, SmallGateMetadata, + SmallGateValues, wire, +}; #[derive(Clone, Debug, PartialEq)] pub struct OwnedGateInstance { pub semantic_id: GateSemanticId, pub operands: SmallGateValues, + pub metadata: SmallGateMetadata, } impl OwnedGateInstance { @@ -14,9 +18,41 @@ impl OwnedGateInstance { Self { semantic_id, operands: operands.into_iter().collect(), + metadata: SmallGateMetadata::new(), } } + pub fn with_metadata( + mut self, + key: impl Into, + value: impl Into, + ) -> Self { + self.set_metadata(key, value); + self + } + + pub fn set_metadata(&mut self, key: impl Into, value: impl Into) { + let key = key.into(); + if let Some(entry) = self.metadata.iter_mut().find(|entry| entry.key == key) { + entry.value = value.into(); + } else { + self.metadata.push(GateMetadata::new(key, value)); + } + } + + pub fn metadata(&self, key: &str) -> Option<&MetadataValue> { + self.metadata + .iter() + .find(|entry| entry.key == key) + .map(|entry| &entry.value) + } + + pub fn metadata_iter(&self) -> impl ExactSizeIterator + '_ { + self.metadata + .iter() + .map(|entry| (entry.key.as_str(), &entry.value)) + } + pub fn qubit_operands(&self) -> impl Iterator + '_ { self.operands.iter().filter_map(|value| match value { GateValue::Qubit(qubit) => Some(*qubit), diff --git a/selene-core/rust/gatewire/metadata.rs b/selene-core/rust/gatewire/metadata.rs new file mode 100644 index 00000000..98ee01d8 --- /dev/null +++ b/selene-core/rust/gatewire/metadata.rs @@ -0,0 +1,96 @@ +use smallvec::SmallVec; + +pub const GW_METADATA_VALUE_KIND_BOOL: u32 = 1; +pub const GW_METADATA_VALUE_KIND_I64: u32 = 2; +pub const GW_METADATA_VALUE_KIND_U64: u32 = 3; +pub const GW_METADATA_VALUE_KIND_F64: u32 = 4; +pub const GW_METADATA_VALUE_KIND_STRING: u32 = 5; +pub const GW_METADATA_VALUE_KIND_BYTES: u32 = 6; + +#[derive(Clone, Debug, PartialEq)] +pub enum MetadataValue { + Bool(bool), + I64(i64), + U64(u64), + F64(f64), + String(String), + Bytes(Vec), +} + +impl MetadataValue { + pub const fn kind(&self) -> u32 { + match self { + Self::Bool(_) => GW_METADATA_VALUE_KIND_BOOL, + Self::I64(_) => GW_METADATA_VALUE_KIND_I64, + Self::U64(_) => GW_METADATA_VALUE_KIND_U64, + Self::F64(_) => GW_METADATA_VALUE_KIND_F64, + Self::String(_) => GW_METADATA_VALUE_KIND_STRING, + Self::Bytes(_) => GW_METADATA_VALUE_KIND_BYTES, + } + } +} + +impl From for MetadataValue { + fn from(value: bool) -> Self { + Self::Bool(value) + } +} + +impl From for MetadataValue { + fn from(value: i64) -> Self { + Self::I64(value) + } +} + +impl From for MetadataValue { + fn from(value: u64) -> Self { + Self::U64(value) + } +} + +impl From for MetadataValue { + fn from(value: f64) -> Self { + Self::F64(value) + } +} + +impl From for MetadataValue { + fn from(value: String) -> Self { + Self::String(value) + } +} + +impl From<&str> for MetadataValue { + fn from(value: &str) -> Self { + Self::String(value.to_owned()) + } +} + +impl From> for MetadataValue { + fn from(value: Vec) -> Self { + Self::Bytes(value) + } +} + +impl From<&[u8]> for MetadataValue { + fn from(value: &[u8]) -> Self { + Self::Bytes(value.to_vec()) + } +} + +#[derive(Clone, Debug, PartialEq)] +pub struct GateMetadata { + pub key: String, + pub value: MetadataValue, +} + +impl GateMetadata { + pub fn new(key: impl Into, value: impl Into) -> Self { + Self { + key: key.into(), + value: value.into(), + } + } +} + +pub type SmallGateMetadata = SmallVec<[GateMetadata; 2]>; diff --git a/selene-core/rust/gatewire/tests.rs b/selene-core/rust/gatewire/tests.rs index 1fcab057..bc31042a 100644 --- a/selene-core/rust/gatewire/tests.rs +++ b/selene-core/rust/gatewire/tests.rs @@ -1,5 +1,7 @@ use super::builtin::{PhasedX, PhasedXX, QuantinuumGate, RZ, ZZPhase}; -use crate::gatewire::{Angle, DynamicGateSet, GateSet, Qubit}; +use crate::gatewire::{ + Angle, DynamicGateSet, GateOperand, GateSet, MetadataValue, OwnedGateInstance, Qubit, +}; use crate::runtime::Operation; crate::define_gateset! { @@ -33,6 +35,49 @@ fn dynamic_gateset_roundtrip() { assert_eq!(dynamic.len(), 3); } +#[test] +fn gate_metadata_roundtrip_uses_wire_v2() { + let gate = OwnedGateInstance::new( + RZ::semantic_id(), + [Qubit(0).into_value(), Angle(0.25).into_value()], + ) + .with_metadata("source", "compiler-pass") + .with_metadata("logical_id", 42_u64) + .with_metadata("payload", vec![1_u8, 2, 3]); + + let bytes = gate.serialize(); + assert_eq!(&bytes[4..6], &2_u16.to_le_bytes()); + + let decoded = OwnedGateInstance::deserialize(&bytes).unwrap(); + assert_eq!( + decoded.metadata("source"), + Some(&MetadataValue::String("compiler-pass".to_owned())) + ); + assert_eq!( + decoded.metadata("logical_id"), + Some(&MetadataValue::U64(42)) + ); + assert_eq!( + decoded.metadata("payload"), + Some(&MetadataValue::Bytes(vec![1, 2, 3])) + ); +} + +#[test] +fn gate_without_metadata_still_uses_wire_v1() { + let set = GateSet::::new().unwrap(); + let gate = ExampleGateSet::PhasedXX(PhasedXX { + q0: Qubit(0), + q1: Qubit(1), + theta: Angle(0.25), + phi: Angle(1.25), + }); + let bytes = set.serialize_gate(&gate).unwrap(); + + assert_eq!(&bytes[4..6], &1_u16.to_le_bytes()); + assert_eq!(set.try_deserialize(&bytes).unwrap().unwrap(), gate); +} + #[test] fn dynamic_gateset_subset_and_superset_use_semantic_ids() { let all = DynamicGateSet::from_declarations([ diff --git a/selene-core/rust/gatewire/typed.rs b/selene-core/rust/gatewire/typed.rs index 36f06127..2ea832d8 100644 --- a/selene-core/rust/gatewire/typed.rs +++ b/selene-core/rust/gatewire/typed.rs @@ -27,6 +27,7 @@ pub trait GateView: Sized { } #[derive(Clone, Debug, PartialEq)] +#[allow(clippy::large_enum_variant)] pub enum TryDecode { Decoded(G), Unknown(OwnedGateInstance), diff --git a/selene-core/rust/gatewire/wire.rs b/selene-core/rust/gatewire/wire.rs index 57dfba5d..0ac2d00a 100644 --- a/selene-core/rust/gatewire/wire.rs +++ b/selene-core/rust/gatewire/wire.rs @@ -1,16 +1,23 @@ use crate::gatewire::{ DynamicGateSet, GateDecl, GateError, GateSemanticId, GateValue, OperandKind, OperandSpec, OwnedGateInstance, + metadata::{ + GW_METADATA_VALUE_KIND_BOOL, GW_METADATA_VALUE_KIND_BYTES, GW_METADATA_VALUE_KIND_F64, + GW_METADATA_VALUE_KIND_I64, GW_METADATA_VALUE_KIND_STRING, GW_METADATA_VALUE_KIND_U64, + GateMetadata, MetadataValue, + }, }; const GATE_MAGIC: [u8; 4] = *b"GWG1"; const GATESET_MAGIC: [u8; 4] = *b"GWS1"; -const WIRE_VERSION: u16 = 1; +const GATESET_WIRE_VERSION: u16 = 1; +const GATE_WIRE_VERSION_V1: u16 = 1; +const GATE_WIRE_VERSION_V2: u16 = 2; pub fn serialize_gateset(set: &DynamicGateSet) -> Vec { let mut out = Vec::new(); out.extend_from_slice(&GATESET_MAGIC); - write_u16(&mut out, WIRE_VERSION); + write_u16(&mut out, GATESET_WIRE_VERSION); write_u16(&mut out, 0); write_u32(&mut out, set.len() as u32); @@ -35,7 +42,7 @@ pub fn deserialize_gateset(data: &[u8]) -> Result { return Err(GateError::Decode("bad gateset magic")); } let version = cur.read_u16()?; - if version != WIRE_VERSION { + if version != GATESET_WIRE_VERSION { return Err(GateError::Decode("unsupported gateset wire version")); } let _reserved = cur.read_u16()?; @@ -68,7 +75,12 @@ pub fn deserialize_gateset(data: &[u8]) -> Result { pub fn serialize_gate_instance(instance: &OwnedGateInstance) -> Vec { let mut out = Vec::new(); out.extend_from_slice(&GATE_MAGIC); - write_u16(&mut out, WIRE_VERSION); + let version = if instance.metadata.is_empty() { + GATE_WIRE_VERSION_V1 + } else { + GATE_WIRE_VERSION_V2 + }; + write_u16(&mut out, version); write_u16(&mut out, 0); out.extend_from_slice(&instance.semantic_id.bytes); write_u32(&mut out, instance.operands.len() as u32); @@ -85,6 +97,22 @@ pub fn serialize_gate_instance(instance: &OwnedGateInstance) -> Vec { } } + if version >= GATE_WIRE_VERSION_V2 { + write_u32(&mut out, instance.metadata.len() as u32); + for entry in &instance.metadata { + write_string(&mut out, &entry.key); + write_u32(&mut out, entry.value.kind()); + match &entry.value { + MetadataValue::Bool(v) => out.push(if *v { 1 } else { 0 }), + MetadataValue::I64(v) => write_u64(&mut out, *v as u64), + MetadataValue::U64(v) => write_u64(&mut out, *v), + MetadataValue::F64(v) => write_u64(&mut out, v.to_bits()), + MetadataValue::String(v) => write_string(&mut out, v), + MetadataValue::Bytes(v) => write_bytes(&mut out, v), + } + } + } + out } @@ -95,7 +123,7 @@ pub fn deserialize_gate_instance(data: &[u8]) -> Result Result= GATE_WIRE_VERSION_V2 { + let metadata_count = cur.read_u32()? as usize; + let mut metadata = Vec::with_capacity(metadata_count); + for _ in 0..metadata_count { + let key = cur.read_string()?; + let kind = cur.read_u32()?; + let value = match kind { + GW_METADATA_VALUE_KIND_BOOL => match cur.read_u8()? { + 0 => MetadataValue::Bool(false), + 1 => MetadataValue::Bool(true), + _ => return Err(GateError::Decode("invalid bool metadata value")), + }, + GW_METADATA_VALUE_KIND_I64 => MetadataValue::I64(cur.read_u64()? as i64), + GW_METADATA_VALUE_KIND_U64 => MetadataValue::U64(cur.read_u64()?), + GW_METADATA_VALUE_KIND_F64 => MetadataValue::F64(f64::from_bits(cur.read_u64()?)), + GW_METADATA_VALUE_KIND_STRING => MetadataValue::String(cur.read_string()?), + GW_METADATA_VALUE_KIND_BYTES => MetadataValue::Bytes(cur.read_bytes_vec()?), + _ => return Err(GateError::Decode("invalid metadata value kind")), + }; + metadata.push(GateMetadata { key, value }); + } + metadata + } else { + Vec::new() + }; + cur.finish()?; - Ok(OwnedGateInstance::new(semantic_id, operands)) + let mut instance = OwnedGateInstance::new(semantic_id, operands); + instance.metadata = metadata.into_iter().collect(); + Ok(instance) } fn write_u16(out: &mut Vec, value: u16) { @@ -139,6 +195,11 @@ fn write_string(out: &mut Vec, value: &str) { out.extend_from_slice(value.as_bytes()); } +fn write_bytes(out: &mut Vec, value: &[u8]) { + write_u32(out, value.len() as u32); + out.extend_from_slice(value); +} + struct Cursor<'a> { data: &'a [u8], offset: usize, @@ -195,6 +256,11 @@ impl<'a> Cursor<'a> { Ok(String::from_utf8(self.read_exact(len)?.to_vec())?) } + fn read_bytes_vec(&mut self) -> Result, GateError> { + let len = self.read_u32()? as usize; + Ok(self.read_exact(len)?.to_vec()) + } + fn finish(self) -> Result<(), GateError> { if self.offset == self.data.len() { Ok(()) diff --git a/selene-core/rust/operation.rs b/selene-core/rust/operation.rs index 509aecf5..2cbcd9ea 100644 --- a/selene-core/rust/operation.rs +++ b/selene-core/rust/operation.rs @@ -152,6 +152,13 @@ impl Operation { } } + pub fn with_gate_metadata_from(mut self, source: &OwnedGateInstance) -> Self { + if let Self::Gate { gate } = &mut self { + gate.metadata = source.metadata.clone(); + } + self + } + pub fn as_gate(&self) -> Result> { let Self::Gate { gate } = self else { return Ok(None); diff --git a/selene-ext/interfaces/base_qis/c/CMakeLists.txt b/selene-ext/interfaces/base_qis/c/CMakeLists.txt index 1d0b0823..f82909c4 100644 --- a/selene-ext/interfaces/base_qis/c/CMakeLists.txt +++ b/selene-ext/interfaces/base_qis/c/CMakeLists.txt @@ -8,6 +8,11 @@ include(CMakePackageConfigHelpers) find_package(Selene REQUIRED) get_target_property(selene_include_dirs Selene::Selene INTERFACE_INCLUDE_DIRECTORIES) +set(SELENE_CORE_INCLUDE_DIR "" CACHE PATH "Directory containing selene-core C headers") +if(NOT SELENE_CORE_INCLUDE_DIR) + message(FATAL_ERROR "SELENE_CORE_INCLUDE_DIR is required to build the base QIS interface") +endif() + # we provide three shared libraries with the same interface, but # with different logging levels. @@ -42,8 +47,13 @@ function(build_base_qis_interface log_level suffix) SELENE_LOG_LEVEL=${log_level} BUILDING_QIS_INTERFACE=1 ) + target_include_directories(${runtime_target} PRIVATE + "${selene_include_dirs}" + "${SELENE_CORE_INCLUDE_DIR}" + ) target_link_libraries(${runtime_target} PUBLIC base_qis_selene_interface + ${CMAKE_DL_LIBS} ) if(MSVC) target_compile_options(${runtime_target} PRIVATE /TC /Zl) diff --git a/selene-ext/interfaces/base_qis/c/include/base_qis/gate_metadata.h b/selene-ext/interfaces/base_qis/c/include/base_qis/gate_metadata.h new file mode 100644 index 00000000..17444839 --- /dev/null +++ b/selene-ext/interfaces/base_qis/c/include/base_qis/gate_metadata.h @@ -0,0 +1,42 @@ +#ifndef BASE_QIS_GATE_METADATA_H +#define BASE_QIS_GATE_METADATA_H + +#include + +#include +#include + +#ifndef SELENE_LOG_LEVEL +#define SELENE_LOG_LEVEL 0 +#endif + +#define QIS_GATE_METADATA_CAPACITY 4 + +#if defined(_MSC_VER) +#include +#define QIS_RETURN_ADDRESS() _ReturnAddress() +#else +#define QIS_RETURN_ADDRESS() __builtin_return_address(0) +#endif + +EXPORT size_t qis_capture_gate_metadata( + void* return_address, + GwGateMetadata* out, + size_t out_len +); + +#if (SELENE_LOG_LEVEL) == 2 +#define QIS_EMIT_GATE(semantic_id, values, values_len) \ + do { \ + GwGateMetadata qis_metadata[QIS_GATE_METADATA_CAPACITY]; \ + size_t qis_metadata_len = qis_capture_gate_metadata( \ + QIS_RETURN_ADDRESS(), qis_metadata, QIS_GATE_METADATA_CAPACITY \ + ); \ + emit_gate((semantic_id), (values), (values_len), qis_metadata, qis_metadata_len); \ + } while (0) +#else +#define QIS_EMIT_GATE(semantic_id, values, values_len) \ + emit_gate((semantic_id), (values), (values_len), NULL, 0) +#endif + +#endif diff --git a/selene-ext/interfaces/base_qis/c/src/gate_metadata.c b/selene-ext/interfaces/base_qis/c/src/gate_metadata.c new file mode 100644 index 00000000..3f2d105c --- /dev/null +++ b/selene-ext/interfaces/base_qis/c/src/gate_metadata.c @@ -0,0 +1,116 @@ +#define _GNU_SOURCE + +#include + +#include +#include +#include + +#if defined(_WIN32) +#include +#define QIS_THREAD_LOCAL __declspec(thread) +#elif defined(__APPLE__) || defined(__unix__) +#include +#define QIS_THREAD_LOCAL _Thread_local +#else +#define QIS_THREAD_LOCAL +#endif + +typedef struct { + const char* path; + uintptr_t offset; + bool has_module; +} QisModuleInfo; + +static GwGateMetadata u64_metadata(const char* key, uint64_t value) { + GwGateMetadata metadata = { + .abi_size = sizeof(GwGateMetadata), + .key_ptr = key, + .key_len = strlen(key), + .value_kind = GW_METADATA_VALUE_KIND_U64, + .data.u64_value = value, + .bytes_ptr = NULL, + .bytes_len = 0, + }; + return metadata; +} + +static GwGateMetadata string_metadata(const char* key, const char* value) { + GwGateMetadata metadata = { + .abi_size = sizeof(GwGateMetadata), + .key_ptr = key, + .key_len = strlen(key), + .value_kind = GW_METADATA_VALUE_KIND_STRING, + .data.u64_value = 0, + .bytes_ptr = (const uint8_t*)value, + .bytes_len = strlen(value), + }; + return metadata; +} + +static QisModuleInfo module_info(void* return_address) { + QisModuleInfo result = { + .path = NULL, + .offset = 0, + .has_module = false, + }; + +#if defined(_WIN32) + HMODULE module = NULL; + if (GetModuleHandleExA( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + (LPCSTR)return_address, + &module + )) { + static QIS_THREAD_LOCAL char path[MAX_PATH]; + DWORD len = GetModuleFileNameA(module, path, (DWORD)sizeof(path)); + if (len > 0 && len < sizeof(path)) { + result.path = path; + result.offset = (uintptr_t)return_address - (uintptr_t)module; + result.has_module = true; + } + } +#elif defined(__APPLE__) || defined(__unix__) + Dl_info info; + if (dladdr(return_address, &info) != 0 && info.dli_fbase != NULL) { + result.path = info.dli_fname; + result.offset = (uintptr_t)return_address - (uintptr_t)info.dli_fbase; + result.has_module = true; + } +#else + (void)return_address; +#endif + + return result; +} + +size_t qis_capture_gate_metadata(void* return_address, GwGateMetadata* out, size_t out_len) { + size_t len = 0; + if (out == NULL || out_len == 0) { + return 0; + } + + out[len++] = u64_metadata( + "qis.call_site.return_address", (uint64_t)(uintptr_t)return_address + ); + if (len == out_len) { + return len; + } + + QisModuleInfo module = module_info(return_address); + if (module.has_module) { + out[len++] = u64_metadata("qis.call_site.module_offset", (uint64_t)module.offset); + if (len == out_len) { + return len; + } + if (module.path != NULL) { + out[len++] = string_metadata("qis.call_site.module", module.path); + if (len == out_len) { + return len; + } + } + } + + out[len++] = string_metadata("qis.symbolization", "return-address-v1"); + return len; +} diff --git a/selene-ext/interfaces/helios_qis/c/src/helios_ops.c b/selene-ext/interfaces/helios_qis/c/src/helios_ops.c index 3650a0d3..fd828d7e 100644 --- a/selene-ext/interfaces/helios_qis/c/src/helios_ops.c +++ b/selene-ext/interfaces/helios_qis/c/src/helios_ops.c @@ -2,6 +2,7 @@ #include #include // selene_ functions +#include #include // selene_instance #include // unwrap @@ -53,12 +54,20 @@ static GwGateValue angle_value(double angle) { return value; } -static void emit_gate(GwSemanticId semantic_id, GwGateValue const* values, size_t values_len) { +static void emit_gate( + GwSemanticId semantic_id, + GwGateValue const* values, + size_t values_len, + GwGateMetadata const* metadata, + size_t metadata_len +) { GwGateInstanceView gate = { .abi_size = sizeof(GwGateInstanceView), .semantic_id = semantic_id, .values_ptr = values, .values_len = values_len, + .metadata_ptr = metadata, + .metadata_len = metadata_len, }; size_t len = 0; check_gw(gw_gate_serialized_len(&gate, &len)); @@ -88,21 +97,21 @@ void ___rxy(uint64_t q, double theta, double phi) { DIAGNOSTIC("___rxy(%" PRIu64 ", %f, %f)\n", q, theta, phi); init_gate_ids(); GwGateValue values[] = {qubit_value(q), angle_value(theta), angle_value(phi)}; - emit_gate(phased_x_id, values, 3); + QIS_EMIT_GATE(phased_x_id, values, 3); DIAGNOSTIC(" [done]\n"); } void ___rzz(uint64_t q1, uint64_t q2, double theta) { DIAGNOSTIC("___rzz(%" PRIu64 ", %" PRIu64 ", %f)\n", q1, q2, theta); init_gate_ids(); GwGateValue values[] = {qubit_value(q1), qubit_value(q2), angle_value(theta)}; - emit_gate(zz_phase_id, values, 3); + QIS_EMIT_GATE(zz_phase_id, values, 3); DIAGNOSTIC(" [done]\n"); } void ___rz(uint64_t q, double theta) { DIAGNOSTIC("___rz(%" PRIu64 ", %f)\n", q, theta); init_gate_ids(); GwGateValue values[] = {qubit_value(q), angle_value(theta)}; - emit_gate(rz_id, values, 2); + QIS_EMIT_GATE(rz_id, values, 2); DIAGNOSTIC(" [done]\n"); } void ___reset(uint64_t q) { diff --git a/selene-ext/interfaces/sol_qis/c/src/sol_ops.c b/selene-ext/interfaces/sol_qis/c/src/sol_ops.c index 01a6b122..19c4d634 100644 --- a/selene-ext/interfaces/sol_qis/c/src/sol_ops.c +++ b/selene-ext/interfaces/sol_qis/c/src/sol_ops.c @@ -2,6 +2,7 @@ #include #include // selene_ functions +#include #include // selene_instance #include // unwrap @@ -53,12 +54,20 @@ static GwGateValue angle_value(double angle) { return value; } -static void emit_gate(GwSemanticId semantic_id, GwGateValue const* values, size_t values_len) { +static void emit_gate( + GwSemanticId semantic_id, + GwGateValue const* values, + size_t values_len, + GwGateMetadata const* metadata, + size_t metadata_len +) { GwGateInstanceView gate = { .abi_size = sizeof(GwGateInstanceView), .semantic_id = semantic_id, .values_ptr = values, .values_len = values_len, + .metadata_ptr = metadata, + .metadata_len = metadata_len, }; size_t len = 0; check_gw(gw_gate_serialized_len(&gate, &len)); @@ -88,21 +97,21 @@ void ___rp(uint64_t q, double theta, double phi) { DIAGNOSTIC("___rp(%" PRIu64 ", %f, %f)\n", q, theta, phi); init_gate_ids(); GwGateValue values[] = {qubit_value(q), angle_value(theta), angle_value(phi)}; - emit_gate(phased_x_id, values, 3); + QIS_EMIT_GATE(phased_x_id, values, 3); DIAGNOSTIC(" [done]\n"); } void ___rz(uint64_t q, double theta) { DIAGNOSTIC("___rz(%" PRIu64 ", %f)\n", q, theta); init_gate_ids(); GwGateValue values[] = {qubit_value(q), angle_value(theta)}; - emit_gate(rz_id, values, 2); + QIS_EMIT_GATE(rz_id, values, 2); DIAGNOSTIC(" [done]\n"); } void ___rpp(uint64_t q1, uint64_t q2, double theta, double phi) { DIAGNOSTIC("___rpp(%" PRIu64 ", %" PRIu64 ", %f, %f)\n", q1, q2, theta, phi); init_gate_ids(); GwGateValue values[] = {qubit_value(q1), qubit_value(q2), angle_value(theta), angle_value(phi)}; - emit_gate(phased_xx_id, values, 4); + QIS_EMIT_GATE(phased_xx_id, values, 4); DIAGNOSTIC(" [done]\n"); } void ___reset(uint64_t q) { diff --git a/selene-ext/runtimes/simple/rust/lib.rs b/selene-ext/runtimes/simple/rust/lib.rs index 54708a6c..b82aac28 100644 --- a/selene-ext/runtimes/simple/rust/lib.rs +++ b/selene-ext/runtimes/simple/rust/lib.rs @@ -151,7 +151,7 @@ impl RuntimeInterface for SimpleRuntime { let QubitStatus::Active = self.qubits[qubit_id as usize] else { bail!("Qubit {qubit_id} is not active"); }; - self.push(Operation::phased_x(qubit_id, theta, phi)?); + self.push(Operation::phased_x(qubit_id, theta, phi)?.with_gate_metadata_from(gate)); Ok(()) } Some(builtin::QuantinuumGate::ZZPhase { @@ -165,7 +165,10 @@ impl RuntimeInterface for SimpleRuntime { if qubit_id_2 >= self.qubits.len() as u64 { bail!("applying ZZPhase gate to out-of-bounds qubit2 {qubit_id_2}"); } - self.push(Operation::zz_phase(qubit_id_1, qubit_id_2, theta)?); + self.push( + Operation::zz_phase(qubit_id_1, qubit_id_2, theta)? + .with_gate_metadata_from(gate), + ); Ok(()) } Some(builtin::QuantinuumGate::RZ { qubit_id, theta }) => { @@ -175,7 +178,7 @@ impl RuntimeInterface for SimpleRuntime { let QubitStatus::Active = self.qubits[qubit_id as usize] else { bail!("Qubit {qubit_id} is not active"); }; - self.push(Operation::rz(qubit_id, theta)?); + self.push(Operation::rz(qubit_id, theta)?.with_gate_metadata_from(gate)); Ok(()) } Some(builtin::QuantinuumGate::PhasedXX { @@ -196,7 +199,10 @@ impl RuntimeInterface for SimpleRuntime { let QubitStatus::Active = self.qubits[qubit_id_2 as usize] else { bail!("Qubit {qubit_id_2} is not active"); }; - self.push(Operation::phased_xx(qubit_id_1, qubit_id_2, theta, phi)?); + self.push( + Operation::phased_xx(qubit_id_1, qubit_id_2, theta, phi)? + .with_gate_metadata_from(gate), + ); Ok(()) } None => bail!("SimpleRuntime does not support this gate"), diff --git a/selene-sim/python/selene_sim/event_hooks/instruction_log.py b/selene-sim/python/selene_sim/event_hooks/instruction_log.py index ef9a0f45..4bb1d324 100644 --- a/selene-sim/python/selene_sim/event_hooks/instruction_log.py +++ b/selene-sim/python/selene_sim/event_hooks/instruction_log.py @@ -202,18 +202,23 @@ def append_to_circuit(self, circuit: "pytket.Circuit"): ) def to_dict(self) -> dict: - return { + result: dict[str, Any] = { "op": "Gate", "gate": self.gate_name(), "qubits": self.qubits(), "params": self.params(), } + metadata = self.metadata() + if metadata: + result["metadata"] = metadata + return result def to_trace_event(self) -> GateEvent: return GateEvent( gate_name=self.gate_name(), qubits=self.qubits(), params=self.params(), + metadata=self.metadata(), ) @staticmethod @@ -256,6 +261,9 @@ def params(self) -> list[int | float | bool]: if operand.kind.name != "QUBIT" ] + def metadata(self) -> dict[str, str | int | float | bool | bytes]: + return {entry.key: entry.value.value for entry in self.gate.metadata} + @dataclass class Reset(Operation): diff --git a/selene-sim/python/tests/test_gate_metadata_events.py b/selene-sim/python/tests/test_gate_metadata_events.py new file mode 100644 index 00000000..f7e25823 --- /dev/null +++ b/selene-sim/python/tests/test_gate_metadata_events.py @@ -0,0 +1,36 @@ +from selene_core import Gate, GateValue, MetadataValue, RZ +from selene_sim.event_hooks.instruction_log import GateInstruction + + +def test_gate_instruction_exposes_metadata_in_dict_and_trace(): + gate = Gate( + RZ.semantic_id, + [GateValue.qubit(0), GateValue.f64(0.25)], + { + "source": "compiler", + "logical_id": MetadataValue.u64(42), + "payload": b"abc", + }, + ) + instruction = GateInstruction.from_iterator(iter([gate.serialize()])) + + assert instruction.to_dict()["metadata"] == { + "source": "compiler", + "logical_id": 42, + "payload": b"abc", + } + + trace_event = instruction.to_trace_event() + assert trace_event.metadata == { + "source": "compiler", + "logical_id": 42, + "payload": b"abc", + } + + +def test_gate_instruction_omits_empty_metadata_from_dict(): + gate = Gate(RZ.semantic_id, [GateValue.qubit(0), GateValue.f64(0.25)]) + instruction = GateInstruction.from_iterator(iter([gate.serialize()])) + + assert "metadata" not in instruction.to_dict() + assert instruction.to_trace_event().metadata == {} diff --git a/selene-sim/python/tests/test_interactive.py b/selene-sim/python/tests/test_interactive.py index 31807990..03bb789b 100644 --- a/selene-sim/python/tests/test_interactive.py +++ b/selene-sim/python/tests/test_interactive.py @@ -8,7 +8,13 @@ import numpy as np from selene_core import Gateset, PhasedX, RZ, ZZPhase -from selene_sim import DepolarizingErrorModel, Quest, SoftRZRuntime, SimpleRuntime +from selene_sim import ( + DepolarizingErrorModel, + IdealErrorModel, + Quest, + SoftRZRuntime, + SimpleRuntime, +) from selene_sim.interactive import ( InteractiveFullStack, InteractiveSimulator, @@ -195,6 +201,46 @@ def test_interactive_full_stack_error_model_io(): ) +def test_interactive_full_stack_gate_metadata_reaches_trace(): + from selene_sim.event_hooks import CircuitExtractor + + hook = CircuitExtractor() + gates = NATIVE_GATES + metadata = {"source": "interactive-test", "logical_id": 7} + s = InteractiveFullStack( + simulator=Quest(random_seed=1234), + runtime=SimpleRuntime(), + error_model=IdealErrorModel(), + n_qubits=1, + event_hook=hook, + gateset=NATIVE_GATES, + ) + + q = s.qalloc() + s.gate( + gates.RZ(q.id, pi / 4) + .with_metadata("source", metadata["source"]) + .with_metadata("logical_id", metadata["logical_id"]) + ) + s.measure(q) + + trace = hook.shots[0].get_trace().clear_simulator_perf_timing() + metadata_by_source = { + record.source.kind: record.event.metadata + for record in trace.events + if record.event.kind == "Gate" + and record.event.gate_name == "RZ" + and record.event.metadata + } + + assert metadata_by_source == { + "UserProgram": metadata, + "Runtime": metadata, + "ErrorModel": metadata, + "Simulator": metadata, + } + + def test_interactive_full_stack_gateset_negotiation(): s = InteractiveFullStack( simulator=Quest(random_seed=1234), diff --git a/selene-sim/python/tests/test_qis.py b/selene-sim/python/tests/test_qis.py index abf9771c..3d4f6419 100644 --- a/selene-sim/python/tests/test_qis.py +++ b/selene-sim/python/tests/test_qis.py @@ -2,10 +2,15 @@ from pathlib import Path import yaml +from selene_core import symbolize_qis_call_sites +from selene_core.build_utils.utils import invoke_zig +from selene_core.trace import EventRecord, GateEvent, SimulatorSource, Trace from selene_sim.event_hooks import CircuitExtractor, MetricStore, MultiEventHook +from selene_sim.event_hooks.instruction_log import GateInstruction, Source from selene_sim import Quest, SoftRZRuntime from selene_sim.build import build from selene_sim.exceptions import SeleneStartupError +from selene_base_qis_plugin import LogLevel from selene_helios_qis_plugin import HeliosInterface from selene_sol_qis_plugin import SolInterface @@ -13,6 +18,31 @@ QIS_RESOURCE_DIR = RESOURCE_DIR / "qis" +def _macho_section_address(path: Path, section_name: str) -> int: + from filebytes.mach_o import MachO + + binary = MachO(str(path)) + if binary.isFat: + binary = binary.fatArches[0] + for command in binary.loadCommands: + if not hasattr(command, "sections"): + continue + for section in command.sections: + if section.name == section_name: + return int(section.header.addr) + raise AssertionError(f"section {section_name} not found in {path}") + + +def _pe_section_rva(path: Path, section_name: str) -> int: + from filebytes.pe import PE + + binary = PE(str(path)) + for section in binary.sections: + if section.name == section_name: + return int(section.header.VirtualAddress) + raise AssertionError(f"section {section_name} not found in {path}") + + @pytest.mark.parametrize( "program_name", [ @@ -94,6 +124,231 @@ def test_qis_circuit_log(snapshot, program_name: str): snapshot.assert_match(yaml.dump(circuits), f"{program_name}_circuits.yaml") +@pytest.mark.parametrize( + ("interface", "qis_file"), + [ + ( + HeliosInterface(log_level=LogLevel.DIAGNOSTIC), + QIS_RESOURCE_DIR / "helios" / "add_3_11-any.ll", + ), + ( + SolInterface(log_level=LogLevel.DIAGNOSTIC), + QIS_RESOURCE_DIR / "sol" / "add_3_11-any.ll", + ), + ], +) +def test_qis_diagnostic_log_records_gate_call_site_metadata_in_trace( + interface, qis_file: Path +): + runner = build(qis_file, interface=interface) + circuit_extractor = CircuitExtractor() + + results = runner.run_shots( + Quest(), + n_qubits=10, + n_shots=1, + random_seed=1024, + event_hook=circuit_extractor, + ) + list(list(shot) for shot in results) + + shot_instructions = circuit_extractor.shots[0] + user_gate_instructions = [ + instruction + for instruction in shot_instructions + if instruction.source is Source.USER + and isinstance(instruction.operation, GateInstruction) + ] + assert user_gate_instructions + + for instruction in user_gate_instructions: + metadata = instruction.operation.metadata() + assert metadata["qis.symbolization"] == "return-address-v1" + assert metadata["qis.call_site.return_address"] > 0 + if "qis.call_site.module_offset" in metadata: + assert metadata["qis.call_site.module_offset"] > 0 + if "qis.call_site.module" in metadata: + assert metadata["qis.call_site.module"] + + trace = shot_instructions.get_trace() + traced_sources = { + record.source.kind + for record in trace.events + if isinstance(record.event, GateEvent) + and record.event.metadata.get("qis.symbolization") == "return-address-v1" + and record.event.metadata.get("qis.call_site.return_address", 0) > 0 + } + assert traced_sources >= {"UserProgram", "Runtime", "ErrorModel", "Simulator"} + + +def test_qis_diagnostic_trace_pass_symbolizes_simulator_gate_call_site(tmp_path): + source_lines = [ + "#include ", + "extern void setup(uint64_t);", + "extern uint64_t teardown(void);", + "extern uint64_t ___qalloc(void);", + "extern void ___reset(uint64_t);", + "extern void ___qfree(uint64_t);", + "extern void ___rxy(uint64_t, double, double);", + "", + "__attribute__((noinline)) static void apply_named_gate(uint64_t q) {", + " ___rxy(q, 3.141592653589793, 0.0);", + "}", + "", + "uint64_t qmain(uint64_t tc) {", + " setup(tc);", + " uint64_t q = ___qalloc();", + " ___reset(q);", + " apply_named_gate(q);", + " ___reset(q);", + " ___qfree(q);", + " return teardown();", + "}", + ] + gate_call_line = source_lines.index(" ___rxy(q, 3.141592653589793, 0.0);") + 1 + source_path = tmp_path / "debug_qis.c" + object_path = tmp_path / "debug_qis.o" + source_path.write_text("\n".join(source_lines) + "\n") + + invoke_zig( + "cc", + "-g", + "-O0", + "-fno-omit-frame-pointer", + "-c", + source_path, + "-o", + object_path, + cache_dir=tmp_path / "zig-cache", + ) + + runner = build( + object_path, + interface=HeliosInterface(log_level=LogLevel.DIAGNOSTIC), + build_dir=tmp_path / "selene-build", + ) + circuit_extractor = CircuitExtractor() + results = runner.run_shots( + Quest(), + n_qubits=1, + n_shots=1, + random_seed=1024, + event_hook=circuit_extractor, + ) + list(list(shot) for shot in results) + + enriched_trace = symbolize_qis_call_sites(circuit_extractor.shots[0].get_trace()) + simulator_gate_events = [ + record.event + for record in enriched_trace.events + if isinstance(record.source, SimulatorSource) + and isinstance(record.event, GateEvent) + and record.event.gate_name == "PhasedX" + ] + + assert simulator_gate_events + debug_stack = simulator_gate_events[0].debug_stack + assert debug_stack + assert debug_stack[0].function == "apply_named_gate" + assert Path(debug_stack[0].file).name == source_path.name + assert debug_stack[0].line == gate_call_line + assert "debug_stack" in simulator_gate_events[0].model_dump() + + +def test_qis_diagnostic_trace_pass_ignores_non_elf_modules(tmp_path): + module_path = tmp_path / "program.exe" + module_path.write_bytes(b"MZ") + trace = Trace( + events=[ + EventRecord( + source=SimulatorSource(index=0, duration_ns=0), + event=GateEvent( + gate_name="PhasedX", + metadata={ + "qis.call_site.module": str(module_path), + "qis.call_site.module_offset": 1, + "qis.call_site.return_address": 1, + }, + ), + ) + ] + ) + + enriched_trace = symbolize_qis_call_sites(trace) + + assert enriched_trace.events[0].event.debug_stack == [] + + +@pytest.mark.parametrize( + ("target", "output_name", "compile_flags", "module_offset"), + [ + ( + "x86_64-macos.11.0-none", + "debug_qis.o", + ["-c", "-g"], + lambda path: _macho_section_address(path, "__text"), + ), + ( + "x86_64-windows-gnu", + "debug_qis.exe", + ["-gdwarf-4"], + lambda path: _pe_section_rva(path, ".text"), + ), + ], +) +def test_qis_diagnostic_trace_pass_symbolizes_non_elf_dwarf_containers( + tmp_path, target, output_name, compile_flags, module_offset +): + source_lines = [ + "__attribute__((noinline)) int marker(void) {", + " return 7;", + "}", + "int main(void) {", + " return marker();", + "}", + ] + marker_line = source_lines.index("__attribute__((noinline)) int marker(void) {") + 1 + source_path = tmp_path / "debug_qis.c" + module_path = tmp_path / output_name + source_path.write_text("\n".join(source_lines) + "\n") + + invoke_zig( + "cc", + "-O0", + *compile_flags, + source_path, + "-o", + module_path, + "-target", + target, + handle_triple=False, + cache_dir=tmp_path / f"zig-cache-{output_name}", + ) + + trace = Trace( + events=[ + EventRecord( + source=SimulatorSource(index=0, duration_ns=0), + event=GateEvent( + gate_name="PhasedX", + metadata={ + "qis.call_site.module": str(module_path), + "qis.call_site.module_offset": module_offset(module_path), + }, + ), + ) + ] + ) + + enriched_trace = symbolize_qis_call_sites(trace) + debug_stack = enriched_trace.events[0].event.debug_stack + + assert debug_stack + assert debug_stack[0].function == "marker" + assert Path(debug_stack[0].file).name == source_path.name + assert debug_stack[0].line == marker_line + + def test_full_stack_gateset_handshake_rejects_unsupported_downstream_gate(): sol_file = QIS_RESOURCE_DIR / "sol" / "add_3_11-any.ll" assert sol_file.exists() diff --git a/uv.lock b/uv.lock index 0f1c88e7..b47a535d 100644 --- a/uv.lock +++ b/uv.lock @@ -257,6 +257,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, ] +[[package]] +name = "filebytes" +version = "0.10.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/44/4ea92a74ca7d7940a29d6c437f62a91fd05d43bfa04fc8306b5dc541d01d/filebytes-0.10.2.tar.gz", hash = "sha256:764202f74d79e7587f04b6ad46f7c50485d8f32c4aeddd02200f1651a0892741", size = 20358, upload-time = "2020-02-09T22:38:36.711Z" } + [[package]] name = "graphviz" version = "0.20.3" @@ -1073,6 +1079,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/32/a7125fb28c4261a627f999d5fb4afff25b523800faed2c30979949d6facd/pydot-4.0.1-py3-none-any.whl", hash = "sha256:869c0efadd2708c0be1f916eb669f3d664ca684bc57ffb7ecc08e70d5e93fee6", size = 37087, upload-time = "2025-06-17T20:09:55.25Z" }, ] +[[package]] +name = "pyelftools" +version = "0.33" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/11/767522582afab1b884d277de0e6e011640cb9d7292a38694b4b1a1df1ae8/pyelftools-0.33.tar.gz", hash = "sha256:660d82dcbeb8e83d1702bd97f223f761625da06111c0cc988eac6b8ab0c1b61f", size = 15068655, upload-time = "2026-05-29T12:56:22.553Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/46/2a/f9697576603dae937727827505a6126a066affb227034e77e6f9068910da/pyelftools-0.33-py3-none-any.whl", hash = "sha256:f215ad5f47d3f1373a21496a6c9e0707c622840d0622f23ff7ce08678b020036", size = 201178, upload-time = "2026-05-29T12:56:20.587Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1414,6 +1429,7 @@ source = { editable = "selene-core" } dependencies = [ { name = "blake3" }, { name = "cffi" }, + { name = "filebytes" }, { name = "hugr" }, { name = "lief" }, { name = "llvmlite", version = "0.45.1", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine == 'x86_64' and sys_platform == 'darwin'" }, @@ -1422,6 +1438,7 @@ dependencies = [ { name = "networkx", version = "3.6.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "pydantic" }, { name = "pydot" }, + { name = "pyelftools" }, { name = "pyyaml" }, { name = "typing-extensions" }, { name = "ziglang" }, @@ -1436,6 +1453,7 @@ dev = [ requires-dist = [ { name = "blake3", specifier = ">=1.0.0" }, { name = "cffi", specifier = ">=1.17.1" }, + { name = "filebytes", specifier = ">=0.10.2" }, { name = "hugr", specifier = ">=0.13.0" }, { name = "lief", specifier = ">=0.16.5" }, { name = "llvmlite", marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'", specifier = "~=0.47" }, @@ -1443,6 +1461,7 @@ requires-dist = [ { name = "networkx", specifier = ">=2.6,<4" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pydot", specifier = ">=4.0.0" }, + { name = "pyelftools", specifier = ">=0.33" }, { name = "pyyaml", specifier = "~=6.0" }, { name = "typing-extensions", specifier = ">=4" }, { name = "ziglang", specifier = "~=0.13" }, From 8bdaa5029eff4b7d7875472f8b814a3cd8d4611d Mon Sep 17 00:00:00 2001 From: Jake Arkinstall <65358059+jake-arkinstall@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:28:30 +0100 Subject: [PATCH 2/3] Use Symbolic for non-dwarf debug info --- selene-core/pyproject.toml | 3 +- selene-core/python/selene_core/__init__.py | 2 +- .../{trace_passes.py => debug_info.py} | 108 ++++++++++++++++-- uv.lock | 29 +++++ 4 files changed, 132 insertions(+), 10 deletions(-) rename selene-core/python/selene_core/{trace_passes.py => debug_info.py} (82%) diff --git a/selene-core/pyproject.toml b/selene-core/pyproject.toml index 1e9530f5..d15e8184 100644 --- a/selene-core/pyproject.toml +++ b/selene-core/pyproject.toml @@ -20,6 +20,7 @@ dependencies = [ "pydot>=4.0.0", "pyelftools>=0.33", "pyyaml~=6.0", + "symbolic>=13.8.0", "typing_extensions>=4", "ziglang~=0.13", ] @@ -46,7 +47,7 @@ path = "hatch_build.py" [tool.uv] cache-keys = [ - { file = "python/selene_core/trace.py" }, + { file = "python/selene_core/**/*.py" }, { file = "c/include/selene/*.h" }, { file = "Cargo.toml" }, ] diff --git a/selene-core/python/selene_core/__init__.py b/selene-core/python/selene_core/__init__.py index 6036802c..945f202d 100644 --- a/selene-core/python/selene_core/__init__.py +++ b/selene-core/python/selene_core/__init__.py @@ -12,7 +12,7 @@ DEFAULT_BUILD_PLANNER, ) from .headers import get_include_directory -from .trace_passes import QisCallSiteSymbolizer, symbolize_qis_call_sites +from .debug_info import QisCallSiteSymbolizer, symbolize_qis_call_sites from .gatewire import ( BOOL, F64, diff --git a/selene-core/python/selene_core/trace_passes.py b/selene-core/python/selene_core/debug_info.py similarity index 82% rename from selene-core/python/selene_core/trace_passes.py rename to selene-core/python/selene_core/debug_info.py index 3b7a5278..ecaa5871 100644 --- a/selene-core/python/selene_core/trace_passes.py +++ b/selene-core/python/selene_core/debug_info.py @@ -4,7 +4,7 @@ from enum import Enum from io import BytesIO from pathlib import Path -from typing import BinaryIO +from typing import BinaryIO, Protocol from .trace import DebugStackFrame, GateEvent, Trace @@ -45,6 +45,89 @@ class _FunctionRange: name: str +class _DebugInfo(Protocol): + def close(self) -> None: ... + + def symbolize( + self, module_offset: int | None, return_address: int | None + ) -> list[DebugStackFrame]: ... + + +class _CompositeDebugInfo: + def __init__(self, primary: _DebugInfo | None, fallback: _DebugInfo | None): + self.primary = primary + self.fallback = fallback + + def close(self) -> None: + if self.primary is not None: + self.primary.close() + if self.fallback is not None: + self.fallback.close() + + def symbolize( + self, module_offset: int | None, return_address: int | None + ) -> list[DebugStackFrame]: + if self.primary is not None: + stack = self.primary.symbolize(module_offset, return_address) + if any(frame.file is not None or frame.line is not None for frame in stack): + return stack + if self.fallback is not None: + stack = self.fallback.symbolize(module_offset, return_address) + if stack: + return stack + if self.primary is not None: + return self.primary.symbolize(module_offset, return_address) + return [] + + +class _SymbolicDebugInfo: + def __init__(self, path: Path): + from symbolic.debuginfo import Archive + + self.archive = Archive.open(str(path)) + self.symcache = next(self.archive.iter_objects()).make_symcache() + self._cache: dict[tuple[int | None, int | None], list[DebugStackFrame]] = {} + + def close(self) -> None: + pass + + def symbolize( + self, module_offset: int | None, return_address: int | None + ) -> list[DebugStackFrame]: + key = (module_offset, return_address) + if key in self._cache: + return self._cache[key] + + for address in self._address_candidates(module_offset, return_address): + stack = [ + DebugStackFrame( + function=location.symbol or None, + file=location.full_path or None, + line=location.line or None, + ) + for location in self.symcache.lookup(address) + ] + if stack: + self._cache[key] = stack + return stack + + self._cache[key] = [] + return [] + + @staticmethod + def _address_candidates( + module_offset: int | None, return_address: int | None + ) -> list[int]: + candidates = [] + for address in (module_offset, return_address): + if address is not None and address > 0 and address - 1 not in candidates: + candidates.append(address - 1) + for address in (module_offset, return_address): + if address is not None and address not in candidates: + candidates.append(address) + return candidates + + class _DwarfDebugInfo: """DWARF symbolizer. @@ -313,7 +396,7 @@ def _decode(value) -> str: class QisCallSiteSymbolizer: def __init__(self) -> None: - self._modules: dict[Path, _DwarfDebugInfo | None] = {} + self._modules: dict[Path, _DebugInfo | None] = {} def close(self) -> None: for module in self._modules.values(): @@ -336,22 +419,31 @@ def symbolize_event(self, event: GateEvent) -> list[DebugStackFrame]: return [] return module.symbolize(module_offset, return_address) - def _module(self, path: Path) -> _DwarfDebugInfo | None: + def _module(self, path: Path) -> _DebugInfo | None: if path in self._modules: return self._modules[path] object_format = _detect_object_format(path) - module: _DwarfDebugInfo | None + module: _DebugInfo | None + fallback: _DwarfDebugInfo | None + try: + primary = _SymbolicDebugInfo(path) + except Exception: + primary = None try: match object_format: case _ObjectFormat.ELF: - module = _DwarfDebugInfo.from_elf(path) + fallback = _DwarfDebugInfo.from_elf(path) case _ObjectFormat.MACHO: - module = _DwarfDebugInfo.from_macho(path) + fallback = _DwarfDebugInfo.from_macho(path) case _ObjectFormat.PE: - module = _DwarfDebugInfo.from_pe(path) + fallback = _DwarfDebugInfo.from_pe(path) case _: - module = None + fallback = None except Exception: + fallback = None + if primary is not None or fallback is not None: + module = _CompositeDebugInfo(primary, fallback) + else: module = None self._modules[path] = module return module diff --git a/uv.lock b/uv.lock index b47a535d..ee60a8bd 100644 --- a/uv.lock +++ b/uv.lock @@ -631,6 +631,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "milksnake" +version = "0.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/37/9c/100deced3999e748500dda3027e2a19b0074199ba27cdc5a6988d22919b8/milksnake-0.1.6.tar.gz", hash = "sha256:0198f8932b4e136c29c0d0d490ff1bac03f82c3a7b2ee6f666e3683b64314fd9", size = 10483, upload-time = "2023-10-12T11:34:39.781Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/5b/1688cbd7244f039a2c1a762e246f04f7fc3eff07932776ac9944da3ea208/milksnake-0.1.6-py2.py3-none-any.whl", hash = "sha256:31e3eafaf2a48e177bb4b2dacef2c7ae8c5b2147a19c6d626209b819490e6f1d", size = 11136, upload-time = "2023-10-12T11:34:37.927Z" }, +] + [[package]] name = "mpmath" version = "1.3.0" @@ -1440,6 +1452,7 @@ dependencies = [ { name = "pydot" }, { name = "pyelftools" }, { name = "pyyaml" }, + { name = "symbolic" }, { name = "typing-extensions" }, { name = "ziglang" }, ] @@ -1463,6 +1476,7 @@ requires-dist = [ { name = "pydot", specifier = ">=4.0.0" }, { name = "pyelftools", specifier = ">=0.33" }, { name = "pyyaml", specifier = "~=6.0" }, + { name = "symbolic", specifier = ">=13.8.0" }, { name = "typing-extensions", specifier = ">=4" }, { name = "ziglang", specifier = "~=0.13" }, ] @@ -1544,6 +1558,21 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9d/76/f789f7a86709c6b087c5a2f52f911838cad707cc613162401badc665acfe/setuptools-82.0.1-py3-none-any.whl", hash = "sha256:a59e362652f08dcd477c78bb6e7bd9d80a7995bc73ce773050228a348ce2e5bb", size = 1006223, upload-time = "2026-03-09T12:47:15.026Z" }, ] +[[package]] +name = "symbolic" +version = "13.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "milksnake" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/fd/6d2d832a9c197af263eac249ca7ee5ebdd0f2e941292de79dfeaaa50ad05/symbolic-13.8.0.tar.gz", hash = "sha256:3b9faea228890a14eeaea5c71963be3eb94a56401607e5775c92f8786681b7a8", size = 6242685, upload-time = "2026-07-02T12:26:00.357Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/74/34f02fd8959980addfcc852b9a70811a9f864c71da142abfad108fe15f79/symbolic-13.8.0-py2.py3-none-macosx_10_15_x86_64.whl", hash = "sha256:006bb3198f510854020c6c6535ad83199a65db5d7b6a95312a0db10fa39b8e66", size = 2559577, upload-time = "2026-07-02T12:25:52.612Z" }, + { url = "https://files.pythonhosted.org/packages/c0/ad/a0656aaaafe2f99b5a645e04a101058c78caf6157daeabbf6ff6b44e535f/symbolic-13.8.0-py2.py3-none-macosx_11_0_arm64.whl", hash = "sha256:554cec8b08238f456b566f91bf28c92d1d1ad480068bc66ece5788384a6b72a6", size = 2341770, upload-time = "2026-07-02T12:25:53.97Z" }, + { url = "https://files.pythonhosted.org/packages/5f/8b/7ae2b12ccce5857e7afa6d890fa7a73e8dd058ec87fa13158789babedc38/symbolic-13.8.0-py2.py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:16d4f1662f3036193c957abe25f58ff9dc1c254c9889ff75d802a7c6c517444c", size = 16877718, upload-time = "2026-07-02T12:25:55.287Z" }, + { url = "https://files.pythonhosted.org/packages/80/56/a6d1794f44d960dd310b2fa9b2a1f0d46826b024eaa5b784031f40d7bf3f/symbolic-13.8.0-py2.py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:1be4b23e7f8cd3c08440a836027dde072a7421375fe8c97ec6989c8baf49649f", size = 19177663, upload-time = "2026-07-02T12:25:58.333Z" }, +] + [[package]] name = "sympy" version = "1.14.0" From 474291de52e8c431034616db684744303714b648 Mon Sep 17 00:00:00 2001 From: Jake Arkinstall <65358059+jake-arkinstall@users.noreply.github.com> Date: Wed, 8 Jul 2026 11:50:25 +0100 Subject: [PATCH 3/3] Add support for pdb files --- .../build_utils/builtins/helios.py | 9 +- .../build_utils/builtins/selene.py | 9 +- .../selene_core/build_utils/builtins/sol.py | 9 +- .../python/selene_core/build_utils/utils.py | 15 ++ selene-core/python/selene_core/debug_info.py | 212 ++++++++++++++++-- selene-sim/python/tests/test_qis.py | 65 +++++- 6 files changed, 296 insertions(+), 23 deletions(-) diff --git a/selene-core/python/selene_core/build_utils/builtins/helios.py b/selene-core/python/selene_core/build_utils/builtins/helios.py index 6c67792e..7b208552 100644 --- a/selene-core/python/selene_core/build_utils/builtins/helios.py +++ b/selene-core/python/selene_core/build_utils/builtins/helios.py @@ -7,7 +7,7 @@ Artifact, Step, ) -from ..utils import invoke_zig +from ..utils import debug_objects_for_executable, invoke_zig from ..symbols import get_symbols_from_object, get_symbols_from_llvm, SymbolTable from ..planner import BuildPlanner @@ -335,7 +335,12 @@ def apply(cls, build_ctx: BuildCtx, input_artifact: Artifact) -> Artifact: return Artifact( out_path, SeleneExecutableKind, - metadata={"library_search_dirs": library_search_dirs}, + metadata={ + "library_search_dirs": library_search_dirs, + "debug_objects": debug_objects_for_executable( + input_artifact.resource, out_path + ), + }, ) diff --git a/selene-core/python/selene_core/build_utils/builtins/selene.py b/selene-core/python/selene_core/build_utils/builtins/selene.py index 6d6bfa71..1d207398 100644 --- a/selene-core/python/selene_core/build_utils/builtins/selene.py +++ b/selene-core/python/selene_core/build_utils/builtins/selene.py @@ -18,7 +18,7 @@ from ..planner import BuildPlanner from ..types import ArtifactKind, Step, BuildCtx, Artifact -from ..utils import invoke_zig +from ..utils import debug_objects_for_executable, invoke_zig from ..symbols import get_symbols_from_object @@ -149,7 +149,12 @@ def apply(cls, build_ctx: BuildCtx, input_artifact: Artifact) -> Artifact: ) return cls._make_artifact( out_path, - metadata={"library_search_dirs": library_search_dirs}, + metadata={ + "library_search_dirs": library_search_dirs, + "debug_objects": debug_objects_for_executable( + input_artifact.resource, out_path + ), + }, ) diff --git a/selene-core/python/selene_core/build_utils/builtins/sol.py b/selene-core/python/selene_core/build_utils/builtins/sol.py index 4ce77371..b9406db2 100644 --- a/selene-core/python/selene_core/build_utils/builtins/sol.py +++ b/selene-core/python/selene_core/build_utils/builtins/sol.py @@ -7,7 +7,7 @@ Artifact, Step, ) -from ..utils import invoke_zig +from ..utils import debug_objects_for_executable, invoke_zig from ..symbols import get_symbols_from_object, get_symbols_from_llvm, SymbolTable from ..planner import BuildPlanner @@ -331,7 +331,12 @@ def apply(cls, build_ctx: BuildCtx, input_artifact: Artifact) -> Artifact: return Artifact( out_path, SeleneExecutableKind, - metadata={"library_search_dirs": library_search_dirs}, + metadata={ + "library_search_dirs": library_search_dirs, + "debug_objects": debug_objects_for_executable( + input_artifact.resource, out_path + ), + }, ) diff --git a/selene-core/python/selene_core/build_utils/utils.py b/selene-core/python/selene_core/build_utils/utils.py index b696436e..e95da763 100644 --- a/selene-core/python/selene_core/build_utils/utils.py +++ b/selene-core/python/selene_core/build_utils/utils.py @@ -84,3 +84,18 @@ def invoke_zig( raise RuntimeError( f"zig command failed:\n Command: {' '.join(argv)}\n Error: {stderr.decode()}" ) + + +def debug_objects_for_executable(input_object: Path, executable: Path) -> list[Path]: + """ + Return debug-information artifacts associated with a linked executable. + + The input object usually remains useful debug provenance on platforms where + the final linked image keeps only a debug map. Some linkers also emit + sidecar debug files next to the executable, such as PDBs on Windows. + """ + debug_objects = [input_object] + pdb_path = executable.with_suffix(".pdb") + if pdb_path.is_file(): + debug_objects.append(pdb_path) + return debug_objects diff --git a/selene-core/python/selene_core/debug_info.py b/selene-core/python/selene_core/debug_info.py index ecaa5871..35f118ce 100644 --- a/selene-core/python/selene_core/debug_info.py +++ b/selene-core/python/selene_core/debug_info.py @@ -4,7 +4,7 @@ from enum import Enum from io import BytesIO from pathlib import Path -from typing import BinaryIO, Protocol +from typing import Any, BinaryIO, Protocol from .trace import DebugStackFrame, GateEvent, Trace @@ -45,6 +45,12 @@ class _FunctionRange: name: str +@dataclass(frozen=True) +class _SymbolicSite: + function: str + offset: int + + class _DebugInfo(Protocol): def close(self) -> None: ... @@ -54,15 +60,15 @@ def symbolize( class _CompositeDebugInfo: - def __init__(self, primary: _DebugInfo | None, fallback: _DebugInfo | None): + def __init__(self, primary: _DebugInfo | None, fallbacks: list[_DebugInfo]): self.primary = primary - self.fallback = fallback + self.fallbacks = fallbacks def close(self) -> None: if self.primary is not None: self.primary.close() - if self.fallback is not None: - self.fallback.close() + for fallback in self.fallbacks: + fallback.close() def symbolize( self, module_offset: int | None, return_address: int | None @@ -71,10 +77,23 @@ def symbolize( stack = self.primary.symbolize(module_offset, return_address) if any(frame.file is not None or frame.line is not None for frame in stack): return stack - if self.fallback is not None: - stack = self.fallback.symbolize(module_offset, return_address) + + for fallback in self.fallbacks: + stack = fallback.symbolize(module_offset, return_address) if stack: return stack + + if isinstance(self.primary, _SymbolicDebugInfo): + site = self.primary.symbolic_site(module_offset, return_address) + if site is not None: + for fallback in self.fallbacks: + if isinstance(fallback, _DwarfDebugInfo): + frame = fallback.symbolize_function_offset( + site.function, site.offset + ) + if frame is not None: + return [frame] + if self.primary is not None: return self.primary.symbolize(module_offset, return_address) return [] @@ -101,7 +120,7 @@ def symbolize( for address in self._address_candidates(module_offset, return_address): stack = [ DebugStackFrame( - function=location.symbol or None, + function=_symbolic_function_name(location.symbol), file=location.full_path or None, line=location.line or None, ) @@ -114,6 +133,24 @@ def symbolize( self._cache[key] = [] return [] + def symbolic_site( + self, module_offset: int | None, return_address: int | None + ) -> _SymbolicSite | None: + for address in self._address_candidates(module_offset, return_address): + for location in self.symcache.lookup(address): + if ( + location.symbol + and location.sym_addr is not None + and location.instr_addr is not None + and location.instr_addr >= location.sym_addr + ): + return _SymbolicSite( + function=_symbolic_function_name(location.symbol) + or location.symbol, + offset=int(location.instr_addr - location.sym_addr), + ) + return None + @staticmethod def _address_candidates( module_offset: int | None, return_address: int | None @@ -268,6 +305,14 @@ def _symbolize_address(self, address: int) -> DebugStackFrame | None: column=line.column if line is not None else None, ) + def symbolize_function_offset( + self, function_name: str, offset: int + ) -> DebugStackFrame | None: + function = self._find_named_function(function_name) + if function is None: + return None + return self._symbolize_address(function.start + offset) + def _find_line(self, address: int) -> _LineEntry | None: for line in self._lines: if line.start <= address < line.end: @@ -284,6 +329,12 @@ def _find_function(self, address: int) -> _FunctionRange | None: return None return min(matches, key=lambda function: function.end - function.start) + def _find_named_function(self, name: str) -> _FunctionRange | None: + matches = [function for function in self._functions if function.name == name] + if not matches: + return None + return min(matches, key=lambda function: function.end - function.start) + def _collect_lines(self) -> list[_LineEntry]: if self._dwarf is None: return [] @@ -424,7 +475,7 @@ def _module(self, path: Path) -> _DebugInfo | None: return self._modules[path] object_format = _detect_object_format(path) module: _DebugInfo | None - fallback: _DwarfDebugInfo | None + fallbacks: list[_DebugInfo] = [] try: primary = _SymbolicDebugInfo(path) except Exception: @@ -432,17 +483,20 @@ def _module(self, path: Path) -> _DebugInfo | None: try: match object_format: case _ObjectFormat.ELF: - fallback = _DwarfDebugInfo.from_elf(path) + fallbacks.append(_DwarfDebugInfo.from_elf(path)) case _ObjectFormat.MACHO: - fallback = _DwarfDebugInfo.from_macho(path) + fallbacks.append(_DwarfDebugInfo.from_macho(path)) case _ObjectFormat.PE: - fallback = _DwarfDebugInfo.from_pe(path) + fallbacks.append(_DwarfDebugInfo.from_pe(path)) case _: - fallback = None + pass except Exception: - fallback = None - if primary is not None or fallback is not None: - module = _CompositeDebugInfo(primary, fallback) + pass + + fallbacks.extend(_load_debug_object_modules(path)) + + if primary is not None or fallbacks: + module = _CompositeDebugInfo(primary, fallbacks) else: module = None self._modules[path] = module @@ -474,6 +528,132 @@ def _detect_object_format(path: Path) -> _ObjectFormat: return _ObjectFormat.UNKNOWN +def _symbolic_function_name(name: str | None) -> str | None: + if name is None: + return None + bare_name, separator, _signature = name.partition("(") + if ( + separator + and name.endswith(")") + and bare_name + and (bare_name[0].isalpha() or bare_name[0] == "_") + and all(char.isalnum() or char == "_" for char in bare_name) + ): + return bare_name + return name + + +def _load_debug_object_modules(module_path: Path) -> list[_DebugInfo]: + modules: list[_DebugInfo] = [] + for debug_object in _debug_object_paths(module_path): + if debug_object.suffix.lower() == ".pdb": + try: + modules.append(_SymbolicDebugInfo(debug_object)) + continue + except Exception: + pass + try: + match _detect_object_format(debug_object): + case _ObjectFormat.ELF: + modules.append(_DwarfDebugInfo.from_elf(debug_object)) + case _ObjectFormat.MACHO: + modules.append(_DwarfDebugInfo.from_macho(debug_object)) + case _ObjectFormat.PE: + modules.append(_DwarfDebugInfo.from_pe(debug_object)) + case _: + pass + except Exception: + pass + return modules + + +def _debug_object_paths(module_path: Path) -> list[Path]: + paths = [] + seen = set() + for path in [ + *_manifest_debug_objects(module_path), + *_sibling_debug_objects(module_path), + ]: + try: + key = path.resolve(strict=False) + except OSError: + key = path + if key not in seen: + paths.append(path) + seen.add(key) + return paths + + +def _sibling_debug_objects(module_path: Path) -> list[Path]: + pdb_path = module_path.with_suffix(".pdb") + return [pdb_path] if pdb_path.is_file() else [] + + +def _manifest_debug_objects(module_path: Path) -> list[Path]: + manifest_path = _manifest_path_for_module(module_path) + if manifest_path is None: + return [] + + try: + import yaml + + manifest = yaml.safe_load(manifest_path.read_text()) + except Exception: + return [] + + if not isinstance(manifest, dict): + return [] + artifacts = manifest.get("artifacts") + if not isinstance(artifacts, list): + return [] + + for artifact in artifacts: + if not isinstance(artifact, dict): + continue + resource = artifact.get("resource") + if not isinstance(resource, str): + continue + if not _same_path(Path(resource), module_path): + continue + metadata = artifact.get("metadata") + if not isinstance(metadata, dict): + return [] + return _coerce_debug_object_paths(metadata.get("debug_objects"), manifest_path) + return [] + + +def _manifest_path_for_module(module_path: Path) -> Path | None: + for candidate in ( + module_path.parent.parent / "selene.yaml", + module_path.parent / "selene.yaml", + ): + if candidate.is_file(): + return candidate + return None + + +def _same_path(left: Path, right: Path) -> bool: + try: + return left.resolve(strict=False) == right.resolve(strict=False) + except OSError: + return left == right + + +def _coerce_debug_object_paths(value: Any, manifest_path: Path) -> list[Path]: + if not isinstance(value, list): + return [] + result = [] + for item in value: + if not isinstance(item, str): + continue + path = Path(item) + if not path.is_absolute(): + path = manifest_path.parent / path + if path.is_file(): + result.append(path) + return result + + @dataclass(frozen=True) class _DebugSection: data: bytes diff --git a/selene-sim/python/tests/test_qis.py b/selene-sim/python/tests/test_qis.py index 3d4f6419..6e503346 100644 --- a/selene-sim/python/tests/test_qis.py +++ b/selene-sim/python/tests/test_qis.py @@ -4,7 +4,19 @@ import yaml from selene_core import symbolize_qis_call_sites from selene_core.build_utils.utils import invoke_zig -from selene_core.trace import EventRecord, GateEvent, SimulatorSource, Trace +from selene_core.debug_info import ( + _CompositeDebugInfo, + _DwarfDebugInfo, + _SymbolicDebugInfo, + _SymbolicSite, +) +from selene_core.trace import ( + DebugStackFrame, + EventRecord, + GateEvent, + SimulatorSource, + Trace, +) from selene_sim.event_hooks import CircuitExtractor, MetricStore, MultiEventHook from selene_sim.event_hooks.instruction_log import GateInstruction, Source from selene_sim import Quest, SoftRZRuntime @@ -43,6 +55,57 @@ def _pe_section_rva(path: Path, section_name: str) -> int: raise AssertionError(f"section {section_name} not found in {path}") +def test_qis_debug_info_companion_object_symbolizes_symbol_only_primary(tmp_path): + source_lines = [ + "__attribute__((noinline)) int apply_named_gate(void) {", + " return 7;", + "}", + ] + gate_line = source_lines.index(" return 7;") + 1 + source_path = tmp_path / "debug_companion.c" + object_path = tmp_path / "debug_companion.o" + source_path.write_text("\n".join(source_lines) + "\n") + + invoke_zig( + "cc", + "-g", + "-O0", + "-c", + source_path, + "-o", + object_path, + "-target", + "x86_64-linux-gnu", + handle_triple=False, + cache_dir=tmp_path / "zig-cache", + ) + + dwarf = _DwarfDebugInfo.from_elf(object_path) + try: + function = dwarf._find_named_function("apply_named_gate") + assert function is not None + line = next(entry for entry in dwarf._lines if entry.line == gate_line) + offset = max(line.start, line.end - 1) - function.start + + primary = object.__new__(_SymbolicDebugInfo) + primary.close = lambda: None + primary.symbolize = lambda _module_offset, _return_address: [ + DebugStackFrame(function="apply_named_gate") + ] + primary.symbolic_site = lambda _module_offset, _return_address: _SymbolicSite( + "apply_named_gate", offset + ) + + stack = _CompositeDebugInfo(primary, [dwarf]).symbolize(None, None) + + assert stack + assert stack[0].function == "apply_named_gate" + assert Path(stack[0].file).name == source_path.name + assert stack[0].line == gate_line + finally: + dwarf.close() + + @pytest.mark.parametrize( "program_name", [