diff --git a/pyproject.toml b/pyproject.toml index d1c4e1d5..c089fa46 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "gitpython>=3.1.45", "hjson>=3.1.0", "jinja2>=3.1.6", + "plotly>=6.7.0", "psutil>=7.2.2", "pydantic>=2.9.2", "pyyaml>=6.0.2", diff --git a/src/dvsim/instrumentation/__init__.py b/src/dvsim/instrumentation/__init__.py index 1f13f8d2..7f6fd9e0 100644 --- a/src/dvsim/instrumentation/__init__.py +++ b/src/dvsim/instrumentation/__init__.py @@ -12,7 +12,14 @@ JobMetrics, SchedulerMetrics, ) -from dvsim.instrumentation.runtime import flush, get, set_instrumentation, set_report_path +from dvsim.instrumentation.runtime import ( + flush, + gen_html_report, + get, + get_report, + set_instrumentation, + set_report_path, +) __all__ = ( "InstrumentationAggregator", @@ -23,7 +30,9 @@ "SchedulerInstrumentation", "SchedulerMetrics", "flush", + "gen_html_report", "get", + "get_report", "set_instrumentation", "set_report_path", ) diff --git a/src/dvsim/instrumentation/report/__init__.py b/src/dvsim/instrumentation/report/__init__.py new file mode 100644 index 00000000..624c1e31 --- /dev/null +++ b/src/dvsim/instrumentation/report/__init__.py @@ -0,0 +1,19 @@ +# Copyright lowRISC contributors (OpenTitan project). +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 + +"""DVSim Scheduler Instrumentation report.""" + +from dvsim.instrumentation.report.base import ( + InstrumentationVisualizer, + RenderProfile, + render_html_report, +) +from dvsim.instrumentation.report.registry import ReportVisualizationRegistry + +__all__ = ( + "InstrumentationVisualizer", + "RenderProfile", + "ReportVisualizationRegistry", + "render_html_report", +) diff --git a/src/dvsim/instrumentation/report/base.py b/src/dvsim/instrumentation/report/base.py new file mode 100644 index 00000000..3425da8e --- /dev/null +++ b/src/dvsim/instrumentation/report/base.py @@ -0,0 +1,132 @@ +# Copyright lowRISC contributors (OpenTitan project). +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 + +"""DVSim scheduler instrumentation reporting & visualizations.""" + +from collections.abc import Sequence +from enum import Enum +from pathlib import Path +from typing import Protocol + +import plotly.offline +from typing_extensions import Self + +from dvsim.instrumentation import InstrumentationResults +from dvsim.logging import log +from dvsim.report.artifacts import ReportArtifacts, render_static_content +from dvsim.templates.render import render_template + +__all__ = ( + "InstrumentationVisualizer", + "RenderProfile", + "render_html_report", +) + + +class RenderProfile(Enum): + """Levels of visualization rendering detail, which impact report size & responsiveness.""" + + NORMAL = "normal" + HIGH = "high" + FULL = "full" + + +class InstrumentationVisualizer(Protocol): + """Builder & renderer for HTML instrumentation visualizations.""" + + # A short name / title of the visualization, used in the HTML report navigation tab + title: str + + def render(self, results: InstrumentationResults) -> str | None: + """Render a visualization from the instrumentation results as a HTML fragment. + + If the required data is not provide in the instrumentation results (e.g. not enough + data, or not the correct type of data recorded), or the visualization should not be + generated, this can also optionally return `None`. + + """ + ... + + @classmethod + def for_profile(cls, profile: RenderProfile) -> Self: + """Create a visualizer instance configured for a given rendering profile (if supported).""" + log.debug("Render profile %s not used by visualization '%s'", profile.name, cls.title) + return cls() + + +def render_html_report( + results: InstrumentationResults, + *, + visualizations: Sequence[InstrumentationVisualizer] | None = None, + outdir: Path | None = None, + json_path: Path | None = None, +) -> ReportArtifacts: + """Render a HTML instrumentation report for some results & visualizations. + + Args: + results: The instrumentation results to generate a report from. + visualizations: The list of visualizations (if any) to display in the report. + outdir: The optional directory to write the 'metrics.html' report to, if desired. + json_path: Optional path to the 'metrics.json' file. + + Returns: + The generated file contents for the report - 'metrics.html' and static CSS/JS content. + + """ + log.info("Rendering instrumentation HTML report...") + + visualizations = visualizations or [] + renders: list[tuple[InstrumentationVisualizer, str]] = [] + for i, vis in enumerate(visualizations, start=1): + log.debug( + "Attempting to render instrumentation visualization: %s [%d/%d]", + vis.title, + i, + len(visualizations), + ) + render = vis.render(results) + if render is not None: + log.info("Rendered instrumentation visualization: %s", vis.title) + renders.append((vis, render)) + + metrics_json_path = json_path + if metrics_json_path and outdir and metrics_json_path.is_relative_to(outdir): + metrics_json_path = metrics_json_path.relative_to(outdir) + if outdir is not None: + outdir.mkdir(parents=True, exist_ok=True) + + artifacts = {} + + # Render the visualizations to a single metrics.html file + artifacts["metrics.html"] = render_template( + path="reports/instrumentation_report.html", + data={"renders": renders, "metrics_json": metrics_json_path}, + ) + if outdir is not None: + report_path = outdir / "metrics.html" + report_path.write_text(artifacts["metrics.html"]) + log.info("HTML instrumentation report written to %s", report_path) + + # Render static content needed for the report + artifacts.update( + render_static_content( + static_files=[ + "css/style.css", + "css/bootstrap.min.css", + "js/bootstrap.bundle.min.js", + "js/htmx.min.js", + ], + outdir=outdir, + ) + ) + + # Render static plotly.js separately. We generate the static minified JS from the plotly + # library itself to make sure we are using the correct version. + if renders: + plotly_js_path = "js/plotly.min.js" + artifacts[plotly_js_path] = plotly.offline.get_plotlyjs() + if outdir is not None: + (outdir / plotly_js_path).write_text(artifacts[plotly_js_path]) + + return artifacts diff --git a/src/dvsim/instrumentation/report/registry.py b/src/dvsim/instrumentation/report/registry.py new file mode 100644 index 00000000..548f807a --- /dev/null +++ b/src/dvsim/instrumentation/report/registry.py @@ -0,0 +1,47 @@ +# Copyright lowRISC contributors (OpenTitan project). +# Licensed under the Apache License, Version 2.0, see LICENSE for details. +# SPDX-License-Identifier: Apache-2.0 + +"""DVSim scheduler instrumentation report visualization registry.""" + +from typing import ClassVar + +from dvsim.instrumentation.report.base import InstrumentationVisualizer, RenderProfile + +__all__ = ("ReportVisualizationRegistry",) + + +class ReportVisualizationRegistry: + """Registry for scheduler instrumentation visualizer classes.""" + + _registry: ClassVar[dict[str, type[InstrumentationVisualizer]]] = {} + + @classmethod + def register(cls, vis_cls: type[InstrumentationVisualizer]) -> None: + """Register a new instrumentation visualization type.""" + cls._registry[vis_cls.title] = vis_cls + + @classmethod + def clear(cls) -> None: + """Clear any registered instrumentation visualization types.""" + cls._registry.clear() + + @classmethod + def registered(cls) -> dict[str, type[InstrumentationVisualizer]]: + """Get the current state of the registered instrumentation types.""" + return cls._registry.copy() + + @classmethod + def create(cls, profile: RenderProfile | None = None) -> list[InstrumentationVisualizer]: + """Create instances of registered visualization types for a given (optional) profile. + + Args: + profile: The rendering profile (level of detail) to target, if provided. + + Returns: + A list of InstrumentationVisualizer implementations created for the given profile. + + """ + if profile is None: + return [vis_cls() for vis_cls in cls._registry.values()] + return [vis_cls.for_profile(profile) for vis_cls in cls._registry.values()] diff --git a/src/dvsim/instrumentation/runtime.py b/src/dvsim/instrumentation/runtime.py index d7447e6b..2ee25ef6 100644 --- a/src/dvsim/instrumentation/runtime.py +++ b/src/dvsim/instrumentation/runtime.py @@ -2,15 +2,21 @@ # Licensed under the Apache License, Version 2.0, see LICENSE for details. # SPDX-License-Identifier: Apache-2.0 -"""DVSim scheduler instrumentation for timing-related information.""" +"""DVSim scheduler instrumentation runtime implementation.""" from pathlib import Path from dvsim.instrumentation.base import InstrumentationAggregator, InstrumentationResults +from dvsim.instrumentation.report import ( + RenderProfile, + ReportVisualizationRegistry, + render_html_report, +) from dvsim.logging import log __all__ = ( "flush", + "gen_html_report", "get", "get_report", "set_instrumentation", @@ -19,7 +25,10 @@ class _Runtime: + """Runtime singleton for the configured scheduler instrumentation.""" + def __init__(self) -> None: + """Construct a Runtime for the scheduler instrumentation.""" self.instrumentation: InstrumentationAggregator | None = None self.report_path: Path | None = None self.report: InstrumentationResults | None = None @@ -71,3 +80,34 @@ def flush() -> InstrumentationResults | None: def get_report() -> InstrumentationResults | None: """Get the latest flushed instrumentation report contents, if any exist.""" return _runtime.report + + +def gen_html_report( + results: InstrumentationResults, + *, + profile: RenderProfile | None = None, + outdir: Path | None = None, + json_path: Path | None = None, +) -> None: + """Generate a HTML report of the instrumentation results with rendered visualizations. + + Args: + results: The instrumentation results to render a HTML report for. + profile: Optional rendering profile to customize level of detail vs. report optimization. + outdir: The path to the directory to write the generated HTML report files to. If not + provided, this defaults to the parent directory of the configured report path. + json_path: The path to the metrics.json file. If not provided, this defaults to the + configured report path (if it exists). + + """ + if outdir is None: + if _runtime.report_path is None: + return + outdir = _runtime.report_path.parent + + if json_path is None: + json_path = _runtime.report_path + + log.debug("HTML instrumentation report will be written to %s", outdir) + visualizations = ReportVisualizationRegistry.create(profile) + render_html_report(results, visualizations=visualizations, outdir=outdir, json_path=json_path) diff --git a/src/dvsim/scheduler/runner.py b/src/dvsim/scheduler/runner.py index 3fc5876a..587f5c8e 100644 --- a/src/dvsim/scheduler/runner.py +++ b/src/dvsim/scheduler/runner.py @@ -141,6 +141,6 @@ async def run_scheduler( # Finalize instrumentation if inst is not None: inst.stop() - instrumentation.flush() + instrumentation.gen_html_report(instrumentation.flush()) return results diff --git a/src/dvsim/templates/reports/instrumentation_report.html b/src/dvsim/templates/reports/instrumentation_report.html new file mode 100644 index 00000000..7747a285 --- /dev/null +++ b/src/dvsim/templates/reports/instrumentation_report.html @@ -0,0 +1,73 @@ + +{% extends "reports/wrapper.html" %} +{% block head_content %} +{% if renders %} + +{% endif %} +{% endblock %} +{% block content %} +
+
+
+

Instrumentation report

+
+ {% if metrics_json %} +
+ JSON +
+ {% endif %} +
+ +
+
+ {% if not renders %} +

No instrumentation visualizations matched the available report data.

+ {% else %} + +
+ {% for vis, fragment in renders %} + {% set class = "tab-pane fade" + (" show active" if loop.index0 == 0 else "") %} + {% set slug = vis.title.lower() | replace("_", "-") | replace (" ", "-") %} +
+
+ {{ fragment | safe }} +
+
+ {% endfor %} +
+ {% endif %} +
+
+
+ + +{% endblock %} diff --git a/src/dvsim/templates/reports/wrapper.html b/src/dvsim/templates/reports/wrapper.html index 3f84d392..0e98b5a8 100644 --- a/src/dvsim/templates/reports/wrapper.html +++ b/src/dvsim/templates/reports/wrapper.html @@ -16,6 +16,7 @@ + {% block head_content %}{% endblock %} diff --git a/uv.lock b/uv.lock index 8a481817..5784a873 100644 --- a/uv.lock +++ b/uv.lock @@ -374,6 +374,7 @@ dependencies = [ { name = "gitpython" }, { name = "hjson" }, { name = "jinja2" }, + { name = "plotly" }, { name = "psutil" }, { name = "pydantic" }, { name = "pyyaml" }, @@ -465,6 +466,7 @@ requires-dist = [ { name = "hjson", specifier = ">=3.1.0" }, { name = "ipython", marker = "extra == 'debug'", specifier = ">=8.18.1" }, { name = "jinja2", specifier = ">=3.1.6" }, + { name = "plotly", specifier = ">=6.7.0" }, { name = "psutil", specifier = ">=7.2.2" }, { name = "pydantic", specifier = ">=2.9.2" }, { name = "pyhamcrest", marker = "extra == 'test'", specifier = ">=2.1.0" }, @@ -828,6 +830,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, ] +[[package]] +name = "narwhals" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/f3/257adc69a71011b4c8cda321b00f02c5bf1980ae38ffd05a58d9632d4de8/narwhals-2.20.0.tar.gz", hash = "sha256:c10994975fa7dc5a68c2cffcddbd5908fc8ebb2d463c5bab085309c0ee1f551e", size = 627848, upload-time = "2026-04-20T12:11:45.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/69/f24d3d1c38ad69e256138b4ec2452a8c7cf66be49dc214771ae99dd4f0a0/narwhals-2.20.0-py3-none-any.whl", hash = "sha256:16e750ea5507d4ba6e8d03455b5f93a535e0405976561baea235bca5dc9f475d", size = 449373, upload-time = "2026-04-20T12:11:43.596Z" }, +] + [[package]] name = "nodeenv" version = "1.10.0" @@ -867,6 +878,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/c3/059298687310d527a58bb01f3b1965787ee3b40dce76752eda8b44e9a2c5/pexpect-4.9.0-py2.py3-none-any.whl", hash = "sha256:7236d1e080e4936be2dc3e326cec0af72acf9212a7e1d060210e70a47e253523", size = 63772, upload-time = "2023-11-25T06:56:14.81Z" }, ] +[[package]] +name = "plotly" +version = "6.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3a/7f/0f100df1172aadf88a929a9dbb902656b0880ba4b960fe5224867159d8f4/plotly-6.7.0.tar.gz", hash = "sha256:45eea0ff27e2a23ccd62776f77eb43aa1ca03df4192b76036e380bb479b892c6", size = 6911286, upload-time = "2026-04-09T20:36:45.738Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/ad/cba91b3bcf04073e4d1655a5c1710ef3f457f56f7d1b79dcc3d72f4dd912/plotly-6.7.0-py3-none-any.whl", hash = "sha256:ac8aca1c25c663a59b5b9140a549264a5badde2e057d79b8c772ae2920e32ff0", size = 9898444, upload-time = "2026-04-09T20:36:39.812Z" }, +] + [[package]] name = "pluggy" version = "1.6.0"