Skip to content
Merged
3 changes: 3 additions & 0 deletions guppylang/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,6 @@ repository = "https://github.com/quantinuum/guppylang"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.uv.sources]
selene-sim = { path = "../../../../Desktop/selene_sim-0.3.0-py3-none-macosx_12_0_arm64.whl" }
8 changes: 8 additions & 0 deletions guppylang/src/guppylang/defs.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
"""
...

Expand Down Expand Up @@ -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()
Expand All @@ -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:
Expand Down Expand Up @@ -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
Expand Down
27 changes: 26 additions & 1 deletion guppylang/src/guppylang/emulator/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,36 @@ 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)
return f"{header}\n{rendered}"
Comment thread
aborgna-q marked this conversation as resolved.
Outdated
return str(underlying_exception)

@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."""
Expand Down
60 changes: 60 additions & 0 deletions guppylang/src/guppylang/emulator/stack_trace.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
"""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:
renderer = DiagnosticsRenderer(source)
renderer.render_snippet(
Comment thread
aborgna-q marked this conversation as resolved.
Outdated
span,
label,
span.end.line,
is_primary=True,
prefix_lines=renderer.PREFIX_ERROR_CONTEXT_LINES,
)
header = (
f'File "{span.start.file}", line {span.start.line}, in {function_name}:'
)
blocks.append(f"{header}\n" + "\n".join(renderer.buffer))

for entry in stack_trace.entries:
for i, symbol in enumerate(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 i == 0 else None, symbol.function_name
)

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"
29 changes: 27 additions & 2 deletions guppylang/src/guppylang/optimizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -126,6 +130,8 @@ def main() -> None:
TypeVar,
)

from guppylang.emulator.exceptions import EmulatorBuildError

if TYPE_CHECKING:
from collections.abc import Sequence

Expand Down Expand Up @@ -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]:
Expand Down Expand Up @@ -257,7 +266,18 @@ 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.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens when optimization is enabled?
Do stack traces get mangled, or do we lose all the info?

Could this be a warning instead (once we have those #1654)?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The main issues arising so far is stack traces being squashed due to LLVM tail calls and also Quantinuum/tket2#1964 - I think at least until the latter is solved I think having it error is better as showing wrong code snippets just looks confusing even with a warning, but later I think keeping it as a warning and just showing partial traces when some of the info is optimised is an option.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

I would add a non-doc comment explaining that, and linking to the issue.

"""
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
Expand All @@ -267,7 +287,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:
Expand Down
Empty file.
9 changes: 9 additions & 0 deletions tests/stack_traces/always_panic.err
Original file line number Diff line number Diff line change
@@ -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

10 changes: 10 additions & 0 deletions tests/stack_traces/always_panic.py
Original file line number Diff line number Diff line change
@@ -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()
9 changes: 9 additions & 0 deletions tests/stack_traces/global_panic_unwrap_error.err
Original file line number Diff line number Diff line change
@@ -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)

13 changes: 13 additions & 0 deletions tests/stack_traces/global_panic_unwrap_error.py
Original file line number Diff line number Diff line change
@@ -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()
23 changes: 23 additions & 0 deletions tests/stack_traces/multi_file_out_of_bounds.err
Original file line number Diff line number Diff line change
@@ -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)
| ^

16 changes: 16 additions & 0 deletions tests/stack_traces/multi_file_out_of_bounds.py
Original file line number Diff line number Diff line change
@@ -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()
8 changes: 8 additions & 0 deletions tests/stack_traces/test_helper.py
Original file line number Diff line number Diff line change
@@ -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]
35 changes: 35 additions & 0 deletions tests/test_optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
26 changes: 26 additions & 0 deletions tests/test_stack_traces.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
"""Snapshot tests for emulator panic stack traces."""

from __future__ import annotations

import importlib
from pathlib import Path

import pytest
from guppylang.emulator import EmulatorError

TEST_CASES_DIR = Path(__file__).parent / "stack_traces"
FILES = [
path
for path in TEST_CASES_DIR.glob("*.py")
if path.name != "__init__.py" and path.name != "test_helper.py"
]
Comment on lines +14 to +18


@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")
snapshot.snapshot_dir = str(file.parent)
snapshot.assert_match(output + "\n", file.with_suffix(".err").name)
Loading
Loading