diff --git a/guppylang/src/guppylang/defs.py b/guppylang/src/guppylang/defs.py index 24a4b6539..db076b9fb 100644 --- a/guppylang/src/guppylang/defs.py +++ b/guppylang/src/guppylang/defs.py @@ -195,6 +195,8 @@ def emulator( Returns: An `EmulatorInstance` that can be used to run the function in an emulator. + Raises: + EmulatorBuildError: If debug mode is enabled without minimal optimization. """ ... @@ -280,6 +282,8 @@ def emulator( Returns: An `EmulatorInstance` that can be used to run the function in an emulator. + Raises: + EmulatorBuildError: If debug mode is enabled without minimal optimization. """ return ( self._with_default_opt() @@ -294,6 +298,7 @@ def _emulator( builder: EmulatorBuilder | None = None, libs: list[Package] | None = None, platform: Platform = "helios", + debug_mode: bool = False, ) -> EmulatorInstance: """Build an emulator instance from a compiled package.""" if libs is not None: @@ -337,6 +342,9 @@ def _emulator( ) ) + if debug_mode: + builder = builder.with_build_arg("debug", True) + return builder.build(mod, n_qubits=qubits, arg_specs=arg_specs) @pretty_errors diff --git a/guppylang/src/guppylang/emulator/exceptions.py b/guppylang/src/guppylang/emulator/exceptions.py index 6185d3d17..12d9b443e 100644 --- a/guppylang/src/guppylang/emulator/exceptions.py +++ b/guppylang/src/guppylang/emulator/exceptions.py @@ -14,11 +14,49 @@ def __init__( failing_shot: QsysShot, underlying_exception: Exception | None = None, ): - super().__init__(str(underlying_exception)) + super().__init__(self._render_message(underlying_exception)) self.completed_shots = completed_shots self.failing_shot = failing_shot self.underlying_exception = underlying_exception + @staticmethod + def _render_message(underlying_exception: Exception | None) -> str: + if underlying_exception is None: + return "" + stack_trace = getattr(underlying_exception, "stack_trace", None) + if stack_trace is not None: + from .stack_trace import render_stack_trace + + message = getattr( + underlying_exception, "message", str(underlying_exception) + ) + rendered = render_stack_trace(stack_trace, message) + if rendered is not None: + header = EmulatorError._panic_header(underlying_exception) + logs = EmulatorError._render_logs(underlying_exception) + return f"{header}\n{rendered}{logs}" + return str(underlying_exception) + + @staticmethod + def _render_logs(underlying_exception: Exception) -> str: + # Renders captured stdout/stderr the way exceptions in Selene do. + sections = [] + for name in ("stdout", "stderr"): + contents: str = getattr(underlying_exception, name, "") + if contents: + sections.append( + f"\n----- {name} -----\n{contents}\n------------------\n" + ) + return "".join(sections) + + @staticmethod + def _panic_header(underlying_exception: Exception) -> str: + code = getattr(underlying_exception, "code", None) + message = getattr(underlying_exception, "message", str(underlying_exception)) + if isinstance(code, int): + return f"Panic (#{code}): {message}" + return str(underlying_exception) + @property def failed_shot_index(self) -> int: """The index of the shot that failed.""" diff --git a/guppylang/src/guppylang/emulator/stack_trace.py b/guppylang/src/guppylang/emulator/stack_trace.py new file mode 100644 index 000000000..3ffe54a57 --- /dev/null +++ b/guppylang/src/guppylang/emulator/stack_trace.py @@ -0,0 +1,70 @@ +"""Render Selene emulator panic stack traces.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from guppylang_internals.diagnostic import DiagnosticsRenderer +from guppylang_internals.span import Loc, SourceMap, Span + +if TYPE_CHECKING: + from selene_sim.stack_trace import StackTrace + + +def render_stack_trace(stack_trace: StackTrace | None, message: str) -> str | None: + """Render a Selene stack trace as source-annotated snippets. + + Each symbolized frame is rendered as its own code snippet. Returns `None` if no + frame could be resolved to a readable source location, e.g. because debug info + wasn't emitted. + """ + if stack_trace is None: + return None + + source = SourceMap() + blocks: list[str] = [] + + def append_frame_snippet(span: Span, label: str | None, function_name: str) -> None: + header = ( + f'File "{span.start.file}", line {span.start.line}, in {function_name}:' + ) + renderer = DiagnosticsRenderer(source) + try: + renderer.render_snippet( + span, + label, + span.end.line, + is_primary=True, + prefix_lines=renderer.PREFIX_ERROR_CONTEXT_LINES, + ) + except Exception: # noqa: BLE001 + # The source may have changed since compilation, so the recorded + # location might no longer be renderable. Fall back to the header + # rather than masking the panic we're reporting. + blocks.append(header) + return + blocks.append(f"{header}\n" + "\n".join(renderer.buffer)) + + is_first_frame = True + + for entry in stack_trace.entries: + for symbol in entry.symbols: + if symbol.filename not in source.sources: + source.add_file(symbol.filename) + if not source.sources[symbol.filename]: + # Source file isn't available, skip this frame. + continue + + loc = Loc(symbol.filename, symbol.line, symbol.column) + span = Span(loc, loc.shift_right(1)) + append_frame_snippet( + span, message if is_first_frame else None, symbol.function_name + ) + is_first_frame = False + + if not blocks: + return None + + trace = "\n\n".join(blocks) + indented_trace = "\n".join(f" {line}" for line in trace.splitlines()) + return f"Guppy traceback (most recent call last):\n{indented_trace}\n" diff --git a/guppylang/src/guppylang/optimizer.py b/guppylang/src/guppylang/optimizer.py index 7807e1d49..dca4a4281 100644 --- a/guppylang/src/guppylang/optimizer.py +++ b/guppylang/src/guppylang/optimizer.py @@ -58,6 +58,10 @@ def main() -> None: ``with_minimal_opt()`` is shorthand for selecting :py:attr:`OptimizationLevel.Minimal`. It disables optional optimizations on the program. +Emulation in debug mode (enabling panic traces) requires minimal optimization. Calling +``emulator()`` with debug mode enabled and any optimization passes configured raises +``EmulatorBuildError``. + .. code-block:: python emulator = main.with_minimal_opt().emulator(n_qubits=1) @@ -126,6 +130,8 @@ def main() -> None: TypeVar, ) +from guppylang.emulator.exceptions import EmulatorBuildError + if TYPE_CHECKING: from collections.abc import Sequence @@ -179,6 +185,9 @@ class OptimizationLevel(Enum): This is useful for low-level program analysis or when more control over the optimization passes is desired. + + Note that any rewrites applied at this level must preserve enough debug information + to allow for stack traces to be generated in the event of a panic. """ def passes(self) -> list[ComposablePass]: @@ -257,7 +266,20 @@ def emulator( platform: Platform | None = None, debug_mode: bool = False, ) -> EmulatorInstance: - """Compile this function for emulation with the configured optimizations.""" + """Compile this function for emulation with the configured optimizations. + + Emulation in debug mode (enabling panic traces) requires minimal optimization. + """ + # TODO: Consider changing this to a warning instead of an error after + # https://github.com/Quantinuum/tket2/issues/1964 is resolved. + if debug_mode and self.passes: + raise EmulatorBuildError( + ValueError( + "Emulation in debug mode (enabling panic traces) requires minimal " + "optimization. Call `with_minimal_opt()` before `emulator()`, or " + "disable debug mode." + ) + ) # If platform is set, use it. # Else if platform is not explicitly provided, use the target platform @@ -267,7 +289,12 @@ def emulator( platform = "helios" return self.definition._emulator( - self.compile_function(debug_mode), n_qubits, builder, libs, platform + self.compile_function(debug_mode), + n_qubits, + builder, + libs, + platform, + debug_mode=debug_mode, ) def compile(self, debug_mode: bool = False) -> Package: diff --git a/tests/stack_traces/__init__.py b/tests/stack_traces/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/stack_traces/always_panic.err b/tests/stack_traces/always_panic.err new file mode 100644 index 000000000..ba92d5f74 --- /dev/null +++ b/tests/stack_traces/always_panic.err @@ -0,0 +1,9 @@ +Panic (#1001): Always panics +Guppy traceback (most recent call last): + File "$PATH_TO_FILE/always_panic.py", line 7, in stack_traces.always_panic.main: + | + 5 | @guppy + 6 | def main() -> None: + 7 | panic("Always panics") + | ^ Always panics + diff --git a/tests/stack_traces/always_panic.py b/tests/stack_traces/always_panic.py new file mode 100644 index 000000000..277b1fca0 --- /dev/null +++ b/tests/stack_traces/always_panic.py @@ -0,0 +1,10 @@ +from guppylang import guppy +from guppylang.std.platform import panic + + +@guppy +def main() -> None: + panic("Always panics") + + +main.with_minimal_opt().emulator(n_qubits=1, debug_mode=True).run() diff --git a/tests/stack_traces/global_panic_unwrap_error.err b/tests/stack_traces/global_panic_unwrap_error.err new file mode 100644 index 000000000..defe198d7 --- /dev/null +++ b/tests/stack_traces/global_panic_unwrap_error.err @@ -0,0 +1,9 @@ +Panic (#1002): Float value too big to convert to int of given width (64) +Guppy traceback (most recent call last): + File "$PATH_TO_FILE/global_panic_unwrap_error.py", line 10, in stack_traces.global_panic_unwrap_error.main: + | + 8 | # through a global compiler function, therefore testing stack traces for panics + 9 | # that are not directly annotated. + 10 | return int(0.0 / 0.0) + | ^ Float value too big to convert to int of given width (64) + diff --git a/tests/stack_traces/global_panic_unwrap_error.py b/tests/stack_traces/global_panic_unwrap_error.py new file mode 100644 index 000000000..026d5cfed --- /dev/null +++ b/tests/stack_traces/global_panic_unwrap_error.py @@ -0,0 +1,13 @@ +from guppylang import guppy +from guppylang.std.num import int + + +@guppy +def main() -> int: + # This should internally use the `UnwrapOpCompiler` which relies on a panic built + # through a global compiler function, therefore testing stack traces for panics + # that are not directly annotated. + return int(0.0 / 0.0) + + +main.with_minimal_opt().emulator(n_qubits=1, debug_mode=True).run() diff --git a/tests/stack_traces/multi_file_out_of_bounds.err b/tests/stack_traces/multi_file_out_of_bounds.err new file mode 100644 index 000000000..3c5209810 --- /dev/null +++ b/tests/stack_traces/multi_file_out_of_bounds.err @@ -0,0 +1,23 @@ +Panic (#1001): Array index out of bounds +Guppy traceback (most recent call last): + File "$PATH_TO_FILE/test_helper.py", line 8, in stack_traces.test_helper.array_with_three_elements: + | + 6 | def array_with_three_elements(x: int) -> int: + 7 | arr = array(1, 2, 3) + 8 | return arr[x] + | ^ Array index out of bounds + + File "$PATH_TO_FILE/multi_file_out_of_bounds.py", line 8, in stack_traces.multi_file_out_of_bounds.array_out_of_bounds: + | + 6 | @guppy + 7 | def array_out_of_bounds(x: int) -> int: + 8 | return array_with_three_elements(x) + | ^ + + File "$PATH_TO_FILE/multi_file_out_of_bounds.py", line 13, in stack_traces.multi_file_out_of_bounds.main: + | + 11 | @guppy + 12 | def main() -> None: + 13 | array_out_of_bounds(5) + | ^ + diff --git a/tests/stack_traces/multi_file_out_of_bounds.py b/tests/stack_traces/multi_file_out_of_bounds.py new file mode 100644 index 000000000..d14ee4d93 --- /dev/null +++ b/tests/stack_traces/multi_file_out_of_bounds.py @@ -0,0 +1,16 @@ +from guppylang import guppy + +from tests.stack_traces.test_helper import array_with_three_elements + + +@guppy +def array_out_of_bounds(x: int) -> int: + return array_with_three_elements(x) + + +@guppy +def main() -> None: + array_out_of_bounds(5) + + +main.with_minimal_opt().emulator(n_qubits=1, debug_mode=True).run() diff --git a/tests/stack_traces/recursive.err b/tests/stack_traces/recursive.err new file mode 100644 index 000000000..a7dbdaede --- /dev/null +++ b/tests/stack_traces/recursive.err @@ -0,0 +1,37 @@ +Panic (#1001): No more qubits available to allocate. +Guppy traceback (most recent call last): + File "$PATH_TO_FILE/recursive.py", line 7, in stack_traces.recursive.recursive_allocate: + | + 5 | @guppy + 6 | def recursive_allocate(source: qubit) -> None: + 7 | q = qubit() + | ^ No more qubits available to allocate. + + File "$PATH_TO_FILE/recursive.py", line 9, in stack_traces.recursive.recursive_allocate: + | + 7 | q = qubit() + 8 | cx(source, q) + 9 | recursive_allocate(q) + | ^ + + File "$PATH_TO_FILE/recursive.py", line 9, in stack_traces.recursive.recursive_allocate: + | + 7 | q = qubit() + 8 | cx(source, q) + 9 | recursive_allocate(q) + | ^ + + File "$PATH_TO_FILE/recursive.py", line 9, in stack_traces.recursive.recursive_allocate: + | + 7 | q = qubit() + 8 | cx(source, q) + 9 | recursive_allocate(q) + | ^ + + File "$PATH_TO_FILE/recursive.py", line 16, in stack_traces.recursive.main: + | + 14 | def main() -> None: + 15 | q = qubit() + 16 | recursive_allocate(q) + | ^ + diff --git a/tests/stack_traces/recursive.py b/tests/stack_traces/recursive.py new file mode 100644 index 000000000..14380c625 --- /dev/null +++ b/tests/stack_traces/recursive.py @@ -0,0 +1,20 @@ +from guppylang import guppy +from guppylang.std.quantum import cx, discard, qubit + + +@guppy +def recursive_allocate(source: qubit) -> None: + q = qubit() + cx(source, q) + recursive_allocate(q) + discard(q) + + +@guppy +def main() -> None: + q = qubit() + recursive_allocate(q) + discard(q) + + +main.with_minimal_opt().emulator(n_qubits=4, debug_mode=True).run() diff --git a/tests/stack_traces/test_helper.py b/tests/stack_traces/test_helper.py new file mode 100644 index 000000000..697c51c8f --- /dev/null +++ b/tests/stack_traces/test_helper.py @@ -0,0 +1,8 @@ +from guppylang import guppy +from guppylang.std.array import array + + +@guppy +def array_with_three_elements(x: int) -> int: + arr = array(1, 2, 3) + return arr[x] diff --git a/tests/test_optimization.py b/tests/test_optimization.py index 0c4a1f9af..4e98d9f45 100644 --- a/tests/test_optimization.py +++ b/tests/test_optimization.py @@ -12,7 +12,9 @@ OptimizerInstance, guppy, ) +from guppylang.emulator.exceptions import EmulatorBuildError from guppylang.optimizer import _RemoveRedundanciesPass +from hugr.metadata import HugrDebugInfo from hugr.passes.composable import ComposablePass, PassResult if TYPE_CHECKING: @@ -174,3 +176,36 @@ def main() -> None: classical.compile() minimal.compile() assert custom_pass.calls == 0 + + +def test_debug_emulator_requires_minimal_optimization() -> None: + @guppy + def main() -> None: + pass + + with pytest.raises(EmulatorBuildError, match="with_minimal_opt"): + main.emulator(n_qubits=0, debug_mode=True) + + with pytest.raises(EmulatorBuildError, match="with_minimal_opt"): + main.with_opt_level(OptimizationLevel.Classical).emulator( + n_qubits=0, debug_mode=True + ) + + with pytest.raises(EmulatorBuildError, match="with_minimal_opt"): + main.with_minimal_opt().with_optimization(CountingPass()).emulator( + n_qubits=0, debug_mode=True + ) + + +def test_debug_compile_allows_optimization() -> None: + @guppy + def main() -> None: + pass + + hugr = ( + main.with_opt_level(OptimizationLevel.Classical) + .compile(debug_mode=True) + .modules[0] + ) + meta = hugr[hugr.module_root].metadata + assert HugrDebugInfo in meta diff --git a/tests/test_stack_traces.py b/tests/test_stack_traces.py new file mode 100644 index 000000000..d31466359 --- /dev/null +++ b/tests/test_stack_traces.py @@ -0,0 +1,30 @@ +"""Snapshot tests for emulator panic stack traces.""" + +from __future__ import annotations + +import importlib +import re +from pathlib import Path + +import pytest +from guppylang.emulator import EmulatorError + +TEST_CASES_DIR = Path(__file__).parent / "stack_traces" +EMULATOR_LOGS = re.compile(r"^----- std(?:out|err) -----$", re.MULTILINE) +FILES = [ + path + for path in TEST_CASES_DIR.glob("*.py") + if path.name != "__init__.py" and path.name != "test_helper.py" +] + + +@pytest.mark.parametrize("file", FILES) +def test_stack_trace(file: Path, snapshot: pytest.Snapshot) -> None: + with pytest.raises(EmulatorError) as exc_info: + importlib.import_module(f"tests.stack_traces.{file.stem}") + + output = str(exc_info.value).replace(str(TEST_CASES_DIR.resolve()), "$PATH_TO_FILE") + # The emulator's captured stdout/stderr varies by machine. + output = EMULATOR_LOGS.split(output, maxsplit=1)[0].rstrip("\n") + "\n" + snapshot.snapshot_dir = str(file.parent) + snapshot.assert_match(output + "\n", file.with_suffix(".err").name)