Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,15 @@

A viewer for ome-zarr images.

## Orthoviewer startup performance diagnostics

Use the orthoviewer performance flags to print startup timings
with step and cumulative durations:

```sh
oz-viewer ortho /path/to/data.zarr --perf-startup --perf-table --perf-table-title "My Startup Profile"
```

## Development

The easiest way to get started is to use the [github cli](https://cli.github.com)
Expand Down
4 changes: 3 additions & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ dependencies = [
"typer >= 0.12",
"rich >= 13",
"numpy >= 1.24",
"cellier[pyside]>=0.0.13",
"cellier[pyside]>=0.0.18",
"jupyterlab>=4.5.6",
"aiohttp >= 3.9",
"zarr >= 3.0",
Expand Down Expand Up @@ -155,3 +155,5 @@ OME = "OME"
ome = "ome"
# lod = Level of Detail (graphics term), not a typo for "load"
lod = "lod"
# nd = N-dimensional (numpy/array convention), not a typo for "and"
nd = "nd"
62 changes: 61 additions & 1 deletion src/oz_viewer/_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@
print_ping_results,
print_success_panel,
)
from oz_viewer._perf import (
StartupPerfTracer,
configure_perf_logging,
perf_enabled_from_env,
)

app = typer.Typer(
name="oz-viewer",
Expand Down Expand Up @@ -141,6 +146,17 @@ def ortho(
help="Create a synthetic anisotropic OME-Zarr and open it in the viewer.",
),
] = False,
multichannel: Annotated[
int | None,
typer.Option(
"--multichannel",
help=(
"Dimension index to treat as the channel axis, enabling multichannel"
" mode. If omitted, single-channel mode is used."
),
show_default=False,
),
] = None,
theme: Annotated[
str,
typer.Option(
Expand All @@ -151,15 +167,57 @@ def ortho(
),
),
] = "dark",
perf_startup: Annotated[
bool,
typer.Option(
"--perf-startup/--no-perf-startup",
help="Enable startup performance logging diagnostics.",
),
] = False,
perf_log_file: Annotated[
Path | None,
typer.Option(
"--perf-log-file",
help="Write startup performance logs to a file instead of stderr.",
show_default=False,
),
] = None,
perf_table: Annotated[
bool,
typer.Option(
"--perf-table/--no-perf-table",
help="Display startup timings as a Rich table at startup completion.",
),
] = False,
perf_table_title: Annotated[
str,
typer.Option(
"--perf-table-title",
help="Title used for the startup performance Rich table.",
),
] = "Orthoviewer startup timings",
) -> None:
"""Open an OME-Zarr store in the 4-panel orthoviewer."""
from oz_viewer.viewer import launch_orthoviewer

perf_enabled = perf_startup or perf_enabled_from_env()
configure_perf_logging(
enabled=perf_enabled,
log_file=str(perf_log_file) if perf_log_file is not None else None,
)
perf = StartupPerfTracer(
enabled=perf_enabled,
show_table=perf_table,
table_title=perf_table_title,
)
perf.mark("cli.ortho.start", make_example=make_example, theme=theme)

if make_example:
from oz_viewer.data._blobs import make_example_zarr

zarr_path = make_example_zarr()
zarr_uri = f"file://{zarr_path}"
perf.mark("cli.ortho.example_created", zarr_path=zarr_path)
else:
raw = path or path_option
if raw is None:
Expand All @@ -176,8 +234,10 @@ def ortho(
)
raise typer.Exit(code=1)
zarr_uri = _resolve_zarr_uri(raw)
perf.mark("cli.ortho.uri_resolved", zarr_uri=zarr_uri)

launch_orthoviewer(zarr_uri, theme=theme)
perf.mark("cli.ortho.launch")
launch_orthoviewer(zarr_uri, channel_axis=multichannel, theme=theme, perf=perf)


@app.command(name="theme")
Expand Down
122 changes: 122 additions & 0 deletions src/oz_viewer/_perf.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""Performance logging utilities for opt-in startup diagnostics."""

from __future__ import annotations

import logging
import os
from dataclasses import dataclass, field
from time import perf_counter
from uuid import uuid4

_PERF_LOGGER_NAME = "oz_viewer.perf"
_ENV_VAR_PERF = "OZ_VIEWER_PERF"
_TRUTHY = {"1", "true", "yes", "on"}


@dataclass(slots=True)
class _PerfEvent:
step: str
elapsed_s: float
delta_s: float
fields: dict[str, object]


def perf_enabled_from_env() -> bool:
"""Return whether performance logging is enabled through an env var."""
return os.getenv(_ENV_VAR_PERF, "").strip().lower() in _TRUTHY


def configure_perf_logging(
*, enabled: bool, log_file: str | None = None
) -> logging.Logger:
"""Configure the dedicated performance logger.

The logger is isolated from the default logging tree so users only see
performance output when this explicit configuration is enabled.
"""
logger = logging.getLogger(_PERF_LOGGER_NAME)
logger.propagate = False

for handler in list(logger.handlers):
handler.close()
logger.removeHandler(handler)

if not enabled:
logger.setLevel(logging.CRITICAL + 1)
return logger

logger.setLevel(logging.INFO)
handler: logging.Handler
if log_file:
handler = logging.FileHandler(log_file)
else:
handler = logging.StreamHandler()
handler.setFormatter(logging.Formatter("[%(name)s] %(message)s"))
logger.addHandler(handler)
return logger


@dataclass(slots=True)
class StartupPerfTracer:
"""Lightweight startup milestone tracer for perf diagnostics."""

enabled: bool
show_table: bool = False
table_title: str = "Orthoviewer startup timings"
run_id: str = field(default_factory=lambda: uuid4().hex[:8])
_t0: float = field(default_factory=perf_counter)
_last_elapsed_s: float = 0.0
_events: list[_PerfEvent] = field(default_factory=list)
_table_reported: bool = False

def mark(self, step: str, /, **fields: object) -> None:
"""Emit a perf milestone with elapsed startup time."""
if not self.enabled:
return

logger = logging.getLogger(_PERF_LOGGER_NAME)
if not logger.isEnabledFor(logging.INFO):
return

elapsed = perf_counter() - self._t0
delta = max(0.0, elapsed - self._last_elapsed_s)
self._last_elapsed_s = elapsed
self._events.append(
_PerfEvent(step=step, elapsed_s=elapsed, delta_s=delta, fields=dict(fields))
)

suffix = ""
if fields:
details = " ".join(f"{key}={value}" for key, value in fields.items())
suffix = f" {details}"
logger.info("run=%s +%.3fs step=%s%s", self.run_id, elapsed, step, suffix)

def report_rich_table(self, *, title: str | None = None) -> None:
"""Render a Rich table of step and cumulative startup timings."""
if not self.enabled or not self.show_table or self._table_reported:
return

from rich.console import Console
from rich.table import Table

table = Table(title=title or self.table_title)
table.add_column("#", justify="right", no_wrap=True)
table.add_column("Step")
table.add_column("Step (ms)", justify="right")
table.add_column("Cumulative (ms)", justify="right")
table.add_column("Details")

for idx, event in enumerate(self._events, start=1):
details = " ".join(
f"{key}={value}" for key, value in sorted(event.fields.items())
)
table.add_row(
str(idx),
event.step,
f"{event.delta_s * 1000:.1f}",
f"{event.elapsed_s * 1000:.1f}",
details,
)

Console(stderr=True).print(table)
self._table_reported = True
Loading
Loading