From f0b1e82652607742adc93bd59f51595b362d5c59 Mon Sep 17 00:00:00 2001 From: "Jeong, YunWon" <69878+youknowone@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:18:03 +0900 Subject: [PATCH] Add tracing profiler and Python 3.15 offline profiling tools (#9271) * Add deterministic profiler and canonical Python 3.15 tools Use the merged operation critical sections with interpreter-bound profiler entry checks and short payload borrows. Trace owned code references for GC. Import the canonical tracing and offline profiling output closure via the repository updater, retaining existing test markers. Assisted-by: dot:GPT-6.1 * Clarify standalone profiling license cross-reference Preserve license terms and copyright notices; point to the reproduced Apache text below instead of CPython's original OpenSSL section above. Assisted-by: dot:GPT-6.1 * Defer unsupported sampling package from profiler extraction Keep the deterministic tracing profiler and its canonical tools together. Defer the entirely new sampling production, tests, assets and their license as one closure until native record and binary-collector support is ready. Retain canonical pstats for deterministic empty-profile handling; its upstream sampled-file sorting issue is not patched here. Assisted-by: dot:GPT-6.1 * Stage profiling root as a namespace until sampling is available Omit the whole newly added canonical package initializer rather than advertise an absent sampling module or hand-edit canonical __all__. Explicit profiling.tracing and cProfile imports remain available. The namespace root intentionally lacks canonical metadata/automatic exports. This addresses test___all__ failures in the prior Linux, macOS and Windows CI runs. No test assertions, skips or canonical file contents are changed. Assisted-by: dot:GPT-6.1 * Match canonical static builtin profiler labels and metadata Normalize the raw builtin receiver instead of its call-argument accessor. Snapshot static method module metadata at construction, matching CPython, so explicit module mutation and deletion remain observable. Avoid a profiler-time fallback that would obscure deleted metadata. Correct float.__getformat__ to CPython's classmethod calling convention, including subclass binding, rather than special-casing its profiler label. Assisted-by: dot:GPT-6.1 * Support collision-safe frozen package containers Generate empty frozen packages only for source namespace directories with real frozen descendants. Preserve genuine modules and packages across frozen-library registrations while retaining real-real replacement order. Record the container marker in FrozenModule and its library envelope; legacy module/package encodings remain unchanged. This is closed-tree frozen packaging, not full PEP 420 namespace merging. Add structural codec, mock generator, and registration-priority checks. * Revert frozen package containers before restoring canonical profiling root The container implementation changed filesystem namespace precedence. Restore the existing importer and frozen envelope unchanged; the following offline sampling closure restores the canonical regular package root. This reverses d5a80492343cf3f2481abf12da4e49d09d95d6e9. Assisted-by: dot:GPT-6.1 * Restore canonical profiling package with offline sampling codec Provide native records, status constants and genuine uncompressed binary reader/writer APIs for canonical offline tools. Keep the unsupported live process capability absent and preserve existing canonical test gates. Restore the complete selected sampling production/test/assets closure via update_lib, including its asyncio frame-schema dependency. The prior frozen container extension is reverted separately; canonical packages work with both filesystem and frozen imports without changing namespace semantics. Assisted-by: dot:GPT-6.1 * Keep platform-dependent C long normalization explicit Allow only the c_long-to-i32 conversion lint: Windows uses the same type, while LP64 requires the existing truncation to match CPython normalization. No runtime behavior or test assertions change. Assisted-by: dot:GPT-6.1 * Include profiling notices in library and desktop packages Bundle a byte-identical copy of the root profiling notice in rustpython-pylib and include the root notice in configured desktop resources. Avoid a symlink so Windows checkouts preserve the actual notice text. Terms are unchanged. Assisted-by: dot:GPT-6.1 * Keep profiling asset license notice in pylib crate * Serve frozen profiling assets through native resource readers * Preserve canonical profiling asset line endings on checkout --------- Co-authored-by: youknowdot --- .gitattributes | 17 + Cargo.lock | 1 + Cargo.toml | 2 +- Lib/asyncio/tools.py | 47 +- Lib/cProfile.py | 13 + Lib/profile.py | 10 +- Lib/profiling/__init__.py | 13 + Lib/profiling/sampling/__init__.py | 23 + .../sampling/_assets/tachyon-logo.png | Bin 0 -> 94008 bytes Lib/profiling/sampling/_css_utils.py | 22 + .../_flamegraph_assets/flamegraph.css | 1085 +++++ .../sampling/_flamegraph_assets/flamegraph.js | 1528 +++++++ .../flamegraph_template.html | 464 +++ Lib/profiling/sampling/_format_utils.py | 5 + .../sampling/_heatmap_assets/heatmap.css | 1555 ++++++++ .../sampling/_heatmap_assets/heatmap.js | 738 ++++ .../sampling/_heatmap_assets/heatmap_index.js | 104 + .../heatmap_index_template.html | 136 + .../heatmap_pyfile_template.html | 131 + .../_heatmap_assets/heatmap_shared.js | 64 + .../sampling/_shared_assets/base.css | 514 +++ Lib/profiling/sampling/_shared_assets/base.js | 58 + .../4.1.3/d3-flamegraph-tooltip.min.js | 1 + .../d3-flame-graph/4.1.3/d3-flamegraph.css | 46 + .../d3-flame-graph/4.1.3/d3-flamegraph.min.js | 1 + .../sampling/_vendor/d3/7.8.5/d3.min.js | 2 + Lib/profiling/sampling/binary_collector.py | 151 + Lib/profiling/sampling/binary_reader.py | 148 + Lib/profiling/sampling/collector.py | 292 ++ Lib/profiling/sampling/constants.py | 57 + Lib/profiling/sampling/gecko_collector.py | 1006 +++++ Lib/profiling/sampling/heatmap_collector.py | 1145 ++++++ Lib/profiling/sampling/jsonl_collector.py | 268 ++ Lib/profiling/sampling/module_utils.py | 102 + Lib/profiling/sampling/opcode_utils.py | 94 + Lib/profiling/sampling/pstats_collector.py | 429 ++ Lib/profiling/sampling/sample.py | 665 ++++ Lib/profiling/sampling/stack_collector.py | 907 +++++ Lib/profiling/sampling/string_table.py | 53 + Lib/profiling/tracing/__init__.py | 220 + Lib/profiling/tracing/__main__.py | 6 + Lib/profiling/tracing/_utils.py | 32 + Lib/pstats.py | 69 +- Lib/test/test_asyncio/test_tools.py | 311 +- Lib/test/test_profile.py | 6 +- Lib/test/test_profiling/__init__.py | 5 + Lib/test/test_profiling/__main__.py | 4 + Lib/test/test_profiling/test_heatmap.py | 862 ++++ .../test_sampling_profiler/__init__.py | 9 + .../test_sampling_profiler/helpers.py | 202 + .../test_sampling_profiler/mocks.py | 112 + .../test_binary_format.py | 1788 +++++++++ .../test_sampling_profiler/test_collectors.py | 3543 +++++++++++++++++ .../test_profiling/test_tracing_profiler.py | 308 ++ crates/host_env/src/fs.rs | 4 + crates/pylib/Cargo.toml | 5 +- crates/pylib/LICENSE-profiling | 225 ++ crates/pylib/build.rs | 131 + crates/pylib/src/lib.rs | 9 + crates/stdlib/src/_remote_debugging.rs | 711 +++- crates/stdlib/src/_remote_debugging/binary.rs | 1313 ++++++ crates/vm/src/builtins/code.rs | 31 +- crates/vm/src/builtins/float.rs | 8 +- crates/vm/src/frozen_resources.rs | 719 ++++ crates/vm/src/function/method.rs | 16 +- crates/vm/src/import.rs | 7 +- crates/vm/src/lib.rs | 1 + crates/vm/src/stdlib/_imp.rs | 4 +- crates/vm/src/stdlib/_imp/resources.rs | 275 ++ crates/vm/src/stdlib/_lsprof.rs | 800 ++++ crates/vm/src/stdlib/mod.rs | 2 + crates/vm/src/stdlib/time.rs | 3 +- crates/vm/src/vm/interpreter.rs | 46 +- crates/vm/src/vm/mod.rs | 10 +- crates/vm/src/vm/thread/critical_section.rs | 4 - crates/vm/src/vm/thread/lock.rs | 4 - crates/wasm/src/vm_class.rs | 6 +- example_projects/frozen_stdlib/src/main.rs | 6 +- examples/custom_tls_providers.rs | 7 +- src/interpreter.rs | 6 +- 80 files changed, 23512 insertions(+), 215 deletions(-) create mode 100644 Lib/cProfile.py create mode 100644 Lib/profiling/__init__.py create mode 100644 Lib/profiling/sampling/__init__.py create mode 100644 Lib/profiling/sampling/_assets/tachyon-logo.png create mode 100644 Lib/profiling/sampling/_css_utils.py create mode 100644 Lib/profiling/sampling/_flamegraph_assets/flamegraph.css create mode 100644 Lib/profiling/sampling/_flamegraph_assets/flamegraph.js create mode 100644 Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html create mode 100644 Lib/profiling/sampling/_format_utils.py create mode 100644 Lib/profiling/sampling/_heatmap_assets/heatmap.css create mode 100644 Lib/profiling/sampling/_heatmap_assets/heatmap.js create mode 100644 Lib/profiling/sampling/_heatmap_assets/heatmap_index.js create mode 100644 Lib/profiling/sampling/_heatmap_assets/heatmap_index_template.html create mode 100644 Lib/profiling/sampling/_heatmap_assets/heatmap_pyfile_template.html create mode 100644 Lib/profiling/sampling/_heatmap_assets/heatmap_shared.js create mode 100644 Lib/profiling/sampling/_shared_assets/base.css create mode 100644 Lib/profiling/sampling/_shared_assets/base.js create mode 100644 Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph-tooltip.min.js create mode 100644 Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph.css create mode 100644 Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph.min.js create mode 100644 Lib/profiling/sampling/_vendor/d3/7.8.5/d3.min.js create mode 100644 Lib/profiling/sampling/binary_collector.py create mode 100644 Lib/profiling/sampling/binary_reader.py create mode 100644 Lib/profiling/sampling/collector.py create mode 100644 Lib/profiling/sampling/constants.py create mode 100644 Lib/profiling/sampling/gecko_collector.py create mode 100644 Lib/profiling/sampling/heatmap_collector.py create mode 100644 Lib/profiling/sampling/jsonl_collector.py create mode 100644 Lib/profiling/sampling/module_utils.py create mode 100644 Lib/profiling/sampling/opcode_utils.py create mode 100644 Lib/profiling/sampling/pstats_collector.py create mode 100644 Lib/profiling/sampling/sample.py create mode 100644 Lib/profiling/sampling/stack_collector.py create mode 100644 Lib/profiling/sampling/string_table.py create mode 100644 Lib/profiling/tracing/__init__.py create mode 100644 Lib/profiling/tracing/__main__.py create mode 100644 Lib/profiling/tracing/_utils.py create mode 100644 Lib/test/test_profiling/__init__.py create mode 100644 Lib/test/test_profiling/__main__.py create mode 100644 Lib/test/test_profiling/test_heatmap.py create mode 100644 Lib/test/test_profiling/test_sampling_profiler/__init__.py create mode 100644 Lib/test/test_profiling/test_sampling_profiler/helpers.py create mode 100644 Lib/test/test_profiling/test_sampling_profiler/mocks.py create mode 100644 Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py create mode 100644 Lib/test/test_profiling/test_sampling_profiler/test_collectors.py create mode 100644 Lib/test/test_profiling/test_tracing_profiler.py create mode 100644 crates/pylib/LICENSE-profiling create mode 100644 crates/stdlib/src/_remote_debugging/binary.rs create mode 100644 crates/vm/src/frozen_resources.rs create mode 100644 crates/vm/src/stdlib/_imp/resources.rs create mode 100644 crates/vm/src/stdlib/_lsprof.rs diff --git a/.gitattributes b/.gitattributes index 44fc4c8f9..72ef6114d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -71,3 +71,20 @@ Lib/test/certdata/*.0 generated Lib/test/levenshtein_examples.json generated Lib/test/test_stable_abi_ctypes.py generated Lib/token.py generated + +# Preserve canonical profiling resource bytes for frozen asset hashing. +Lib/profiling/sampling/_flamegraph_assets/flamegraph.css text eol=lf +Lib/profiling/sampling/_flamegraph_assets/flamegraph.js text eol=lf +Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html text eol=lf +Lib/profiling/sampling/_heatmap_assets/heatmap.css text eol=lf +Lib/profiling/sampling/_heatmap_assets/heatmap.js text eol=lf +Lib/profiling/sampling/_heatmap_assets/heatmap_index.js text eol=lf +Lib/profiling/sampling/_heatmap_assets/heatmap_index_template.html text eol=lf +Lib/profiling/sampling/_heatmap_assets/heatmap_pyfile_template.html text eol=lf +Lib/profiling/sampling/_heatmap_assets/heatmap_shared.js text eol=lf +Lib/profiling/sampling/_shared_assets/base.css text eol=lf +Lib/profiling/sampling/_shared_assets/base.js text eol=lf +Lib/profiling/sampling/_vendor/d3/7.8.5/d3.min.js text eol=lf +Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph-tooltip.min.js text eol=lf +Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph.css text eol=lf +Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph.min.js text eol=lf diff --git a/Cargo.lock b/Cargo.lock index 43f4b2fbd..0ad0b7ffe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3921,6 +3921,7 @@ dependencies = [ "glob", "rustpython-compiler-core", "rustpython-derive", + "sha2 0.11.1", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 47edef74d..5b4efbd10 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -155,7 +155,7 @@ homepage = "https://rustpython.github.io/" license_file = "LICENSE" authors = ["RustPython Team"] publisher = "RustPython Team" -resources = ["LICENSE", "README.md", "Lib"] +resources = ["LICENSE", "crates/pylib/LICENSE-profiling", "README.md", "Lib"] icons = ["32x32.png"] [package.metadata.packager.nsis] diff --git a/Lib/asyncio/tools.py b/Lib/asyncio/tools.py index f39e11fdd..2ac1738d1 100644 --- a/Lib/asyncio/tools.py +++ b/Lib/asyncio/tools.py @@ -1,6 +1,6 @@ """Tools to analyze tasks running in asyncio programs.""" -from collections import defaultdict, namedtuple +from collections import defaultdict from itertools import count from enum import Enum import sys @@ -27,10 +27,10 @@ class CycleFoundException(Exception): # ─── indexing helpers ─────────────────────────────────────────── def _format_stack_entry(elem: str|FrameInfo) -> str: if not isinstance(elem, str): - if elem.lineno == 0 and elem.filename == "": + if elem.location.lineno == 0 and elem.filename == "": return f"{elem.funcname}" else: - return f"{elem.funcname} {elem.filename}:{elem.lineno}" + return f"{elem.funcname} {elem.filename}:{elem.location.lineno}" return elem @@ -231,27 +231,38 @@ def exit_with_permission_help_text(): print( "Error: The specified process cannot be attached to due to insufficient permissions.\n" "See the Python documentation for details on required privileges and troubleshooting:\n" - "https://docs.python.org/3.14/howto/remote_debugging.html#permission-requirements\n" + "https://docs.python.org/3/howto/remote_debugging.html#permission-requirements\n", + file=sys.stderr, ) sys.exit(1) -def _get_awaited_by_tasks(pid: int) -> list: - try: - return get_all_awaited_by(pid) - except RuntimeError as e: - while e.__context__ is not None: - e = e.__context__ - print(f"Error retrieving tasks: {e}") - sys.exit(1) - except PermissionError as e: - exit_with_permission_help_text() +_TRANSIENT_ERRORS = (RuntimeError, OSError, UnicodeDecodeError, MemoryError) -def display_awaited_by_tasks_table(pid: int) -> None: +def _get_awaited_by_tasks(pid: int, retries: int = 3) -> list: + for attempt in range(retries + 1): + try: + return get_all_awaited_by(pid) + except PermissionError: + exit_with_permission_help_text() + except ProcessLookupError: + print(f"Error: process {pid} not found.", file=sys.stderr) + sys.exit(1) + except _TRANSIENT_ERRORS as e: + if attempt < retries: + continue + if isinstance(e, RuntimeError): + while e.__context__ is not None: + e = e.__context__ + print(f"Error retrieving tasks: {e}", file=sys.stderr) + sys.exit(1) + + +def display_awaited_by_tasks_table(pid: int, retries: int = 3) -> None: """Build and print a table of all pending tasks under `pid`.""" - tasks = _get_awaited_by_tasks(pid) + tasks = _get_awaited_by_tasks(pid, retries=retries) table = build_task_table(tasks) # Print the table in a simple tabular format print( @@ -262,10 +273,10 @@ def display_awaited_by_tasks_table(pid: int) -> None: print(f"{row[0]:<10} {row[1]:<20} {row[2]:<20} {row[3]:<50} {row[4]:<50} {row[5]:<15} {row[6]:<15}") -def display_awaited_by_tasks_tree(pid: int) -> None: +def display_awaited_by_tasks_tree(pid: int, retries: int = 3) -> None: """Build and print a tree of all pending tasks under `pid`.""" - tasks = _get_awaited_by_tasks(pid) + tasks = _get_awaited_by_tasks(pid, retries=retries) try: result = build_async_tree(tasks) except CycleFoundException as e: diff --git a/Lib/cProfile.py b/Lib/cProfile.py new file mode 100644 index 000000000..cc6255f61 --- /dev/null +++ b/Lib/cProfile.py @@ -0,0 +1,13 @@ +"""Compatibility wrapper for cProfile module. + +This module maintains backward compatibility by importing from the new +profiling.tracing module. +""" + +from profiling.tracing import run, runctx, Profile + +__all__ = ["run", "runctx", "Profile"] + +if __name__ == "__main__": + from profiling.tracing.__main__ import main + main() diff --git a/Lib/profile.py b/Lib/profile.py index a5afb12c9..304284da4 100644 --- a/Lib/profile.py +++ b/Lib/profile.py @@ -28,9 +28,18 @@ import io import sys import time import marshal +import warnings __all__ = ["run", "runctx", "Profile"] +# Emit deprecation warning as per PEP 799 +warnings.warn( + "The profile module is deprecated and will be removed in Python 3.17. " + "Use profiling.tracing (or cProfile) for tracing profilers instead.", + DeprecationWarning, + stacklevel=2 +) + # Sample timer for use with #i_count = 0 #def integer_timer(): @@ -598,7 +607,6 @@ def main(): '__file__': spec.origin, '__name__': spec.name, '__package__': None, - '__cached__': None, } try: runctx(code, globs, None, options.outfile, options.sort) diff --git a/Lib/profiling/__init__.py b/Lib/profiling/__init__.py new file mode 100644 index 000000000..c94e1e16e --- /dev/null +++ b/Lib/profiling/__init__.py @@ -0,0 +1,13 @@ +"""Python profiling tools. + +This package provides two types of profilers: + +- profiling.tracing: Deterministic tracing profiler that instruments every + function call and return. Higher overhead but provides exact call counts + and timing. + +- profiling.sampling: Statistical sampling profiler that periodically samples + the call stack. Low overhead and suitable for production use. +""" + +__all__ = ("tracing", "sampling") diff --git a/Lib/profiling/sampling/__init__.py b/Lib/profiling/sampling/__init__.py new file mode 100644 index 000000000..71579a390 --- /dev/null +++ b/Lib/profiling/sampling/__init__.py @@ -0,0 +1,23 @@ +"""Statistical sampling profiler for Python. + +This module provides low-overhead profiling by periodically sampling the +call stack rather than tracing every function call. +""" + +from .collector import Collector +from .pstats_collector import PstatsCollector +from .stack_collector import CollapsedStackCollector +from .heatmap_collector import HeatmapCollector +from .gecko_collector import GeckoCollector +from .jsonl_collector import JsonlCollector +from .string_table import StringTable + +__all__ = ( + "Collector", + "PstatsCollector", + "CollapsedStackCollector", + "HeatmapCollector", + "GeckoCollector", + "JsonlCollector", + "StringTable", +) diff --git a/Lib/profiling/sampling/_assets/tachyon-logo.png b/Lib/profiling/sampling/_assets/tachyon-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..03e67823d5afc94c156953519418d5a8dc18283a GIT binary patch literal 94008 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�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≺;RD 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 Vw~RBgAp0jnML|>kr<{4v{|E4fh8O?< literal 0 HcmV?d00001 diff --git a/Lib/profiling/sampling/_css_utils.py b/Lib/profiling/sampling/_css_utils.py new file mode 100644 index 000000000..40912e9b3 --- /dev/null +++ b/Lib/profiling/sampling/_css_utils.py @@ -0,0 +1,22 @@ +import importlib.resources + + +def get_combined_css(component: str) -> str: + template_dir = importlib.resources.files(__package__) + + base_css = (template_dir / "_shared_assets" / "base.css").read_text(encoding="utf-8") + + if component == "flamegraph": + component_css = ( + template_dir / "_flamegraph_assets" / "flamegraph.css" + ).read_text(encoding="utf-8") + elif component == "heatmap": + component_css = (template_dir / "_heatmap_assets" / "heatmap.css").read_text( + encoding="utf-8" + ) + else: + raise ValueError( + f"Unknown component: {component}. Expected 'flamegraph' or 'heatmap'." + ) + + return f"{base_css}\n\n{component_css}" diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css new file mode 100644 index 000000000..c93ee1e9d --- /dev/null +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.css @@ -0,0 +1,1085 @@ +/* ========================================================================== + Flamegraph Viewer - Component-Specific CSS + + DEPENDENCY: Requires _shared_assets/base.css to be loaded first + This file extends the shared foundation with flamegraph-specific styles. + ========================================================================== */ + +/* -------------------------------------------------------------------------- + Differential Flamegraph + -------------------------------------------------------------------------- */ + +:root { + /* Regression colors */ + --diff-regression-deep: #d32f2f; + --diff-regression-medium: #e57373; + --diff-regression-light: #ef9a9a; + --diff-regression-verylight: #ffcdd2; + + /* Improvement colors */ + --diff-improvement-deep: #1976d2; + --diff-improvement-medium: #42a5f5; + --diff-improvement-light: #64b5f6; + --diff-improvement-verylight: #90caf9; + + /* Other differential colors */ + --diff-neutral: #bdbdbd; + --diff-new: #9575cd; + --diff-elided: #b39ddb; +} + +/* Dark mode differential colors - adjusted for contrast against dark backgrounds */ +[data-theme="dark"] { + --diff-regression-deep: #ef5350; + --diff-regression-medium: #e57373; + --diff-regression-light: #ef9a9a; + --diff-regression-verylight: #ffcdd2; + + --diff-improvement-deep: #42a5f5; + --diff-improvement-medium: #64b5f6; + --diff-improvement-light: #90caf9; + --diff-improvement-verylight: #bbdefb; + + --diff-neutral: #757575; + --diff-new: #b39ddb; + --diff-elided: #ce93d8; +} + +/* -------------------------------------------------------------------------- + Layout Overrides (Flamegraph-specific) + -------------------------------------------------------------------------- */ + +html, body { + height: 100%; + overflow: hidden; +} + +.app-layout { + height: 100vh; +} + +.main-content { + display: flex; + flex: 1; + min-height: 0; +} + +/* -------------------------------------------------------------------------- + Search Input (Flamegraph-specific) + -------------------------------------------------------------------------- */ + +.search-wrapper { + flex: 1; + max-width: 360px; + position: relative; +} + +.search-input { + width: 100%; + padding: 8px 36px 8px 14px; + font-family: var(--font-sans); + font-size: 13px; + color: #2e3338; + background: rgba(255, 255, 255, 0.95); + border: 2px solid rgba(255, 255, 255, 0.3); + border-radius: 20px; + outline: none; + transition: all var(--transition-fast); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.search-input::placeholder { + color: #6c757d; +} + +.search-input:focus { + border-color: rgba(255, 255, 255, 0.8); + background: white; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.15); +} + +/* Dark theme search input */ +[data-theme="dark"] .search-input { + color: #e6edf3; + background: rgba(33, 38, 45, 0.95); + border: 2px solid rgba(88, 166, 255, 0.3); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} + +[data-theme="dark"] .search-input::placeholder { + color: #8b949e; +} + +[data-theme="dark"] .search-input:focus { + border-color: rgba(88, 166, 255, 0.6); + background: rgba(33, 38, 45, 1); + box-shadow: 0 4px 16px rgba(88, 166, 255, 0.2); +} + +.search-input.has-matches { + border-color: rgba(40, 167, 69, 0.8); + box-shadow: 0 4px 16px rgba(40, 167, 69, 0.2); +} + +.search-input.no-matches { + border-color: rgba(220, 53, 69, 0.8); + box-shadow: 0 4px 16px rgba(220, 53, 69, 0.2); +} + +.search-clear { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + width: 20px; + height: 20px; + padding: 0; + display: none; + align-items: center; + justify-content: center; + font-size: 14px; + line-height: 1; + color: #6c757d; + background: transparent; + border: none; + border-radius: 50%; + cursor: pointer; + transition: color var(--transition-fast); +} + +.search-clear:hover { + color: #2e3338; +} + +[data-theme="dark"] .search-clear { + color: #8b949e; +} + +[data-theme="dark"] .search-clear:hover { + color: #e6edf3; +} + +.search-wrapper.has-value .search-clear { + display: flex; +} + +/* -------------------------------------------------------------------------- + Sidebar + -------------------------------------------------------------------------- */ + +.sidebar { + width: var(--sidebar-width); + background: var(--bg-secondary); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; + overflow: hidden; + position: relative; +} + +.sidebar.collapsed { + width: var(--sidebar-collapsed) !important; + transition: width var(--transition-normal); +} + +.sidebar-toggle { + position: absolute; + top: 12px; + right: 10px; + width: 26px; + height: 26px; + display: flex; + align-items: center; + justify-content: center; + color: var(--text-muted); + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + transition: all var(--transition-fast); + z-index: 10; +} + +.sidebar-toggle svg { + transition: transform var(--transition-fast); +} + +.sidebar-toggle:hover { + color: var(--accent); + border-color: var(--accent); + background: var(--accent-glow); +} + +.sidebar.collapsed .sidebar-toggle { + right: 9px; +} + +.sidebar.collapsed .sidebar-toggle svg { + transform: rotate(180deg); +} + +.sidebar-content { + flex: 1; + overflow-y: auto; + padding: 44px 14px 14px; +} + +.sidebar.collapsed .sidebar-content { + display: none; +} + +.sidebar-resize-handle { + position: absolute; + top: 0; + right: 0; + width: 6px; + height: 100%; + cursor: col-resize; + background: transparent; + transition: background var(--transition-fast); + z-index: 11; +} + +.sidebar-resize-handle:hover, +.sidebar-resize-handle.resizing { + background: var(--python-gold); +} + +.sidebar-resize-handle::before { + content: ''; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: 2px; + height: 40px; + background: var(--border); + border-radius: 1px; + opacity: 0; + transition: opacity var(--transition-fast); +} + +.sidebar-resize-handle:hover::before { + opacity: 1; +} + +.sidebar.collapsed .sidebar-resize-handle { + display: none; +} + +body.resizing-sidebar { + cursor: col-resize; + user-select: none; +} + +/* Sidebar Logo */ +.sidebar-logo { + display: flex; + justify-content: center; + margin-bottom: 16px; +} + +.sidebar-logo-img { + width: 220px; + height: 180px; + display: flex; + align-items: center; + justify-content: center; +} + +.sidebar-logo-img svg, +.sidebar-logo-img img { + width: 100%; + height: 100%; + object-fit: contain; +} + +/* Sidebar sections */ +.sidebar-section { + margin-bottom: 20px; +} + +.sidebar-section:last-child { + margin-bottom: 0; +} + +.section-title { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.8px; + color: var(--accent); + margin: 0; + flex: 1; +} + +/* View Mode Section */ +.view-mode-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.view-mode-section .section-content { + display: flex; + flex-direction: column; + gap: 10px; + align-items: center; +} + +/* Collapsible sections */ +.collapsible .section-header { + display: flex; + align-items: center; + width: 100%; + padding: 0 0 8px 0; + margin-bottom: 10px; + background: none; + border: none; + border-bottom: 2px solid var(--python-gold); + cursor: pointer; + transition: all var(--transition-fast); +} + +.collapsible .section-header:hover { + opacity: 0.8; +} + +.section-chevron { + color: var(--text-muted); + transition: transform var(--transition-fast); +} + +.collapsible.collapsed .section-chevron { + transform: rotate(-90deg); +} + +.section-content { + transition: max-height var(--transition-slow) ease-out, opacity var(--transition-normal) ease-out, padding var(--transition-normal) ease-out; + max-height: 1000px; + opacity: 1; +} + +.collapsible.collapsed .section-content { + max-height: 0; + opacity: 0; + padding-top: 0; + pointer-events: none; + transition: max-height var(--transition-slow) ease-in, opacity var(--transition-normal) ease-in, padding var(--transition-normal) ease-in; +} + +/* -------------------------------------------------------------------------- + Profile Summary Cards + -------------------------------------------------------------------------- */ + +.summary-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 6px; +} + +.summary-card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + background: var(--bg-primary); + border: 2px solid var(--border); + border-radius: 8px; + transition: all var(--transition-fast); + animation: slideUp 0.4s ease-out backwards; + animation-delay: calc(var(--i, 0) * 0.08s); + overflow: hidden; + position: relative; +} + +.summary-card:nth-child(1) { --i: 0; --card-color: var(--card-blue); } +.summary-card:nth-child(2) { --i: 1; --card-color: var(--card-green); } +.summary-card:nth-child(3) { --i: 2; --card-color: var(--card-yellow); } +.summary-card:nth-child(4) { --i: 3; --card-color: var(--card-purple); } + +.summary-card:hover { + border-color: rgba(var(--card-color), 0.6); + background: linear-gradient(135deg, rgba(var(--card-color), 0.08) 0%, var(--bg-primary) 100%); + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(var(--card-color), 0.15); +} + +.summary-icon { + font-size: 14px; + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, rgba(var(--card-color), 0.15) 0%, rgba(var(--card-color), 0.05) 100%); + border: 1px solid rgba(var(--card-color), 0.2); + border-radius: 6px; + flex-shrink: 0; + transition: all var(--transition-fast); +} + +.summary-card:hover .summary-icon { + transform: scale(1.05); + background: linear-gradient(135deg, rgba(var(--card-color), 0.25) 0%, rgba(var(--card-color), 0.1) 100%); +} + +.summary-data { + min-width: 0; + flex: 1; + overflow: hidden; +} + +.summary-value { + font-family: var(--font-mono); + font-size: 13px; + font-weight: 800; + color: rgb(var(--card-color)); + line-height: 1.2; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.summary-label { + font-size: 8px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* -------------------------------------------------------------------------- + Progress Bars + -------------------------------------------------------------------------- */ + +.bar-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 5px; +} + +.bar-label { + font-size: 9px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.2px; +} + +.bar-value { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 700; + color: var(--accent); +} + +.bar { + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + overflow: hidden; +} + +.bar-fill { + height: 100%; + background: linear-gradient(90deg, #28a745 0%, #20c997 50%, #17a2b8 100%); + border-radius: 3px; + transition: width 0.6s ease-out; + position: relative; + overflow: hidden; +} + +.bar-fill::after { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.4) 50%, + transparent 100% + ); + animation: shimmer 2s ease-in-out infinite; +} + +/* -------------------------------------------------------------------------- + Efficiency Section Container + -------------------------------------------------------------------------- */ + +.efficiency-section { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border); +} + +/* -------------------------------------------------------------------------- + Thread Stats Progress Bars (in Sidebar) + -------------------------------------------------------------------------- */ + +.thread-stats-section { + display: block; +} + +.stats-container { + display: flex; + flex-direction: column; + gap: 10px; +} + +.stat-item { + animation: fadeIn 0.4s ease-out backwards; + animation-delay: calc(var(--i, 0) * 0.05s); +} + +.stat-item:nth-child(1) { --i: 0; } +.stat-item:nth-child(2) { --i: 1; } +.stat-item:nth-child(3) { --i: 2; } +.stat-item:nth-child(4) { --i: 3; } +.stat-item:nth-child(5) { --i: 4; } + +/* Color variants for bar-fill */ +.bar-fill--green { + background: linear-gradient(90deg, #28a745 0%, #20c997 100%); +} + +.bar-fill--yellow { + background: linear-gradient(90deg, #ffc107 0%, #ffdb4d 100%); +} + +.bar-fill--purple { + background: linear-gradient(90deg, #6f42c1 0%, #9b6dd6 100%); +} + +.bar-fill--red { + background: linear-gradient(90deg, #dc3545 0%, #ff6b7a 100%); +} + +/* -------------------------------------------------------------------------- + Hotspot Cards + -------------------------------------------------------------------------- */ + +.hotspot { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px; + margin-bottom: 8px; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px; + cursor: pointer; + transition: all var(--transition-fast); + opacity: 0; + transform: translateY(8px); + box-shadow: var(--shadow-sm); +} + +.hotspot.visible { + opacity: 1; + transform: translateY(0); +} + +.hotspot:hover { + border-color: var(--accent); + box-shadow: var(--shadow-md); + transform: translateY(-2px); +} + +.hotspot.active { + border-color: var(--python-gold); + background: var(--accent-glow); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +.hotspot:last-child { + margin-bottom: 0; +} + +.hotspot-rank { + width: 26px; + height: 26px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-weight: 700; + font-size: 12px; + flex-shrink: 0; + background: linear-gradient(135deg, var(--python-blue) 0%, var(--python-blue-light) 100%); + color: white; + box-shadow: 0 2px 4px rgba(55, 118, 171, 0.3); +} + +.hotspot-rank--1 { background: linear-gradient(135deg, #d4af37, #f4d03f); color: #5a4a00; } +.hotspot-rank--2 { background: linear-gradient(135deg, #a8a8a8, #c0c0c0); color: #4a4a4a; } +.hotspot-rank--3 { background: linear-gradient(135deg, #cd7f32, #e6a55a); color: #5a3d00; } + +.hotspot-info { + flex: 1; + min-width: 0; +} + +.hotspot-func { + font-family: var(--font-mono); + font-size: 11px; + font-weight: 600; + color: var(--text-primary); + line-height: 1.3; + word-break: break-word; + margin-bottom: 2px; +} + +.hotspot-file { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-muted); + margin-bottom: 3px; + word-break: break-all; +} + +.hotspot-stats { + font-family: var(--font-mono); + font-size: 10px; + color: var(--text-secondary); +} + +.hotspot-percent { + color: var(--accent); + font-weight: 600; +} + +/* -------------------------------------------------------------------------- + Legend + -------------------------------------------------------------------------- */ + + +.legend { + display: flex; + flex-direction: column; + gap: 4px; +} + +.legend-item { + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + background: var(--bg-primary); + border-radius: 4px; + border: 1px solid var(--border-subtle); + font-size: 11px; +} + +.legend-color { + width: 20px; + height: 10px; + border-radius: 2px; + flex-shrink: 0; + border: 1px solid rgba(0, 0, 0, 0.08); +} + +.legend-label { + color: var(--text-primary); + font-weight: 500; + flex: 1; +} + +.legend-range { + font-family: var(--font-mono); + font-size: 9px; + color: var(--text-muted); +} + +/* -------------------------------------------------------------------------- + Thread Filter + -------------------------------------------------------------------------- */ + +.filter-section { + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.filter-label { + display: block; + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + margin-bottom: 6px; +} + +.filter-select { + width: 100%; + padding: 7px 28px 7px 10px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-primary); + background: var(--bg-primary); + border: 2px solid var(--accent); + border-radius: 6px; + cursor: pointer; + outline: none; + appearance: none; + background-image: url("data:image/svg+xml;charset=UTF-8,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233776ab' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3e%3cpolyline points='6 9 12 15 18 9'%3e%3c/polyline%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right 6px center; + background-size: 14px; + transition: all var(--transition-fast); +} + +.filter-select:hover { + border-color: var(--accent-hover); + box-shadow: 0 2px 6px var(--accent-glow); +} + +.filter-select:focus { + border-color: var(--accent); + box-shadow: 0 0 0 3px var(--accent-glow); +} + +/* -------------------------------------------------------------------------- + Chart Area + -------------------------------------------------------------------------- */ + +.chart-area { + flex: 1; + min-width: 0; + overflow: hidden; + background: var(--bg-primary); + position: relative; +} + +#chart { + width: 100%; + height: 100%; + padding: 16px; + overflow: auto; +} + +/* D3 Flamegraph overrides */ +.d3-flame-graph rect { + stroke: rgba(55, 118, 171, 0.3); + stroke-width: 1px; + cursor: pointer; + transition: filter 0.1s ease; +} + +.d3-flame-graph rect:hover { + stroke: var(--python-blue); + stroke-width: 2px; + filter: brightness(1.08); +} + +.d3-flame-graph text { + font-family: var(--font-sans); + font-size: 12px; + font-weight: 500; + fill: var(--text-primary); + pointer-events: none; +} + +/* Search highlight */ +.d3-flame-graph rect.search-match { + stroke: #ff6b35 !important; + stroke-width: 2px !important; + stroke-dasharray: 4 2; +} + +.d3-flame-graph rect.search-dim { + opacity: 0.25; +} + +/* -------------------------------------------------------------------------- + Tooltip + -------------------------------------------------------------------------- */ + +.python-tooltip { + position: absolute; + z-index: 1000; + pointer-events: none; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px; + padding: 14px; + max-width: 480px; + box-shadow: var(--shadow-lg); + font-family: var(--font-sans); + font-size: 13px; + color: var(--text-primary); + word-wrap: break-word; + overflow-wrap: break-word; + line-height: 1.5; +} + +.tooltip-header { + margin-bottom: 10px; +} + +.tooltip-title { + font-size: 14px; + font-weight: 600; + color: var(--accent); + line-height: 1.3; + word-break: break-word; + margin-bottom: 4px; +} + +.tooltip-location { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: 4px 8px; + border-radius: 4px; + word-break: break-all; +} + +.tooltip-stats { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 14px; + font-size: 12px; +} + +.tooltip-stat-label { + color: var(--text-secondary); + font-weight: 500; +} + +.tooltip-stat-value { + color: var(--text-primary); + font-weight: 600; +} + +.tooltip-stat-value.accent { + color: var(--accent); +} + +.tooltip-diff { + margin-top: 12px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.tooltip-diff-title { + font-size: 11px; + font-weight: 600; + color: var(--accent); + margin-bottom: 8px; +} + +.tooltip-diff-row { + display: grid; + grid-template-columns: auto 1fr; + gap: 4px 14px; + font-size: 12px; + margin-bottom: 4px; +} + +.tooltip-diff-row.regression .tooltip-stat-value { + color: var(--diff-regression-deep); + font-weight: 700; +} + +.tooltip-diff-row.improvement .tooltip-stat-value { + color: var(--diff-improvement-deep); + font-weight: 700; +} + +.tooltip-diff-row.neutral .tooltip-stat-value { + color: var(--text-secondary); +} + +.tooltip-source { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border); +} + +.tooltip-source-title { + font-size: 11px; + font-weight: 600; + color: var(--accent); + margin-bottom: 6px; +} + +.tooltip-source-code { + font-family: var(--font-mono); + font-size: 10px; + line-height: 1.5; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 8px; + max-height: 140px; + overflow-y: auto; + white-space: pre-wrap; + word-break: break-all; +} + +.tooltip-source-line { + color: var(--text-secondary); + padding: 1px 0; +} + +.tooltip-source-line.current { + color: var(--accent); + font-weight: 600; +} + +.tooltip-hint { + margin-top: 10px; + padding-top: 8px; + border-top: 1px solid var(--border); + font-size: 11px; + color: var(--text-muted); + text-align: center; +} + +/* -------------------------------------------------------------------------- + Tooltip Bytecode/Opcode Section + -------------------------------------------------------------------------- */ + +.tooltip-opcodes { + margin-top: 16px; + padding-top: 12px; + border-top: 1px solid var(--border); +} + +.tooltip-opcodes-title { + color: var(--accent); + font-size: 13px; + margin-bottom: 8px; + font-weight: 600; +} + +.tooltip-opcodes-list { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: 6px; + padding: 10px; +} + +.tooltip-opcode-row { + display: grid; + grid-template-columns: 1fr 60px 60px; + gap: 8px; + align-items: center; + padding: 3px 0; +} + +.tooltip-opcode-name { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-primary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tooltip-opcode-name.specialized { + color: var(--spec-high-text); +} + +.tooltip-opcode-base-hint { + color: var(--text-muted); + font-size: 11px; + margin-left: 4px; +} + +.tooltip-opcode-badge { + background: var(--spec-high); + color: white; + font-size: 9px; + padding: 1px 4px; + border-radius: 3px; + margin-left: 4px; +} + +.tooltip-opcode-count { + text-align: right; + font-size: 11px; + color: var(--text-secondary); +} + +.tooltip-opcode-bar { + background: var(--bg-secondary); + border-radius: 2px; + height: 8px; + overflow: hidden; +} + +.tooltip-opcode-bar-fill { + background: linear-gradient(90deg, var(--python-blue), var(--python-blue-light)); + height: 100%; +} + +/* -------------------------------------------------------------------------- + Responsive (Flamegraph-specific) + -------------------------------------------------------------------------- */ + +@media (max-width: 900px) { + .sidebar { + position: fixed; + left: 0; + top: var(--topbar-height); + bottom: var(--statusbar-height); + z-index: 100; + box-shadow: var(--shadow-lg); + } + + .sidebar.collapsed { + width: var(--sidebar-collapsed); + } + + .search-wrapper { + max-width: 220px; + } +} + +@media (max-width: 600px) { + .search-wrapper { + max-width: 160px; + } + + .brand-info { + display: none; + } +} + +/* -------------------------------------------------------------------------- + Flamegraph Root Node Styling + -------------------------------------------------------------------------- */ + +/* Style the root node - no border, themed text */ +.d3-flame-graph g:first-of-type rect { + stroke: none; +} + +.d3-flame-graph g:first-of-type .d3-flame-graph-label { + color: var(--text-muted); +} + +/* -------------------------------------------------------------------------- + Flamegraph-Specific Toggle Override + -------------------------------------------------------------------------- */ + +#toggle-invert .toggle-track.on, +#toggle-elided .toggle-track.on, +#toggle-path-display .toggle-track.on { + background: #8e44ad; + border-color: #8e44ad; + box-shadow: 0 0 8px rgba(142, 68, 173, 0.3); +} + +.toggle-switch:focus-visible { + border-radius: 4px; +} diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js new file mode 100644 index 000000000..d66da0402 --- /dev/null +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -0,0 +1,1528 @@ +const EMBEDDED_DATA = {{FLAMEGRAPH_DATA}}; + +// Global string table for resolving string indices +let stringTable = []; +let normalData = null; +let invertedData = null; +let currentThreadFilter = 'all'; +let isInverted = false; +let useModuleNames = true; +let zoomedNodeValue = null; + +// Heat colors are now defined in CSS variables (--heat-1 through --heat-8) +// and automatically switch with theme changes - no JS color arrays needed! + +// Opcode mappings - loaded from embedded data (generated by Python) +let OPCODE_NAMES = {}; +let DEOPT_MAP = {}; + +// Initialize opcode mappings from embedded data +function initOpcodeMapping(data) { + if (data && data.opcode_mapping) { + OPCODE_NAMES = data.opcode_mapping.names || {}; + DEOPT_MAP = data.opcode_mapping.deopt || {}; + } +} + +// Get opcode info from opcode number +function getOpcodeInfo(opcode) { + const opname = OPCODE_NAMES[opcode] || `<${opcode}>`; + const baseOpcode = DEOPT_MAP[opcode]; + const isSpecialized = baseOpcode !== undefined; + const baseOpname = isSpecialized ? (OPCODE_NAMES[baseOpcode] || `<${baseOpcode}>`) : opname; + + return { + opname: opname, + baseOpname: baseOpname, + isSpecialized: isSpecialized + }; +} + +// ============================================================================ +// String Resolution +// ============================================================================ + +function resolveString(index, table = stringTable) { + if (index === null || index === undefined) { + return null; + } + if (typeof index === 'number' && index >= 0 && index < table.length) { + return table[index]; + } + return String(index); +} + +function resolveStringIndices(node, table) { + if (!node) return node; + + const resolved = { ...node }; + + if (typeof resolved.name === 'number') { + resolved.name = resolveString(resolved.name, table); + } + if (typeof resolved.filename === 'number') { + resolved.filename = resolveString(resolved.filename, table); + } + if (typeof resolved.funcname === 'number') { + resolved.funcname = resolveString(resolved.funcname, table); + } + if (typeof resolved.module === 'number') { + resolved.module = resolveString(resolved.module, table); + } + if (typeof resolved.label === 'number') { + resolved.label = resolveString(resolved.label, table); + } + + if (Array.isArray(resolved.source)) { + resolved.source = resolved.source.map(index => + typeof index === 'number' ? resolveString(index, table) : index + ); + } + + if (Array.isArray(resolved.children)) { + resolved.children = resolved.children.map(child => resolveStringIndices(child, table)); + } + + return resolved; +} + +// Escape HTML special characters +function escapeHtml(str) { + return str.replace(/&/g, "&").replace(//g, ">"); +} + +// Get display path based on user preference (module or full path) +function getDisplayName(moduleName, filename) { + if (useModuleNames) { + return moduleName || filename; + } + return filename; +} + +function samplesToMilliseconds(samples, data) { + return (samples * data.stats.sample_interval_usec / 1000).toFixed(2); +} + +function selectFlamegraphData(selectedThreadId = null) { + let baseData = isShowingElided ? elidedFlamegraphData : normalData; + + if (selectedThreadId !== null) { + baseData = filterDataByThread(baseData, selectedThreadId); + } + + if (!isInverted) { + return baseData; + } + + // Thread-filtered trees have different values, so invert them after filtering + // instead of using the cached all-thread tree. + if (selectedThreadId !== null) { + return generateInvertedFlamegraph(baseData); + } + + if (isShowingElided) { + if (!invertedElidedData) { + invertedElidedData = generateInvertedFlamegraph(baseData); + } + return invertedElidedData; + } + + if (!invertedData) { + invertedData = generateInvertedFlamegraph(baseData); + } + return invertedData; +} + +function updateFlamegraphView() { + const selectedThreadId = currentThreadFilter !== 'all' ? parseInt(currentThreadFilter, 10) : null; + const selectedData = selectFlamegraphData(selectedThreadId); + const tooltip = createPythonTooltip(selectedData); + const chart = createFlamegraph(tooltip, selectedData.value, selectedData); + renderFlamegraph(chart, selectedData); + populateThreadStats(selectedData, selectedThreadId); +} + +// ============================================================================ +// Theme & UI Controls +// ============================================================================ + +function toggleTheme() { + toggleAndSaveTheme(); + + // Re-render flamegraph with new theme colors + if (window.flamegraphData && normalData) { + updateFlamegraphView(); + } +} + +function toggleSidebar() { + const sidebar = document.getElementById('sidebar'); + if (sidebar) { + const isCollapsing = !sidebar.classList.contains('collapsed'); + + if (isCollapsing) { + // Save current width before collapsing + const currentWidth = sidebar.offsetWidth; + sidebar.dataset.expandedWidth = currentWidth; + localStorage.setItem('flamegraph-sidebar-width', currentWidth); + } else { + // Restore width when expanding + const savedWidth = sidebar.dataset.expandedWidth || localStorage.getItem('flamegraph-sidebar-width'); + if (savedWidth) { + sidebar.style.width = savedWidth + 'px'; + } + } + + sidebar.classList.toggle('collapsed'); + localStorage.setItem('flamegraph-sidebar', sidebar.classList.contains('collapsed') ? 'collapsed' : 'expanded'); + + // Resize chart after sidebar animation + setTimeout(() => { + resizeChart(); + }, 300); + } +} + +function resizeChart() { + if (window.flamegraphChart && window.flamegraphData) { + const chartArea = document.querySelector('.chart-area'); + if (chartArea) { + window.flamegraphChart.width(chartArea.clientWidth - 32); + d3.select("#chart").datum(window.flamegraphData).call(window.flamegraphChart); + } + } +} + +function toggleSection(sectionId) { + const section = document.getElementById(sectionId); + if (section) { + section.classList.toggle('collapsed'); + // Save state + const collapsedSections = JSON.parse(localStorage.getItem('flamegraph-collapsed-sections') || '{}'); + collapsedSections[sectionId] = section.classList.contains('collapsed'); + localStorage.setItem('flamegraph-collapsed-sections', JSON.stringify(collapsedSections)); + } +} + +// Restore theme from localStorage, or use browser preference +function restoreUIState() { + applyTheme(getPreferredTheme()); + + // Restore sidebar state + const savedSidebar = localStorage.getItem('flamegraph-sidebar'); + if (savedSidebar === 'collapsed') { + const sidebar = document.getElementById('sidebar'); + if (sidebar) sidebar.classList.add('collapsed'); + } + + // Restore sidebar width + const savedWidth = localStorage.getItem('flamegraph-sidebar-width'); + if (savedWidth) { + const sidebar = document.getElementById('sidebar'); + if (sidebar) { + sidebar.style.width = savedWidth + 'px'; + } + } + + // Restore collapsed sections + const collapsedSections = JSON.parse(localStorage.getItem('flamegraph-collapsed-sections') || '{}'); + for (const [sectionId, isCollapsed] of Object.entries(collapsedSections)) { + if (isCollapsed) { + const section = document.getElementById(sectionId); + if (section) section.classList.add('collapsed'); + } + } +} + +// ============================================================================ +// Logo/Favicon Setup +// ============================================================================ + +function setupLogos() { + const logo = document.querySelector('.sidebar-logo-img img'); + if (!logo) return; + + const navbarLogoContainer = document.getElementById('navbar-logo'); + if (navbarLogoContainer) { + const navbarLogo = logo.cloneNode(true); + navbarLogoContainer.appendChild(navbarLogo); + } + + const favicon = document.createElement('link'); + favicon.rel = 'icon'; + favicon.type = 'image/png'; + favicon.href = logo.src; + document.head.appendChild(favicon); +} + +// ============================================================================ +// Status Bar +// ============================================================================ + +function updateStatusBar(nodeData, rootValue, data) { + const funcname = resolveString(nodeData.funcname) || resolveString(nodeData.name) || "--"; + const filename = resolveString(nodeData.filename) || ""; + const moduleName = resolveString(nodeData.module) || ""; + const lineno = nodeData.lineno; + const timeMs = samplesToMilliseconds(nodeData.value, data); + const percent = rootValue > 0 ? ((nodeData.value / rootValue) * 100).toFixed(1) : "0.0"; + + const brandEl = document.getElementById('status-brand'); + const taglineEl = document.getElementById('status-tagline'); + if (brandEl) brandEl.style.display = 'none'; + if (taglineEl) taglineEl.style.display = 'none'; + + const locationEl = document.getElementById('status-location'); + const funcItem = document.getElementById('status-func-item'); + const timeItem = document.getElementById('status-time-item'); + const percentItem = document.getElementById('status-percent-item'); + + if (locationEl) locationEl.style.display = filename && filename !== "~" ? 'flex' : 'none'; + if (funcItem) funcItem.style.display = 'flex'; + if (timeItem) timeItem.style.display = 'flex'; + if (percentItem) percentItem.style.display = 'flex'; + + const fileEl = document.getElementById('status-file'); + if (fileEl && filename && filename !== "~") { + const displayName = getDisplayName(moduleName, filename); + fileEl.textContent = lineno ? `${displayName}:${lineno}` : displayName; + } + + const funcEl = document.getElementById('status-func'); + if (funcEl) funcEl.textContent = funcname.length > 40 ? funcname.substring(0, 37) + '...' : funcname; + + const timeEl = document.getElementById('status-time'); + if (timeEl) timeEl.textContent = `${timeMs} ms`; + + const percentEl = document.getElementById('status-percent'); + if (percentEl) percentEl.textContent = `${percent}%`; +} + +function clearStatusBar() { + const ids = ['status-location', 'status-func-item', 'status-time-item', 'status-percent-item']; + ids.forEach(id => { + const el = document.getElementById(id); + if (el) el.style.display = 'none'; + }); + + const brandEl = document.getElementById('status-brand'); + const taglineEl = document.getElementById('status-tagline'); + if (brandEl) brandEl.style.display = 'flex'; + if (taglineEl) taglineEl.style.display = 'flex'; +} + +// ============================================================================ +// Tooltip +// ============================================================================ + +function createPythonTooltip(data) { + const pythonTooltip = flamegraph.tooltip.defaultFlamegraphTooltip(); + + pythonTooltip.show = function (d, element) { + if (!this._tooltip) { + this._tooltip = d3.select("body") + .append("div") + .attr("class", "python-tooltip") + .style("opacity", 0); + } + + const timeMs = samplesToMilliseconds(d.data.value, data); + const selfSamples = d.data.self || 0; + const selfMs = samplesToMilliseconds(selfSamples, data); + const percentage = ((d.data.value / data.value) * 100).toFixed(2); + const relativePercentage = Math.min(100, ((d.data.value / (zoomedNodeValue ?? data.value)) * 100)).toFixed(2); + const calls = d.data.calls || 0; + const childCount = d.children ? d.children.length : 0; + const source = d.data.source; + + const funcname = resolveString(d.data.funcname) || resolveString(d.data.name); + const filename = resolveString(d.data.filename) || ""; + const moduleName = resolveString(d.data.module) || ""; + const displayName = escapeHtml(useModuleNames ? (moduleName || filename) : filename); + const isSpecialFrame = filename === "~"; + + // Build source section + let sourceSection = ""; + if (source && Array.isArray(source) && source.length > 0) { + const sourceLines = source + .map((line) => { + const isCurrent = line.startsWith("→"); + const escaped = escapeHtml(line); + return `
${escaped}
`; + }) + .join(""); + + sourceSection = ` +
+
Source Code:
+
${sourceLines}
+
`; + } + + // Create bytecode/opcode section if available + let opcodeSection = ""; + const opcodes = d.data.opcodes; + if (opcodes && typeof opcodes === 'object' && Object.keys(opcodes).length > 0) { + // Sort opcodes by sample count (descending) + const sortedOpcodes = Object.entries(opcodes) + .sort((a, b) => b[1] - a[1]) + .slice(0, 8); // Limit to top 8 + + const totalOpcodeSamples = sortedOpcodes.reduce((sum, [, count]) => sum + count, 0); + const maxCount = sortedOpcodes[0][1] || 1; + + const opcodeLines = sortedOpcodes.map(([opcode, count]) => { + const opcodeInfo = getOpcodeInfo(parseInt(opcode, 10)); + const pct = ((count / totalOpcodeSamples) * 100).toFixed(1); + const barWidth = (count / maxCount) * 100; + const specializedBadge = opcodeInfo.isSpecialized + ? 'SPECIALIZED' + : ''; + const baseOpHint = opcodeInfo.isSpecialized + ? `(${opcodeInfo.baseOpname})` + : ''; + const nameClass = opcodeInfo.isSpecialized + ? 'tooltip-opcode-name specialized' + : 'tooltip-opcode-name'; + + return ` +
+
+ ${opcodeInfo.opname}${baseOpHint}${specializedBadge} +
+
${count.toLocaleString()} (${pct}%)
+
+
+
+
`; + }).join(''); + + opcodeSection = ` +
+
Bytecode Instructions:
+
+ ${opcodeLines} +
+
`; + } + + const fileLocationHTML = isSpecialFrame ? "" : ` +
${displayName}${d.data.lineno ? ":" + d.data.lineno : ""}
`; + + // Differential stats section + let diffSection = ""; + if (d.data.diff !== undefined && d.data.baseline !== undefined) { + const baselineSelf = samplesToMilliseconds(d.data.baseline, data); + const currentSelf = samplesToMilliseconds(d.data.self_time || 0, data); + const diffMs = samplesToMilliseconds(d.data.diff, data); + const diffPct = d.data.diff_pct; + const sign = d.data.diff >= 0 ? "+" : ""; + const diffClass = d.data.diff > 0 ? "regression" : (d.data.diff < 0 ? "improvement" : "neutral"); + + diffSection = ` +
+
Self-Time Comparison:
+
+ Baseline Self: + ${baselineSelf} ms +
+
+ Current Self: + ${currentSelf} ms +
+
+ Difference: + ${sign}${diffMs} ms (${sign}${diffPct.toFixed(1)}%) +
+
`; + } + + const tooltipHTML = ` +
+
${funcname}
+ ${fileLocationHTML} +
+
+ Total Time: + ${timeMs} ms + + ${selfSamples > 0 ? ` + Self Time: + ${selfMs} ms + ` : ''} + + Percentage: + ${percentage}% + + ${relativePercentage != percentage && relativePercentage != "100.00" ? ` + Relative Percentage: + ${relativePercentage}% + ` : ''} + + ${calls > 0 ? ` + Function Calls: + ${calls.toLocaleString()} + ` : ''} + + ${childCount > 0 ? ` + Child Functions: + ${childCount} + ` : ''} +
+ ${diffSection} + ${sourceSection} + ${opcodeSection} +
+ ${childCount > 0 ? "Click to zoom into this function" : "Leaf function - no children"} +
+ `; + + // Position tooltip + const event = d3.event || window.event; + const mouseX = event.pageX || event.clientX; + const mouseY = event.pageY || event.clientY; + const padding = 12; + + this._tooltip.html(tooltipHTML); + + // Measure tooltip + const node = this._tooltip.style("display", "block").style("opacity", 0).node(); + const tooltipWidth = node.offsetWidth || 320; + const tooltipHeight = node.offsetHeight || 200; + + // Calculate position + let left = mouseX + padding; + let top = mouseY + padding; + + if (left + tooltipWidth > window.innerWidth) { + left = mouseX - tooltipWidth - padding; + if (left < 0) left = padding; + } + + if (top + tooltipHeight > window.innerHeight) { + top = mouseY - tooltipHeight - padding; + if (top < 0) top = padding; + } + + this._tooltip + .style("left", left + "px") + .style("top", top + "px") + .transition() + .duration(150) + .style("opacity", 1); + + // Update status bar + updateStatusBar(d.data, data.value, data); + }; + + pythonTooltip.hide = function () { + if (this._tooltip) { + this._tooltip.transition().duration(150).style("opacity", 0); + } + clearStatusBar(); + }; + + return pythonTooltip; +} + +// ============================================================================ +// Flamegraph Creation +// ============================================================================ + +function ensureLibraryLoaded() { + if (typeof flamegraph === "undefined") { + console.error("d3-flame-graph library not loaded"); + document.getElementById("chart").innerHTML = + '
Error: d3-flame-graph library failed to load
'; + throw new Error("d3-flame-graph library failed to load"); + } +} + +const HEAT_THRESHOLDS = [ + [0.6, 8], + [0.35, 7], + [0.18, 6], + [0.12, 5], + [0.06, 4], + [0.03, 3], + [0.01, 2], +]; + +function getHeatLevel(percentage) { + for (const [threshold, level] of HEAT_THRESHOLDS) { + if (percentage >= threshold) return level; + } + return 1; +} + +function getHeatColors() { + const style = getComputedStyle(document.documentElement); + const colors = {}; + for (let i = 1; i <= 8; i++) { + colors[i] = style.getPropertyValue(`--heat-${i}`).trim(); + } + return colors; +} + +function getDiffColors() { + const style = getComputedStyle(document.documentElement); + return { + elided: style.getPropertyValue('--diff-elided').trim(), + new: style.getPropertyValue('--diff-new').trim(), + neutral: style.getPropertyValue('--diff-neutral').trim(), + regressionDeep: style.getPropertyValue('--diff-regression-deep').trim(), + regressionMedium: style.getPropertyValue('--diff-regression-medium').trim(), + regressionLight: style.getPropertyValue('--diff-regression-light').trim(), + regressionVerylight: style.getPropertyValue('--diff-regression-verylight').trim(), + improvementDeep: style.getPropertyValue('--diff-improvement-deep').trim(), + improvementMedium: style.getPropertyValue('--diff-improvement-medium').trim(), + improvementLight: style.getPropertyValue('--diff-improvement-light').trim(), + improvementVerylight: style.getPropertyValue('--diff-improvement-verylight').trim(), + }; +} + +function getDiffColorForNode(node, diffColors) { + if (isShowingElided) { + return diffColors.elided; + } + + const diff_pct = node.data.diff_pct || 0; + const diff_samples = node.data.diff || 0; + const self_time = node.data.self_time || 0; + + if (diff_pct === 100 && self_time > 0 && Math.abs(diff_samples - self_time) < 0.1) { + return diffColors.new; + } + + // Neutral zone: small percentage change + if (Math.abs(diff_pct) < 15) { + return diffColors.neutral; + } + + // Regression (red scale) + if (diff_pct > 0) { + if (diff_pct >= 100) return diffColors.regressionDeep; + if (diff_pct > 50) return diffColors.regressionMedium; + if (diff_pct > 30) return diffColors.regressionLight; + return diffColors.regressionVerylight; + } + + // Improvement (blue scale) + if (diff_pct <= -100) return diffColors.improvementDeep; + if (diff_pct < -50) return diffColors.improvementMedium; + if (diff_pct < -30) return diffColors.improvementLight; + return diffColors.improvementVerylight; +} + +function createFlamegraph(tooltip, rootValue, data) { + const chartArea = document.querySelector('.chart-area'); + const width = chartArea ? chartArea.clientWidth - 32 : window.innerWidth - 320; + const heatColors = getHeatColors(); + + const isDifferential = data && data.stats && data.stats.is_differential; + const diffColors = isDifferential ? getDiffColors() : null; + + let chart = flamegraph() + .width(width) + .cellHeight(20) + .transitionDuration(300) + .minFrameSize(1) + .tooltip(tooltip) + .inverted(true) + .getName(d => resolveString(useModuleNames ? d.data.label : d.data.name) || resolveString(d.data.name) || '') + .setColorMapper(function (d) { + if (d.depth === 0) return 'transparent'; + + if (isDifferential) { + return getDiffColorForNode(d, diffColors); + } + + const percentage = d.data.value / rootValue; + const level = getHeatLevel(percentage); + return heatColors[level]; + }) + .onClick(function (d) { + zoomedNodeValue = d.data.value; + }); + + return chart; +} + +function renderFlamegraph(chart, data) { + d3.select("#chart").datum(data).call(chart); + window.flamegraphChart = chart; + window.flamegraphData = data; + zoomedNodeValue = null; + populateStats(data); +} + +// ============================================================================ +// Search +// ============================================================================ + +function updateSearchHighlight(searchTerm, searchInput) { + d3.selectAll("#chart rect") + .classed("search-match", false) + .classed("search-dim", false); + + // Clear active state from all hotspots + document.querySelectorAll('.hotspot').forEach(h => h.classList.remove('active')); + + if (searchTerm && searchTerm.length > 0) { + let matchCount = 0; + + d3.selectAll("#chart rect").each(function (d) { + if (d && d.data) { + const name = resolveString(d.data.name) || ""; + const funcname = resolveString(d.data.funcname) || ""; + const filename = resolveString(d.data.filename) || ""; + const moduleName = resolveString(d.data.module) || ""; + const displayName = getDisplayName(moduleName, filename); + const lineno = d.data.lineno; + const term = searchTerm.toLowerCase(); + + // Check if search term looks like path:line pattern + const fileLineMatch = term.match(/^(.+):(\d+)$/); + let matches = false; + + if (fileLineMatch) { + const searchFile = fileLineMatch[1]; + const searchLine = parseInt(fileLineMatch[2], 10); + matches = displayName.toLowerCase().includes(searchFile) && lineno === searchLine; + } else { + // Regular substring search + matches = + name.toLowerCase().includes(term) || + funcname.toLowerCase().includes(term) || + displayName.toLowerCase().includes(term); + } + + if (matches) { + matchCount++; + d3.select(this).classed("search-match", true); + } else { + d3.select(this).classed("search-dim", true); + } + } + }); + + if (searchInput) { + searchInput.classList.remove("has-matches", "no-matches"); + searchInput.classList.add(matchCount > 0 ? "has-matches" : "no-matches"); + } + + // Mark matching hotspot as active + document.querySelectorAll('.hotspot').forEach(h => { + if (h.dataset.searchterm && h.dataset.searchterm.toLowerCase() === searchTerm.toLowerCase()) { + h.classList.add('active'); + } + }); + } else if (searchInput) { + searchInput.classList.remove("has-matches", "no-matches"); + } +} + +function searchForHotspot(funcname) { + const searchInput = document.getElementById('search-input'); + const searchWrapper = document.querySelector('.search-wrapper'); + if (searchInput) { + // Toggle: if already searching for this term, clear it + if (searchInput.value.trim() === funcname) { + clearSearch(); + } else { + searchInput.value = funcname; + if (searchWrapper) { + searchWrapper.classList.add('has-value'); + } + performSearch(); + } + } +} + +function initSearchHandlers() { + const searchInput = document.getElementById("search-input"); + const searchWrapper = document.querySelector(".search-wrapper"); + if (!searchInput) return; + + let searchTimeout; + function performSearch() { + const term = searchInput.value.trim(); + updateSearchHighlight(term, searchInput); + // Toggle has-value class for clear button visibility + if (searchWrapper) { + searchWrapper.classList.toggle("has-value", term.length > 0); + } + } + + searchInput.addEventListener("input", function () { + clearTimeout(searchTimeout); + searchTimeout = setTimeout(performSearch, 150); + }); + + window.performSearch = performSearch; +} + +function clearSearch() { + const searchInput = document.getElementById("search-input"); + const searchWrapper = document.querySelector(".search-wrapper"); + if (searchInput) { + searchInput.value = ""; + searchInput.classList.remove("has-matches", "no-matches"); + if (searchWrapper) { + searchWrapper.classList.remove("has-value"); + } + // Clear highlights + d3.selectAll("#chart rect") + .classed("search-match", false) + .classed("search-dim", false); + // Clear active hotspot + document.querySelectorAll('.hotspot').forEach(h => h.classList.remove('active')); + } +} + +// ============================================================================ +// Resize Handler +// ============================================================================ + +function handleResize() { + let resizeTimeout; + window.addEventListener("resize", function () { + clearTimeout(resizeTimeout); + resizeTimeout = setTimeout(resizeChart, 100); + }); +} + +function initSidebarResize() { + const sidebar = document.getElementById('sidebar'); + const resizeHandle = document.getElementById('sidebar-resize-handle'); + if (!sidebar || !resizeHandle) return; + + let isResizing = false; + let startX = 0; + let startWidth = 0; + const minWidth = 200; + const maxWidth = 600; + + resizeHandle.addEventListener('mousedown', function(e) { + isResizing = true; + startX = e.clientX; + startWidth = sidebar.offsetWidth; + resizeHandle.classList.add('resizing'); + document.body.classList.add('resizing-sidebar'); + e.preventDefault(); + }); + + document.addEventListener('mousemove', function(e) { + if (!isResizing) return; + + const deltaX = e.clientX - startX; + const newWidth = Math.min(Math.max(startWidth + deltaX, minWidth), maxWidth); + sidebar.style.width = newWidth + 'px'; + e.preventDefault(); + }); + + document.addEventListener('mouseup', function() { + if (isResizing) { + isResizing = false; + resizeHandle.classList.remove('resizing'); + document.body.classList.remove('resizing-sidebar'); + + // Save the new width + const width = sidebar.offsetWidth; + localStorage.setItem('flamegraph-sidebar-width', width); + + // Resize chart after sidebar resize + setTimeout(() => { + resizeChart(); + }, 10); + } + }); +} + +// ============================================================================ +// Thread Stats +// ============================================================================ + +// Mode constants (must match constants.py) +const PROFILING_MODE_WALL = 0; +const PROFILING_MODE_CPU = 1; +const PROFILING_MODE_GIL = 2; +const PROFILING_MODE_ALL = 3; + +function populateThreadStats(data, selectedThreadId = null) { + const stats = data?.stats; + if (!stats || !stats.thread_stats) { + return; + } + + const mode = stats.mode !== undefined ? stats.mode : PROFILING_MODE_WALL; + let threadStats; + + if (selectedThreadId !== null && stats.per_thread_stats && stats.per_thread_stats[selectedThreadId]) { + threadStats = stats.per_thread_stats[selectedThreadId]; + } else { + threadStats = stats.thread_stats; + } + + if (!threadStats || typeof threadStats.total !== 'number' || threadStats.total <= 0) { + return; + } + + const section = document.getElementById('thread-stats-bar'); + if (!section) { + return; + } + + section.style.display = 'block'; + + const gilHeldStat = document.getElementById('gil-held-stat'); + const gilReleasedStat = document.getElementById('gil-released-stat'); + const gilWaitingStat = document.getElementById('gil-waiting-stat'); + + if (mode === PROFILING_MODE_GIL) { + // In GIL mode, hide GIL-related stats + if (gilHeldStat) gilHeldStat.style.display = 'none'; + if (gilReleasedStat) gilReleasedStat.style.display = 'none'; + if (gilWaitingStat) gilWaitingStat.style.display = 'none'; + } else { + // Show all stats + if (gilHeldStat) gilHeldStat.style.display = 'block'; + if (gilReleasedStat) gilReleasedStat.style.display = 'block'; + if (gilWaitingStat) gilWaitingStat.style.display = 'block'; + + const gilHeldPct = threadStats.has_gil_pct || 0; + const gilHeldPctElem = document.getElementById('gil-held-pct'); + if (gilHeldPctElem) gilHeldPctElem.textContent = `${gilHeldPct.toFixed(1)}%`; + const gilHeldFill = document.getElementById('gil-held-fill'); + if (gilHeldFill) gilHeldFill.style.width = `${gilHeldPct}%`; + + // GIL Released = not holding GIL and not waiting for it + const gilReleasedPct = Math.max(0, 100 - (threadStats.has_gil_pct || 0) - (threadStats.gil_requested_pct || 0)); + const gilReleasedPctElem = document.getElementById('gil-released-pct'); + if (gilReleasedPctElem) gilReleasedPctElem.textContent = `${gilReleasedPct.toFixed(1)}%`; + const gilReleasedFill = document.getElementById('gil-released-fill'); + if (gilReleasedFill) gilReleasedFill.style.width = `${gilReleasedPct}%`; + + const gilWaitingPct = threadStats.gil_requested_pct || 0; + const gilWaitingPctElem = document.getElementById('gil-waiting-pct'); + if (gilWaitingPctElem) gilWaitingPctElem.textContent = `${gilWaitingPct.toFixed(1)}%`; + const gilWaitingFill = document.getElementById('gil-waiting-fill'); + if (gilWaitingFill) gilWaitingFill.style.width = `${gilWaitingPct}%`; + } + + const gcPct = threadStats.gc_pct || 0; + const gcPctElem = document.getElementById('gc-pct'); + if (gcPctElem) gcPctElem.textContent = `${gcPct.toFixed(1)}%`; + const gcFill = document.getElementById('gc-fill'); + if (gcFill) gcFill.style.width = `${gcPct}%`; + + // Exception stats + const excPct = threadStats.has_exception_pct || 0; + const excPctElem = document.getElementById('exc-pct'); + if (excPctElem) excPctElem.textContent = `${excPct.toFixed(1)}%`; + const excFill = document.getElementById('exc-fill'); + if (excFill) excFill.style.width = `${excPct}%`; +} + +// ============================================================================ +// Profile Summary Stats +// ============================================================================ + +function formatNumber(num) { + if (num >= 1000000) return (num / 1000000).toFixed(1) + 'M'; + if (num >= 1000) return (num / 1000).toFixed(1) + 'K'; + return num.toLocaleString(); +} + +function formatDuration(seconds) { + if (seconds >= 3600) { + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + return `${h}h ${m}m`; + } + if (seconds >= 60) { + const m = Math.floor(seconds / 60); + const s = Math.floor(seconds % 60); + return `${m}m ${s}s`; + } + return seconds.toFixed(2) + 's'; +} + +function populateProfileSummary(data) { + const stats = data.stats || {}; + const totalSamples = currentThreadFilter !== 'all' + ? (data.value ?? 0) + : (stats.total_samples ?? data.value ?? 0); + const duration = stats.duration_sec || 0; + const sampleRate = stats.sample_rate || (duration > 0 ? totalSamples / duration : 0); + const errorRate = stats.error_rate; + const missedSamples = stats.missed_samples; + + const samplesEl = document.getElementById('stat-total-samples'); + if (samplesEl) samplesEl.textContent = formatNumber(totalSamples); + + const durationEl = document.getElementById('stat-duration'); + if (durationEl) durationEl.textContent = duration > 0 ? formatDuration(duration) : '--'; + + const rateEl = document.getElementById('stat-sample-rate'); + if (rateEl) rateEl.textContent = sampleRate > 0 ? formatNumber(Math.round(sampleRate)) : '--'; + + // Count unique functions + // Use normal (non-inverted) tree structure, but respect thread filtering + const uniqueFunctions = new Set(); + function collectUniqueFunctions(node) { + if (!node) return; + const filename = resolveString(node.filename) || 'unknown'; + const funcname = resolveString(node.funcname) || resolveString(node.name) || 'unknown'; + const lineno = node.lineno || 0; + const key = `${filename}|${lineno}|${funcname}`; + uniqueFunctions.add(key); + if (node.children) node.children.forEach(collectUniqueFunctions); + } + // In inverted mode, use normalData (with thread filter if active) + // In normal mode, use the passed data (already has thread filter applied if any) + let functionCountSource; + if (!normalData) { + functionCountSource = data; + } else if (isInverted) { + if (currentThreadFilter !== 'all') { + functionCountSource = filterDataByThread(normalData, parseInt(currentThreadFilter)); + } else { + functionCountSource = normalData; + } + } else { + functionCountSource = data; + } + collectUniqueFunctions(functionCountSource); + + const functionsEl = document.getElementById('stat-functions'); + if (functionsEl) functionsEl.textContent = formatNumber(uniqueFunctions.size); + + // Efficiency bar + if (errorRate !== undefined && errorRate !== null) { + const efficiency = Math.max(0, Math.min(100, (100 - errorRate))); + + const efficiencySection = document.getElementById('efficiency-section'); + if (efficiencySection) efficiencySection.style.display = 'block'; + + const efficiencyValue = document.getElementById('stat-efficiency'); + if (efficiencyValue) efficiencyValue.textContent = efficiency.toFixed(1) + '%'; + + const efficiencyFill = document.getElementById('efficiency-fill'); + if (efficiencyFill) efficiencyFill.style.width = efficiency + '%'; + } + // MissedSamples bar + if (missedSamples !== undefined && missedSamples !== null) { + const sampleEfficiency = Math.max(0, missedSamples); + + const efficiencySection = document.getElementById('efficiency-section'); + if (efficiencySection) efficiencySection.style.display = 'block'; + + const sampleEfficiencyValue = document.getElementById('stat-missed-samples'); + if (sampleEfficiencyValue) sampleEfficiencyValue.textContent = sampleEfficiency.toFixed(1) + '%'; + + const sampleEfficiencyFill = document.getElementById('missed-samples-fill'); + if (sampleEfficiencyFill) sampleEfficiencyFill.style.width = sampleEfficiency + '%'; + } +} + +// ============================================================================ +// Elided Stacks (Differential) +// ============================================================================ + +let elidedFlamegraphData = null; +let invertedElidedData = null; +let isShowingElided = false; + +function setupElidedToggle(data) { + const stats = data.stats || {}; + const elidedCount = stats.elided_count || 0; + const elidedFlamegraph = stats.elided_flamegraph; + + if (!elidedCount || !elidedFlamegraph) { + return; + } + + elidedFlamegraphData = resolveStringIndices(elidedFlamegraph, elidedFlamegraph.strings); + + const toggleElided = document.getElementById('toggle-elided'); + if (toggleElided) { + toggleElided.style.display = 'flex'; + + toggleElided.onclick = function() { + isShowingElided = !isShowingElided; + updateToggleUI('toggle-elided', isShowingElided); + updateFlamegraphView(); + }; + } +} + +// ============================================================================ +// Hotspot Stats +// ============================================================================ + +function populateStats(data) { + // Populate profile summary + populateProfileSummary(data); + + // Populate thread statistics if available + populateThreadStats(data); + + // Setup elided stacks toggle if this is a differential flamegraph + setupElidedToggle(data); + + // For hotspots: use normal (non-inverted) tree structure, but respect thread filtering. + // In inverted view, the tree structure changes but the hottest functions remain the same. + // However, if a thread filter is active, we need to show that thread's hotspots. + let hotspotSource; + if (!normalData) { + hotspotSource = data; + } else if (isInverted) { + // In inverted mode, use normalData (with thread filter if active) + if (currentThreadFilter !== 'all') { + hotspotSource = filterDataByThread(normalData, parseInt(currentThreadFilter)); + } else { + hotspotSource = normalData; + } + } else { + // In normal mode, use the passed data (already has thread filter applied if any) + hotspotSource = data; + } + const totalSamples = hotspotSource.value || 0; + + const functionMap = new Map(); + + function collectFunctions(node) { + if (!node) return; + + let filename = resolveString(node.filename); + let funcname = resolveString(node.funcname); + let moduleName = resolveString(node.module); + + if (!filename || !funcname) { + const nameStr = resolveString(node.name); + if (nameStr?.includes('(')) { + const match = nameStr.match(/^(.+?)\s*\((.+?):(\d+)\)$/); + if (match) { + funcname = funcname || match[1]; + filename = filename || match[2]; + } + } + } + + filename = filename || 'unknown'; + funcname = funcname || 'unknown'; + moduleName = moduleName || 'unknown'; + + if (filename !== 'unknown' && funcname !== 'unknown' && node.value > 0) { + const directSamples = node.self || 0; + + const funcKey = `${filename}:${node.lineno || '?'}:${funcname}`; + + if (functionMap.has(funcKey)) { + const existing = functionMap.get(funcKey); + existing.directSamples += directSamples; + existing.directPercent = (existing.directSamples / totalSamples) * 100; + if (directSamples > existing.maxSingleSamples) { + existing.filename = filename; + existing.module = moduleName; + existing.lineno = node.lineno || '?'; + existing.maxSingleSamples = directSamples; + } + } else { + functionMap.set(funcKey, { + filename: filename, + module: moduleName, + lineno: node.lineno || '?', + funcname: funcname, + directSamples, + directPercent: (directSamples / totalSamples) * 100, + maxSingleSamples: directSamples + }); + } + } + + if (node.children) { + node.children.forEach(child => collectFunctions(child)); + } + } + + collectFunctions(hotspotSource); + + const hotSpots = Array.from(functionMap.values()) + .filter(f => f.directPercent > 0.5) + .sort((a, b) => b.directPercent - a.directPercent) + .slice(0, 3); + + // Populate and animate hotspot cards + for (let i = 0; i < 3; i++) { + const num = i + 1; + const card = document.getElementById(`hotspot-${num}`); + const funcEl = document.getElementById(`hotspot-func-${num}`); + const fileEl = document.getElementById(`hotspot-file-${num}`); + const percentEl = document.getElementById(`hotspot-percent-${num}`); + const samplesEl = document.getElementById(`hotspot-samples-${num}`); + + if (i < hotSpots.length && hotSpots[i]) { + const h = hotSpots[i]; + const filename = h.filename || 'unknown'; + const lineno = h.lineno ?? '?'; + const moduleName = h.module || 'unknown'; + const isSpecialFrame = filename === '~' && (lineno === 0 || lineno === '?'); + + let funcDisplay = h.funcname || 'unknown'; + if (funcDisplay.length > 28) funcDisplay = funcDisplay.substring(0, 25) + '...'; + + if (funcEl) funcEl.textContent = funcDisplay; + if (fileEl) { + if (isSpecialFrame) { + fileEl.textContent = '--'; + } else { + const displayName = getDisplayName(moduleName, filename); + fileEl.textContent = `${displayName}:${lineno}`; + } + } + if (percentEl) percentEl.textContent = `${h.directPercent.toFixed(1)}%`; + if (samplesEl) samplesEl.textContent = ` (${h.directSamples.toLocaleString()})`; + } else { + if (funcEl) funcEl.textContent = '--'; + if (fileEl) fileEl.textContent = '--'; + if (percentEl) percentEl.textContent = '--'; + if (samplesEl) samplesEl.textContent = ''; + } + + // Add click handler and animate entrance + if (card) { + if (i < hotSpots.length && hotSpots[i]) { + const h = hotSpots[i]; + const moduleName = h.module || 'unknown'; + const filename = h.filename || 'unknown'; + const displayName = getDisplayName(moduleName, filename); + const hasValidLocation = displayName !== 'unknown' && h.lineno !== '?'; + const searchTerm = hasValidLocation ? `${displayName}:${h.lineno}` : h.funcname; + card.dataset.searchterm = searchTerm; + card.onclick = () => searchForHotspot(searchTerm); + card.style.cursor = 'pointer'; + } else { + card.onclick = null; + delete card.dataset.searchterm; + card.style.cursor = 'default'; + } + + setTimeout(() => { + card.classList.add('visible'); + }, 100 + i * 80); + } + } +} + +// ============================================================================ +// Thread Filter +// ============================================================================ + +function initThreadFilter(data) { + const threadFilter = document.getElementById('thread-filter'); + const threadSection = document.getElementById('thread-section'); + + if (!threadFilter || !data.threads || data.stats?.is_differential) return; + + threadFilter.innerHTML = ''; + + const threads = data.threads || []; + threads.forEach(threadId => { + const option = document.createElement('option'); + option.value = threadId; + option.textContent = `Thread ${threadId}`; + threadFilter.appendChild(option); + }); + + if (threads.length > 1 && threadSection) { + threadSection.style.display = 'block'; + } +} + +function filterByThread() { + const threadFilter = document.getElementById('thread-filter'); + if (!threadFilter || !normalData) return; + + const selectedThread = threadFilter.value; + currentThreadFilter = selectedThread; + + updateFlamegraphView(); +} + +function filterDataByThread(data, threadId) { + function filterNode(node) { + const threadValues = node.thread_values?.[threadId]; + if (!threadValues) { + return null; + } + + const { + thread_values: _threadValues, + thread_opcodes: threadOpcodes, + ...sharedNode + } = node; + const filteredNode = { + ...sharedNode, + value: threadValues[0], + self: threadValues[1], + opcodes: threadOpcodes?.[threadId] ?? {}, + children: [] + }; + + if (node.children && Array.isArray(node.children)) { + filteredNode.children = node.children + .map(child => filterNode(child)) + .filter(child => child !== null); + } + + return filteredNode; + } + + return filterNode(data); +} + +// ============================================================================ +// Control Functions +// ============================================================================ + +function resetZoom() { + if (window.flamegraphChart) { + zoomedNodeValue = null; + window.flamegraphChart.resetZoom(); + } +} + +function exportSVG() { + const svgElement = document.querySelector("#chart svg"); + if (!svgElement) { + console.warn("Cannot export: No flamegraph SVG found"); + return; + } + const serializer = new XMLSerializer(); + const svgString = serializer.serializeToString(svgElement); + const blob = new Blob([svgString], { type: "image/svg+xml" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "python-performance-flamegraph.svg"; + a.click(); + URL.revokeObjectURL(url); +} + +// ============================================================================ +// Inverted Flamegraph +// ============================================================================ + +// Example: "file.py|10|foo" or "~|0|" for special frames +function getInvertNodeKey(node) { + return `${node.filename || '~'}|${node.lineno || 0}|${node.funcname || node.name}`; +} + +function accumulateInvertedNode(parent, stackFrame, leaf, isDifferential) { + const key = getInvertNodeKey(stackFrame); + + if (!parent.children[key]) { + const newNode = { + name: stackFrame.name, + label: stackFrame.label, + value: 0, + self: 0, + children: {}, + filename: stackFrame.filename, + module: stackFrame.module, + lineno: stackFrame.lineno, + funcname: stackFrame.funcname, + source: stackFrame.source, + opcodes: null, + threads: new Set() + }; + + if (isDifferential) { + newNode.baseline = 0; + newNode.baseline_total = 0; + newNode.self_time = 0; + newNode.diff = 0; + newNode.diff_pct = 0; + } + + parent.children[key] = newNode; + } + + const node = parent.children[key]; + node.value += leaf.value; + node.self += stackFrame.self || 0; + if (leaf.threads) { + leaf.threads.forEach(t => node.threads.add(t)); + } + if (stackFrame.opcodes) { + if (!node.opcodes) { + node.opcodes = { ...stackFrame.opcodes }; + } else { + for (const [op, count] of Object.entries(stackFrame.opcodes)) { + node.opcodes[op] = (node.opcodes[op] || 0) + count; + } + } + } + + if (isDifferential) { + node.baseline += stackFrame.baseline || 0; + node.baseline_total += stackFrame.baseline_total || 0; + node.self_time += stackFrame.self_time || 0; + node.diff += stackFrame.diff || 0; + + if (node.baseline > 0) { + node.diff_pct = (node.diff / node.baseline) * 100.0; + } else if (node.self_time > 0) { + node.diff_pct = 100.0; + } + } + + return node; +} + +function processLeaf(invertedRoot, path, leafNode, isDifferential) { + if (!path || path.length === 0) { + return; + } + + let invertedParent = accumulateInvertedNode(invertedRoot, leafNode, leafNode, isDifferential); + + // Walk backwards through the call stack + for (let i = path.length - 2; i >= 0; i--) { + invertedParent = accumulateInvertedNode(invertedParent, path[i], leafNode, isDifferential); + } +} + +function traverseInvert(path, currentNode, invertedRoot, isDifferential) { + const selfValue = currentNode.self || 0; + + if (selfValue > 0) { + processLeaf(invertedRoot, path, { ...currentNode, value: selfValue }, isDifferential); + } + + const children = currentNode.children || []; + children.forEach(child => traverseInvert(path.concat([child]), child, invertedRoot, isDifferential)); +} + +function convertInvertDictToArray(node) { + if (node.threads instanceof Set) { + node.threads = Array.from(node.threads).sort((a, b) => a - b); + } + + const children = node.children; + if (children && typeof children === 'object' && !Array.isArray(children)) { + node.children = Object.values(children); + node.children.sort((a, b) => b.value - a.value || a.name.localeCompare(b.name)); + node.children.forEach(convertInvertDictToArray); + } + return node; +} + +function generateInvertedFlamegraph(data) { + const isDifferential = data && data.stats && data.stats.is_differential; + + const invertedRoot = { + name: data.name, + label: data.label, + value: data.value, + children: {}, + stats: data.stats, + threads: data.threads + }; + + const children = data.children || []; + if (children.length === 0) { + // Single-frame tree: the root is its own leaf + processLeaf(invertedRoot, [data], data, isDifferential); + } else { + children.forEach(child => traverseInvert([child], child, invertedRoot, isDifferential)); + } + + convertInvertDictToArray(invertedRoot); + return invertedRoot; +} + +function toggleInvert() { + isInverted = !isInverted; + updateToggleUI('toggle-invert', isInverted); + updateFlamegraphView(); +} + +function togglePathDisplay() { + useModuleNames = !useModuleNames; + updateToggleUI('toggle-path-display', useModuleNames); + updateFlamegraphView(); +} + +// ============================================================================ +// Initialization +// ============================================================================ + +function initFlamegraph() { + ensureLibraryLoaded(); + restoreUIState(); + setupLogos(); + + if (EMBEDDED_DATA.strings) { + stringTable = EMBEDDED_DATA.strings; + normalData = resolveStringIndices(EMBEDDED_DATA, EMBEDDED_DATA.strings); + } else { + normalData = EMBEDDED_DATA; + } + + // Initialize opcode mapping from embedded data + initOpcodeMapping(EMBEDDED_DATA); + + // Inverted data will be built on first toggle + invertedData = null; + + initThreadFilter(normalData); + + // Toggle legend based on differential mode + const isDifferential = normalData && normalData.stats && normalData.stats.is_differential; + const heatmapLegend = document.getElementById('heatmap-legend-section'); + const diffLegend = document.getElementById('diff-legend-section'); + if (isDifferential) { + if (heatmapLegend) heatmapLegend.style.display = 'none'; + if (diffLegend) diffLegend.style.display = 'block'; + } else { + if (heatmapLegend) heatmapLegend.style.display = 'block'; + if (diffLegend) diffLegend.style.display = 'none'; + } + + const tooltip = createPythonTooltip(normalData); + const chart = createFlamegraph(tooltip, normalData.value, normalData); + renderFlamegraph(chart, normalData); + initSearchHandlers(); + initSidebarResize(); + handleResize(); + + const toggleInvertBtn = document.getElementById('toggle-invert'); + if (toggleInvertBtn) { + toggleInvertBtn.addEventListener('click', toggleInvert); + } + + const togglePathDisplayBtn = document.getElementById('toggle-path-display'); + if (togglePathDisplayBtn) { + togglePathDisplayBtn.addEventListener('click', togglePathDisplay); + } +} + +// Keyboard shortcut: Enter/Space activates toggle switches +document.addEventListener('keydown', function(e) { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { + return; + } + if ((e.key === 'Enter' || e.key === ' ') && e.target.classList.contains('toggle-switch')) { + e.preventDefault(); + e.target.click(); + } +}); + +if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", initFlamegraph); +} else { + initFlamegraph(); +} diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html b/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html new file mode 100644 index 000000000..f1c5bb030 --- /dev/null +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph_template.html @@ -0,0 +1,464 @@ + + + + + + {{TITLE}} + + + + + + + +
+ + + + diff --git a/Lib/profiling/sampling/_format_utils.py b/Lib/profiling/sampling/_format_utils.py new file mode 100644 index 000000000..237a4f418 --- /dev/null +++ b/Lib/profiling/sampling/_format_utils.py @@ -0,0 +1,5 @@ +import locale + + +def fmt(value: int | float, decimals: int = 1) -> str: + return locale.format_string(f'%.{decimals}f', value, grouping=True) diff --git a/Lib/profiling/sampling/_heatmap_assets/heatmap.css b/Lib/profiling/sampling/_heatmap_assets/heatmap.css new file mode 100644 index 000000000..8f7f034ba --- /dev/null +++ b/Lib/profiling/sampling/_heatmap_assets/heatmap.css @@ -0,0 +1,1555 @@ +/* ========================================================================== + Heatmap Viewer - Component-Specific CSS + + DEPENDENCY: Requires _shared_assets/base.css to be loaded first + This file extends the shared foundation with heatmap-specific styles. + ========================================================================== */ + +/* Heatmap heat colors - using base.css colors with 60% opacity */ +[data-theme="dark"] { + --heat-1: rgba(90, 123, 167, 0.60); + --heat-2: rgba(106, 148, 168, 0.60); + --heat-3: rgba(122, 172, 172, 0.60); + --heat-4: rgba(142, 196, 152, 0.60); + --heat-5: rgba(168, 216, 136, 0.60); + --heat-6: rgba(200, 222, 122, 0.60); + --heat-7: rgba(244, 212, 93, 0.60); + --heat-8: rgba(255, 122, 69, 0.60); +} + +/* -------------------------------------------------------------------------- + Layout Overrides (Heatmap-specific) + -------------------------------------------------------------------------- */ + +.app-layout { + min-height: 100vh; +} + +/* Sticky top bar for heatmap views */ +.top-bar { + position: sticky; + top: 0; + z-index: 100; +} + +/* -------------------------------------------------------------------------- + Main Content Area + -------------------------------------------------------------------------- */ + +.main-content { + flex: 1; + padding: 24px 3%; + width: 100%; + max-width: 100%; +} + +/* -------------------------------------------------------------------------- + Stats Summary Cards - Enhanced with Icons & Animations + -------------------------------------------------------------------------- */ + +.stats-summary { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 12px; + margin-bottom: 24px; +} + +.stat-card { + display: flex; + align-items: center; + gap: 12px; + background: var(--bg-primary); + border: 2px solid var(--border); + border-radius: 10px; + padding: 14px 16px; + transition: all var(--transition-fast); + animation: slideUp 0.5s ease-out backwards; + animation-delay: calc(var(--i, 0) * 0.08s); + position: relative; + overflow: hidden; +} + +.stat-card:nth-child(1) { --i: 0; --card-color: var(--card-blue); } +.stat-card:nth-child(2) { --i: 1; --card-color: var(--card-green); } +.stat-card:nth-child(3) { --i: 2; --card-color: var(--card-yellow); } +.stat-card:nth-child(4) { --i: 3; --card-color: var(--card-purple); } + +.stat-card:hover { + border-color: rgba(var(--card-color), 0.6); + background: linear-gradient(135deg, rgba(var(--card-color), 0.08) 0%, var(--bg-primary) 100%); + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(var(--card-color), 0.15); +} + +.stat-icon { + width: 40px; + height: 40px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + background: linear-gradient(135deg, rgba(var(--card-color), 0.15) 0%, rgba(var(--card-color), 0.05) 100%); + border: 1px solid rgba(var(--card-color), 0.2); + border-radius: 10px; + flex-shrink: 0; + transition: all var(--transition-fast); +} + +.stat-card:hover .stat-icon { + transform: scale(1.05) rotate(-2deg); + background: linear-gradient(135deg, rgba(var(--card-color), 0.25) 0%, rgba(var(--card-color), 0.1) 100%); +} + +.stat-data { + flex: 1; + min-width: 0; +} + +.stat-value { + font-family: var(--font-mono); + font-size: 1.35em; + font-weight: 800; + color: rgb(var(--card-color)); + display: block; + line-height: 1.1; + letter-spacing: -0.3px; +} + +.stat-label { + font-size: 10px; + font-weight: 600; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.3px; + margin-top: 2px; +} + +/* Sparkline decoration for stats */ +.stat-sparkline { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 30px; + opacity: 0.1; + background: linear-gradient(180deg, + transparent 0%, + rgba(var(--card-color), 0.3) 100% + ); + pointer-events: none; +} + +/* -------------------------------------------------------------------------- + Rate Cards (Error Rate, Missed Samples) with Progress Bars + -------------------------------------------------------------------------- */ + +.rate-card { + display: flex; + flex-direction: column; + gap: 12px; + background: var(--bg-primary); + border: 2px solid var(--border); + border-radius: 12px; + padding: 18px 20px; + transition: all var(--transition-fast); + animation: slideUp 0.5s ease-out backwards; + position: relative; + overflow: hidden; +} + +.rate-card:nth-child(5) { animation-delay: 0.32s; --rate-color: var(--card-red); } +.rate-card:nth-child(6) { animation-delay: 0.40s; --rate-color: var(--card-orange); } + +.rate-card:hover { + border-color: rgba(var(--rate-color), 0.5); + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(var(--rate-color), 0.15); +} + +.rate-header { + display: flex; + justify-content: space-between; + align-items: center; +} + +.rate-info { + display: flex; + align-items: center; + gap: 10px; +} + +.rate-icon { + width: 36px; + height: 36px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + background: linear-gradient(135deg, rgba(var(--rate-color), 0.15) 0%, rgba(var(--rate-color), 0.05) 100%); + border: 1px solid rgba(var(--rate-color), 0.2); + border-radius: 10px; + flex-shrink: 0; +} + +.rate-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.rate-value { + font-family: var(--font-mono); + font-size: 1.4em; + font-weight: 800; + color: rgb(var(--rate-color)); +} + +.rate-bar { + height: 8px; + background: var(--bg-tertiary); + border-radius: 4px; + overflow: hidden; + position: relative; +} + +.rate-fill { + height: 100%; + border-radius: 4px; + transition: width 0.8s ease-out; + position: relative; + overflow: hidden; +} + +.rate-fill.error { + background: linear-gradient(90deg, #dc3545 0%, #ff6b6b 100%); +} + +.rate-fill.warning { + background: linear-gradient(90deg, #ff9800 0%, #ffc107 100%); +} + +.rate-fill.good { + background: linear-gradient(90deg, #28a745 0%, #20c997 100%); +} + +/* Shimmer animation on rate bars */ +.rate-fill::after { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.4) 50%, + transparent 100% + ); + animation: shimmer 2.5s ease-in-out infinite; +} + +/* -------------------------------------------------------------------------- + Section Headers + -------------------------------------------------------------------------- */ + +.section-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; + padding-bottom: 12px; + border-bottom: 2px solid var(--python-gold); +} + +.section-title { + font-size: 18px; + font-weight: 700; + color: var(--text-primary); + margin: 0; + flex: 1; +} + +/* -------------------------------------------------------------------------- + Filter Controls + -------------------------------------------------------------------------- */ + +.filter-controls { + display: flex; + gap: 8px; + flex-wrap: wrap; + align-items: center; + margin-bottom: 16px; +} + +.control-btn { + padding: 8px 16px; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border); + border-radius: 6px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); +} + +.control-btn:hover { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +/* -------------------------------------------------------------------------- + Type Sections (stdlib, project, etc) + -------------------------------------------------------------------------- */ + +.type-section { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + margin-bottom: 12px; +} + +.type-header { + padding: 12px 16px; + background: var(--header-gradient); + color: white; + cursor: pointer; + display: flex; + align-items: center; + gap: 10px; + user-select: none; + transition: all var(--transition-fast); + font-weight: 600; +} + +.type-header:hover { + opacity: 0.95; +} + +.type-icon { + font-size: 12px; + transition: transform var(--transition-fast); + min-width: 12px; +} + +.type-title { + font-size: 14px; + flex: 1; +} + +.type-stats { + font-size: 12px; + opacity: 0.9; + background: rgba(255, 255, 255, 0.15); + padding: 4px 10px; + border-radius: 4px; + font-family: var(--font-mono); +} + +.type-content { + padding: 12px; +} + +/* -------------------------------------------------------------------------- + Folder Nodes (hierarchical structure) + -------------------------------------------------------------------------- */ + +.folder-node { + margin-bottom: 6px; +} + +.folder-header { + padding: 8px 12px; + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; + display: flex; + align-items: center; + gap: 8px; + user-select: none; + transition: all var(--transition-fast); +} + +.folder-header:hover { + background: var(--accent-glow); + border-color: var(--accent); +} + +.folder-icon { + font-size: 10px; + color: var(--accent); + transition: transform var(--transition-fast); + min-width: 12px; +} + +.folder-name { + flex: 1; + font-weight: 500; + color: var(--text-primary); + font-size: 13px; +} + +.folder-stats { + font-size: 11px; + color: var(--text-secondary); + background: var(--bg-tertiary); + padding: 2px 8px; + border-radius: 4px; + font-family: var(--font-mono); +} + +.folder-content { + padding-left: 20px; + margin-top: 6px; +} + +/* -------------------------------------------------------------------------- + File Items + -------------------------------------------------------------------------- */ + +.files-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 8px; +} + +.file-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + background: var(--bg-primary); + border: 1px solid var(--border-subtle); + border-radius: 6px; + transition: all var(--transition-fast); +} + +.file-item:hover { + background: var(--bg-secondary); + border-color: var(--border); +} + +.file-item .file-link { + flex: 1; + min-width: 0; + font-size: 13px; +} + +.file-samples { + font-size: 12px; + color: var(--text-secondary); + font-weight: 600; + white-space: nowrap; + width: 130px; + flex-shrink: 0; + text-align: right; + font-family: var(--font-mono); +} + +.heatmap-bar-container { + width: 120px; + flex-shrink: 0; + display: flex; + align-items: center; +} + +.heatmap-bar { + flex-shrink: 0; + border-radius: 2px; +} + +/* Links */ +.file-link { + color: var(--accent); + text-decoration: none; + font-weight: 500; + transition: color var(--transition-fast); +} + +.file-link:hover { + color: var(--accent-hover); + text-decoration: underline; +} + +/* -------------------------------------------------------------------------- + Module Badges + -------------------------------------------------------------------------- */ + +.module-badge { + display: inline-block; + padding: 3px 8px; + border-radius: 4px; + font-size: 11px; + font-weight: 600; +} + +.badge-stdlib { + background: rgba(40, 167, 69, 0.15); + color: #28a745; +} + +.badge-site-packages { + background: rgba(0, 123, 255, 0.15); + color: #007bff; +} + +.badge-project { + background: rgba(255, 193, 7, 0.2); + color: #d39e00; +} + +.badge-other { + background: var(--bg-tertiary); + color: var(--text-secondary); +} + +[data-theme="dark"] .badge-stdlib { + background: rgba(40, 167, 69, 0.25); + color: #5dd879; +} + +[data-theme="dark"] .badge-site-packages { + background: rgba(88, 166, 255, 0.25); + color: #79b8ff; +} + +[data-theme="dark"] .badge-project { + background: rgba(255, 212, 59, 0.25); + color: #ffd43b; +} + +/* ========================================================================== + FILE VIEW STYLES (Code Display) + ========================================================================== */ + +.code-view { + font-family: var(--font-mono); + min-height: 100vh; +} + +/* Code Header (Top Bar for file view) */ +.code-header { + height: var(--topbar-height); + background: var(--header-gradient); + display: flex; + align-items: center; + padding: 0 16px; + gap: 16px; + box-shadow: 0 2px 10px rgba(55, 118, 171, 0.25); + border-bottom: 2px solid var(--python-gold); + position: sticky; + top: 0; + z-index: 100; +} + +.code-header-content { + display: flex; + align-items: center; + justify-content: space-between; + width: 94%; + max-width: 100%; + margin: 0 auto; +} + +.code-header h1 { + font-size: 14px; + font-weight: 600; + color: white; + margin: 0; + font-family: var(--font-mono); + display: flex; + align-items: center; + gap: 8px; +} + +/* File Stats Bar */ +.file-stats { + background: var(--bg-secondary); + padding: 16px 24px; + border-bottom: 1px solid var(--border); +} + +.file-stats .stats-grid { + width: 94%; + max-width: 100%; + margin: 0 auto; + display: grid; + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); + gap: 12px; +} + +.stat-item { + background: var(--bg-primary); + padding: 12px; + border-radius: 8px; + box-shadow: var(--shadow-sm); + text-align: center; + border: 1px solid var(--border); + transition: all var(--transition-fast); +} + +.stat-item:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); + border-color: var(--accent); +} + +.stat-item .stat-value { + font-size: 1.4em; + font-weight: 700; + color: var(--accent); +} + +.stat-item .stat-label { + color: var(--text-muted); + font-size: 10px; + margin-top: 2px; +} + +/* Legend */ +.legend { + background: var(--bg-secondary); + padding: 12px 24px; + border-bottom: 1px solid var(--border); +} + +.legend-content { + width: 100%; + display: flex; + align-items: center; + gap: 20px; + flex-wrap: nowrap; +} + +.legend-separator { + width: 1px; + height: 24px; + background: var(--border); + flex-shrink: 0; +} + +.legend-title { + font-weight: 600; + color: var(--text-primary); + font-size: 13px; + font-family: var(--font-sans); + flex-shrink: 0; +} + +.legend-gradient { + width: 150px; + flex-shrink: 0; + height: 20px; + background: linear-gradient(90deg, + var(--bg-tertiary) 0%, + var(--heat-2) 25%, + var(--heat-4) 50%, + var(--heat-6) 75%, + var(--heat-8) 100% + ); + border-radius: 4px; + border: 1px solid var(--border); +} + +.legend-labels { + display: flex; + gap: 12px; + font-size: 11px; + color: var(--text-muted); + font-family: var(--font-sans); + flex-shrink: 0; +} + +/* Legend Controls Group - wraps toggles and bytecode button together */ +.legend-controls { + display: flex; + align-items: center; + gap: 20px; + flex-shrink: 0; + margin-left: auto; +} + +/* Heatmap-Specific Toggle Overrides */ +#toggle-color-mode .toggle-track.on { + background: #8e44ad; + border-color: #8e44ad; + box-shadow: 0 0 8px rgba(142, 68, 173, 0.3); +} + +#toggle-cold .toggle-track.on { + background: #e67e22; + border-color: #e67e22; + box-shadow: 0 0 8px rgba(230, 126, 34, 0.3); +} + +/* Code Container */ +.code-container { + width: 94%; + max-width: 100%; + margin: 16px auto; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px 8px 8px 8px; + box-shadow: var(--shadow-sm); + /* Allow horizontal scroll for long lines, but don't clip sticky header */ +} + +/* Code Header Row */ +.code-header-row { + position: sticky; + top: var(--topbar-height); + z-index: 50; + display: flex; + background: var(--bg-secondary); + border-bottom: 2px solid var(--border); + font-weight: 700; + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + border-radius: 8px 8px 0 0; +} + +.header-line-number { + flex-shrink: 0; + width: 60px; + padding: 8px 10px; + text-align: right; + border-right: 1px solid var(--border); +} + +.header-samples-self, +.header-samples-cumulative { + flex-shrink: 0; + width: 90px; + padding: 8px 10px; + text-align: right; + border-right: 1px solid var(--border); +} + +.header-samples-self { + color: var(--heat-8); +} + +.header-samples-cumulative { + color: var(--accent); +} + +.header-content { + flex: 1; + padding: 8px 15px; +} + +/* Code Lines */ +.code-line { + position: relative; + display: flex; + min-height: 20px; + line-height: 20px; + font-size: 13px; + transition: background var(--transition-fast); + scroll-margin-top: calc(var(--topbar-height) + 50px); +} + +.code-line:hover { + filter: brightness(0.97); +} + +[data-theme="dark"] .code-line:hover { + filter: brightness(1.1); +} + +.line-number { + flex-shrink: 0; + width: 60px; + padding: 0 10px; + text-align: right; + color: var(--text-muted); + background: var(--bg-secondary); + border-right: 1px solid var(--border); + user-select: none; + transition: all var(--transition-fast); +} + +.line-number:hover { + background: var(--accent); + color: white; + cursor: pointer; +} + +.line-samples-self, +.line-samples-cumulative { + flex-shrink: 0; + width: 90px; + padding: 0 10px; + text-align: right; + background: var(--bg-secondary); + border-right: 1px solid var(--border); + font-weight: 600; + user-select: none; + font-size: 12px; +} + +.line-samples-self { + color: var(--heat-8); +} + +.line-samples-cumulative { + color: var(--accent); +} + +.line-content { + flex: 1; + padding: 0 15px; + white-space: pre; + overflow-x: auto; +} + +/* Scrollbar Styling */ +.line-content::-webkit-scrollbar { + height: 6px; +} + +.line-content::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +.line-content::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* Navigation Buttons */ +.line-nav-buttons { + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + display: flex; + gap: 4px; + align-items: center; +} + +.nav-btn { + padding: 2px 6px; + font-size: 12px; + font-weight: 500; + border: 1px solid var(--accent); + border-radius: 4px; + background: var(--bg-primary); + color: var(--accent); + cursor: pointer; + transition: all var(--transition-fast); + user-select: none; + line-height: 1; +} + +.nav-btn:hover:not(:disabled) { + background: var(--accent); + color: white; + transform: translateY(-1px); + box-shadow: var(--shadow-sm); +} + +.nav-btn:active:not(:disabled) { + transform: translateY(0); +} + +.nav-btn:disabled { + opacity: 0.3; + cursor: not-allowed; + color: var(--text-muted); + background: var(--bg-secondary); + border-color: var(--border); +} + +.nav-btn.caller { + color: var(--nav-caller); + border-color: var(--nav-caller); +} + +.nav-btn.callee { + color: var(--nav-callee); + border-color: var(--nav-callee); +} + +.nav-btn.caller:hover:not(:disabled) { + background: var(--nav-caller-hover); + color: white; +} + +.nav-btn.callee:hover:not(:disabled) { + background: var(--nav-callee-hover); + color: white; +} + +/* Highlighted target line */ +.code-line:target { + animation: highlight-line 2s ease-out; +} + +@keyframes highlight-line { + 0% { + background: rgba(255, 212, 59, 0.6) !important; + outline: 3px solid var(--python-gold); + outline-offset: -3px; + } + 50% { + background: rgba(255, 212, 59, 0.5) !important; + outline: 3px solid var(--python-gold); + outline-offset: -3px; + } + 100% { + outline: 3px solid transparent; + outline-offset: -3px; + } +} + +/* Popup menu for multiple callees */ +.callee-menu { + position: absolute; + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow-lg); + padding: 8px; + z-index: 1000; + min-width: 250px; + max-width: 400px; + max-height: 300px; + overflow-y: auto; +} + +.callee-menu-header { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid var(--border); + font-size: 13px; + font-family: var(--font-sans); +} + +.callee-menu-item { + padding: 8px; + margin: 4px 0; + border-radius: 6px; + cursor: pointer; + transition: background var(--transition-fast); + display: flex; + flex-direction: column; + gap: 4px; +} + +.callee-menu-item:hover { + background: var(--bg-secondary); +} + +.callee-menu-func { + font-weight: 500; + color: var(--accent); + font-size: 12px; +} + +.callee-menu-file { + font-size: 11px; + color: var(--text-muted); +} + +.count-badge { + display: inline-block; + background: var(--accent); + color: white; + font-size: 10px; + padding: 2px 6px; + border-radius: 4px; + font-weight: 600; + margin-left: 6px; +} + +/* Callee menu scrollbar */ +.callee-menu::-webkit-scrollbar { + width: 6px; +} + +.callee-menu::-webkit-scrollbar-track { + background: var(--bg-secondary); + border-radius: 3px; +} + +.callee-menu::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 3px; +} + +/* -------------------------------------------------------------------------- + Scroll Minimap Marker + -------------------------------------------------------------------------- */ + +#scroll_marker { + position: fixed; + z-index: 1000; + right: 0; + top: 0; + width: 12px; + height: 100%; + background: var(--bg-secondary); + border-left: 1px solid var(--border); + pointer-events: none; +} + +#scroll_marker .marker { + position: absolute; + min-height: 3px; + width: 100%; + pointer-events: none; +} + +#scroll_marker .marker.cold { + background: var(--heat-1); +} + +#scroll_marker .marker.cool { + background: var(--heat-2); +} + +#scroll_marker .marker.mild { + background: var(--heat-3); +} + +#scroll_marker .marker.warm { + background: var(--heat-4); +} + +#scroll_marker .marker.hot { + background: var(--heat-5); +} + +#scroll_marker .marker.very-hot { + background: var(--heat-6); +} + +#scroll_marker .marker.intense { + background: var(--heat-7); +} + +#scroll_marker .marker.extreme { + background: var(--heat-8); +} + +/* -------------------------------------------------------------------------- + Responsive (Heatmap-specific) + -------------------------------------------------------------------------- */ + +@media (max-width: 1100px) { + .stats-summary { + grid-template-columns: repeat(2, 1fr); + } + + .legend-content { + flex-wrap: wrap; + justify-content: center; + } + + .legend-controls { + margin-left: 0; + } +} + +@media (max-width: 900px) { + .main-content { + padding: 16px; + } +} + +@media (max-width: 600px) { + .stats-summary { + grid-template-columns: 1fr; + } + + .file-stats .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .legend-content { + flex-direction: column; + align-items: center; + gap: 12px; + } + + .legend-gradient { + width: 100%; + max-width: none; + } + + .legend-separator { + width: 80%; + height: 1px; + } + + .legend-controls { + flex-direction: column; + gap: 12px; + } + + .legend-controls .toggle-switch { + justify-content: center; + } + + .legend-controls .toggle-switch .toggle-label:first-child { + width: 70px; + text-align: right; + } + + .legend-controls .toggle-switch .toggle-label:last-child { + width: 90px; + text-align: left; + } + + /* Compact code columns on small screens */ + .header-line-number, + .line-number { + width: 40px; + } + + .header-samples-self, + .header-samples-cumulative, + .line-samples-self, + .line-samples-cumulative { + width: 55px; + font-size: 10px; + } + + /* Adjust padding - headers need vertical, data rows don't */ + .header-line-number, + .header-samples-self, + .header-samples-cumulative { + padding: 8px 4px; + } + + .line-number, + .line-samples-self, + .line-samples-cumulative { + padding: 0 4px; + } + + .bytecode-panel { + margin: 8px 10px 8px 160px; + } +} + +.bytecode-toggle { + flex-shrink: 0; + width: 20px; + height: 20px; + padding: 0; + margin: 0 4px; + border: none; + background: transparent; + color: var(--code-accent); + cursor: pointer; + font-size: 10px; + transition: transform var(--transition-fast), color var(--transition-fast); + display: inline-flex; + align-items: center; + justify-content: center; +} + +.bytecode-toggle:hover { + color: var(--accent); +} + +.bytecode-spacer { + flex-shrink: 0; + width: 20px; + height: 20px; + margin: 0 4px; +} + +.bytecode-panel { + background: var(--bg-primary); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: var(--shadow-md); + font-family: var(--font-mono); + font-size: 12px; + color: var(--text-primary); + line-height: 1.5; + word-wrap: break-word; + overflow-wrap: break-word; + padding: 0; + margin: 8px 10px 8px 250px; + position: relative; + z-index: 1; + overflow-y: auto; + max-height: 500px; + flex: 1; + transition: padding 0.3s cubic-bezier(0.4, 0, 0.2, 1); +} + +.bytecode-panel.expanded { + padding: 14px; +} + +.bytecode-wrapper { + position: relative; + display: flex; + overflow: visible; + max-height: 0; + opacity: 0; + transition: max-height 0.4s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.3s ease-in-out; +} + +.bytecode-wrapper.expanded { + max-height: 600px; + opacity: 1; + transition: max-height 0.5s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.4s ease-in-out; +} + +/* Column backdrop matching table header columns (line/self/total) */ +.bytecode-columns { + display: none; + position: absolute; + left: 0; + overflow: hidden; + pointer-events: none; + z-index: 0; +} + +.bytecode-wrapper.expanded .bytecode-columns { + display: flex; + top: 0; + bottom: 0; +} + +.bytecode-panel::-webkit-scrollbar { + width: 8px; +} + +.bytecode-panel::-webkit-scrollbar-track { + background: var(--bg-secondary); + border-radius: 4px; +} + +.bytecode-panel::-webkit-scrollbar-thumb { + background: var(--border); + border-radius: 4px; +} + +.bytecode-panel::-webkit-scrollbar-thumb:hover { + background: var(--text-muted); +} + +/* Specialization summary bar */ +.bytecode-spec-summary { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + margin-bottom: 10px; + border-radius: var(--radius-sm); + background: rgba(100, 100, 100, 0.1); +} + +.bytecode-spec-summary .spec-pct { + font-size: 1.4em; + font-weight: 700; +} + +.bytecode-spec-summary .spec-label { + font-weight: 500; + text-transform: uppercase; + font-size: 0.85em; + letter-spacing: 0.5px; +} + +.bytecode-spec-summary .spec-detail { + color: var(--text-secondary); + font-size: 0.9em; + margin-left: auto; +} + +.bytecode-spec-summary.high { + background: var(--spec-high-bg); + border-left: 3px solid var(--spec-high); +} +.bytecode-spec-summary.high .spec-pct, +.bytecode-spec-summary.high .spec-label { + color: var(--spec-high-text); +} + +.bytecode-spec-summary.medium { + background: var(--spec-medium-bg); + border-left: 3px solid var(--spec-medium); +} +.bytecode-spec-summary.medium .spec-pct, +.bytecode-spec-summary.medium .spec-label { + color: var(--spec-medium-text); +} + +.bytecode-spec-summary.low { + background: var(--spec-low-bg); + border-left: 3px solid var(--spec-low); +} +.bytecode-spec-summary.low .spec-pct, +.bytecode-spec-summary.low .spec-label { + color: var(--spec-low-text); +} + +.bytecode-header { + display: grid; + grid-template-columns: 1fr 80px 80px; + gap: 12px; + padding: 4px 8px; + font-weight: 600; + color: var(--text-secondary); + border-bottom: 1px solid var(--code-border); + margin-bottom: 4px; +} + +.bytecode-expand-all { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 12px; + background: var(--bg-secondary); + border: 1px solid var(--code-border); + border-radius: var(--radius-sm); + color: var(--text-secondary); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all var(--transition-fast); + flex-shrink: 0; +} + +.bytecode-expand-all:hover, +.bytecode-expand-all.expanded { + background: var(--accent); + color: white; + border-color: var(--accent); +} + +.bytecode-expand-all .expand-icon { + font-size: 10px; + transition: transform var(--transition-fast); +} + +.bytecode-expand-all.expanded .expand-icon { + transform: rotate(90deg); +} + +/* ======================================== + INSTRUCTION SPAN HIGHLIGHTING + (triggered only from bytecode panel hover) + ======================================== */ + +/* Highlight from bytecode panel hover */ +.instr-span.highlight-from-bytecode { + outline: 3px solid #ff6b6b !important; + background-color: rgba(255, 107, 107, 0.4) !important; + border-radius: 2px; +} + +/* Bytecode instruction row */ +.bytecode-instruction { + display: grid; + grid-template-columns: 1fr 80px 80px; + gap: 12px; + align-items: center; + padding: 4px 8px; + margin: 2px 0; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color var(--transition-fast); +} + +.bytecode-instruction:hover, +.bytecode-instruction.highlight { + background-color: rgba(55, 118, 171, 0.15); +} + +.bytecode-instruction[data-locations] { + cursor: pointer; +} + +.bytecode-instruction[data-locations]:hover { + background-color: rgba(255, 107, 107, 0.2); +} + +.bytecode-opname { + font-weight: 600; + font-family: var(--font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bytecode-opname.specialized { + color: #2e7d32; +} + +[data-theme="dark"] .bytecode-opname.specialized { + color: #81c784; +} + +.bytecode-opname .base-op { + color: var(--code-text-muted); + font-weight: normal; + font-size: 0.9em; + margin-left: 4px; +} + +.bytecode-samples { + text-align: right; + font-weight: 600; + color: var(--accent); + font-family: var(--font-mono); +} + +.bytecode-samples.hot { + color: #ff6b6b; +} + +.bytecode-heatbar { + width: 60px; + height: 12px; + background: var(--bg-secondary); + border-radius: 2px; + overflow: hidden; + border: 1px solid var(--code-border); +} + +.bytecode-heatbar-fill { + height: 100%; + background: linear-gradient(90deg, #00d4ff 0%, #ff6b00 100%); +} + +.specialization-badge { + display: inline-block; + padding: 1px 6px; + font-size: 0.75em; + background: #e8f5e9; + color: #2e7d32; + border-radius: 3px; + margin-left: 6px; + font-weight: 600; +} + +[data-theme="dark"] .specialization-badge { + background: rgba(129, 199, 132, 0.2); + color: #81c784; +} + +.bytecode-empty { + color: var(--code-text-muted); + font-style: italic; + padding: 8px; +} + +.bytecode-error { + color: #d32f2f; + font-style: italic; + padding: 8px; +} + +/* ======================================== + SPAN TOOLTIPS + ======================================== */ + +.span-tooltip { + position: absolute; + z-index: 10000; + background: var(--bg-primary); + color: var(--text-primary); + padding: 10px 14px; + border-radius: var(--radius-md); + border: 1px solid var(--border); + font-family: var(--font-sans); + font-size: 12px; + box-shadow: var(--shadow-lg); + pointer-events: none; + min-width: 160px; + max-width: 300px; +} + +.span-tooltip::after { + content: ''; + position: absolute; + bottom: -7px; + left: 50%; + transform: translateX(-50%); + border-width: 7px 7px 0; + border-style: solid; + border-color: var(--bg-primary) transparent transparent; + filter: drop-shadow(0 1px 1px rgba(0, 0, 0, 0.1)); +} + +.span-tooltip-header { + font-weight: 600; + margin-bottom: 8px; + padding-bottom: 6px; + border-bottom: 1px solid var(--border); + color: var(--text-primary); +} + +.span-tooltip-header.hot { + color: #e65100; +} + +.span-tooltip-header.warm { + color: #f59e0b; +} + +.span-tooltip-header.cold { + color: var(--text-muted); +} + +.span-tooltip-row { + display: flex; + justify-content: space-between; + margin: 4px 0; + gap: 16px; +} + +.span-tooltip-label { + color: var(--text-secondary); +} + +.span-tooltip-value { + font-weight: 600; + text-align: right; + color: var(--text-primary); +} + +.span-tooltip-value.highlight { + color: var(--accent); +} + +.span-tooltip-section { + font-weight: 600; + color: var(--text-secondary); + font-size: 11px; + margin-top: 8px; + margin-bottom: 4px; + padding-top: 6px; + border-top: 1px solid var(--border); +} + +.span-tooltip-opcode { + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-primary); + background: var(--bg-secondary); + padding: 3px 8px; + margin: 2px 0; + border-radius: var(--radius-sm); + border-left: 2px solid var(--accent); +} diff --git a/Lib/profiling/sampling/_heatmap_assets/heatmap.js b/Lib/profiling/sampling/_heatmap_assets/heatmap.js new file mode 100644 index 000000000..1f698779f --- /dev/null +++ b/Lib/profiling/sampling/_heatmap_assets/heatmap.js @@ -0,0 +1,738 @@ +// Tachyon Profiler - Heatmap JavaScript +// Interactive features for the heatmap visualization +// Aligned with Flamegraph viewer design patterns + +// ============================================================================ +// State Management +// ============================================================================ + +let currentMenu = null; +let colorMode = 'self'; // 'self' or 'cumulative' - default to self +let coldCodeHidden = false; + +// ============================================================================ +// Theme Support +// ============================================================================ + +function toggleTheme() { + toggleAndSaveTheme(); + applyLineColors(); + + // Rebuild scroll marker with new theme colors + buildScrollMarker(); +} + +// ============================================================================ +// Utility Functions +// ============================================================================ + +function createElement(tag, className, textContent = '') { + const el = document.createElement(tag); + if (className) el.className = className; + if (textContent) el.textContent = textContent; + return el; +} + +function calculateMenuPosition(buttonRect, menuWidth, menuHeight) { + const viewport = { width: window.innerWidth, height: window.innerHeight }; + const scroll = { + x: window.pageXOffset || document.documentElement.scrollLeft, + y: window.pageYOffset || document.documentElement.scrollTop + }; + + const left = buttonRect.right + menuWidth + 10 < viewport.width + ? buttonRect.right + scroll.x + 10 + : Math.max(scroll.x + 10, buttonRect.left + scroll.x - menuWidth - 10); + + const top = buttonRect.bottom + menuHeight + 10 < viewport.height + ? buttonRect.bottom + scroll.y + 5 + : Math.max(scroll.y + 10, buttonRect.top + scroll.y - menuHeight - 10); + + return { left, top }; +} + +// ============================================================================ +// Menu Management +// ============================================================================ + +function closeMenu() { + if (currentMenu) { + currentMenu.remove(); + currentMenu = null; + } +} + +function showNavigationMenu(button, items, title) { + closeMenu(); + + const menu = createElement('div', 'callee-menu'); + menu.appendChild(createElement('div', 'callee-menu-header', title)); + + items.forEach(linkData => { + const item = createElement('div', 'callee-menu-item'); + + const funcDiv = createElement('div', 'callee-menu-func'); + funcDiv.textContent = linkData.func; + + if (linkData.count !== undefined && linkData.count > 0) { + const countBadge = createElement('span', 'count-badge'); + countBadge.textContent = linkData.count.toLocaleString(); + countBadge.title = `${linkData.count.toLocaleString()} samples`; + funcDiv.appendChild(document.createTextNode(' ')); + funcDiv.appendChild(countBadge); + } + + item.appendChild(funcDiv); + item.appendChild(createElement('div', 'callee-menu-file', linkData.file)); + item.addEventListener('click', () => navigateToLine(linkData.link)); + menu.appendChild(item); + }); + + const pos = calculateMenuPosition(button.getBoundingClientRect(), 350, 300); + menu.style.left = `${pos.left}px`; + menu.style.top = `${pos.top}px`; + + document.body.appendChild(menu); + currentMenu = menu; +} + +// ============================================================================ +// Navigation +// ============================================================================ + +function handleNavigationClick(button, e) { + e.stopPropagation(); + + const navData = button.getAttribute('data-nav'); + if (navData) { + navigateToLine(JSON.parse(navData).link); + return; + } + + const navMulti = button.getAttribute('data-nav-multi'); + if (navMulti) { + const items = JSON.parse(navMulti); + const title = button.classList.contains('caller') ? 'Choose a caller:' : 'Choose a callee:'; + showNavigationMenu(button, items, title); + } +} + +function restartLineHighlight(target) { + target.style.animation = 'none'; + // Force style recalculation so restoring the animation restarts it. + void target.offsetWidth; + target.style.animation = ''; +} + +function navigateToLine(link) { + const url = new URL(link, window.location.href); + + if (url.href === window.location.href) { + scrollToTargetLine(); + } else { + window.location.href = link; + } +} + +function scrollToTargetLine() { + if (!window.location.hash) return; + const target = document.querySelector(window.location.hash); + if (target) { + target.scrollIntoView({ behavior: 'smooth', block: 'start' }); + restartLineHighlight(target); + } +} + +// ============================================================================ +// Sample Count & Intensity +// ============================================================================ + +function getSampleCount(line) { + let text; + if (colorMode === 'self') { + text = line.querySelector('.line-samples-self')?.textContent.trim().replace(/,/g, ''); + } else { + text = line.querySelector('.line-samples-cumulative')?.textContent.trim().replace(/,/g, ''); + } + return parseInt(text) || 0; +} + +// ============================================================================ +// Scroll Minimap +// ============================================================================ + +function buildScrollMarker() { + const existing = document.getElementById('scroll_marker'); + if (existing) existing.remove(); + + if (document.body.scrollHeight <= window.innerHeight) return; + + const allLines = document.querySelectorAll('.code-line'); + const lines = Array.from(allLines).filter(line => line.style.display !== 'none'); + const markerScale = window.innerHeight / document.body.scrollHeight; + const lineHeight = Math.min(Math.max(3, window.innerHeight / lines.length), 10); + const maxSamples = Math.max(...Array.from(lines, getSampleCount)); + + const scrollMarker = createElement('div', ''); + scrollMarker.id = 'scroll_marker'; + + let prevLine = -99, lastMark, lastTop; + + lines.forEach((line, index) => { + const samples = getSampleCount(line); + if (samples === 0) return; + + const lineTop = Math.floor(line.offsetTop * markerScale); + const lineNumber = index + 1; + const intensityClass = maxSamples > 0 ? (intensityToClass(samples / maxSamples) || 'cold') : 'cold'; + + if (lineNumber === prevLine + 1 && lastMark?.classList.contains(intensityClass)) { + lastMark.style.height = `${lineTop + lineHeight - lastTop}px`; + } else { + lastMark = createElement('div', `marker ${intensityClass}`); + lastMark.style.height = `${lineHeight}px`; + lastMark.style.top = `${lineTop}px`; + scrollMarker.appendChild(lastMark); + lastTop = lineTop; + } + + prevLine = lineNumber; + }); + + document.body.appendChild(scrollMarker); +} + +function applyLineColors() { + const lines = document.querySelectorAll('.code-line'); + lines.forEach(line => { + let intensity; + if (colorMode === 'self') { + intensity = parseFloat(line.getAttribute('data-self-intensity')) || 0; + } else { + intensity = parseFloat(line.getAttribute('data-cumulative-intensity')) || 0; + } + + const color = intensityToColor(intensity); + line.style.background = color; + }); +} + +// ============================================================================ +// Toggle Controls +// ============================================================================ + +function toggleColdCode() { + coldCodeHidden = !coldCodeHidden; + applyHotFilter(); + updateToggleUI('toggle-cold', coldCodeHidden); + buildScrollMarker(); +} + +function applyHotFilter() { + const lines = document.querySelectorAll('.code-line'); + + lines.forEach(line => { + const selfSamples = line.querySelector('.line-samples-self')?.textContent.trim(); + const cumulativeSamples = line.querySelector('.line-samples-cumulative')?.textContent.trim(); + + let isCold; + if (colorMode === 'self') { + isCold = !selfSamples || selfSamples === ''; + } else { + isCold = !cumulativeSamples || cumulativeSamples === ''; + } + + if (isCold) { + line.style.display = coldCodeHidden ? 'none' : 'flex'; + } else { + line.style.display = 'flex'; + } + }); +} + +function toggleColorMode() { + colorMode = colorMode === 'self' ? 'cumulative' : 'self'; + applyLineColors(); + + updateToggleUI('toggle-color-mode', colorMode === 'cumulative'); + + if (coldCodeHidden) { + applyHotFilter(); + } + + buildScrollMarker(); +} + +// ============================================================================ +// Initialization +// ============================================================================ + +document.addEventListener('DOMContentLoaded', function() { + restoreUIState(); + applyLineColors(); + + // Initialize navigation buttons + document.querySelectorAll('.nav-btn').forEach(button => { + button.addEventListener('click', e => handleNavigationClick(button, e)); + }); + + // Initialize line number permalink handlers + document.querySelectorAll('.line-number').forEach(lineNum => { + lineNum.style.cursor = 'pointer'; + lineNum.addEventListener('click', e => { + window.location.hash = `line-${e.target.textContent.trim()}`; + }); + }); + + // Initialize toggle buttons + const toggleColdBtn = document.getElementById('toggle-cold'); + if (toggleColdBtn) toggleColdBtn.addEventListener('click', toggleColdCode); + + const colorModeBtn = document.getElementById('toggle-color-mode'); + if (colorModeBtn) colorModeBtn.addEventListener('click', toggleColorMode); + + // Initialize specialization view toggle (hide if no bytecode data) + const hasBytecode = document.querySelectorAll('.bytecode-toggle').length > 0; + + const specViewBtn = document.getElementById('toggle-spec-view'); + if (specViewBtn) { + if (hasBytecode) { + specViewBtn.addEventListener('click', toggleSpecView); + } else { + specViewBtn.style.display = 'none'; + } + } + + // Initialize expand-all bytecode button + const expandAllBtn = document.getElementById('toggle-all-bytecode'); + if (expandAllBtn) { + if (hasBytecode) { + expandAllBtn.addEventListener('click', toggleAllBytecode); + } else { + expandAllBtn.style.display = 'none'; + } + } + + // Initialize span tooltips + initSpanTooltips(); + + // Build scroll marker and scroll to target + setTimeout(buildScrollMarker, 200); + setTimeout(scrollToTargetLine, 100); +}); + +// Close menu when clicking outside +document.addEventListener('click', e => { + if (currentMenu && !currentMenu.contains(e.target) && !e.target.classList.contains('nav-btn')) { + closeMenu(); + } +}); + +// ======================================== +// SPECIALIZATION VIEW TOGGLE +// ======================================== + +let specViewEnabled = false; + +/** + * Calculate heat color for given intensity (0-1) + * Hot spans (>30%) get warm orange, cold spans get dimmed gray + * @param {number} intensity - Value between 0 and 1 + * @returns {string} rgba color string + */ +function calculateHeatColor(intensity) { + // Hot threshold: only spans with >30% of max samples get color + if (intensity > 0.3) { + // Normalize intensity above threshold to 0-1 + const normalizedIntensity = (intensity - 0.3) / 0.7; + // Warm orange-red with increasing opacity for hotter spans + const alpha = 0.25 + normalizedIntensity * 0.35; // 0.25 to 0.6 + const hotColor = getComputedStyle(document.documentElement).getPropertyValue('--span-hot-base').trim(); + return `rgba(${hotColor}, ${alpha})`; + } else if (intensity > 0) { + // Cold spans: very subtle gray, almost invisible + const coldColor = getComputedStyle(document.documentElement).getPropertyValue('--span-cold-base').trim(); + return `rgba(${coldColor}, 0.1)`; + } + return 'transparent'; +} + +/** + * Apply intensity-based heat colors to source spans + * Hot spans get orange highlight, cold spans get dimmed + * @param {boolean} enable - Whether to enable or disable span coloring + */ +function applySpanHeatColors(enable) { + document.querySelectorAll('.instr-span').forEach(span => { + const samples = enable ? (parseInt(span.dataset.samples) || 0) : 0; + if (samples > 0) { + const intensity = samples / (parseInt(span.dataset.maxSamples) || 1); + span.style.backgroundColor = calculateHeatColor(intensity); + span.style.borderRadius = '2px'; + span.style.padding = '0 1px'; + span.style.cursor = 'pointer'; + } else { + span.style.cssText = ''; + } + }); +} + +// ======================================== +// SPAN TOOLTIPS +// ======================================== + +let activeTooltip = null; + +/** + * Create and show tooltip for a span + */ +function showSpanTooltip(span) { + hideSpanTooltip(); + + const samples = parseInt(span.dataset.samples) || 0; + const maxSamples = parseInt(span.dataset.maxSamples) || 1; + const pct = span.dataset.pct || '0'; + const opcodes = span.dataset.opcodes || ''; + + if (samples === 0) return; + + const intensity = samples / maxSamples; + const isHot = intensity > 0.7; + const isWarm = intensity > 0.3; + const hotnessText = isHot ? 'Hot' : isWarm ? 'Warm' : 'Cold'; + const hotnessClass = isHot ? 'hot' : isWarm ? 'warm' : 'cold'; + + // Build opcodes rows - each opcode on its own row + let opcodesHtml = ''; + if (opcodes) { + const opcodeList = opcodes.split(',').map(op => op.trim()).filter(op => op); + if (opcodeList.length > 0) { + opcodesHtml = ` +
Opcodes:
+ ${opcodeList.map(op => `
${op}
`).join('')} + `; + } + } + + const tooltip = document.createElement('div'); + tooltip.className = 'span-tooltip'; + tooltip.innerHTML = ` +
${hotnessText}
+
+ Samples: + ${samples.toLocaleString()} +
+
+ % of line: + ${pct}% +
+ ${opcodesHtml} + `; + + document.body.appendChild(tooltip); + activeTooltip = tooltip; + + // Position tooltip above the span + const rect = span.getBoundingClientRect(); + const tooltipRect = tooltip.getBoundingClientRect(); + + let left = rect.left + (rect.width / 2) - (tooltipRect.width / 2); + let top = rect.top - tooltipRect.height - 8; + + // Keep tooltip in viewport + if (left < 5) left = 5; + if (left + tooltipRect.width > window.innerWidth - 5) { + left = window.innerWidth - tooltipRect.width - 5; + } + if (top < 5) { + top = rect.bottom + 8; // Show below if no room above + } + + tooltip.style.left = `${left + window.scrollX}px`; + tooltip.style.top = `${top + window.scrollY}px`; +} + +/** + * Hide active tooltip + */ +function hideSpanTooltip() { + if (activeTooltip) { + activeTooltip.remove(); + activeTooltip = null; + } +} + +/** + * Initialize span tooltip handlers + */ +function initSpanTooltips() { + document.addEventListener('mouseover', (e) => { + const span = e.target.closest('.instr-span'); + if (span && specViewEnabled) { + showSpanTooltip(span); + } + }); + + document.addEventListener('mouseout', (e) => { + const span = e.target.closest('.instr-span'); + if (span) { + hideSpanTooltip(); + } + }); +} + +function toggleSpecView() { + specViewEnabled = !specViewEnabled; + const lines = document.querySelectorAll('.code-line'); + + if (specViewEnabled) { + lines.forEach(line => { + const specColor = line.getAttribute('data-spec-color'); + line.style.background = specColor || 'transparent'; + }); + } else { + applyLineColors(); + } + + applySpanHeatColors(specViewEnabled); + updateToggleUI('toggle-spec-view', specViewEnabled); + + // Disable/enable color mode toggle based on spec view state + const colorModeToggle = document.getElementById('toggle-color-mode'); + if (colorModeToggle) { + colorModeToggle.classList.toggle('disabled', specViewEnabled); + } + + buildScrollMarker(); +} + +// ======================================== +// BYTECODE PANEL TOGGLE +// ======================================== + +/** + * Toggle bytecode panel visibility for a source line + * @param {HTMLElement} button - The toggle button that was clicked + */ +function toggleBytecode(button) { + const lineDiv = button.closest('.code-line'); + const lineId = lineDiv.id; + const lineNum = lineId.replace('line-', ''); + const panel = document.getElementById(`bytecode-${lineNum}`); + const wrapper = document.getElementById(`bytecode-wrapper-${lineNum}`); + + if (!panel || !wrapper) return; + + const isExpanded = panel.classList.contains('expanded'); + + if (isExpanded) { + panel.classList.remove('expanded'); + wrapper.classList.remove('expanded'); + button.classList.remove('expanded'); + button.innerHTML = '▶'; // Right arrow + } else { + if (!panel.dataset.populated) { + populateBytecodePanel(panel, button); + } + panel.classList.add('expanded'); + wrapper.classList.add('expanded'); + button.classList.add('expanded'); + button.innerHTML = '▼'; // Down arrow + } +} + +/** + * Populate bytecode panel with instruction data + * @param {HTMLElement} panel - The panel element to populate + * @param {HTMLElement} button - The button containing the bytecode data + */ +function populateBytecodePanel(panel, button) { + const bytecodeJson = button.getAttribute('data-bytecode'); + if (!bytecodeJson) return; + + // Get line number from parent + const lineDiv = button.closest('.code-line'); + const lineNum = lineDiv ? lineDiv.id.replace('line-', '') : null; + + try { + const instructions = JSON.parse(bytecodeJson); + if (!instructions.length) { + panel.innerHTML = '
No bytecode data
'; + panel.dataset.populated = 'true'; + return; + } + + const maxSamples = Math.max(...instructions.map(i => i.samples), 1); + + // Calculate specialization stats + const totalSamples = instructions.reduce((sum, i) => sum + i.samples, 0); + const specializedSamples = instructions + .filter(i => i.is_specialized) + .reduce((sum, i) => sum + i.samples, 0); + const specPct = totalSamples > 0 ? Math.round(100 * specializedSamples / totalSamples) : 0; + const specializedCount = instructions.filter(i => i.is_specialized).length; + + // Determine specialization level class + let specClass = 'low'; + if (specPct >= 67) specClass = 'high'; + else if (specPct >= 33) specClass = 'medium'; + + // Build specialization summary + const instruction_word = instructions.length === 1 ? 'instruction' : 'instructions'; + const sample_word = totalSamples === 1 ? 'sample' : 'samples'; + let html = `
+ ${specPct}% + specialized + (${specializedCount}/${instructions.length} ${instruction_word}, ${specializedSamples.toLocaleString()}/${totalSamples.toLocaleString()} ${sample_word}) +
`; + + html += '
' + + 'Instruction' + + 'Samples' + + 'Heat
'; + + for (const instr of instructions) { + const heatPct = (instr.samples / maxSamples) * 100; + const isHot = heatPct > 50; + const specializedClass = instr.is_specialized ? ' specialized' : ''; + const baseOpHtml = instr.is_specialized + ? `(${escapeHtml(instr.base_opname)})` : ''; + const badge = instr.is_specialized + ? 'SPECIALIZED' : ''; + + // Build location data attributes for cross-referencing with source spans + const hasLocations = instr.locations && instr.locations.length > 0; + const locationData = hasLocations + ? `data-locations='${JSON.stringify(instr.locations)}' data-line="${lineNum}" data-opcode="${instr.opcode}"` + : ''; + + html += `
+ ${escapeHtml(instr.opname)}${baseOpHtml}${badge} + ${instr.samples.toLocaleString()} +
+
`; + } + + panel.innerHTML = html; + panel.dataset.populated = 'true'; + + // Add hover handlers for bytecode instructions to highlight source spans + panel.querySelectorAll('.bytecode-instruction[data-locations]').forEach(instrEl => { + instrEl.addEventListener('mouseenter', highlightSourceFromBytecode); + instrEl.addEventListener('mouseleave', unhighlightSourceFromBytecode); + }); + } catch (e) { + panel.innerHTML = '
Error loading bytecode
'; + console.error('Error parsing bytecode data:', e); + } +} + +/** + * Highlight source spans when hovering over bytecode instruction + */ +function highlightSourceFromBytecode(e) { + const instrEl = e.currentTarget; + const lineNum = instrEl.dataset.line; + const locationsStr = instrEl.dataset.locations; + + if (!lineNum) return; + + const lineDiv = document.getElementById(`line-${lineNum}`); + if (!lineDiv) return; + + // Parse locations and highlight matching spans by column range + try { + const locations = JSON.parse(locationsStr || '[]'); + const spans = lineDiv.querySelectorAll('.instr-span'); + spans.forEach(span => { + const spanStart = parseInt(span.dataset.colStart); + const spanEnd = parseInt(span.dataset.colEnd); + for (const loc of locations) { + // Match if span's range matches instruction's location + if (spanStart === loc.col_offset && spanEnd === loc.end_col_offset) { + span.classList.add('highlight-from-bytecode'); + break; + } + } + }); + } catch (err) { + console.error('Error parsing locations:', err); + } + + // Also highlight the instruction row itself + instrEl.classList.add('highlight'); +} + +/** + * Remove highlighting from source spans + */ +function unhighlightSourceFromBytecode(e) { + const instrEl = e.currentTarget; + const lineNum = instrEl.dataset.line; + + if (!lineNum) return; + + const lineDiv = document.getElementById(`line-${lineNum}`); + if (!lineDiv) return; + + const spans = lineDiv.querySelectorAll('.instr-span.highlight-from-bytecode'); + spans.forEach(span => { + span.classList.remove('highlight-from-bytecode'); + }); + + instrEl.classList.remove('highlight'); +} + +/** + * Escape HTML special characters + * @param {string} text - Text to escape + * @returns {string} Escaped HTML + */ +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +/** + * Toggle all bytecode panels at once + */ +function toggleAllBytecode() { + const buttons = document.querySelectorAll('.bytecode-toggle'); + if (buttons.length === 0) return; + + const someExpanded = Array.from(buttons).some(b => b.classList.contains('expanded')); + const expandAllBtn = document.getElementById('toggle-all-bytecode'); + + buttons.forEach(button => { + const isExpanded = button.classList.contains('expanded'); + if (someExpanded ? isExpanded : !isExpanded) { + toggleBytecode(button); + } + }); + + // Update the expand-all button state + if (expandAllBtn) { + expandAllBtn.classList.toggle('expanded', !someExpanded); + } +} + +// Keyboard shortcut: 'b' toggles all bytecode panels, Enter/Space activates toggle switches +document.addEventListener('keydown', function(e) { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') { + return; + } + if (e.key === 'b' && !e.ctrlKey && !e.altKey && !e.metaKey) { + toggleAllBytecode(); + } + if ((e.key === 'Enter' || e.key === ' ') && e.target.classList.contains('toggle-switch')) { + e.preventDefault(); + e.target.click(); + } +}); + +// Handle hash changes +window.addEventListener('hashchange', () => setTimeout(scrollToTargetLine, 50)); + +// Rebuild scroll marker on resize +window.addEventListener('resize', buildScrollMarker); diff --git a/Lib/profiling/sampling/_heatmap_assets/heatmap_index.js b/Lib/profiling/sampling/_heatmap_assets/heatmap_index.js new file mode 100644 index 000000000..db4b54850 --- /dev/null +++ b/Lib/profiling/sampling/_heatmap_assets/heatmap_index.js @@ -0,0 +1,104 @@ +// Tachyon Profiler - Heatmap Index JavaScript +// Index page specific functionality + +// ============================================================================ +// Heatmap Bar Coloring +// ============================================================================ + +function applyHeatmapBarColors() { + const bars = document.querySelectorAll('.heatmap-bar[data-intensity]'); + bars.forEach(bar => { + const intensity = parseFloat(bar.getAttribute('data-intensity')) || 0; + const color = intensityToColor(intensity); + bar.style.backgroundColor = color; + }); +} + +// ============================================================================ +// Theme Support +// ============================================================================ + +function toggleTheme() { + toggleAndSaveTheme(); + applyHeatmapBarColors(); +} + +// ============================================================================ +// Type Section Toggle (stdlib, project, etc) +// ============================================================================ + +function toggleTypeSection(header) { + const section = header.parentElement; + const content = section.querySelector('.type-content'); + const icon = header.querySelector('.type-icon'); + + if (content.style.display === 'none') { + content.style.display = 'block'; + icon.textContent = '\u25BC'; + } else { + content.style.display = 'none'; + icon.textContent = '\u25B6'; + } +} + +// ============================================================================ +// Folder Toggle +// ============================================================================ + +function toggleFolder(header) { + const folder = header.parentElement; + const content = folder.querySelector('.folder-content'); + const icon = header.querySelector('.folder-icon'); + + if (content.style.display === 'none') { + content.style.display = 'block'; + icon.textContent = '\u25BC'; + folder.classList.remove('collapsed'); + } else { + content.style.display = 'none'; + icon.textContent = '\u25B6'; + folder.classList.add('collapsed'); + } +} + +// ============================================================================ +// Expand/Collapse All +// ============================================================================ + +function expandAll() { + // Expand all type sections + document.querySelectorAll('.type-section').forEach(section => { + const content = section.querySelector('.type-content'); + const icon = section.querySelector('.type-icon'); + content.style.display = 'block'; + icon.textContent = '\u25BC'; + }); + + // Expand all folders + document.querySelectorAll('.folder-node').forEach(folder => { + const content = folder.querySelector('.folder-content'); + const icon = folder.querySelector('.folder-icon'); + content.style.display = 'block'; + icon.textContent = '\u25BC'; + folder.classList.remove('collapsed'); + }); +} + +function collapseAll() { + document.querySelectorAll('.folder-node').forEach(folder => { + const content = folder.querySelector('.folder-content'); + const icon = folder.querySelector('.folder-icon'); + content.style.display = 'none'; + icon.textContent = '\u25B6'; + folder.classList.add('collapsed'); + }); +} + +// ============================================================================ +// Initialization +// ============================================================================ + +document.addEventListener('DOMContentLoaded', function() { + restoreUIState(); + applyHeatmapBarColors(); +}); diff --git a/Lib/profiling/sampling/_heatmap_assets/heatmap_index_template.html b/Lib/profiling/sampling/_heatmap_assets/heatmap_index_template.html new file mode 100644 index 000000000..8d04149ab --- /dev/null +++ b/Lib/profiling/sampling/_heatmap_assets/heatmap_index_template.html @@ -0,0 +1,136 @@ + + + + + + Tachyon Profiler - Heatmap Report + + + +
+ +
+
+ + Tachyon + + Heatmap Report +
+
+ + + + + + +
+
+ + +
+ +
+
+
📄
+
+ + Files Profiled +
+
+
+
+
📊
+
+ + Total Snapshots +
+
+
+
+
⏱
+
+ + Duration +
+
+
+
+
⚡
+
+ + Samples/sec +
+
+
+
+
+
+
⚠
+ Error Rate +
+ +
+
+
+
+
+
+
+
+
💥
+ Missed Samples +
+ +
+
+
+
+
+
+ + +
+

Profiled Files

+
+ +
+ + +
+ +
+ +
+
+ + +
+ + Tachyon Profiler + + + Python Sampling Profiler + +
+
+ + + + diff --git a/Lib/profiling/sampling/_heatmap_assets/heatmap_pyfile_template.html b/Lib/profiling/sampling/_heatmap_assets/heatmap_pyfile_template.html new file mode 100644 index 000000000..2a9c07647 --- /dev/null +++ b/Lib/profiling/sampling/_heatmap_assets/heatmap_pyfile_template.html @@ -0,0 +1,131 @@ + + + + + + <!-- FILENAME --> - Heatmap + + + +
+ +
+
+ + Tachyon + + +
+
+ + + + + + + + + + + +
+
+ + +
+
+
+
+
Self Samples
+
+
+
+
Cumulative
+
+
+
+
Lines Hit
+
+
+
%
+
% of Total
+
+
+
+
Max Self
+
+
+
+
Max Total
+
+
+
+ + +
+
+ Intensity: +
+
+ Cold + + Hot +
+ +
+
+ Self Time +
+ Total Time +
+
+ Show All +
+ Hot Only +
+
+ Heat +
+ Specialization +
+ + +
+
+
+ + +
+
+
Line
+
Self
+
Total
+
Code
+
+ +
+
+ + + + diff --git a/Lib/profiling/sampling/_heatmap_assets/heatmap_shared.js b/Lib/profiling/sampling/_heatmap_assets/heatmap_shared.js new file mode 100644 index 000000000..fb7613358 --- /dev/null +++ b/Lib/profiling/sampling/_heatmap_assets/heatmap_shared.js @@ -0,0 +1,64 @@ +// Tachyon Profiler - Shared Heatmap JavaScript +// Common utilities shared between index and file views + +// ============================================================================ +// Heat Level Mapping (Single source of truth for intensity thresholds) +// ============================================================================ + +// Maps intensity (0-1) to heat level (0-8). Level 0 = no heat, 1-8 = heat levels. +function intensityToHeatLevel(intensity) { + if (intensity <= 0) return 0; + if (intensity <= 0.125) return 1; + if (intensity <= 0.25) return 2; + if (intensity <= 0.375) return 3; + if (intensity <= 0.5) return 4; + if (intensity <= 0.625) return 5; + if (intensity <= 0.75) return 6; + if (intensity <= 0.875) return 7; + return 8; +} + +// Class names corresponding to heat levels 1-8 (used by scroll marker) +const HEAT_CLASS_NAMES = ['cold', 'cool', 'mild', 'warm', 'hot', 'very-hot', 'intense', 'extreme']; + +function intensityToClass(intensity) { + const level = intensityToHeatLevel(intensity); + return level === 0 ? null : HEAT_CLASS_NAMES[level - 1]; +} + +// ============================================================================ +// Color Mapping (Intensity to Heat Color) +// ============================================================================ + +function intensityToColor(intensity) { + const level = intensityToHeatLevel(intensity); + if (level === 0) { + return 'transparent'; + } + const rootStyle = getComputedStyle(document.documentElement); + return rootStyle.getPropertyValue(`--heat-${level}`).trim(); +} + +// ============================================================================ +// Theme Support +// ============================================================================ + +// Restore theme from localStorage, or use browser preference +function restoreUIState() { + applyTheme(getPreferredTheme()); +} + +// ============================================================================ +// Favicon (Reuse logo image as favicon) +// ============================================================================ + +(function() { + const logo = document.querySelector('.brand-logo img'); + if (logo) { + const favicon = document.createElement('link'); + favicon.rel = 'icon'; + favicon.type = 'image/png'; + favicon.href = logo.src; + document.head.appendChild(favicon); + } +})(); diff --git a/Lib/profiling/sampling/_shared_assets/base.css b/Lib/profiling/sampling/_shared_assets/base.css new file mode 100644 index 000000000..2164f3f3a --- /dev/null +++ b/Lib/profiling/sampling/_shared_assets/base.css @@ -0,0 +1,514 @@ +/* ========================================================================== + Python Profiler - Shared CSS Foundation + Design system shared between Flamegraph and Heatmap viewers + ========================================================================== */ + +/* -------------------------------------------------------------------------- + CSS Variables & Theme System + -------------------------------------------------------------------------- */ + +:root { + /* Typography */ + --font-sans: "Source Sans Pro", "Lucida Grande", "Lucida Sans Unicode", + "Geneva", "Verdana", sans-serif; + --font-mono: 'SF Mono', 'Monaco', 'Consolas', 'Liberation Mono', monospace; + + /* Python brand colors (theme-independent) */ + --python-blue: #3776ab; + --python-blue-light: #4584bb; + --python-blue-lighter: #5592cc; + --python-gold: #ffd43b; + --python-gold-dark: #ffcd02; + --python-gold-light: #ffdc5c; + + /* Heat palette - defined per theme below */ + + /* Layout */ + --sidebar-width: 280px; + --sidebar-collapsed: 44px; + --topbar-height: 56px; + --statusbar-height: 32px; + + /* Border radius */ + --radius-sm: 4px; + --radius-md: 8px; + --radius-lg: 12px; + + /* Transitions */ + --transition-fast: 0.15s ease; + --transition-normal: 0.25s ease; + --transition-slow: 0.3s ease; +} + +/* Light theme (default) */ +:root, [data-theme="light"] { + --bg-primary: #ffffff; + --bg-secondary: #f8f9fa; + --bg-tertiary: #e9ecef; + --border: #e9ecef; + --border-subtle: #f0f2f5; + + --text-primary: #2e3338; + --text-secondary: #5a6c7d; + --text-muted: #6f767e; + + --accent: #3776ab; + --accent-hover: #2d5aa0; + --accent-glow: rgba(55, 118, 171, 0.15); + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.08); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.15); + + --header-gradient: linear-gradient(135deg, #3776ab 0%, #4584bb 100%); + + /* Light mode heat palette - blue to yellow to orange to red (cold to hot) */ + --heat-1: #7ba3d1; + --heat-2: #a8d0ef; + --heat-3: #d6e9f8; + --heat-4: #ffe6a8; + --heat-5: #ffd43b; + --heat-6: #ffb84d; + --heat-7: #ff9966; + --heat-8: #ff6347; + + /* Code view specific */ + --code-bg: #ffffff; + --code-bg-line: #f8f9fa; + --code-border: #e9ecef; + --code-text: #2e3338; + --code-text-muted: #8b949e; + --code-accent: #3776ab; + + /* Navigation colors */ + --nav-caller: #2563eb; + --nav-caller-hover: #1d4ed8; + --nav-callee: #dc2626; + --nav-callee-hover: #b91c1c; + + /* Specialization status colors */ + --spec-high: #4caf50; + --spec-high-text: #2e7d32; + --spec-high-bg: rgba(76, 175, 80, 0.15); + --spec-medium: #ff9800; + --spec-medium-text: #e65100; + --spec-medium-bg: rgba(255, 152, 0, 0.15); + --spec-low: #9e9e9e; + --spec-low-text: #616161; + --spec-low-bg: rgba(158, 158, 158, 0.15); + + /* Heatmap span highlighting colors */ + --span-hot-base: 255, 100, 50; + --span-cold-base: 150, 150, 150; + + /* Summary card colors - optimized for 4.5:1 contrast on light bg */ + --card-blue: 44, 102, 149; + --card-green: 26, 116, 49; + --card-yellow: 134, 100, 4; + --card-purple: 102, 57, 166; + --card-red: 180, 40, 50; + --card-orange: 166, 90, 0; +} + +/* Dark theme */ +[data-theme="dark"] { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --border: #30363d; + --border-subtle: #21262d; + + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --text-muted: #757e8a; + + --accent: #58a6ff; + --accent-hover: #79b8ff; + --accent-glow: rgba(88, 166, 255, 0.15); + + --shadow-sm: 0 1px 3px rgba(0, 0, 0, 0.3); + --shadow-md: 0 4px 12px rgba(0, 0, 0, 0.4); + --shadow-lg: 0 8px 24px rgba(0, 0, 0, 0.5); + + --header-gradient: linear-gradient(135deg, #21262d 0%, #30363d 100%); + + /* Dark mode heat palette - cool to warm gradient for visualization */ + --heat-1: rgba(90, 123, 167, 1); + --heat-2: rgba(106, 148, 168, 1); + --heat-3: rgba(122, 172, 172, 1); + --heat-4: rgba(142, 196, 152, 1); + --heat-5: rgba(168, 216, 136, 1); + --heat-6: rgba(200, 222, 122, 1); + --heat-7: rgba(244, 212, 93, 1); + --heat-8: rgba(255, 122, 69, 1); + + /* Code view specific - dark mode */ + --code-bg: #0d1117; + --code-bg-line: #161b22; + --code-border: #30363d; + --code-text: #e6edf3; + --code-text-muted: #6e7681; + --code-accent: #58a6ff; + + /* Navigation colors - dark theme friendly */ + --nav-caller: #58a6ff; + --nav-caller-hover: #4184e4; + --nav-callee: #f87171; + --nav-callee-hover: #e53e3e; + + /* Specialization status colors - dark theme */ + --spec-high: #81c784; + --spec-high-text: #81c784; + --spec-high-bg: rgba(129, 199, 132, 0.2); + --spec-medium: #ffb74d; + --spec-medium-text: #ffb74d; + --spec-medium-bg: rgba(255, 183, 77, 0.2); + --spec-low: #bdbdbd; + --spec-low-text: #9e9e9e; + --spec-low-bg: rgba(189, 189, 189, 0.15); + + /* Heatmap span highlighting colors - dark theme */ + --span-hot-base: 255, 107, 53; + --span-cold-base: 189, 189, 189; + + /* Summary card colors - optimized for 4.5:1 contrast on dark bg */ + --card-blue: 88, 166, 255; + --card-green: 63, 185, 80; + --card-yellow: 210, 153, 34; + --card-purple: 163, 113, 247; + --card-red: 248, 113, 113; + --card-orange: 251, 146, 60; +} + +/* -------------------------------------------------------------------------- + Base Styles + -------------------------------------------------------------------------- */ + +*, *::before, *::after { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; +} + +body { + font-family: var(--font-sans); + font-size: 14px; + line-height: 1.6; + color: var(--text-primary); + background: var(--bg-primary); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + transition: background var(--transition-normal), color var(--transition-normal); +} + +/* -------------------------------------------------------------------------- + Layout Structure + -------------------------------------------------------------------------- */ + +.app-layout { + display: flex; + flex-direction: column; +} + +/* -------------------------------------------------------------------------- + Top Bar + -------------------------------------------------------------------------- */ + +.top-bar { + height: var(--topbar-height); + background: var(--header-gradient); + display: flex; + align-items: center; + padding: 0 16px; + gap: 16px; + flex-shrink: 0; + box-shadow: 0 2px 10px rgba(55, 118, 171, 0.25); + border-bottom: 2px solid var(--python-gold); +} + +/* Brand / Logo */ +.brand { + display: flex; + align-items: center; + gap: 12px; + color: white; + text-decoration: none; + flex-shrink: 0; +} + +.brand-logo { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 40px; + flex-shrink: 0; +} + +/* Style the inlined SVG/img inside brand-logo */ +.brand-logo svg, +.brand-logo img { + width: 48px; + height: 40px; + display: block; + object-fit: contain; + filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.2)); +} + +.brand-info { + display: flex; + flex-direction: column; + line-height: 1.15; +} + +.brand-text { + font-family: var(--font-sans); + font-weight: 700; + font-size: 16px; + letter-spacing: -0.3px; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.15); + color: inherit; + text-decoration: none; +} + +.brand-subtitle { + font-weight: 500; + font-size: 10px; + opacity: 0.9; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.brand-divider { + width: 1px; + height: 16px; + background: rgba(255, 255, 255, 0.3); +} + +/* Toolbar */ +.toolbar { + display: flex; + align-items: center; + gap: 6px; + margin-left: auto; +} + +.toolbar-btn { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + padding: 0; + font-size: 15px; + color: white; + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 6px; + cursor: pointer; + text-decoration: none; + transition: all var(--transition-fast); +} + +.toolbar-btn:hover { + background: rgba(255, 255, 255, 0.22); + border-color: rgba(255, 255, 255, 0.35); +} + +.toolbar-btn:active { + transform: scale(0.95); +} + +/* -------------------------------------------------------------------------- + Status Bar + -------------------------------------------------------------------------- */ + +.status-bar { + height: var(--statusbar-height); + background: var(--bg-secondary); + border-top: 1px solid var(--border); + display: flex; + align-items: center; + padding: 0 16px; + gap: 16px; + font-family: var(--font-mono); + font-size: 11px; + color: var(--text-secondary); + flex-shrink: 0; +} + +.status-item { + display: flex; + align-items: center; + gap: 5px; +} + +.status-item::before { + content: ''; + width: 4px; + height: 4px; + background: var(--python-gold); + border-radius: 50%; +} + +.status-item:first-child::before { + display: none; +} + +.status-label { + color: var(--text-muted); +} + +.status-value { + color: var(--text-primary); + font-weight: 500; +} + +.status-value.accent { + color: var(--accent); + font-weight: 600; +} + +/* -------------------------------------------------------------------------- + Animations + -------------------------------------------------------------------------- */ + +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(12px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes shimmer { + 0% { left: -100%; } + 100% { left: 100%; } +} + +/* -------------------------------------------------------------------------- + Focus States (Accessibility) + -------------------------------------------------------------------------- */ + +button:focus-visible, +select:focus-visible, +input:focus-visible, +.toggle-switch:focus-visible, +a.toolbar-btn:focus-visible { + outline: 2px solid var(--python-gold); + outline-offset: 2px; +} + +/* -------------------------------------------------------------------------- + Shared Responsive + -------------------------------------------------------------------------- */ + +@media (max-width: 900px) { + .brand-subtitle { + display: none; + } +} + +@media (max-width: 600px) { + .toolbar-btn:not(.theme-toggle) { + display: none; + } +} + +/* -------------------------------------------------------------------------- + Toggle Switch + -------------------------------------------------------------------------- */ + +.toggle-switch { + display: inline-flex; + align-items: center; + gap: 8px; + cursor: pointer; + user-select: none; + font-family: var(--font-sans); + transition: opacity var(--transition-fast); + flex-shrink: 0; +} + +.toggle-switch:hover { + opacity: 0.85; +} + +.toggle-switch .toggle-label { + font-size: 11px; + font-weight: 500; + color: var(--text-muted); + transition: color var(--transition-fast); + white-space: nowrap; + display: inline-flex; + flex-direction: column; +} + +.toggle-switch .toggle-label.active { + color: var(--text-primary); + font-weight: 600; +} + +/* Reserve space for bold text to prevent layout shift on toggle */ +.toggle-switch .toggle-label::after { + content: attr(data-text); + font-weight: 600; + height: 0; + visibility: hidden; +} + +.toggle-switch.disabled { + opacity: 0.4; + pointer-events: none; + cursor: not-allowed; +} + +.toggle-track { + position: relative; + width: 36px; + height: 20px; + background: var(--bg-tertiary); + border: 2px solid var(--border); + border-radius: 12px; + transition: all var(--transition-fast); + box-shadow: inset var(--shadow-sm); +} + +.toggle-track:hover { + border-color: var(--text-muted); +} + +.toggle-track.on { + background: var(--accent); + border-color: var(--accent); + box-shadow: 0 0 8px var(--accent-glow); +} + +.toggle-track::after { + content: ''; + position: absolute; + top: 1px; + left: 1px; + width: 14px; + height: 14px; + background: white; + border-radius: 50%; + box-shadow: var(--shadow-sm); + transition: all var(--transition-fast); +} + +.toggle-track.on::after { + transform: translateX(16px); + box-shadow: var(--shadow-md); +} diff --git a/Lib/profiling/sampling/_shared_assets/base.js b/Lib/profiling/sampling/_shared_assets/base.js new file mode 100644 index 000000000..da8b5851c --- /dev/null +++ b/Lib/profiling/sampling/_shared_assets/base.js @@ -0,0 +1,58 @@ +// Tachyon Profiler - Shared JavaScript +// Common utilities shared between flamegraph and heatmap views + +// ============================================================================ +// Theme Support +// ============================================================================ + +// Storage key for theme preference +const THEME_STORAGE_KEY = 'tachyon-theme'; + +// Get the preferred theme from localStorage or system preference +function getPreferredTheme() { + const saved = localStorage.getItem(THEME_STORAGE_KEY); + if (saved) return saved; + return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; +} + +// Apply theme and update UI +function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + const btn = document.getElementById('theme-btn'); + if (btn) { + const moonIcon = btn.querySelector('.icon-moon'); + const sunIcon = btn.querySelector('.icon-sun'); + if (moonIcon) moonIcon.style.display = theme === 'dark' ? 'none' : ''; + if (sunIcon) sunIcon.style.display = theme === 'dark' ? '' : 'none'; + } +} + +// Toggle theme and save preference. Returns the new theme. +function toggleAndSaveTheme() { + const current = document.documentElement.getAttribute('data-theme') || 'light'; + const next = current === 'light' ? 'dark' : 'light'; + applyTheme(next); + localStorage.setItem(THEME_STORAGE_KEY, next); + return next; +} + +// ============================================================================ +// Toggle Switch UI +// ============================================================================ + +function updateToggleUI(toggleId, isOn) { + const toggle = document.getElementById(toggleId); + if (toggle) { + const track = toggle.querySelector('.toggle-track'); + const labels = toggle.querySelectorAll('.toggle-label'); + if (isOn) { + track.classList.add('on'); + labels[0].classList.remove('active'); + labels[1].classList.add('active'); + } else { + track.classList.remove('on'); + labels[0].classList.add('active'); + labels[1].classList.remove('active'); + } + } +} diff --git a/Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph-tooltip.min.js b/Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph-tooltip.min.js new file mode 100644 index 000000000..746843709 --- /dev/null +++ b/Lib/profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph-tooltip.min.js @@ -0,0 +1 @@ +!function(t,n){"object"==typeof exports&&"object"==typeof module?module.exports=n():"function"==typeof define&&define.amd?define([],n):"object"==typeof exports?exports.flamegraph=n():(t.flamegraph=t.flamegraph||{},t.flamegraph.tooltip=n())}(self,(function(){return(()=>{"use strict";var t={d:(n,e)=>{for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},o:(t,n)=>Object.prototype.hasOwnProperty.call(t,n),r:t=>{"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(t,"__esModule",{value:!0})}},n={};function e(){}function r(t){return null==t?e:function(){return this.querySelector(t)}}function i(t){return null==t?[]:Array.isArray(t)?t:Array.from(t)}function o(){return[]}function u(t){return null==t?o:function(){return this.querySelectorAll(t)}}function a(t){return function(){return this.matches(t)}}function s(t){return function(n){return n.matches(t)}}t.r(n),t.d(n,{defaultFlamegraphTooltip:()=>Ae});var l=Array.prototype.find;function c(){return this.firstElementChild}var f=Array.prototype.filter;function h(){return Array.from(this.children)}function p(t){return new Array(t.length)}function d(t,n){this.ownerDocument=t.ownerDocument,this.namespaceURI=t.namespaceURI,this._next=null,this._parent=t,this.__data__=n}function y(t){return function(){return t}}function v(t,n,e,r,i,o){for(var u,a=0,s=n.length,l=o.length;an?1:t>=n?0:NaN}d.prototype={constructor:d,appendChild:function(t){return this._parent.insertBefore(t,this._next)},insertBefore:function(t,n){return this._parent.insertBefore(t,n)},querySelector:function(t){return this._parent.querySelector(t)},querySelectorAll:function(t){return this._parent.querySelectorAll(t)}};var b="http://www.w3.org/1999/xhtml";const x={svg:"http://www.w3.org/2000/svg",xhtml:b,xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/"};function A(t){var n=t+="",e=n.indexOf(":");return e>=0&&"xmlns"!==(n=t.slice(0,e))&&(t=t.slice(e+1)),x.hasOwnProperty(n)?{space:x[n],local:t}:t}function N(t){return function(){this.removeAttribute(t)}}function k(t){return function(){this.removeAttributeNS(t.space,t.local)}}function M(t,n){return function(){this.setAttribute(t,n)}}function E(t,n){return function(){this.setAttributeNS(t.space,t.local,n)}}function S(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttribute(t):this.setAttribute(t,e)}}function C(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,e)}}function P(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}function O(t){return function(){this.style.removeProperty(t)}}function q(t,n,e){return function(){this.style.setProperty(t,n,e)}}function j(t,n,e){return function(){var r=n.apply(this,arguments);null==r?this.style.removeProperty(t):this.style.setProperty(t,r,e)}}function T(t,n){return t.style.getPropertyValue(n)||P(t).getComputedStyle(t,null).getPropertyValue(n)}function X(t){return function(){delete this[t]}}function R(t,n){return function(){this[t]=n}}function I(t,n){return function(){var e=n.apply(this,arguments);null==e?delete this[t]:this[t]=e}}function L(t){return t.trim().split(/^|\s+/)}function D(t){return t.classList||new H(t)}function H(t){this._node=t,this._names=L(t.getAttribute("class")||"")}function Y(t,n){for(var e=D(t),r=-1,i=n.length;++r=0&&(n=t.slice(e+1),t=t.slice(0,e)),{type:t,name:n}}))}function st(t){return function(){var n=this.__on;if(n){for(var e,r=0,i=-1,o=n.length;r=0&&(this._names.splice(n,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};var pt=[null];function dt(t,n){this._groups=t,this._parents=n}function yt(){return new dt([[document.documentElement]],pt)}dt.prototype=yt.prototype={constructor:dt,select:function(t){"function"!=typeof t&&(t=r(t));for(var n=this._groups,e=n.length,i=new Array(e),o=0;o=M&&(M=k+1);!(N=b[M])&&++M=0;)(r=i[o])&&(u&&4^r.compareDocumentPosition(u)&&u.parentNode.insertBefore(r,u),u=r);return this},sort:function(t){function n(n,e){return n&&e?t(n.__data__,e.__data__):!n-!e}t||(t=w);for(var e=this._groups,r=e.length,i=new Array(r),o=0;o1?this.each((null==n?O:"function"==typeof n?j:q)(t,n,null==e?"":e)):T(this.node(),t)},property:function(t,n){return arguments.length>1?this.each((null==n?X:"function"==typeof n?I:R)(t,n)):this.node()[t]},classed:function(t,n){var e=L(t+"");if(arguments.length<2){for(var r=D(this.node()),i=-1,o=e.length;++i{}};function _t(){for(var t,n=0,e=arguments.length,r={};n=0&&(e=t.slice(r+1),t=t.slice(0,r)),t&&!n.hasOwnProperty(t))throw new Error("unknown type: "+t);return{type:t,name:e}}))}function bt(t,n){for(var e,r=0,i=t.length;r0)for(var e,r,i=new Array(e),o=0;o=0&&n._call.call(void 0,t),n=n._next;--Mt}()}finally{Mt=0,function(){var t,n,e=Nt,r=1/0;for(;e;)e._call?(r>e._time&&(r=e._time),t=e,e=e._next):(n=e._next,e._next=null,e=t?t._next=n:Nt=n);kt=t,Ht(r)}(),Pt=0}}function Dt(){var t=qt.now(),n=t-Ct;n>1e3&&(Ot-=n,Ct=t)}function Ht(t){Mt||(Et&&(Et=clearTimeout(Et)),t-Pt>24?(t<1/0&&(Et=setTimeout(Lt,t-qt.now()-Ot)),St&&(St=clearInterval(St))):(St||(Ct=qt.now(),St=setInterval(Dt,1e3)),Mt=1,jt(Lt)))}function Yt(t,n,e){var r=new Rt;return n=null==n?0:+n,r.restart((e=>{r.stop(),t(e+n)}),n,e),r}Rt.prototype=It.prototype={constructor:Rt,restart:function(t,n,e){if("function"!=typeof t)throw new TypeError("callback is not a function");e=(null==e?Tt():+e)+(null==n?0:+n),this._next||kt===this||(kt?kt._next=this:Nt=this,kt=this),this._call=t,this._time=e,Ht()},stop:function(){this._call&&(this._call=null,this._time=1/0,Ht())}};var Bt=At("start","end","cancel","interrupt"),$t=[];function Vt(t,n,e,r,i,o){var u=t.__transition;if(u){if(e in u)return}else t.__transition={};!function(t,n,e){var r,i=t.__transition;function o(t){e.state=1,e.timer.restart(u,e.delay,e.time),e.delay<=t&&u(t-e.delay)}function u(o){var l,c,f,h;if(1!==e.state)return s();for(l in i)if((h=i[l]).name===e.name){if(3===h.state)return Yt(u);4===h.state?(h.state=6,h.timer.stop(),h.on.call("interrupt",t,t.__data__,h.index,h.group),delete i[l]):+l0)throw new Error("too late; already scheduled");return e}function Ut(t,n){var e=Ft(t,n);if(e.state>3)throw new Error("too late; already running");return e}function Ft(t,n){var e=t.__transition;if(!e||!(e=e[n]))throw new Error("transition not found");return e}function Kt(t,n){return t=+t,n=+n,function(e){return t*(1-e)+n*e}}var Wt,Gt=180/Math.PI,Jt={translateX:0,translateY:0,rotate:0,skewX:0,scaleX:1,scaleY:1};function Qt(t,n,e,r,i,o){var u,a,s;return(u=Math.sqrt(t*t+n*n))&&(t/=u,n/=u),(s=t*e+n*r)&&(e-=t*s,r-=n*s),(a=Math.sqrt(e*e+r*r))&&(e/=a,r/=a,s/=a),t*r180?n+=360:n-t>180&&(t+=360),o.push({i:e.push(i(e)+"rotate(",null,r)-2,x:Kt(t,n)})):n&&e.push(i(e)+"rotate("+n+r)}(o.rotate,u.rotate,a,s),function(t,n,e,o){t!==n?o.push({i:e.push(i(e)+"skewX(",null,r)-2,x:Kt(t,n)}):n&&e.push(i(e)+"skewX("+n+r)}(o.skewX,u.skewX,a,s),function(t,n,e,r,o,u){if(t!==e||n!==r){var a=o.push(i(o)+"scale(",null,",",null,")");u.push({i:a-4,x:Kt(t,e)},{i:a-2,x:Kt(n,r)})}else 1===e&&1===r||o.push(i(o)+"scale("+e+","+r+")")}(o.scaleX,o.scaleY,u.scaleX,u.scaleY,a,s),o=u=null,function(t){for(var n,e=-1,r=s.length;++e>8&15|n>>4&240,n>>4&15|240&n,(15&n)<<4|15&n,1):8===e?Mn(n>>24&255,n>>16&255,n>>8&255,(255&n)/255):4===e?Mn(n>>12&15|n>>8&240,n>>8&15|n>>4&240,n>>4&15|240&n,((15&n)<<4|15&n)/255):null):(n=yn.exec(t))?new Cn(n[1],n[2],n[3],1):(n=vn.exec(t))?new Cn(255*n[1]/100,255*n[2]/100,255*n[3]/100,1):(n=gn.exec(t))?Mn(n[1],n[2],n[3],n[4]):(n=_n.exec(t))?Mn(255*n[1]/100,255*n[2]/100,255*n[3]/100,n[4]):(n=mn.exec(t))?jn(n[1],n[2]/100,n[3]/100,1):(n=wn.exec(t))?jn(n[1],n[2]/100,n[3]/100,n[4]):bn.hasOwnProperty(t)?kn(bn[t]):"transparent"===t?new Cn(NaN,NaN,NaN,0):null}function kn(t){return new Cn(t>>16&255,t>>8&255,255&t,1)}function Mn(t,n,e,r){return r<=0&&(t=n=e=NaN),new Cn(t,n,e,r)}function En(t){return t instanceof sn||(t=Nn(t)),t?new Cn((t=t.rgb()).r,t.g,t.b,t.opacity):new Cn}function Sn(t,n,e,r){return 1===arguments.length?En(t):new Cn(t,n,e,null==r?1:r)}function Cn(t,n,e,r){this.r=+t,this.g=+n,this.b=+e,this.opacity=+r}function Pn(){return"#"+qn(this.r)+qn(this.g)+qn(this.b)}function On(){var t=this.opacity;return(1===(t=isNaN(t)?1:Math.max(0,Math.min(1,t)))?"rgb(":"rgba(")+Math.max(0,Math.min(255,Math.round(this.r)||0))+", "+Math.max(0,Math.min(255,Math.round(this.g)||0))+", "+Math.max(0,Math.min(255,Math.round(this.b)||0))+(1===t?")":", "+t+")")}function qn(t){return((t=Math.max(0,Math.min(255,Math.round(t)||0)))<16?"0":"")+t.toString(16)}function jn(t,n,e,r){return r<=0?t=n=e=NaN:e<=0||e>=1?t=n=NaN:n<=0&&(t=NaN),new Xn(t,n,e,r)}function Tn(t){if(t instanceof Xn)return new Xn(t.h,t.s,t.l,t.opacity);if(t instanceof sn||(t=Nn(t)),!t)return new Xn;if(t instanceof Xn)return t;var n=(t=t.rgb()).r/255,e=t.g/255,r=t.b/255,i=Math.min(n,e,r),o=Math.max(n,e,r),u=NaN,a=o-i,s=(o+i)/2;return a?(u=n===o?(e-r)/a+6*(e0&&s<1?0:u,new Xn(u,a,s,t.opacity)}function Xn(t,n,e,r){this.h=+t,this.s=+n,this.l=+e,this.opacity=+r}function Rn(t,n,e){return 255*(t<60?n+(e-n)*t/60:t<180?e:t<240?n+(e-n)*(240-t)/60:n)}function In(t,n,e,r,i){var o=t*t,u=o*t;return((1-3*t+3*o-u)*n+(4-6*o+3*u)*e+(1+3*t+3*o-3*u)*r+u*i)/6}un(sn,Nn,{copy:function(t){return Object.assign(new this.constructor,this,t)},displayable:function(){return this.rgb().displayable()},hex:xn,formatHex:xn,formatHsl:function(){return Tn(this).formatHsl()},formatRgb:An,toString:An}),un(Cn,Sn,an(sn,{brighter:function(t){return t=null==t?cn:Math.pow(cn,t),new Cn(this.r*t,this.g*t,this.b*t,this.opacity)},darker:function(t){return t=null==t?ln:Math.pow(ln,t),new Cn(this.r*t,this.g*t,this.b*t,this.opacity)},rgb:function(){return this},displayable:function(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Pn,formatHex:Pn,formatRgb:On,toString:On})),un(Xn,(function(t,n,e,r){return 1===arguments.length?Tn(t):new Xn(t,n,e,null==r?1:r)}),an(sn,{brighter:function(t){return t=null==t?cn:Math.pow(cn,t),new Xn(this.h,this.s,this.l*t,this.opacity)},darker:function(t){return t=null==t?ln:Math.pow(ln,t),new Xn(this.h,this.s,this.l*t,this.opacity)},rgb:function(){var t=this.h%360+360*(this.h<0),n=isNaN(t)||isNaN(this.s)?0:this.s,e=this.l,r=e+(e<.5?e:1-e)*n,i=2*e-r;return new Cn(Rn(t>=240?t-240:t+120,i,r),Rn(t,i,r),Rn(t<120?t+240:t-120,i,r),this.opacity)},displayable:function(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl:function(){var t=this.opacity;return(1===(t=isNaN(t)?1:Math.max(0,Math.min(1,t)))?"hsl(":"hsla(")+(this.h||0)+", "+100*(this.s||0)+"%, "+100*(this.l||0)+"%"+(1===t?")":", "+t+")")}}));const Ln=t=>()=>t;function Dn(t,n){return function(e){return t+e*n}}function Hn(t){return 1==(t=+t)?Yn:function(n,e){return e-n?function(t,n,e){return t=Math.pow(t,e),n=Math.pow(n,e)-t,e=1/e,function(r){return Math.pow(t+r*n,e)}}(n,e,t):Ln(isNaN(n)?e:n)}}function Yn(t,n){var e=n-t;return e?Dn(t,e):Ln(isNaN(t)?n:t)}const Bn=function t(n){var e=Hn(n);function r(t,n){var r=e((t=Sn(t)).r,(n=Sn(n)).r),i=e(t.g,n.g),o=e(t.b,n.b),u=Yn(t.opacity,n.opacity);return function(n){return t.r=r(n),t.g=i(n),t.b=o(n),t.opacity=u(n),t+""}}return r.gamma=t,r}(1);function $n(t){return function(n){var e,r,i=n.length,o=new Array(i),u=new Array(i),a=new Array(i);for(e=0;e=1?(e=1,n-1):Math.floor(e*n),i=t[r],o=t[r+1],u=r>0?t[r-1]:2*i-o,a=ro&&(i=n.slice(o,i),a[u]?a[u]+=i:a[++u]=i),(e=e[0])===(r=r[0])?a[u]?a[u]+=r:a[++u]=r:(a[++u]=null,s.push({i:u,x:Kt(e,r)})),o=zn.lastIndex;return o=0&&(t=t.slice(0,n)),!t||"start"===t}))}(n)?zt:Ut;return function(){var u=o(this,t),a=u.on;a!==r&&(i=(r=a).copy()).on(n,e),u.on=i}}var ce=vt.prototype.constructor;function fe(t){return function(){this.style.removeProperty(t)}}function he(t,n,e){return function(r){this.style.setProperty(t,n.call(this,r),e)}}function pe(t,n,e){var r,i;function o(){var o=n.apply(this,arguments);return o!==i&&(r=(i=o)&&he(t,o,e)),r}return o._value=n,o}function de(t){return function(n){this.textContent=t.call(this,n)}}function ye(t){var n,e;function r(){var r=t.apply(this,arguments);return r!==e&&(n=(e=r)&&de(r)),n}return r._value=t,r}var ve=0;function ge(t,n,e,r){this._groups=t,this._parents=n,this._name=e,this._id=r}function _e(){return++ve}var me=vt.prototype;ge.prototype=function(t){return vt().transition(t)}.prototype={constructor:ge,select:function(t){var n=this._name,e=this._id;"function"!=typeof t&&(t=r(t));for(var i=this._groups,o=i.length,u=new Array(o),a=0;a2&&e.state<5,e.state=6,e.timer.stop(),e.on.call(r?"interrupt":"cancel",t,t.__data__,e.index,e.group),delete o[i]):u=!1;u&&delete t.__transition}}(this,t)}))},vt.prototype.transition=function(t){var n,e;t instanceof ge?(n=t._id,t=t._name):(n=_e(),(e=we).time=Tt(),t=null==t?null:t+"");for(var r=this._groups,i=r.length,o=0;o{"use strict";var t={d:(n,e)=>{for(var r in e)t.o(e,r)&&!t.o(n,r)&&Object.defineProperty(n,r,{enumerable:!0,get:e[r]})},o:(t,n)=>Object.prototype.hasOwnProperty.call(t,n)},n={};function e(){}function r(t){return null==t?e:function(){return this.querySelector(t)}}function i(t){return null==t?[]:Array.isArray(t)?t:Array.from(t)}function o(){return[]}function u(t){return null==t?o:function(){return this.querySelectorAll(t)}}function a(t){return function(){return this.matches(t)}}function l(t){return function(n){return n.matches(t)}}t.d(n,{default:()=>xr});var s=Array.prototype.find;function c(){return this.firstElementChild}var h=Array.prototype.filter;function f(){return Array.from(this.children)}function p(t){return new Array(t.length)}function d(t,n){this.ownerDocument=t.ownerDocument,this.namespaceURI=t.namespaceURI,this._next=null,this._parent=t,this.__data__=n}function g(t){return function(){return t}}function v(t,n,e,r,i,o){for(var u,a=0,l=n.length,s=o.length;an?1:t>=n?0:NaN}d.prototype={constructor:d,appendChild:function(t){return this._parent.insertBefore(t,this._next)},insertBefore:function(t,n){return this._parent.insertBefore(t,n)},querySelector:function(t){return this._parent.querySelector(t)},querySelectorAll:function(t){return this._parent.querySelectorAll(t)}};var b="http://www.w3.org/1999/xhtml";const x={svg:"http://www.w3.org/2000/svg",xhtml:b,xlink:"http://www.w3.org/1999/xlink",xml:"http://www.w3.org/XML/1998/namespace",xmlns:"http://www.w3.org/2000/xmlns/"};function M(t){var n=t+="",e=n.indexOf(":");return e>=0&&"xmlns"!==(n=t.slice(0,e))&&(t=t.slice(e+1)),x.hasOwnProperty(n)?{space:x[n],local:t}:t}function A(t){return function(){this.removeAttribute(t)}}function N(t){return function(){this.removeAttributeNS(t.space,t.local)}}function k(t,n){return function(){this.setAttribute(t,n)}}function E(t,n){return function(){this.setAttributeNS(t.space,t.local,n)}}function S(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttribute(t):this.setAttribute(t,e)}}function C(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,e)}}function j(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}function q(t){return function(){this.style.removeProperty(t)}}function O(t,n,e){return function(){this.style.setProperty(t,n,e)}}function P(t,n,e){return function(){var r=n.apply(this,arguments);null==r?this.style.removeProperty(t):this.style.setProperty(t,r,e)}}function H(t,n){return t.style.getPropertyValue(n)||j(t).getComputedStyle(t,null).getPropertyValue(n)}function L(t){return function(){delete this[t]}}function T(t,n){return function(){this[t]=n}}function D(t,n){return function(){var e=n.apply(this,arguments);null==e?delete this[t]:this[t]=e}}function B(t){return t.trim().split(/^|\s+/)}function X(t){return t.classList||new z(t)}function z(t){this._node=t,this._names=B(t.getAttribute("class")||"")}function R(t,n){for(var e=X(t),r=-1,i=n.length;++r=0&&(n=t.slice(e+1),t=t.slice(0,e)),{type:t,name:n}}))}function lt(t){return function(){var n=this.__on;if(n){for(var e,r=0,i=-1,o=n.length;r=0&&(this._names.splice(n,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};var pt=[null];function dt(t,n){this._groups=t,this._parents=n}function gt(){return new dt([[document.documentElement]],pt)}dt.prototype=gt.prototype={constructor:dt,select:function(t){"function"!=typeof t&&(t=r(t));for(var n=this._groups,e=n.length,i=new Array(e),o=0;o=k&&(k=N+1);!(A=b[k])&&++k=0;)(r=i[o])&&(u&&4^r.compareDocumentPosition(u)&&u.parentNode.insertBefore(r,u),u=r);return this},sort:function(t){function n(n,e){return n&&e?t(n.__data__,e.__data__):!n-!e}t||(t=_);for(var e=this._groups,r=e.length,i=new Array(r),o=0;o1?this.each((null==n?q:"function"==typeof n?P:O)(t,n,null==e?"":e)):H(this.node(),t)},property:function(t,n){return arguments.length>1?this.each((null==n?L:"function"==typeof n?D:T)(t,n)):this.node()[t]},classed:function(t,n){var e=B(t+"");if(arguments.length<2){for(var r=X(this.node()),i=-1,o=e.length;++i1?r[0]+r.slice(2):r,+t.slice(e+1)]}function wt(t){return(t=mt(Math.abs(t)))?t[1]:NaN}var _t,bt=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function xt(t){if(!(n=bt.exec(t)))throw new Error("invalid format: "+t);var n;return new Mt({fill:n[1],align:n[2],sign:n[3],symbol:n[4],zero:n[5],width:n[6],comma:n[7],precision:n[8]&&n[8].slice(1),trim:n[9],type:n[10]})}function Mt(t){this.fill=void 0===t.fill?" ":t.fill+"",this.align=void 0===t.align?">":t.align+"",this.sign=void 0===t.sign?"-":t.sign+"",this.symbol=void 0===t.symbol?"":t.symbol+"",this.zero=!!t.zero,this.width=void 0===t.width?void 0:+t.width,this.comma=!!t.comma,this.precision=void 0===t.precision?void 0:+t.precision,this.trim=!!t.trim,this.type=void 0===t.type?"":t.type+""}function At(t,n){var e=mt(t,n);if(!e)return t+"";var r=e[0],i=e[1];return i<0?"0."+new Array(-i).join("0")+r:r.length>i+1?r.slice(0,i+1)+"."+r.slice(i+1):r+new Array(i-r.length+2).join("0")}xt.prototype=Mt.prototype,Mt.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(void 0===this.width?"":Math.max(1,0|this.width))+(this.comma?",":"")+(void 0===this.precision?"":"."+Math.max(0,0|this.precision))+(this.trim?"~":"")+this.type};const Nt={"%":(t,n)=>(100*t).toFixed(n),b:t=>Math.round(t).toString(2),c:t=>t+"",d:function(t){return Math.abs(t=Math.round(t))>=1e21?t.toLocaleString("en").replace(/,/g,""):t.toString(10)},e:(t,n)=>t.toExponential(n),f:(t,n)=>t.toFixed(n),g:(t,n)=>t.toPrecision(n),o:t=>Math.round(t).toString(8),p:(t,n)=>At(100*t,n),r:At,s:function(t,n){var e=mt(t,n);if(!e)return t+"";var r=e[0],i=e[1],o=i-(_t=3*Math.max(-8,Math.min(8,Math.floor(i/3))))+1,u=r.length;return o===u?r:o>u?r+new Array(o-u+1).join("0"):o>0?r.slice(0,o)+"."+r.slice(o):"0."+new Array(1-o).join("0")+mt(t,Math.max(0,n+o-1))[0]},X:t=>Math.round(t).toString(16).toUpperCase(),x:t=>Math.round(t).toString(16)};function kt(t){return t}var Et,St,Ct,jt=Array.prototype.map,qt=["y","z","a","f","p","n","µ","m","","k","M","G","T","P","E","Z","Y"];function Ot(t){var n,e,r=void 0===t.grouping||void 0===t.thousands?kt:(n=jt.call(t.grouping,Number),e=t.thousands+"",function(t,r){for(var i=t.length,o=[],u=0,a=n[0],l=0;i>0&&a>0&&(l+a+1>r&&(a=Math.max(1,r-l)),o.push(t.substring(i-=a,i+a)),!((l+=a+1)>r));)a=n[u=(u+1)%n.length];return o.reverse().join(e)}),i=void 0===t.currency?"":t.currency[0]+"",o=void 0===t.currency?"":t.currency[1]+"",u=void 0===t.decimal?".":t.decimal+"",a=void 0===t.numerals?kt:function(t){return function(n){return n.replace(/[0-9]/g,(function(n){return t[+n]}))}}(jt.call(t.numerals,String)),l=void 0===t.percent?"%":t.percent+"",s=void 0===t.minus?"−":t.minus+"",c=void 0===t.nan?"NaN":t.nan+"";function h(t){var n=(t=xt(t)).fill,e=t.align,h=t.sign,f=t.symbol,p=t.zero,d=t.width,g=t.comma,v=t.precision,y=t.trim,m=t.type;"n"===m?(g=!0,m="g"):Nt[m]||(void 0===v&&(v=12),y=!0,m="g"),(p||"0"===n&&"="===e)&&(p=!0,n="0",e="=");var w="$"===f?i:"#"===f&&/[boxX]/.test(m)?"0"+m.toLowerCase():"",_="$"===f?o:/[%p]/.test(m)?l:"",b=Nt[m],x=/[defgprs%]/.test(m);function M(t){var i,o,l,f=w,M=_;if("c"===m)M=b(t)+M,t="";else{var A=(t=+t)<0||1/t<0;if(t=isNaN(t)?c:b(Math.abs(t),v),y&&(t=function(t){t:for(var n,e=t.length,r=1,i=-1;r0&&(i=0)}return i>0?t.slice(0,i)+t.slice(n+1):t}(t)),A&&0==+t&&"+"!==h&&(A=!1),f=(A?"("===h?h:s:"-"===h||"("===h?"":h)+f,M=("s"===m?qt[8+_t/3]:"")+M+(A&&"("===h?")":""),x)for(i=-1,o=t.length;++i(l=t.charCodeAt(i))||l>57){M=(46===l?u+t.slice(i+1):t.slice(i))+M,t=t.slice(0,i);break}}g&&!p&&(t=r(t,1/0));var N=f.length+t.length+M.length,k=N>1)+f+t+M+k.slice(N);break;default:t=k+f+t+M}return a(t)}return v=void 0===v?6:/[gprs]/.test(m)?Math.max(1,Math.min(21,v)):Math.max(0,Math.min(20,v)),M.toString=function(){return t+""},M}return{format:h,formatPrefix:function(t,n){var e=h(((t=xt(t)).type="f",t)),r=3*Math.max(-8,Math.min(8,Math.floor(wt(n)/3))),i=Math.pow(10,-r),o=qt[8+r/3];return function(t){return e(i*t)+o}}}}function Pt(t,n){return null==t||null==n?NaN:tn?1:t>=n?0:NaN}function Ht(t){t.x0=Math.round(t.x0),t.y0=Math.round(t.y0),t.x1=Math.round(t.x1),t.y1=Math.round(t.y1)}function Lt(){var t=1,n=1,e=0,r=!1;function i(i){var o=i.height+1;return i.x0=i.y0=e,i.x1=t,i.y1=n/o,i.eachBefore(function(t,n){return function(r){r.children&&function(t,n,e,r,i){for(var o,u=t.children,a=-1,l=u.length,s=t.value&&(r-n)/t.value;++a=0;)n+=e[r].value;else n=1;t.value=n}function Dt(t,n){t instanceof Map?(t=[void 0,t],void 0===n&&(n=Xt)):void 0===n&&(n=Bt);for(var e,r,i,o,u,a=new It(t),l=[a];e=l.pop();)if((i=n(e.data))&&(u=(i=Array.from(i)).length))for(e.children=i,o=u-1;o>=0;--o)l.push(r=i[o]=new It(i[o])),r.parent=e,r.depth=e.depth+1;return a.eachBefore(Rt)}function Bt(t){return t.children}function Xt(t){return Array.isArray(t)?t[1]:null}function zt(t){void 0!==t.data.value&&(t.value=t.data.value),t.data=t.data.data}function Rt(t){var n=0;do{t.height=n}while((t=t.parent)&&t.height<++n)}function It(t){this.data=t,this.depth=this.height=0,this.parent=null}Et=Ot({thousands:",",grouping:[3],currency:["$",""]}),St=Et.format,Ct=Et.formatPrefix,It.prototype=Dt.prototype={constructor:It,count:function(){return this.eachAfter(Tt)},each:function(t,n){let e=-1;for(const r of this)t.call(n,r,++e,this);return this},eachAfter:function(t,n){for(var e,r,i,o=this,u=[o],a=[],l=-1;o=u.pop();)if(a.push(o),e=o.children)for(r=0,i=e.length;r=0;--r)o.push(e[r]);return this},find:function(t,n){let e=-1;for(const r of this)if(t.call(n,r,++e,this))return r},sum:function(t){return this.eachAfter((function(n){for(var e=+t(n.data)||0,r=n.children,i=r&&r.length;--i>=0;)e+=r[i].value;n.value=e}))},sort:function(t){return this.eachBefore((function(n){n.children&&n.children.sort(t)}))},path:function(t){for(var n=this,e=function(t,n){if(t===n)return t;var e=t.ancestors(),r=n.ancestors(),i=null;t=e.pop(),n=r.pop();for(;t===n;)i=t,t=e.pop(),n=r.pop();return i}(n,t),r=[n];n!==e;)n=n.parent,r.push(n);for(var i=r.length;t!==e;)r.splice(i,0,t),t=t.parent;return r},ancestors:function(){for(var t=this,n=[t];t=t.parent;)n.push(t);return n},descendants:function(){return Array.from(this)},leaves:function(){var t=[];return this.eachBefore((function(n){n.children||t.push(n)})),t},links:function(){var t=this,n=[];return t.each((function(e){e!==t&&n.push({source:e.parent,target:e})})),n},copy:function(){return Dt(this).eachBefore(zt)},[Symbol.iterator]:function*(){var t,n,e,r,i=this,o=[i];do{for(t=o.reverse(),o=[];i=t.pop();)if(yield i,n=i.children)for(e=0,r=n.length;e=0?(o>=$t?10:o>=Vt?5:o>=Yt?2:1)*Math.pow(10,i):-Math.pow(10,-i)/(o>=$t?10:o>=Vt?5:o>=Yt?2:1)}function Ut(t){let n=t,e=t,r=t;function i(t,n,i=0,o=t.length){if(i>>1;r(t[e],n)<0?i=e+1:o=e}while(it(n)-e,e=Pt,r=(n,e)=>Pt(t(n),e)),{left:i,center:function(t,e,r=0,o=t.length){const u=i(t,e,r,o-1);return u>r&&n(t[u-1],e)>-n(t[u],e)?u-1:u},right:function(t,n,i=0,o=t.length){if(i>>1;r(t[e],n)<=0?i=e+1:o=e}while(i>8&15|n>>4&240,n>>4&15|240&n,(15&n)<<4|15&n,1):8===e?mn(n>>24&255,n>>16&255,n>>8&255,(255&n)/255):4===e?mn(n>>12&15|n>>8&240,n>>8&15|n>>4&240,n>>4&15|240&n,((15&n)<<4|15&n)/255):null):(n=an.exec(t))?new bn(n[1],n[2],n[3],1):(n=ln.exec(t))?new bn(255*n[1]/100,255*n[2]/100,255*n[3]/100,1):(n=sn.exec(t))?mn(n[1],n[2],n[3],n[4]):(n=cn.exec(t))?mn(255*n[1]/100,255*n[2]/100,255*n[3]/100,n[4]):(n=hn.exec(t))?Nn(n[1],n[2]/100,n[3]/100,1):(n=fn.exec(t))?Nn(n[1],n[2]/100,n[3]/100,n[4]):pn.hasOwnProperty(t)?yn(pn[t]):"transparent"===t?new bn(NaN,NaN,NaN,0):null}function yn(t){return new bn(t>>16&255,t>>8&255,255&t,1)}function mn(t,n,e,r){return r<=0&&(t=n=e=NaN),new bn(t,n,e,r)}function wn(t){return t instanceof Qt||(t=vn(t)),t?new bn((t=t.rgb()).r,t.g,t.b,t.opacity):new bn}function _n(t,n,e,r){return 1===arguments.length?wn(t):new bn(t,n,e,null==r?1:r)}function bn(t,n,e,r){this.r=+t,this.g=+n,this.b=+e,this.opacity=+r}function xn(){return"#"+An(this.r)+An(this.g)+An(this.b)}function Mn(){var t=this.opacity;return(1===(t=isNaN(t)?1:Math.max(0,Math.min(1,t)))?"rgb(":"rgba(")+Math.max(0,Math.min(255,Math.round(this.r)||0))+", "+Math.max(0,Math.min(255,Math.round(this.g)||0))+", "+Math.max(0,Math.min(255,Math.round(this.b)||0))+(1===t?")":", "+t+")")}function An(t){return((t=Math.max(0,Math.min(255,Math.round(t)||0)))<16?"0":"")+t.toString(16)}function Nn(t,n,e,r){return r<=0?t=n=e=NaN:e<=0||e>=1?t=n=NaN:n<=0&&(t=NaN),new En(t,n,e,r)}function kn(t){if(t instanceof En)return new En(t.h,t.s,t.l,t.opacity);if(t instanceof Qt||(t=vn(t)),!t)return new En;if(t instanceof En)return t;var n=(t=t.rgb()).r/255,e=t.g/255,r=t.b/255,i=Math.min(n,e,r),o=Math.max(n,e,r),u=NaN,a=o-i,l=(o+i)/2;return a?(u=n===o?(e-r)/a+6*(e0&&l<1?0:u,new En(u,a,l,t.opacity)}function En(t,n,e,r){this.h=+t,this.s=+n,this.l=+e,this.opacity=+r}function Sn(t,n,e){return 255*(t<60?n+(e-n)*t/60:t<180?e:t<240?n+(e-n)*(240-t)/60:n)}function Cn(t,n,e,r,i){var o=t*t,u=o*t;return((1-3*t+3*o-u)*n+(4-6*o+3*u)*e+(1+3*t+3*o-3*u)*r+u*i)/6}Wt(Qt,vn,{copy:function(t){return Object.assign(new this.constructor,this,t)},displayable:function(){return this.rgb().displayable()},hex:dn,formatHex:dn,formatHsl:function(){return kn(this).formatHsl()},formatRgb:gn,toString:gn}),Wt(bn,_n,Jt(Qt,{brighter:function(t){return t=null==t?nn:Math.pow(nn,t),new bn(this.r*t,this.g*t,this.b*t,this.opacity)},darker:function(t){return t=null==t?tn:Math.pow(tn,t),new bn(this.r*t,this.g*t,this.b*t,this.opacity)},rgb:function(){return this},displayable:function(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:xn,formatHex:xn,formatRgb:Mn,toString:Mn})),Wt(En,(function(t,n,e,r){return 1===arguments.length?kn(t):new En(t,n,e,null==r?1:r)}),Jt(Qt,{brighter:function(t){return t=null==t?nn:Math.pow(nn,t),new En(this.h,this.s,this.l*t,this.opacity)},darker:function(t){return t=null==t?tn:Math.pow(tn,t),new En(this.h,this.s,this.l*t,this.opacity)},rgb:function(){var t=this.h%360+360*(this.h<0),n=isNaN(t)||isNaN(this.s)?0:this.s,e=this.l,r=e+(e<.5?e:1-e)*n,i=2*e-r;return new bn(Sn(t>=240?t-240:t+120,i,r),Sn(t,i,r),Sn(t<120?t+240:t-120,i,r),this.opacity)},displayable:function(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl:function(){var t=this.opacity;return(1===(t=isNaN(t)?1:Math.max(0,Math.min(1,t)))?"hsl(":"hsla(")+(this.h||0)+", "+100*(this.s||0)+"%, "+100*(this.l||0)+"%"+(1===t?")":", "+t+")")}}));const jn=t=>()=>t;function qn(t,n){return function(e){return t+e*n}}function On(t){return 1==(t=+t)?Pn:function(n,e){return e-n?function(t,n,e){return t=Math.pow(t,e),n=Math.pow(n,e)-t,e=1/e,function(r){return Math.pow(t+r*n,e)}}(n,e,t):jn(isNaN(n)?e:n)}}function Pn(t,n){var e=n-t;return e?qn(t,e):jn(isNaN(t)?n:t)}const Hn=function t(n){var e=On(n);function r(t,n){var r=e((t=_n(t)).r,(n=_n(n)).r),i=e(t.g,n.g),o=e(t.b,n.b),u=Pn(t.opacity,n.opacity);return function(n){return t.r=r(n),t.g=i(n),t.b=o(n),t.opacity=u(n),t+""}}return r.gamma=t,r}(1);function Ln(t){return function(n){var e,r,i=n.length,o=new Array(i),u=new Array(i),a=new Array(i);for(e=0;e=1?(e=1,n-1):Math.floor(e*n),i=t[r],o=t[r+1],u=r>0?t[r-1]:2*i-o,a=ro&&(i=n.slice(o,i),a[u]?a[u]+=i:a[++u]=i),(e=e[0])===(r=r[0])?a[u]?a[u]+=r:a[++u]=r:(a[++u]=null,l.push({i:u,x:Bn(e,r)})),o=Rn.lastIndex;return on&&(e=t,t=n,n=e),s=function(e){return Math.max(t,Math.min(n,e))}),r=l>2?Wn:Kn,i=o=null,h}function h(n){return null==n||isNaN(n=+n)?e:(i||(i=r(u.map(t),a,l)))(t(s(n)))}return h.invert=function(e){return s(n((o||(o=r(a,u.map(t),Bn)))(e)))},h.domain=function(t){return arguments.length?(u=Array.from(t,Fn),c()):u.slice()},h.range=function(t){return arguments.length?(a=Array.from(t),c()):a.slice()},h.rangeRound=function(t){return a=Array.from(t),l=Yn,c()},h.clamp=function(t){return arguments.length?(s=!!t||Zn,c()):s!==Zn},h.interpolate=function(t){return arguments.length?(l=t,c()):l},h.unknown=function(t){return arguments.length?(e=t,h):e},function(e,r){return t=e,n=r,c()}}function te(){return Qn()(Zn,Zn)}function ne(t,n){switch(arguments.length){case 0:break;case 1:this.range(t);break;default:this.range(n).domain(t)}return this}function ee(t,n,e,r){var i,o=function(t,n,e){var r=Math.abs(n-t)/Math.max(0,e),i=Math.pow(10,Math.floor(Math.log(r)/Math.LN10)),o=r/i;return o>=$t?i*=10:o>=Vt?i*=5:o>=Yt&&(i*=2),n0)return[t];if((r=n0){let e=Math.round(t/u),r=Math.round(n/u);for(e*un&&--r,o=new Array(i=r-e+1);++an&&--r,o=new Array(i=r-e+1);++a0;){if((i=Ft(l,s,e))===r)return o[u]=l,o[a]=s,n(o);if(i>0)l=Math.floor(l/i)*i,s=Math.ceil(s/i)*i;else{if(!(i<0))break;l=Math.ceil(l*i)/i,s=Math.floor(s*i)/i}r=i}return t},t}function ie(){var t=te();return t.copy=function(){return Jn(t,ie())},ne.apply(t,arguments),re(t)}function oe(t){return((t*=2)<=1?t*t*t:(t-=2)*t*t+2)/2}var ue={value:()=>{}};function ae(){for(var t,n=0,e=arguments.length,r={};n=0&&(e=t.slice(r+1),t=t.slice(0,r)),t&&!n.hasOwnProperty(t))throw new Error("unknown type: "+t);return{type:t,name:e}}))}function ce(t,n){for(var e,r=0,i=t.length;r0)for(var e,r,i=new Array(e),o=0;o=0&&n._call.call(void 0,t),n=n._next;--ge}()}finally{ge=0,function(){var t,n,e=pe,r=1/0;for(;e;)e._call?(r>e._time&&(r=e._time),t=e,e=e._next):(n=e._next,e._next=null,e=t?t._next=n:pe=n);de=t,Ce(r)}(),we=0}}function Se(){var t=be.now(),n=t-me;n>1e3&&(_e-=n,me=t)}function Ce(t){ge||(ve&&(ve=clearTimeout(ve)),t-we>24?(t<1/0&&(ve=setTimeout(Ee,t-be.now()-_e)),ye&&(ye=clearInterval(ye))):(ye||(me=be.now(),ye=setInterval(Se,1e3)),ge=1,xe(Ee)))}function je(t,n,e){var r=new Ne;return n=null==n?0:+n,r.restart((e=>{r.stop(),t(e+n)}),n,e),r}Ne.prototype=ke.prototype={constructor:Ne,restart:function(t,n,e){if("function"!=typeof t)throw new TypeError("callback is not a function");e=(null==e?Me():+e)+(null==n?0:+n),this._next||de===this||(de?de._next=this:pe=this,de=this),this._call=t,this._time=e,Ce()},stop:function(){this._call&&(this._call=null,this._time=1/0,Ce())}};var qe=fe("start","end","cancel","interrupt"),Oe=[];function Pe(t,n,e,r,i,o){var u=t.__transition;if(u){if(e in u)return}else t.__transition={};!function(t,n,e){var r,i=t.__transition;function o(t){e.state=1,e.timer.restart(u,e.delay,e.time),e.delay<=t&&u(t-e.delay)}function u(o){var s,c,h,f;if(1!==e.state)return l();for(s in i)if((f=i[s]).name===e.name){if(3===f.state)return je(u);4===f.state?(f.state=6,f.timer.stop(),f.on.call("interrupt",t,t.__data__,f.index,f.group),delete i[s]):+s0)throw new Error("too late; already scheduled");return e}function Le(t,n){var e=Te(t,n);if(e.state>3)throw new Error("too late; already running");return e}function Te(t,n){var e=t.__transition;if(!e||!(e=e[n]))throw new Error("transition not found");return e}var De,Be=180/Math.PI,Xe={translateX:0,translateY:0,rotate:0,skewX:0,scaleX:1,scaleY:1};function ze(t,n,e,r,i,o){var u,a,l;return(u=Math.sqrt(t*t+n*n))&&(t/=u,n/=u),(l=t*e+n*r)&&(e-=t*l,r-=n*l),(a=Math.sqrt(e*e+r*r))&&(e/=a,r/=a,l/=a),t*r180?n+=360:n-t>180&&(t+=360),o.push({i:e.push(i(e)+"rotate(",null,r)-2,x:Bn(t,n)})):n&&e.push(i(e)+"rotate("+n+r)}(o.rotate,u.rotate,a,l),function(t,n,e,o){t!==n?o.push({i:e.push(i(e)+"skewX(",null,r)-2,x:Bn(t,n)}):n&&e.push(i(e)+"skewX("+n+r)}(o.skewX,u.skewX,a,l),function(t,n,e,r,o,u){if(t!==e||n!==r){var a=o.push(i(o)+"scale(",null,",",null,")");u.push({i:a-4,x:Bn(t,e)},{i:a-2,x:Bn(n,r)})}else 1===e&&1===r||o.push(i(o)+"scale("+e+","+r+")")}(o.scaleX,o.scaleY,u.scaleX,u.scaleY,a,l),o=u=null,function(t){for(var n,e=-1,r=l.length;++e=0&&(t=t.slice(0,n)),!t||"start"===t}))}(n)?He:Le;return function(){var u=o(this,t),a=u.on;a!==r&&(i=(r=a).copy()).on(n,e),u.on=i}}var cr=vt.prototype.constructor;function hr(t){return function(){this.style.removeProperty(t)}}function fr(t,n,e){return function(r){this.style.setProperty(t,n.call(this,r),e)}}function pr(t,n,e){var r,i;function o(){var o=n.apply(this,arguments);return o!==i&&(r=(i=o)&&fr(t,o,e)),r}return o._value=n,o}function dr(t){return function(n){this.textContent=t.call(this,n)}}function gr(t){var n,e;function r(){var r=t.apply(this,arguments);return r!==e&&(n=(e=r)&&dr(r)),n}return r._value=t,r}var vr=0;function yr(t,n,e,r){this._groups=t,this._parents=n,this._name=e,this._id=r}function mr(){return++vr}var wr=vt.prototype;yr.prototype=function(t){return vt().transition(t)}.prototype={constructor:yr,select:function(t){var n=this._name,e=this._id;"function"!=typeof t&&(t=r(t));for(var i=this._groups,o=i.length,u=new Array(o),a=0;a{p&&(p.textContent="search: "+n+" of "+e+" total samples ( "+St(".3f")(n/e*100,3)+"%)")},d()};const E=k;let S=(t,n,e=!1)=>{if(!n)return!1;let r=b(t);e&&(n=n.toLowerCase(),r=r.toLowerCase());const i=new RegExp(n);return void 0!==r&&r&&r.match(i)};const C=S;let j=function(t){p&&(t?p.textContent=t:"function"==typeof d?d():p.textContent="")};const q=j;let O=function(t){return b(t)+" ("+St(".3f")(100*(t.x1-t.x0),3)+"%, "+x(t)+" samples)"},P=function(t){return t.highlight?"#E600E6":function(t,n){let e=_||"warm";_||void 0===n||""===n||(e="red",void 0!==t&&t&&t.match(/::/)&&(e="yellow"),"kernel"===n?e="orange":"jit"===n?e="green":"inlined"===n&&(e="aqua"));const r=function(t){let n=0;if(t){const e=t.split("`");e.length>1&&(t=e[e.length-1]),n=function(t){let n=0,e=0,r=1;if(t){for(let i=0;i6);i++)n+=r*(t.charCodeAt(i)%10),e+=9*r,r*=.7;e>0&&(n/=e)}return n}(t=t.split("(")[0])}return n}(t);return function(t,n){let e,r,i;return"red"===t?(e=200+Math.round(55*n),r=50+Math.round(80*n),i=r):"orange"===t?(e=190+Math.round(65*n),r=90+Math.round(65*n),i=0):"yellow"===t?(e=175+Math.round(55*n),r=e,i=50+Math.round(20*n)):"green"===t?(e=50+Math.round(60*n),r=200+Math.round(55*n),i=e):"pastelgreen"===t?(e=163+Math.round(75*n),r=195+Math.round(49*n),i=72+Math.round(149*n)):"blue"===t?(e=91+Math.round(126*n),r=156+Math.round(76*n),i=221+Math.round(26*n)):"aqua"===t?(e=50+Math.round(60*n),r=165+Math.round(55*n),i=r):"cold"===t?(e=0+Math.round(55*(1-n)),r=0+Math.round(230*(1-n)),i=200+Math.round(55*n)):(e=200+Math.round(55*n),r=0+Math.round(230*(1-n)),i=0+Math.round(55*(1-n))),"rgb("+e+","+r+","+i+")"}(e,r)}(b(t),A(t))};const H=P;function L(t){t.data.fade=!1,t.data.hide=!1,t.children&&t.children.forEach(L)}function T(t){t.parent&&(t.parent.data.fade=!0,T(t.parent))}function D(t){if(i&&i.hide(),function(t){let n,e,r,i=t,o=i.parent;for(;o;){for(n=o.children,e=n.length;e--;)r=n[e],r!==i&&(r.data.hide=!0);i=o,o=i.parent}}(t),L(t),T(t),I(),y){const n=yt(this).select("svg")._groups[0][0].parentNode.offsetTop,r=(window.innerHeight-n)/e,i=(t.height-r+10)*e;window.scrollTo({top:n+i,left:0,behavior:"smooth"})}"function"==typeof c&&c(t)}function B(t,n){if(t.id===n)return t;{const e=M(t);if(e)for(let t=0;t0){const r=t/(n.x1-n.x0);e=e.filter((function(t){return(t.x1-t.x0)*r>f}))}return e}(r),y=yt(this).select("svg");y.attr("width",t);let w=y.selectAll("g").data(g,(function(t){return t.id}));if(!n||v){const t=Math.max.apply(null,g.map((function(t){return t.depth})));n=(t+3)*e,n{D(n)})),w.exit().remove(),w.on("mouseover",(function(t,n){i&&i.show(n,this),j(O(n)),"function"==typeof h&&h(n)})).on("mouseout",(function(){i&&i.hide(),j(null)}))}))}function $(t,n){n.forEach((function(n){const e=t.find((function(t){return t.name===n.name}));e?(e.value+=n.value,n.children&&(e.children||(e.children=[]),$(e.children,n.children))):t.push(n)}))}function V(t){let n,e,r,i,o,u,a,l;const s=[],c=[],h=[],f=!g;let p=t.data;for(p.hide?(t.value=0,e=t.children,e&&h.push(e)):(t.value=p.fade?0:x(p),s.push(t));n=s.pop();)if(e=n.children,e&&(o=e.length)){for(i=0;o--;)a=e[o],p=a.data,p.hide?(a.value=0,r=a.children,r&&h.push(r)):(p.fade?a.value=0:(l=x(p),a.value=l,i+=l),s.push(a));f&&n.value&&(n.value-=i),c.push(e)}for(o=c.length;o--;){for(e=c[o],i=0,u=e.length;u--;)i+=e[u].value;e[0].parent.value+=i}for(;h.length;)for(e=h.pop(),u=e.length;u--;)a=e[u],a.value=0,r=a.children,r&&h.push(r)}function Y(){r.datum((t=>{if("Node"!==t.constructor.name){const n=Dt(t,M);return function(t){let n=0;!function(t,n){n(t);let e=t.children;if(e){const t=[e];let r,i,o;for(;t.length;)for(e=t.pop(),r=e.length;r--;)i=e[r],n(i),o=i.children,o&&t.push(o)}}(t,(function(t){t.id=n++}))}(n),V(n),n.originalValue=n.value,w&&n.eachAfter((t=>{let n=N(t);const e=t.children;let r=e&&e.length;for(;--r>=0;)n+=e[r].delta;t.delta=n})),n}}))}function F(e){if(!arguments.length)return F;r=e,Y(),r.each((function(e){if(0===yt(this).select("svg").size()){const e=yt(this).append("svg:svg").attr("width",t).attr("class","partition d3-flame-graph");n&&(n($([n.data],[t]),n.data))),Y(),I(),F):F},F.update=function(t){return r?(t&&(r.datum(t),Y()),I(),F):F},F.destroy=function(){return r?(i&&(i.hide(),"function"==typeof i.destroy&&i.destroy()),r.selectAll("svg").remove(),F):F},F.setColorMapper=function(t){return arguments.length?(P=n=>{const e=H(n);return t(n,e)},F):(P=H,F)},F.color=F.setColorMapper,F.setColorHue=function(t){return arguments.length?(_=t,F):(_=null,F)},F.minFrameSize=function(t){return arguments.length?(f=t,F):f},F.setDetailsElement=function(t){return arguments.length?(p=t,F):p},F.details=F.setDetailsElement,F.selfValue=function(t){return arguments.length?(g=t,F):g},F.resetHeightOnZoom=function(t){return arguments.length?(v=t,F):v},F.scrollOnZoom=function(t){return arguments.length?(y=t,F):y},F.getName=function(t){return arguments.length?(b=t,F):b},F.getValue=function(t){return arguments.length?(x=t,F):x},F.getChildren=function(t){return arguments.length?(M=t,F):M},F.getLibtype=function(t){return arguments.length?(A=t,F):A},F.getDelta=function(t){return arguments.length?(N=t,F):N},F.setSearchHandler=function(t){return arguments.length?(k=t,F):(k=E,F)},F.setDetailsHandler=function(t){return arguments.length?(j=t,F):(j=q,F)},F.setSearchMatch=function(t){return arguments.length?(S=t,F):(S=C,F)},F}return vt.prototype.interrupt=function(t){return this.each((function(){!function(t,n){var e,r,i,o=t.__transition,u=!0;if(o){for(i in n=null==n?null:n+"",o)(e=o[i]).name===n?(r=e.state>2&&e.state<5,e.state=6,e.timer.stop(),e.on.call(r?"interrupt":"cancel",t,t.__data__,e.index,e.group),delete o[i]):u=!1;u&&delete t.__transition}}(this,t)}))},vt.prototype.transition=function(t){var n,e;t instanceof yr?(n=t._id,t=t._name):(n=mr(),(e=_r).time=Me(),t=null==t?null:t+"");for(var r=this._groups,i=r.length,o=0;on?1:t>=n?0:NaN}function e(t,n){return null==t||null==n?NaN:nt?1:n>=t?0:NaN}function r(t){let r,o,a;function u(t,n,e=0,i=t.length){if(e>>1;o(t[r],n)<0?e=r+1:i=r}while(en(t(e),r),a=(n,e)=>t(n)-e):(r=t===n||t===e?t:i,o=t,a=t),{left:u,center:function(t,n,e=0,r=t.length){const i=u(t,n,e,r-1);return i>e&&a(t[i-1],n)>-a(t[i],n)?i-1:i},right:function(t,n,e=0,i=t.length){if(e>>1;o(t[r],n)<=0?e=r+1:i=r}while(e{n(t,e,(r<<=2)+0,(i<<=2)+0,o<<=2),n(t,e,r+1,i+1,o),n(t,e,r+2,i+2,o),n(t,e,r+3,i+3,o)}}));function d(t){return function(n,e,r=e){if(!((e=+e)>=0))throw new RangeError("invalid rx");if(!((r=+r)>=0))throw new RangeError("invalid ry");let{data:i,width:o,height:a}=n;if(!((o=Math.floor(o))>=0))throw new RangeError("invalid width");if(!((a=Math.floor(void 0!==a?a:i.length/o))>=0))throw new RangeError("invalid height");if(!o||!a||!e&&!r)return n;const u=e&&t(e),c=r&&t(r),f=i.slice();return u&&c?(p(u,f,i,o,a),p(u,i,f,o,a),p(u,f,i,o,a),g(c,i,f,o,a),g(c,f,i,o,a),g(c,i,f,o,a)):u?(p(u,i,f,o,a),p(u,f,i,o,a),p(u,i,f,o,a)):c&&(g(c,i,f,o,a),g(c,f,i,o,a),g(c,i,f,o,a)),n}}function p(t,n,e,r,i){for(let o=0,a=r*i;o{if(!((o-=a)>=i))return;let u=t*r[i];const c=a*t;for(let t=i,n=i+c;t{if(!((a-=u)>=o))return;let c=n*i[o];const f=u*n,s=f+u;for(let t=o,n=o+f;t=n&&++e;else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(i=+i)>=i&&++e}return e}function _(t){return 0|t.length}function b(t){return!(t>0)}function m(t){return"object"!=typeof t||"length"in t?t:Array.from(t)}function x(t,n){let e,r=0,i=0,o=0;if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(e=n-i,i+=e/++r,o+=e*(n-i));else{let a=-1;for(let u of t)null!=(u=n(u,++a,t))&&(u=+u)>=u&&(e=u-i,i+=e/++r,o+=e*(u-i))}if(r>1)return o/(r-1)}function w(t,n){const e=x(t,n);return e?Math.sqrt(e):e}function M(t,n){let e,r;if(void 0===n)for(const n of t)null!=n&&(void 0===e?n>=n&&(e=r=n):(e>n&&(e=n),r=o&&(e=r=o):(e>o&&(e=o),r0){for(o=t[--i];i>0&&(n=o,e=t[--i],o=n+e,r=e-(o-n),!r););i>0&&(r<0&&t[i-1]<0||r>0&&t[i-1]>0)&&(e=2*r,n=o+e,e==n-o&&(o=n))}return o}}class InternMap extends Map{constructor(t,n=N){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:n}}),null!=t)for(const[n,e]of t)this.set(n,e)}get(t){return super.get(A(this,t))}has(t){return super.has(A(this,t))}set(t,n){return super.set(S(this,t),n)}delete(t){return super.delete(E(this,t))}}class InternSet extends Set{constructor(t,n=N){if(super(),Object.defineProperties(this,{_intern:{value:new Map},_key:{value:n}}),null!=t)for(const n of t)this.add(n)}has(t){return super.has(A(this,t))}add(t){return super.add(S(this,t))}delete(t){return super.delete(E(this,t))}}function A({_intern:t,_key:n},e){const r=n(e);return t.has(r)?t.get(r):e}function S({_intern:t,_key:n},e){const r=n(e);return t.has(r)?t.get(r):(t.set(r,e),e)}function E({_intern:t,_key:n},e){const r=n(e);return t.has(r)&&(e=t.get(r),t.delete(r)),e}function N(t){return null!==t&&"object"==typeof t?t.valueOf():t}function k(t){return t}function C(t,...n){return F(t,k,k,n)}function P(t,...n){return F(t,Array.from,k,n)}function z(t,n){for(let e=1,r=n.length;et.pop().map((([n,e])=>[...t,n,e]))));return t}function $(t,n,...e){return F(t,k,n,e)}function D(t,n,...e){return F(t,Array.from,n,e)}function R(t){if(1!==t.length)throw new Error("duplicate key");return t[0]}function F(t,n,e,r){return function t(i,o){if(o>=r.length)return e(i);const a=new InternMap,u=r[o++];let c=-1;for(const t of i){const n=u(t,++c,i),e=a.get(n);e?e.push(t):a.set(n,[t])}for(const[n,e]of a)a.set(n,t(e,o));return n(a)}(t,0)}function q(t,n){return Array.from(n,(n=>t[n]))}function U(t,...n){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");t=Array.from(t);let[e]=n;if(e&&2!==e.length||n.length>1){const r=Uint32Array.from(t,((t,n)=>n));return n.length>1?(n=n.map((n=>t.map(n))),r.sort(((t,e)=>{for(const r of n){const n=O(r[t],r[e]);if(n)return n}}))):(e=t.map(e),r.sort(((t,n)=>O(e[t],e[n])))),q(t,r)}return t.sort(I(e))}function I(t=n){if(t===n)return O;if("function"!=typeof t)throw new TypeError("compare is not a function");return(n,e)=>{const r=t(n,e);return r||0===r?r:(0===t(e,e))-(0===t(n,n))}}function O(t,n){return(null==t||!(t>=t))-(null==n||!(n>=n))||(tn?1:0)}var B=Array.prototype.slice;function Y(t){return()=>t}const L=Math.sqrt(50),j=Math.sqrt(10),H=Math.sqrt(2);function X(t,n,e){const r=(n-t)/Math.max(0,e),i=Math.floor(Math.log10(r)),o=r/Math.pow(10,i),a=o>=L?10:o>=j?5:o>=H?2:1;let u,c,f;return i<0?(f=Math.pow(10,-i)/a,u=Math.round(t*f),c=Math.round(n*f),u/fn&&--c,f=-f):(f=Math.pow(10,i)*a,u=Math.round(t/f),c=Math.round(n/f),u*fn&&--c),c0))return[];if((t=+t)===(n=+n))return[t];const r=n=i))return[];const u=o-i+1,c=new Array(u);if(r)if(a<0)for(let t=0;t0?(t=Math.floor(t/i)*i,n=Math.ceil(n/i)*i):i<0&&(t=Math.ceil(t*i)/i,n=Math.floor(n*i)/i),r=i}}function K(t){return Math.max(1,Math.ceil(Math.log(v(t))/Math.LN2)+1)}function Q(){var t=k,n=M,e=K;function r(r){Array.isArray(r)||(r=Array.from(r));var i,o,a,u=r.length,c=new Array(u);for(i=0;i=h)if(t>=h&&n===M){const t=V(l,h,e);isFinite(t)&&(t>0?h=(Math.floor(h/t)+1)*t:t<0&&(h=(Math.ceil(h*-t)+1)/-t))}else d.pop()}for(var p=d.length,g=0,y=p;d[g]<=l;)++g;for(;d[y-1]>h;)--y;(g||y0?d[i-1]:l,v.x1=i0)for(i=0;i=n)&&(e=n);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(e=i)&&(e=i)}return e}function tt(t,n){let e,r=-1,i=-1;if(void 0===n)for(const n of t)++i,null!=n&&(e=n)&&(e=n,r=i);else for(let o of t)null!=(o=n(o,++i,t))&&(e=o)&&(e=o,r=i);return r}function nt(t,n){let e;if(void 0===n)for(const n of t)null!=n&&(e>n||void 0===e&&n>=n)&&(e=n);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&(e>i||void 0===e&&i>=i)&&(e=i)}return e}function et(t,n){let e,r=-1,i=-1;if(void 0===n)for(const n of t)++i,null!=n&&(e>n||void 0===e&&n>=n)&&(e=n,r=i);else for(let o of t)null!=(o=n(o,++i,t))&&(e>o||void 0===e&&o>=o)&&(e=o,r=i);return r}function rt(t,n,e=0,r=1/0,i){if(n=Math.floor(n),e=Math.floor(Math.max(0,e)),r=Math.floor(Math.min(t.length-1,r)),!(e<=n&&n<=r))return t;for(i=void 0===i?O:I(i);r>e;){if(r-e>600){const o=r-e+1,a=n-e+1,u=Math.log(o),c=.5*Math.exp(2*u/3),f=.5*Math.sqrt(u*c*(o-c)/o)*(a-o/2<0?-1:1);rt(t,n,Math.max(e,Math.floor(n-a*c/o+f)),Math.min(r,Math.floor(n+(o-a)*c/o+f)),i)}const o=t[n];let a=e,u=r;for(it(t,e,n),i(t[r],o)>0&&it(t,e,r);a0;)--u}0===i(t[e],o)?it(t,e,u):(++u,it(t,u,r)),u<=n&&(e=u+1),n<=u&&(r=u-1)}return t}function it(t,n,e){const r=t[n];t[n]=t[e],t[e]=r}function ot(t,e=n){let r,i=!1;if(1===e.length){let o;for(const a of t){const t=e(a);(i?n(t,o)>0:0===n(t,t))&&(r=a,o=t,i=!0)}}else for(const n of t)(i?e(n,r)>0:0===e(n,n))&&(r=n,i=!0);return r}function at(t,n,e){if(t=Float64Array.from(function*(t,n){if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(yield n);else{let e=-1;for(let r of t)null!=(r=n(r,++e,t))&&(r=+r)>=r&&(yield r)}}(t,e)),(r=t.length)&&!isNaN(n=+n)){if(n<=0||r<2)return nt(t);if(n>=1)return J(t);var r,i=(r-1)*n,o=Math.floor(i),a=J(rt(t,o).subarray(0,o+1));return a+(nt(t.subarray(o+1))-a)*(i-o)}}function ut(t,n,e=o){if((r=t.length)&&!isNaN(n=+n)){if(n<=0||r<2)return+e(t[0],0,t);if(n>=1)return+e(t[r-1],r-1,t);var r,i=(r-1)*n,a=Math.floor(i),u=+e(t[a],a,t);return u+(+e(t[a+1],a+1,t)-u)*(i-a)}}function ct(t,n,e=o){if(!isNaN(n=+n)){if(r=Float64Array.from(t,((n,r)=>o(e(t[r],r,t)))),n<=0)return et(r);if(n>=1)return tt(r);var r,i=Uint32Array.from(t,((t,n)=>n)),a=r.length-1,u=Math.floor(a*n);return rt(i,u,0,a,((t,n)=>O(r[t],r[n]))),(u=ot(i.subarray(0,u+1),(t=>r[t])))>=0?u:-1}}function ft(t){return Array.from(function*(t){for(const n of t)yield*n}(t))}function st(t,n){return[t,n]}function lt(t,n,e){t=+t,n=+n,e=(i=arguments.length)<2?(n=t,t=0,1):i<3?1:+e;for(var r=-1,i=0|Math.max(0,Math.ceil((n-t)/e)),o=new Array(i);++r+t(n)}function kt(t,n){return n=Math.max(0,t.bandwidth()-2*n)/2,t.round()&&(n=Math.round(n)),e=>+t(e)+n}function Ct(){return!this.__axis}function Pt(t,n){var e=[],r=null,i=null,o=6,a=6,u=3,c="undefined"!=typeof window&&window.devicePixelRatio>1?0:.5,f=t===xt||t===Tt?-1:1,s=t===Tt||t===wt?"x":"y",l=t===xt||t===Mt?St:Et;function h(h){var d=null==r?n.ticks?n.ticks.apply(n,e):n.domain():r,p=null==i?n.tickFormat?n.tickFormat.apply(n,e):mt:i,g=Math.max(o,0)+u,y=n.range(),v=+y[0]+c,_=+y[y.length-1]+c,b=(n.bandwidth?kt:Nt)(n.copy(),c),m=h.selection?h.selection():h,x=m.selectAll(".domain").data([null]),w=m.selectAll(".tick").data(d,n).order(),M=w.exit(),T=w.enter().append("g").attr("class","tick"),A=w.select("line"),S=w.select("text");x=x.merge(x.enter().insert("path",".tick").attr("class","domain").attr("stroke","currentColor")),w=w.merge(T),A=A.merge(T.append("line").attr("stroke","currentColor").attr(s+"2",f*o)),S=S.merge(T.append("text").attr("fill","currentColor").attr(s,f*g).attr("dy",t===xt?"0em":t===Mt?"0.71em":"0.32em")),h!==m&&(x=x.transition(h),w=w.transition(h),A=A.transition(h),S=S.transition(h),M=M.transition(h).attr("opacity",At).attr("transform",(function(t){return isFinite(t=b(t))?l(t+c):this.getAttribute("transform")})),T.attr("opacity",At).attr("transform",(function(t){var n=this.parentNode.__axis;return l((n&&isFinite(n=n(t))?n:b(t))+c)}))),M.remove(),x.attr("d",t===Tt||t===wt?a?"M"+f*a+","+v+"H"+c+"V"+_+"H"+f*a:"M"+c+","+v+"V"+_:a?"M"+v+","+f*a+"V"+c+"H"+_+"V"+f*a:"M"+v+","+c+"H"+_),w.attr("opacity",1).attr("transform",(function(t){return l(b(t)+c)})),A.attr(s+"2",f*o),S.attr(s,f*g).text(p),m.filter(Ct).attr("fill","none").attr("font-size",10).attr("font-family","sans-serif").attr("text-anchor",t===wt?"start":t===Tt?"end":"middle"),m.each((function(){this.__axis=b}))}return h.scale=function(t){return arguments.length?(n=t,h):n},h.ticks=function(){return e=Array.from(arguments),h},h.tickArguments=function(t){return arguments.length?(e=null==t?[]:Array.from(t),h):e.slice()},h.tickValues=function(t){return arguments.length?(r=null==t?null:Array.from(t),h):r&&r.slice()},h.tickFormat=function(t){return arguments.length?(i=t,h):i},h.tickSize=function(t){return arguments.length?(o=a=+t,h):o},h.tickSizeInner=function(t){return arguments.length?(o=+t,h):o},h.tickSizeOuter=function(t){return arguments.length?(a=+t,h):a},h.tickPadding=function(t){return arguments.length?(u=+t,h):u},h.offset=function(t){return arguments.length?(c=+t,h):c},h}var zt={value:()=>{}};function $t(){for(var t,n=0,e=arguments.length,r={};n=0&&(n=t.slice(e+1),t=t.slice(0,e)),t&&!r.hasOwnProperty(t))throw new Error("unknown type: "+t);return{type:t,name:n}}))),a=-1,u=o.length;if(!(arguments.length<2)){if(null!=n&&"function"!=typeof n)throw new Error("invalid callback: "+n);for(;++a0)for(var e,r,i=new Array(e),o=0;o=0&&"xmlns"!==(n=t.slice(0,e))&&(t=t.slice(e+1)),Ut.hasOwnProperty(n)?{space:Ut[n],local:t}:t}function Ot(t){return function(){var n=this.ownerDocument,e=this.namespaceURI;return e===qt&&n.documentElement.namespaceURI===qt?n.createElement(t):n.createElementNS(e,t)}}function Bt(t){return function(){return this.ownerDocument.createElementNS(t.space,t.local)}}function Yt(t){var n=It(t);return(n.local?Bt:Ot)(n)}function Lt(){}function jt(t){return null==t?Lt:function(){return this.querySelector(t)}}function Ht(t){return null==t?[]:Array.isArray(t)?t:Array.from(t)}function Xt(){return[]}function Gt(t){return null==t?Xt:function(){return this.querySelectorAll(t)}}function Vt(t){return function(){return this.matches(t)}}function Wt(t){return function(n){return n.matches(t)}}var Zt=Array.prototype.find;function Kt(){return this.firstElementChild}var Qt=Array.prototype.filter;function Jt(){return Array.from(this.children)}function tn(t){return new Array(t.length)}function nn(t,n){this.ownerDocument=t.ownerDocument,this.namespaceURI=t.namespaceURI,this._next=null,this._parent=t,this.__data__=n}function en(t,n,e,r,i,o){for(var a,u=0,c=n.length,f=o.length;un?1:t>=n?0:NaN}function cn(t){return function(){this.removeAttribute(t)}}function fn(t){return function(){this.removeAttributeNS(t.space,t.local)}}function sn(t,n){return function(){this.setAttribute(t,n)}}function ln(t,n){return function(){this.setAttributeNS(t.space,t.local,n)}}function hn(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttribute(t):this.setAttribute(t,e)}}function dn(t,n){return function(){var e=n.apply(this,arguments);null==e?this.removeAttributeNS(t.space,t.local):this.setAttributeNS(t.space,t.local,e)}}function pn(t){return t.ownerDocument&&t.ownerDocument.defaultView||t.document&&t||t.defaultView}function gn(t){return function(){this.style.removeProperty(t)}}function yn(t,n,e){return function(){this.style.setProperty(t,n,e)}}function vn(t,n,e){return function(){var r=n.apply(this,arguments);null==r?this.style.removeProperty(t):this.style.setProperty(t,r,e)}}function _n(t,n){return t.style.getPropertyValue(n)||pn(t).getComputedStyle(t,null).getPropertyValue(n)}function bn(t){return function(){delete this[t]}}function mn(t,n){return function(){this[t]=n}}function xn(t,n){return function(){var e=n.apply(this,arguments);null==e?delete this[t]:this[t]=e}}function wn(t){return t.trim().split(/^|\s+/)}function Mn(t){return t.classList||new Tn(t)}function Tn(t){this._node=t,this._names=wn(t.getAttribute("class")||"")}function An(t,n){for(var e=Mn(t),r=-1,i=n.length;++r=0&&(this._names.splice(n,1),this._node.setAttribute("class",this._names.join(" ")))},contains:function(t){return this._names.indexOf(t)>=0}};var Gn=[null];function Vn(t,n){this._groups=t,this._parents=n}function Wn(){return new Vn([[document.documentElement]],Gn)}function Zn(t){return"string"==typeof t?new Vn([[document.querySelector(t)]],[document.documentElement]):new Vn([[t]],Gn)}Vn.prototype=Wn.prototype={constructor:Vn,select:function(t){"function"!=typeof t&&(t=jt(t));for(var n=this._groups,e=n.length,r=new Array(e),i=0;i=m&&(m=b+1);!(_=y[m])&&++m=0;)(r=i[o])&&(a&&4^r.compareDocumentPosition(a)&&a.parentNode.insertBefore(r,a),a=r);return this},sort:function(t){function n(n,e){return n&&e?t(n.__data__,e.__data__):!n-!e}t||(t=un);for(var e=this._groups,r=e.length,i=new Array(r),o=0;o1?this.each((null==n?gn:"function"==typeof n?vn:yn)(t,n,null==e?"":e)):_n(this.node(),t)},property:function(t,n){return arguments.length>1?this.each((null==n?bn:"function"==typeof n?xn:mn)(t,n)):this.node()[t]},classed:function(t,n){var e=wn(t+"");if(arguments.length<2){for(var r=Mn(this.node()),i=-1,o=e.length;++i=0&&(n=t.slice(e+1),t=t.slice(0,e)),{type:t,name:n}}))}(t+""),a=o.length;if(!(arguments.length<2)){for(u=n?Ln:Yn,r=0;r()=>t;function fe(t,{sourceEvent:n,subject:e,target:r,identifier:i,active:o,x:a,y:u,dx:c,dy:f,dispatch:s}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:n,enumerable:!0,configurable:!0},subject:{value:e,enumerable:!0,configurable:!0},target:{value:r,enumerable:!0,configurable:!0},identifier:{value:i,enumerable:!0,configurable:!0},active:{value:o,enumerable:!0,configurable:!0},x:{value:a,enumerable:!0,configurable:!0},y:{value:u,enumerable:!0,configurable:!0},dx:{value:c,enumerable:!0,configurable:!0},dy:{value:f,enumerable:!0,configurable:!0},_:{value:s}})}function se(t){return!t.ctrlKey&&!t.button}function le(){return this.parentNode}function he(t,n){return null==n?{x:t.x,y:t.y}:n}function de(){return navigator.maxTouchPoints||"ontouchstart"in this}function pe(t,n,e){t.prototype=n.prototype=e,e.constructor=t}function ge(t,n){var e=Object.create(t.prototype);for(var r in n)e[r]=n[r];return e}function ye(){}fe.prototype.on=function(){var t=this._.on.apply(this._,arguments);return t===this._?this:t};var ve=.7,_e=1/ve,be="\\s*([+-]?\\d+)\\s*",me="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)\\s*",xe="\\s*([+-]?(?:\\d*\\.)?\\d+(?:[eE][+-]?\\d+)?)%\\s*",we=/^#([0-9a-f]{3,8})$/,Me=new RegExp(`^rgb\\(${be},${be},${be}\\)$`),Te=new RegExp(`^rgb\\(${xe},${xe},${xe}\\)$`),Ae=new RegExp(`^rgba\\(${be},${be},${be},${me}\\)$`),Se=new RegExp(`^rgba\\(${xe},${xe},${xe},${me}\\)$`),Ee=new RegExp(`^hsl\\(${me},${xe},${xe}\\)$`),Ne=new RegExp(`^hsla\\(${me},${xe},${xe},${me}\\)$`),ke={aliceblue:15792383,antiquewhite:16444375,aqua:65535,aquamarine:8388564,azure:15794175,beige:16119260,bisque:16770244,black:0,blanchedalmond:16772045,blue:255,blueviolet:9055202,brown:10824234,burlywood:14596231,cadetblue:6266528,chartreuse:8388352,chocolate:13789470,coral:16744272,cornflowerblue:6591981,cornsilk:16775388,crimson:14423100,cyan:65535,darkblue:139,darkcyan:35723,darkgoldenrod:12092939,darkgray:11119017,darkgreen:25600,darkgrey:11119017,darkkhaki:12433259,darkmagenta:9109643,darkolivegreen:5597999,darkorange:16747520,darkorchid:10040012,darkred:9109504,darksalmon:15308410,darkseagreen:9419919,darkslateblue:4734347,darkslategray:3100495,darkslategrey:3100495,darkturquoise:52945,darkviolet:9699539,deeppink:16716947,deepskyblue:49151,dimgray:6908265,dimgrey:6908265,dodgerblue:2003199,firebrick:11674146,floralwhite:16775920,forestgreen:2263842,fuchsia:16711935,gainsboro:14474460,ghostwhite:16316671,gold:16766720,goldenrod:14329120,gray:8421504,green:32768,greenyellow:11403055,grey:8421504,honeydew:15794160,hotpink:16738740,indianred:13458524,indigo:4915330,ivory:16777200,khaki:15787660,lavender:15132410,lavenderblush:16773365,lawngreen:8190976,lemonchiffon:16775885,lightblue:11393254,lightcoral:15761536,lightcyan:14745599,lightgoldenrodyellow:16448210,lightgray:13882323,lightgreen:9498256,lightgrey:13882323,lightpink:16758465,lightsalmon:16752762,lightseagreen:2142890,lightskyblue:8900346,lightslategray:7833753,lightslategrey:7833753,lightsteelblue:11584734,lightyellow:16777184,lime:65280,limegreen:3329330,linen:16445670,magenta:16711935,maroon:8388608,mediumaquamarine:6737322,mediumblue:205,mediumorchid:12211667,mediumpurple:9662683,mediumseagreen:3978097,mediumslateblue:8087790,mediumspringgreen:64154,mediumturquoise:4772300,mediumvioletred:13047173,midnightblue:1644912,mintcream:16121850,mistyrose:16770273,moccasin:16770229,navajowhite:16768685,navy:128,oldlace:16643558,olive:8421376,olivedrab:7048739,orange:16753920,orangered:16729344,orchid:14315734,palegoldenrod:15657130,palegreen:10025880,paleturquoise:11529966,palevioletred:14381203,papayawhip:16773077,peachpuff:16767673,peru:13468991,pink:16761035,plum:14524637,powderblue:11591910,purple:8388736,rebeccapurple:6697881,red:16711680,rosybrown:12357519,royalblue:4286945,saddlebrown:9127187,salmon:16416882,sandybrown:16032864,seagreen:3050327,seashell:16774638,sienna:10506797,silver:12632256,skyblue:8900331,slateblue:6970061,slategray:7372944,slategrey:7372944,snow:16775930,springgreen:65407,steelblue:4620980,tan:13808780,teal:32896,thistle:14204888,tomato:16737095,turquoise:4251856,violet:15631086,wheat:16113331,white:16777215,whitesmoke:16119285,yellow:16776960,yellowgreen:10145074};function Ce(){return this.rgb().formatHex()}function Pe(){return this.rgb().formatRgb()}function ze(t){var n,e;return t=(t+"").trim().toLowerCase(),(n=we.exec(t))?(e=n[1].length,n=parseInt(n[1],16),6===e?$e(n):3===e?new qe(n>>8&15|n>>4&240,n>>4&15|240&n,(15&n)<<4|15&n,1):8===e?De(n>>24&255,n>>16&255,n>>8&255,(255&n)/255):4===e?De(n>>12&15|n>>8&240,n>>8&15|n>>4&240,n>>4&15|240&n,((15&n)<<4|15&n)/255):null):(n=Me.exec(t))?new qe(n[1],n[2],n[3],1):(n=Te.exec(t))?new qe(255*n[1]/100,255*n[2]/100,255*n[3]/100,1):(n=Ae.exec(t))?De(n[1],n[2],n[3],n[4]):(n=Se.exec(t))?De(255*n[1]/100,255*n[2]/100,255*n[3]/100,n[4]):(n=Ee.exec(t))?Le(n[1],n[2]/100,n[3]/100,1):(n=Ne.exec(t))?Le(n[1],n[2]/100,n[3]/100,n[4]):ke.hasOwnProperty(t)?$e(ke[t]):"transparent"===t?new qe(NaN,NaN,NaN,0):null}function $e(t){return new qe(t>>16&255,t>>8&255,255&t,1)}function De(t,n,e,r){return r<=0&&(t=n=e=NaN),new qe(t,n,e,r)}function Re(t){return t instanceof ye||(t=ze(t)),t?new qe((t=t.rgb()).r,t.g,t.b,t.opacity):new qe}function Fe(t,n,e,r){return 1===arguments.length?Re(t):new qe(t,n,e,null==r?1:r)}function qe(t,n,e,r){this.r=+t,this.g=+n,this.b=+e,this.opacity=+r}function Ue(){return`#${Ye(this.r)}${Ye(this.g)}${Ye(this.b)}`}function Ie(){const t=Oe(this.opacity);return`${1===t?"rgb(":"rgba("}${Be(this.r)}, ${Be(this.g)}, ${Be(this.b)}${1===t?")":`, ${t})`}`}function Oe(t){return isNaN(t)?1:Math.max(0,Math.min(1,t))}function Be(t){return Math.max(0,Math.min(255,Math.round(t)||0))}function Ye(t){return((t=Be(t))<16?"0":"")+t.toString(16)}function Le(t,n,e,r){return r<=0?t=n=e=NaN:e<=0||e>=1?t=n=NaN:n<=0&&(t=NaN),new Xe(t,n,e,r)}function je(t){if(t instanceof Xe)return new Xe(t.h,t.s,t.l,t.opacity);if(t instanceof ye||(t=ze(t)),!t)return new Xe;if(t instanceof Xe)return t;var n=(t=t.rgb()).r/255,e=t.g/255,r=t.b/255,i=Math.min(n,e,r),o=Math.max(n,e,r),a=NaN,u=o-i,c=(o+i)/2;return u?(a=n===o?(e-r)/u+6*(e0&&c<1?0:a,new Xe(a,u,c,t.opacity)}function He(t,n,e,r){return 1===arguments.length?je(t):new Xe(t,n,e,null==r?1:r)}function Xe(t,n,e,r){this.h=+t,this.s=+n,this.l=+e,this.opacity=+r}function Ge(t){return(t=(t||0)%360)<0?t+360:t}function Ve(t){return Math.max(0,Math.min(1,t||0))}function We(t,n,e){return 255*(t<60?n+(e-n)*t/60:t<180?e:t<240?n+(e-n)*(240-t)/60:n)}pe(ye,ze,{copy(t){return Object.assign(new this.constructor,this,t)},displayable(){return this.rgb().displayable()},hex:Ce,formatHex:Ce,formatHex8:function(){return this.rgb().formatHex8()},formatHsl:function(){return je(this).formatHsl()},formatRgb:Pe,toString:Pe}),pe(qe,Fe,ge(ye,{brighter(t){return t=null==t?_e:Math.pow(_e,t),new qe(this.r*t,this.g*t,this.b*t,this.opacity)},darker(t){return t=null==t?ve:Math.pow(ve,t),new qe(this.r*t,this.g*t,this.b*t,this.opacity)},rgb(){return this},clamp(){return new qe(Be(this.r),Be(this.g),Be(this.b),Oe(this.opacity))},displayable(){return-.5<=this.r&&this.r<255.5&&-.5<=this.g&&this.g<255.5&&-.5<=this.b&&this.b<255.5&&0<=this.opacity&&this.opacity<=1},hex:Ue,formatHex:Ue,formatHex8:function(){return`#${Ye(this.r)}${Ye(this.g)}${Ye(this.b)}${Ye(255*(isNaN(this.opacity)?1:this.opacity))}`},formatRgb:Ie,toString:Ie})),pe(Xe,He,ge(ye,{brighter(t){return t=null==t?_e:Math.pow(_e,t),new Xe(this.h,this.s,this.l*t,this.opacity)},darker(t){return t=null==t?ve:Math.pow(ve,t),new Xe(this.h,this.s,this.l*t,this.opacity)},rgb(){var t=this.h%360+360*(this.h<0),n=isNaN(t)||isNaN(this.s)?0:this.s,e=this.l,r=e+(e<.5?e:1-e)*n,i=2*e-r;return new qe(We(t>=240?t-240:t+120,i,r),We(t,i,r),We(t<120?t+240:t-120,i,r),this.opacity)},clamp(){return new Xe(Ge(this.h),Ve(this.s),Ve(this.l),Oe(this.opacity))},displayable(){return(0<=this.s&&this.s<=1||isNaN(this.s))&&0<=this.l&&this.l<=1&&0<=this.opacity&&this.opacity<=1},formatHsl(){const t=Oe(this.opacity);return`${1===t?"hsl(":"hsla("}${Ge(this.h)}, ${100*Ve(this.s)}%, ${100*Ve(this.l)}%${1===t?")":`, ${t})`}`}}));const Ze=Math.PI/180,Ke=180/Math.PI,Qe=.96422,Je=1,tr=.82521,nr=4/29,er=6/29,rr=3*er*er,ir=er*er*er;function or(t){if(t instanceof ur)return new ur(t.l,t.a,t.b,t.opacity);if(t instanceof pr)return gr(t);t instanceof qe||(t=Re(t));var n,e,r=lr(t.r),i=lr(t.g),o=lr(t.b),a=cr((.2225045*r+.7168786*i+.0606169*o)/Je);return r===i&&i===o?n=e=a:(n=cr((.4360747*r+.3850649*i+.1430804*o)/Qe),e=cr((.0139322*r+.0971045*i+.7141733*o)/tr)),new ur(116*a-16,500*(n-a),200*(a-e),t.opacity)}function ar(t,n,e,r){return 1===arguments.length?or(t):new ur(t,n,e,null==r?1:r)}function ur(t,n,e,r){this.l=+t,this.a=+n,this.b=+e,this.opacity=+r}function cr(t){return t>ir?Math.pow(t,1/3):t/rr+nr}function fr(t){return t>er?t*t*t:rr*(t-nr)}function sr(t){return 255*(t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055)}function lr(t){return(t/=255)<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4)}function hr(t){if(t instanceof pr)return new pr(t.h,t.c,t.l,t.opacity);if(t instanceof ur||(t=or(t)),0===t.a&&0===t.b)return new pr(NaN,0=1?(e=1,n-1):Math.floor(e*n),i=t[r],o=t[r+1],a=r>0?t[r-1]:2*i-o,u=r()=>t;function Cr(t,n){return function(e){return t+e*n}}function Pr(t,n){var e=n-t;return e?Cr(t,e>180||e<-180?e-360*Math.round(e/360):e):kr(isNaN(t)?n:t)}function zr(t){return 1==(t=+t)?$r:function(n,e){return e-n?function(t,n,e){return t=Math.pow(t,e),n=Math.pow(n,e)-t,e=1/e,function(r){return Math.pow(t+r*n,e)}}(n,e,t):kr(isNaN(n)?e:n)}}function $r(t,n){var e=n-t;return e?Cr(t,e):kr(isNaN(t)?n:t)}var Dr=function t(n){var e=zr(n);function r(t,n){var r=e((t=Fe(t)).r,(n=Fe(n)).r),i=e(t.g,n.g),o=e(t.b,n.b),a=$r(t.opacity,n.opacity);return function(n){return t.r=r(n),t.g=i(n),t.b=o(n),t.opacity=a(n),t+""}}return r.gamma=t,r}(1);function Rr(t){return function(n){var e,r,i=n.length,o=new Array(i),a=new Array(i),u=new Array(i);for(e=0;eo&&(i=n.slice(o,i),u[a]?u[a]+=i:u[++a]=i),(e=e[0])===(r=r[0])?u[a]?u[a]+=r:u[++a]=r:(u[++a]=null,c.push({i:a,x:Yr(e,r)})),o=Hr.lastIndex;return o180?n+=360:n-t>180&&(t+=360),o.push({i:e.push(i(e)+"rotate(",null,r)-2,x:Yr(t,n)})):n&&e.push(i(e)+"rotate("+n+r)}(o.rotate,a.rotate,u,c),function(t,n,e,o){t!==n?o.push({i:e.push(i(e)+"skewX(",null,r)-2,x:Yr(t,n)}):n&&e.push(i(e)+"skewX("+n+r)}(o.skewX,a.skewX,u,c),function(t,n,e,r,o,a){if(t!==e||n!==r){var u=o.push(i(o)+"scale(",null,",",null,")");a.push({i:u-4,x:Yr(t,e)},{i:u-2,x:Yr(n,r)})}else 1===e&&1===r||o.push(i(o)+"scale("+e+","+r+")")}(o.scaleX,o.scaleY,a.scaleX,a.scaleY,u,c),o=a=null,function(t){for(var n,e=-1,r=c.length;++e=0&&n._call.call(void 0,t),n=n._next;--yi}function Ci(){xi=(mi=Mi.now())+wi,yi=vi=0;try{ki()}finally{yi=0,function(){var t,n,e=pi,r=1/0;for(;e;)e._call?(r>e._time&&(r=e._time),t=e,e=e._next):(n=e._next,e._next=null,e=t?t._next=n:pi=n);gi=t,zi(r)}(),xi=0}}function Pi(){var t=Mi.now(),n=t-mi;n>bi&&(wi-=n,mi=t)}function zi(t){yi||(vi&&(vi=clearTimeout(vi)),t-xi>24?(t<1/0&&(vi=setTimeout(Ci,t-Mi.now()-wi)),_i&&(_i=clearInterval(_i))):(_i||(mi=Mi.now(),_i=setInterval(Pi,bi)),yi=1,Ti(Ci)))}function $i(t,n,e){var r=new Ei;return n=null==n?0:+n,r.restart((e=>{r.stop(),t(e+n)}),n,e),r}Ei.prototype=Ni.prototype={constructor:Ei,restart:function(t,n,e){if("function"!=typeof t)throw new TypeError("callback is not a function");e=(null==e?Ai():+e)+(null==n?0:+n),this._next||gi===this||(gi?gi._next=this:pi=this,gi=this),this._call=t,this._time=e,zi()},stop:function(){this._call&&(this._call=null,this._time=1/0,zi())}};var Di=$t("start","end","cancel","interrupt"),Ri=[],Fi=0,qi=1,Ui=2,Ii=3,Oi=4,Bi=5,Yi=6;function Li(t,n,e,r,i,o){var a=t.__transition;if(a){if(e in a)return}else t.__transition={};!function(t,n,e){var r,i=t.__transition;function o(t){e.state=qi,e.timer.restart(a,e.delay,e.time),e.delay<=t&&a(t-e.delay)}function a(o){var f,s,l,h;if(e.state!==qi)return c();for(f in i)if((h=i[f]).name===e.name){if(h.state===Ii)return $i(a);h.state===Oi?(h.state=Yi,h.timer.stop(),h.on.call("interrupt",t,t.__data__,h.index,h.group),delete i[f]):+fFi)throw new Error("too late; already scheduled");return e}function Hi(t,n){var e=Xi(t,n);if(e.state>Ii)throw new Error("too late; already running");return e}function Xi(t,n){var e=t.__transition;if(!e||!(e=e[n]))throw new Error("transition not found");return e}function Gi(t,n){var e,r,i,o=t.__transition,a=!0;if(o){for(i in n=null==n?null:n+"",o)(e=o[i]).name===n?(r=e.state>Ui&&e.state=0&&(t=t.slice(0,n)),!t||"start"===t}))}(n)?ji:Hi;return function(){var a=o(this,t),u=a.on;u!==r&&(i=(r=u).copy()).on(n,e),a.on=i}}(e,t,n))},attr:function(t,n){var e=It(t),r="transform"===e?ni:Ki;return this.attrTween(t,"function"==typeof n?(e.local?ro:eo)(e,r,Zi(this,"attr."+t,n)):null==n?(e.local?Ji:Qi)(e):(e.local?no:to)(e,r,n))},attrTween:function(t,n){var e="attr."+t;if(arguments.length<2)return(e=this.tween(e))&&e._value;if(null==n)return this.tween(e,null);if("function"!=typeof n)throw new Error;var r=It(t);return this.tween(e,(r.local?io:oo)(r,n))},style:function(t,n,e){var r="transform"==(t+="")?ti:Ki;return null==n?this.styleTween(t,function(t,n){var e,r,i;return function(){var o=_n(this,t),a=(this.style.removeProperty(t),_n(this,t));return o===a?null:o===e&&a===r?i:i=n(e=o,r=a)}}(t,r)).on("end.style."+t,lo(t)):"function"==typeof n?this.styleTween(t,function(t,n,e){var r,i,o;return function(){var a=_n(this,t),u=e(this),c=u+"";return null==u&&(this.style.removeProperty(t),c=u=_n(this,t)),a===c?null:a===r&&c===i?o:(i=c,o=n(r=a,u))}}(t,r,Zi(this,"style."+t,n))).each(function(t,n){var e,r,i,o,a="style."+n,u="end."+a;return function(){var c=Hi(this,t),f=c.on,s=null==c.value[a]?o||(o=lo(n)):void 0;f===e&&i===s||(r=(e=f).copy()).on(u,i=s),c.on=r}}(this._id,t)):this.styleTween(t,function(t,n,e){var r,i,o=e+"";return function(){var a=_n(this,t);return a===o?null:a===r?i:i=n(r=a,e)}}(t,r,n),e).on("end.style."+t,null)},styleTween:function(t,n,e){var r="style."+(t+="");if(arguments.length<2)return(r=this.tween(r))&&r._value;if(null==n)return this.tween(r,null);if("function"!=typeof n)throw new Error;return this.tween(r,function(t,n,e){var r,i;function o(){var o=n.apply(this,arguments);return o!==i&&(r=(i=o)&&function(t,n,e){return function(r){this.style.setProperty(t,n.call(this,r),e)}}(t,o,e)),r}return o._value=n,o}(t,n,null==e?"":e))},text:function(t){return this.tween("text","function"==typeof t?function(t){return function(){var n=t(this);this.textContent=null==n?"":n}}(Zi(this,"text",t)):function(t){return function(){this.textContent=t}}(null==t?"":t+""))},textTween:function(t){var n="text";if(arguments.length<1)return(n=this.tween(n))&&n._value;if(null==t)return this.tween(n,null);if("function"!=typeof t)throw new Error;return this.tween(n,function(t){var n,e;function r(){var r=t.apply(this,arguments);return r!==e&&(n=(e=r)&&function(t){return function(n){this.textContent=t.call(this,n)}}(r)),n}return r._value=t,r}(t))},remove:function(){return this.on("end.remove",function(t){return function(){var n=this.parentNode;for(var e in this.__transition)if(+e!==t)return;n&&n.removeChild(this)}}(this._id))},tween:function(t,n){var e=this._id;if(t+="",arguments.length<2){for(var r,i=Xi(this.node(),e).tween,o=0,a=i.length;o()=>t;function Qo(t,{sourceEvent:n,target:e,selection:r,mode:i,dispatch:o}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:n,enumerable:!0,configurable:!0},target:{value:e,enumerable:!0,configurable:!0},selection:{value:r,enumerable:!0,configurable:!0},mode:{value:i,enumerable:!0,configurable:!0},_:{value:o}})}function Jo(t){t.preventDefault(),t.stopImmediatePropagation()}var ta={name:"drag"},na={name:"space"},ea={name:"handle"},ra={name:"center"};const{abs:ia,max:oa,min:aa}=Math;function ua(t){return[+t[0],+t[1]]}function ca(t){return[ua(t[0]),ua(t[1])]}var fa={name:"x",handles:["w","e"].map(va),input:function(t,n){return null==t?null:[[+t[0],n[0][1]],[+t[1],n[1][1]]]},output:function(t){return t&&[t[0][0],t[1][0]]}},sa={name:"y",handles:["n","s"].map(va),input:function(t,n){return null==t?null:[[n[0][0],+t[0]],[n[1][0],+t[1]]]},output:function(t){return t&&[t[0][1],t[1][1]]}},la={name:"xy",handles:["n","w","e","s","nw","ne","sw","se"].map(va),input:function(t){return null==t?null:ca(t)},output:function(t){return t}},ha={overlay:"crosshair",selection:"move",n:"ns-resize",e:"ew-resize",s:"ns-resize",w:"ew-resize",nw:"nwse-resize",ne:"nesw-resize",se:"nwse-resize",sw:"nesw-resize"},da={e:"w",w:"e",nw:"ne",ne:"nw",se:"sw",sw:"se"},pa={n:"s",s:"n",nw:"sw",ne:"se",se:"ne",sw:"nw"},ga={overlay:1,selection:1,n:null,e:1,s:null,w:-1,nw:-1,ne:1,se:1,sw:-1},ya={overlay:1,selection:1,n:-1,e:null,s:1,w:null,nw:-1,ne:-1,se:1,sw:1};function va(t){return{type:t}}function _a(t){return!t.ctrlKey&&!t.button}function ba(){var t=this.ownerSVGElement||this;return t.hasAttribute("viewBox")?[[(t=t.viewBox.baseVal).x,t.y],[t.x+t.width,t.y+t.height]]:[[0,0],[t.width.baseVal.value,t.height.baseVal.value]]}function ma(){return navigator.maxTouchPoints||"ontouchstart"in this}function xa(t){for(;!t.__brush;)if(!(t=t.parentNode))return;return t.__brush}function wa(t){var n,e=ba,r=_a,i=ma,o=!0,a=$t("start","brush","end"),u=6;function c(n){var e=n.property("__brush",g).selectAll(".overlay").data([va("overlay")]);e.enter().append("rect").attr("class","overlay").attr("pointer-events","all").attr("cursor",ha.overlay).merge(e).each((function(){var t=xa(this).extent;Zn(this).attr("x",t[0][0]).attr("y",t[0][1]).attr("width",t[1][0]-t[0][0]).attr("height",t[1][1]-t[0][1])})),n.selectAll(".selection").data([va("selection")]).enter().append("rect").attr("class","selection").attr("cursor",ha.selection).attr("fill","#777").attr("fill-opacity",.3).attr("stroke","#fff").attr("shape-rendering","crispEdges");var r=n.selectAll(".handle").data(t.handles,(function(t){return t.type}));r.exit().remove(),r.enter().append("rect").attr("class",(function(t){return"handle handle--"+t.type})).attr("cursor",(function(t){return ha[t.type]})),n.each(f).attr("fill","none").attr("pointer-events","all").on("mousedown.brush",h).filter(i).on("touchstart.brush",h).on("touchmove.brush",d).on("touchend.brush touchcancel.brush",p).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function f(){var t=Zn(this),n=xa(this).selection;n?(t.selectAll(".selection").style("display",null).attr("x",n[0][0]).attr("y",n[0][1]).attr("width",n[1][0]-n[0][0]).attr("height",n[1][1]-n[0][1]),t.selectAll(".handle").style("display",null).attr("x",(function(t){return"e"===t.type[t.type.length-1]?n[1][0]-u/2:n[0][0]-u/2})).attr("y",(function(t){return"s"===t.type[0]?n[1][1]-u/2:n[0][1]-u/2})).attr("width",(function(t){return"n"===t.type||"s"===t.type?n[1][0]-n[0][0]+u:u})).attr("height",(function(t){return"e"===t.type||"w"===t.type?n[1][1]-n[0][1]+u:u}))):t.selectAll(".selection,.handle").style("display","none").attr("x",null).attr("y",null).attr("width",null).attr("height",null)}function s(t,n,e){var r=t.__brush.emitter;return!r||e&&r.clean?new l(t,n,e):r}function l(t,n,e){this.that=t,this.args=n,this.state=t.__brush,this.active=0,this.clean=e}function h(e){if((!n||e.touches)&&r.apply(this,arguments)){var i,a,u,c,l,h,d,p,g,y,v,_=this,b=e.target.__data__.type,m="selection"===(o&&e.metaKey?b="overlay":b)?ta:o&&e.altKey?ra:ea,x=t===sa?null:ga[b],w=t===fa?null:ya[b],M=xa(_),T=M.extent,A=M.selection,S=T[0][0],E=T[0][1],N=T[1][0],k=T[1][1],C=0,P=0,z=x&&w&&o&&e.shiftKey,$=Array.from(e.touches||[e],(t=>{const n=t.identifier;return(t=ne(t,_)).point0=t.slice(),t.identifier=n,t}));Gi(_);var D=s(_,arguments,!0).beforestart();if("overlay"===b){A&&(g=!0);const n=[$[0],$[1]||$[0]];M.selection=A=[[i=t===sa?S:aa(n[0][0],n[1][0]),u=t===fa?E:aa(n[0][1],n[1][1])],[l=t===sa?N:oa(n[0][0],n[1][0]),d=t===fa?k:oa(n[0][1],n[1][1])]],$.length>1&&I(e)}else i=A[0][0],u=A[0][1],l=A[1][0],d=A[1][1];a=i,c=u,h=l,p=d;var R=Zn(_).attr("pointer-events","none"),F=R.selectAll(".overlay").attr("cursor",ha[b]);if(e.touches)D.moved=U,D.ended=O;else{var q=Zn(e.view).on("mousemove.brush",U,!0).on("mouseup.brush",O,!0);o&&q.on("keydown.brush",(function(t){switch(t.keyCode){case 16:z=x&&w;break;case 18:m===ea&&(x&&(l=h-C*x,i=a+C*x),w&&(d=p-P*w,u=c+P*w),m=ra,I(t));break;case 32:m!==ea&&m!==ra||(x<0?l=h-C:x>0&&(i=a-C),w<0?d=p-P:w>0&&(u=c-P),m=na,F.attr("cursor",ha.selection),I(t));break;default:return}Jo(t)}),!0).on("keyup.brush",(function(t){switch(t.keyCode){case 16:z&&(y=v=z=!1,I(t));break;case 18:m===ra&&(x<0?l=h:x>0&&(i=a),w<0?d=p:w>0&&(u=c),m=ea,I(t));break;case 32:m===na&&(t.altKey?(x&&(l=h-C*x,i=a+C*x),w&&(d=p-P*w,u=c+P*w),m=ra):(x<0?l=h:x>0&&(i=a),w<0?d=p:w>0&&(u=c),m=ea),F.attr("cursor",ha[b]),I(t));break;default:return}Jo(t)}),!0),ae(e.view)}f.call(_),D.start(e,m.name)}function U(t){for(const n of t.changedTouches||[t])for(const t of $)t.identifier===n.identifier&&(t.cur=ne(n,_));if(z&&!y&&!v&&1===$.length){const t=$[0];ia(t.cur[0]-t[0])>ia(t.cur[1]-t[1])?v=!0:y=!0}for(const t of $)t.cur&&(t[0]=t.cur[0],t[1]=t.cur[1]);g=!0,Jo(t),I(t)}function I(t){const n=$[0],e=n.point0;var r;switch(C=n[0]-e[0],P=n[1]-e[1],m){case na:case ta:x&&(C=oa(S-i,aa(N-l,C)),a=i+C,h=l+C),w&&(P=oa(E-u,aa(k-d,P)),c=u+P,p=d+P);break;case ea:$[1]?(x&&(a=oa(S,aa(N,$[0][0])),h=oa(S,aa(N,$[1][0])),x=1),w&&(c=oa(E,aa(k,$[0][1])),p=oa(E,aa(k,$[1][1])),w=1)):(x<0?(C=oa(S-i,aa(N-i,C)),a=i+C,h=l):x>0&&(C=oa(S-l,aa(N-l,C)),a=i,h=l+C),w<0?(P=oa(E-u,aa(k-u,P)),c=u+P,p=d):w>0&&(P=oa(E-d,aa(k-d,P)),c=u,p=d+P));break;case ra:x&&(a=oa(S,aa(N,i-C*x)),h=oa(S,aa(N,l+C*x))),w&&(c=oa(E,aa(k,u-P*w)),p=oa(E,aa(k,d+P*w)))}ht+e))}function za(t,n){var e=0,r=null,i=null,o=null;function a(a){var u,c=a.length,f=new Array(c),s=Pa(0,c),l=new Array(c*c),h=new Array(c),d=0;a=Float64Array.from({length:c*c},n?(t,n)=>a[n%c][n/c|0]:(t,n)=>a[n/c|0][n%c]);for(let n=0;nr(f[t],f[n])));for(const e of s){const r=n;if(t){const t=Pa(1+~c,c).filter((t=>t<0?a[~t*c+e]:a[e*c+t]));i&&t.sort(((t,n)=>i(t<0?-a[~t*c+e]:a[e*c+t],n<0?-a[~n*c+e]:a[e*c+n])));for(const r of t)if(r<0){(l[~r*c+e]||(l[~r*c+e]={source:null,target:null})).target={index:e,startAngle:n,endAngle:n+=a[~r*c+e]*d,value:a[~r*c+e]}}else{(l[e*c+r]||(l[e*c+r]={source:null,target:null})).source={index:e,startAngle:n,endAngle:n+=a[e*c+r]*d,value:a[e*c+r]}}h[e]={index:e,startAngle:r,endAngle:n,value:f[e]}}else{const t=Pa(0,c).filter((t=>a[e*c+t]||a[t*c+e]));i&&t.sort(((t,n)=>i(a[e*c+t],a[e*c+n])));for(const r of t){let t;if(e=0))throw new Error(`invalid digits: ${t}`);if(n>15)return qa;const e=10**n;return function(t){this._+=t[0];for(let n=1,r=t.length;nRa)if(Math.abs(s*u-c*f)>Ra&&i){let h=e-o,d=r-a,p=u*u+c*c,g=h*h+d*d,y=Math.sqrt(p),v=Math.sqrt(l),_=i*Math.tan(($a-Math.acos((p+l-g)/(2*y*v)))/2),b=_/v,m=_/y;Math.abs(b-1)>Ra&&this._append`L${t+b*f},${n+b*s}`,this._append`A${i},${i},0,0,${+(s*h>f*d)},${this._x1=t+m*u},${this._y1=n+m*c}`}else this._append`L${this._x1=t},${this._y1=n}`;else;}arc(t,n,e,r,i,o){if(t=+t,n=+n,o=!!o,(e=+e)<0)throw new Error(`negative radius: ${e}`);let a=e*Math.cos(r),u=e*Math.sin(r),c=t+a,f=n+u,s=1^o,l=o?r-i:i-r;null===this._x1?this._append`M${c},${f}`:(Math.abs(this._x1-c)>Ra||Math.abs(this._y1-f)>Ra)&&this._append`L${c},${f}`,e&&(l<0&&(l=l%Da+Da),l>Fa?this._append`A${e},${e},0,1,${s},${t-a},${n-u}A${e},${e},0,1,${s},${this._x1=c},${this._y1=f}`:l>Ra&&this._append`A${e},${e},0,${+(l>=$a)},${s},${this._x1=t+e*Math.cos(i)},${this._y1=n+e*Math.sin(i)}`)}rect(t,n,e,r){this._append`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}h${e=+e}v${+r}h${-e}Z`}toString(){return this._}};function Ia(){return new Ua}Ia.prototype=Ua.prototype;var Oa=Array.prototype.slice;function Ba(t){return function(){return t}}function Ya(t){return t.source}function La(t){return t.target}function ja(t){return t.radius}function Ha(t){return t.startAngle}function Xa(t){return t.endAngle}function Ga(){return 0}function Va(){return 10}function Wa(t){var n=Ya,e=La,r=ja,i=ja,o=Ha,a=Xa,u=Ga,c=null;function f(){var f,s=n.apply(this,arguments),l=e.apply(this,arguments),h=u.apply(this,arguments)/2,d=Oa.call(arguments),p=+r.apply(this,(d[0]=s,d)),g=o.apply(this,d)-Ea,y=a.apply(this,d)-Ea,v=+i.apply(this,(d[0]=l,d)),_=o.apply(this,d)-Ea,b=a.apply(this,d)-Ea;if(c||(c=f=Ia()),h>Ca&&(Ma(y-g)>2*h+Ca?y>g?(g+=h,y-=h):(g-=h,y+=h):g=y=(g+y)/2,Ma(b-_)>2*h+Ca?b>_?(_+=h,b-=h):(_-=h,b+=h):_=b=(_+b)/2),c.moveTo(p*Ta(g),p*Aa(g)),c.arc(0,0,p,g,y),g!==_||y!==b)if(t){var m=v-+t.apply(this,arguments),x=(_+b)/2;c.quadraticCurveTo(0,0,m*Ta(_),m*Aa(_)),c.lineTo(v*Ta(x),v*Aa(x)),c.lineTo(m*Ta(b),m*Aa(b))}else c.quadraticCurveTo(0,0,v*Ta(_),v*Aa(_)),c.arc(0,0,v,_,b);if(c.quadraticCurveTo(0,0,p*Ta(g),p*Aa(g)),c.closePath(),f)return c=null,f+""||null}return t&&(f.headRadius=function(n){return arguments.length?(t="function"==typeof n?n:Ba(+n),f):t}),f.radius=function(t){return arguments.length?(r=i="function"==typeof t?t:Ba(+t),f):r},f.sourceRadius=function(t){return arguments.length?(r="function"==typeof t?t:Ba(+t),f):r},f.targetRadius=function(t){return arguments.length?(i="function"==typeof t?t:Ba(+t),f):i},f.startAngle=function(t){return arguments.length?(o="function"==typeof t?t:Ba(+t),f):o},f.endAngle=function(t){return arguments.length?(a="function"==typeof t?t:Ba(+t),f):a},f.padAngle=function(t){return arguments.length?(u="function"==typeof t?t:Ba(+t),f):u},f.source=function(t){return arguments.length?(n=t,f):n},f.target=function(t){return arguments.length?(e=t,f):e},f.context=function(t){return arguments.length?(c=null==t?null:t,f):c},f}var Za=Array.prototype.slice;function Ka(t,n){return t-n}var Qa=t=>()=>t;function Ja(t,n){for(var e,r=-1,i=n.length;++rr!=d>r&&e<(h-f)*(r-s)/(d-s)+f&&(i=-i)}return i}function nu(t,n,e){var r,i,o,a;return function(t,n,e){return(n[0]-t[0])*(e[1]-t[1])==(e[0]-t[0])*(n[1]-t[1])}(t,n,e)&&(i=t[r=+(t[0]===n[0])],o=e[r],a=n[r],i<=o&&o<=a||a<=o&&o<=i)}function eu(){}var ru=[[],[[[1,1.5],[.5,1]]],[[[1.5,1],[1,1.5]]],[[[1.5,1],[.5,1]]],[[[1,.5],[1.5,1]]],[[[1,1.5],[.5,1]],[[1,.5],[1.5,1]]],[[[1,.5],[1,1.5]]],[[[1,.5],[.5,1]]],[[[.5,1],[1,.5]]],[[[1,1.5],[1,.5]]],[[[.5,1],[1,.5]],[[1.5,1],[1,1.5]]],[[[1.5,1],[1,.5]]],[[[.5,1],[1.5,1]]],[[[1,1.5],[1.5,1]]],[[[.5,1],[1,1.5]]],[]];function iu(){var t=1,n=1,e=K,r=u;function i(t){var n=e(t);if(Array.isArray(n))n=n.slice().sort(Ka);else{const e=M(t,ou);for(n=G(...Z(e[0],e[1],n),n);n[n.length-1]>=e[1];)n.pop();for(;n[1]o(t,n)))}function o(e,i){const o=null==i?NaN:+i;if(isNaN(o))throw new Error(`invalid value: ${i}`);var u=[],c=[];return function(e,r,i){var o,u,c,f,s,l,h=new Array,d=new Array;o=u=-1,f=au(e[0],r),ru[f<<1].forEach(p);for(;++o=r,ru[s<<2].forEach(p);for(;++o0?u.push([t]):c.push(t)})),c.forEach((function(t){for(var n,e=0,r=u.length;e0&&o0&&a=0&&o>=0))throw new Error("invalid size");return t=r,n=o,i},i.thresholds=function(t){return arguments.length?(e="function"==typeof t?t:Array.isArray(t)?Qa(Za.call(t)):Qa(t),i):e},i.smooth=function(t){return arguments.length?(r=t?u:eu,i):r===u},i}function ou(t){return isFinite(t)?t:NaN}function au(t,n){return null!=t&&+t>=n}function uu(t){return null==t||isNaN(t=+t)?-1/0:t}function cu(t,n,e,r){const i=r-n,o=e-n,a=isFinite(i)||isFinite(o)?i/o:Math.sign(i)/Math.sign(o);return isNaN(a)?t:t+a-.5}function fu(t){return t[0]}function su(t){return t[1]}function lu(){return 1}const hu=134217729,du=33306690738754706e-32;function pu(t,n,e,r,i){let o,a,u,c,f=n[0],s=r[0],l=0,h=0;s>f==s>-f?(o=f,f=n[++l]):(o=s,s=r[++h]);let d=0;if(lf==s>-f?(a=f+o,u=o-(a-f),f=n[++l]):(a=s+o,u=o-(a-s),s=r[++h]),o=a,0!==u&&(i[d++]=u);lf==s>-f?(a=o+f,c=a-o,u=o-(a-c)+(f-c),f=n[++l]):(a=o+s,c=a-o,u=o-(a-c)+(s-c),s=r[++h]),o=a,0!==u&&(i[d++]=u);for(;l=33306690738754716e-32*f?c:-function(t,n,e,r,i,o,a){let u,c,f,s,l,h,d,p,g,y,v,_,b,m,x,w,M,T;const A=t-i,S=e-i,E=n-o,N=r-o;m=A*N,h=hu*A,d=h-(h-A),p=A-d,h=hu*N,g=h-(h-N),y=N-g,x=p*y-(m-d*g-p*g-d*y),w=E*S,h=hu*E,d=h-(h-E),p=E-d,h=hu*S,g=h-(h-S),y=S-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,_u[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,_u[1]=b-(v+l)+(l-w),T=_+v,l=T-_,_u[2]=_-(T-l)+(v-l),_u[3]=T;let k=function(t,n){let e=n[0];for(let r=1;r=C||-k>=C)return k;if(l=t-A,u=t-(A+l)+(l-i),l=e-S,f=e-(S+l)+(l-i),l=n-E,c=n-(E+l)+(l-o),l=r-N,s=r-(N+l)+(l-o),0===u&&0===c&&0===f&&0===s)return k;if(C=vu*a+du*Math.abs(k),k+=A*s+N*u-(E*f+S*c),k>=C||-k>=C)return k;m=u*N,h=hu*u,d=h-(h-u),p=u-d,h=hu*N,g=h-(h-N),y=N-g,x=p*y-(m-d*g-p*g-d*y),w=c*S,h=hu*c,d=h-(h-c),p=c-d,h=hu*S,g=h-(h-S),y=S-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,wu[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,wu[1]=b-(v+l)+(l-w),T=_+v,l=T-_,wu[2]=_-(T-l)+(v-l),wu[3]=T;const P=pu(4,_u,4,wu,bu);m=A*s,h=hu*A,d=h-(h-A),p=A-d,h=hu*s,g=h-(h-s),y=s-g,x=p*y-(m-d*g-p*g-d*y),w=E*f,h=hu*E,d=h-(h-E),p=E-d,h=hu*f,g=h-(h-f),y=f-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,wu[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,wu[1]=b-(v+l)+(l-w),T=_+v,l=T-_,wu[2]=_-(T-l)+(v-l),wu[3]=T;const z=pu(P,bu,4,wu,mu);m=u*s,h=hu*u,d=h-(h-u),p=u-d,h=hu*s,g=h-(h-s),y=s-g,x=p*y-(m-d*g-p*g-d*y),w=c*f,h=hu*c,d=h-(h-c),p=c-d,h=hu*f,g=h-(h-f),y=f-g,M=p*y-(w-d*g-p*g-d*y),v=x-M,l=x-v,wu[0]=x-(v+l)+(l-M),_=m+v,l=_-m,b=m-(_-l)+(v-l),v=b-w,l=b-v,wu[1]=b-(v+l)+(l-w),T=_+v,l=T-_,wu[2]=_-(T-l)+(v-l),wu[3]=T;const $=pu(z,mu,4,wu,xu);return xu[$-1]}(t,n,e,r,i,o,f)}const Tu=Math.pow(2,-52),Au=new Uint32Array(512);class Su{static from(t,n=zu,e=$u){const r=t.length,i=new Float64Array(2*r);for(let o=0;o>1;if(n>0&&"number"!=typeof t[0])throw new Error("Expected coords to contain numbers.");this.coords=t;const e=Math.max(2*n-5,0);this._triangles=new Uint32Array(3*e),this._halfedges=new Int32Array(3*e),this._hashSize=Math.ceil(Math.sqrt(n)),this._hullPrev=new Uint32Array(n),this._hullNext=new Uint32Array(n),this._hullTri=new Uint32Array(n),this._hullHash=new Int32Array(this._hashSize).fill(-1),this._ids=new Uint32Array(n),this._dists=new Float64Array(n),this.update()}update(){const{coords:t,_hullPrev:n,_hullNext:e,_hullTri:r,_hullHash:i}=this,o=t.length>>1;let a=1/0,u=1/0,c=-1/0,f=-1/0;for(let n=0;nc&&(c=e),r>f&&(f=r),this._ids[n]=n}const s=(a+c)/2,l=(u+f)/2;let h,d,p,g=1/0;for(let n=0;n0&&(d=n,g=e)}let _=t[2*d],b=t[2*d+1],m=1/0;for(let n=0;nr&&(n[e++]=i,r=this._dists[i])}return this.hull=n.subarray(0,e),this.triangles=new Uint32Array(0),void(this.halfedges=new Uint32Array(0))}if(Mu(y,v,_,b,x,w)<0){const t=d,n=_,e=b;d=p,_=x,b=w,p=t,x=n,w=e}const M=function(t,n,e,r,i,o){const a=e-t,u=r-n,c=i-t,f=o-n,s=a*a+u*u,l=c*c+f*f,h=.5/(a*f-u*c),d=t+(f*s-u*l)*h,p=n+(a*l-c*s)*h;return{x:d,y:p}}(y,v,_,b,x,w);this._cx=M.x,this._cy=M.y;for(let n=0;n0&&Math.abs(f-o)<=Tu&&Math.abs(s-a)<=Tu)continue;if(o=f,a=s,c===h||c===d||c===p)continue;let l=0;for(let t=0,n=this._hashKey(f,s);t=0;)if(y=g,y===l){y=-1;break}if(-1===y)continue;let v=this._addTriangle(y,c,e[y],-1,-1,r[y]);r[c]=this._legalize(v+2),r[y]=v,T++;let _=e[y];for(;g=e[_],Mu(f,s,t[2*_],t[2*_+1],t[2*g],t[2*g+1])<0;)v=this._addTriangle(_,c,g,r[c],-1,r[_]),r[c]=this._legalize(v+2),e[_]=_,T--,_=g;if(y===l)for(;g=n[y],Mu(f,s,t[2*g],t[2*g+1],t[2*y],t[2*y+1])<0;)v=this._addTriangle(g,c,y,-1,r[y],r[g]),this._legalize(v+2),r[g]=v,e[y]=y,T--,y=g;this._hullStart=n[c]=y,e[y]=n[_]=c,e[c]=_,i[this._hashKey(f,s)]=c,i[this._hashKey(t[2*y],t[2*y+1])]=y}this.hull=new Uint32Array(T);for(let t=0,n=this._hullStart;t0?3-e:1+e)/4}(t-this._cx,n-this._cy)*this._hashSize)%this._hashSize}_legalize(t){const{_triangles:n,_halfedges:e,coords:r}=this;let i=0,o=0;for(;;){const a=e[t],u=t-t%3;if(o=u+(t+2)%3,-1===a){if(0===i)break;t=Au[--i];continue}const c=a-a%3,f=u+(t+1)%3,s=c+(a+2)%3,l=n[o],h=n[t],d=n[f],p=n[s];if(Nu(r[2*l],r[2*l+1],r[2*h],r[2*h+1],r[2*d],r[2*d+1],r[2*p],r[2*p+1])){n[t]=p,n[a]=l;const r=e[s];if(-1===r){let n=this._hullStart;do{if(this._hullTri[n]===s){this._hullTri[n]=t;break}n=this._hullPrev[n]}while(n!==this._hullStart)}this._link(t,r),this._link(a,e[o]),this._link(o,s);const u=c+(a+1)%3;i=e&&n[t[a]]>o;)t[a+1]=t[a--];t[a+1]=r}else{let i=e+1,o=r;Pu(t,e+r>>1,i),n[t[e]]>n[t[r]]&&Pu(t,e,r),n[t[i]]>n[t[r]]&&Pu(t,i,r),n[t[e]]>n[t[i]]&&Pu(t,e,i);const a=t[i],u=n[a];for(;;){do{i++}while(n[t[i]]u);if(o=o-e?(Cu(t,n,i,r),Cu(t,n,e,o-1)):(Cu(t,n,e,o-1),Cu(t,n,i,r))}}function Pu(t,n,e){const r=t[n];t[n]=t[e],t[e]=r}function zu(t){return t[0]}function $u(t){return t[1]}const Du=1e-6;class Ru{constructor(){this._x0=this._y0=this._x1=this._y1=null,this._=""}moveTo(t,n){this._+=`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}`}closePath(){null!==this._x1&&(this._x1=this._x0,this._y1=this._y0,this._+="Z")}lineTo(t,n){this._+=`L${this._x1=+t},${this._y1=+n}`}arc(t,n,e){const r=(t=+t)+(e=+e),i=n=+n;if(e<0)throw new Error("negative radius");null===this._x1?this._+=`M${r},${i}`:(Math.abs(this._x1-r)>Du||Math.abs(this._y1-i)>Du)&&(this._+="L"+r+","+i),e&&(this._+=`A${e},${e},0,1,1,${t-e},${n}A${e},${e},0,1,1,${this._x1=r},${this._y1=i}`)}rect(t,n,e,r){this._+=`M${this._x0=this._x1=+t},${this._y0=this._y1=+n}h${+e}v${+r}h${-e}Z`}value(){return this._||null}}class Fu{constructor(){this._=[]}moveTo(t,n){this._.push([t,n])}closePath(){this._.push(this._[0].slice())}lineTo(t,n){this._.push([t,n])}value(){return this._.length?this._:null}}class qu{constructor(t,[n,e,r,i]=[0,0,960,500]){if(!((r=+r)>=(n=+n)&&(i=+i)>=(e=+e)))throw new Error("invalid bounds");this.delaunay=t,this._circumcenters=new Float64Array(2*t.points.length),this.vectors=new Float64Array(2*t.points.length),this.xmax=r,this.xmin=n,this.ymax=i,this.ymin=e,this._init()}update(){return this.delaunay.update(),this._init(),this}_init(){const{delaunay:{points:t,hull:n,triangles:e},vectors:r}=this;let i,o;const a=this.circumcenters=this._circumcenters.subarray(0,e.length/3*2);for(let r,u,c=0,f=0,s=e.length;c1;)i-=2;for(let t=2;t0){if(n>=this.ymax)return null;(i=(this.ymax-n)/r)0){if(t>=this.xmax)return null;(i=(this.xmax-t)/e)this.xmax?2:0)|(nthis.ymax?8:0)}_simplify(t){if(t&&t.length>4){for(let n=0;n2&&function(t){const{triangles:n,coords:e}=t;for(let t=0;t1e-10)return!1}return!0}(t)){this.collinear=Int32Array.from({length:n.length/2},((t,n)=>n)).sort(((t,e)=>n[2*t]-n[2*e]||n[2*t+1]-n[2*e+1]));const t=this.collinear[0],e=this.collinear[this.collinear.length-1],r=[n[2*t],n[2*t+1],n[2*e],n[2*e+1]],i=1e-8*Math.hypot(r[3]-r[1],r[2]-r[0]);for(let t=0,e=n.length/2;t0&&(this.triangles=new Int32Array(3).fill(-1),this.halfedges=new Int32Array(3).fill(-1),this.triangles[0]=r[0],o[r[0]]=1,2===r.length&&(o[r[1]]=0,this.triangles[1]=r[1],this.triangles[2]=r[1]))}voronoi(t){return new qu(this,t)}*neighbors(t){const{inedges:n,hull:e,_hullIndex:r,halfedges:i,triangles:o,collinear:a}=this;if(a){const n=a.indexOf(t);return n>0&&(yield a[n-1]),void(n=0&&i!==e&&i!==r;)e=i;return i}_step(t,n,e){const{inedges:r,hull:i,_hullIndex:o,halfedges:a,triangles:u,points:c}=this;if(-1===r[t]||!c.length)return(t+1)%(c.length>>1);let f=t,s=Iu(n-c[2*t],2)+Iu(e-c[2*t+1],2);const l=r[t];let h=l;do{let r=u[h];const l=Iu(n-c[2*r],2)+Iu(e-c[2*r+1],2);if(l9999?"+"+Ku(n,6):Ku(n,4))+"-"+Ku(t.getUTCMonth()+1,2)+"-"+Ku(t.getUTCDate(),2)+(o?"T"+Ku(e,2)+":"+Ku(r,2)+":"+Ku(i,2)+"."+Ku(o,3)+"Z":i?"T"+Ku(e,2)+":"+Ku(r,2)+":"+Ku(i,2)+"Z":r||e?"T"+Ku(e,2)+":"+Ku(r,2)+"Z":"")}function Ju(t){var n=new RegExp('["'+t+"\n\r]"),e=t.charCodeAt(0);function r(t,n){var r,i=[],o=t.length,a=0,u=0,c=o<=0,f=!1;function s(){if(c)return Hu;if(f)return f=!1,ju;var n,r,i=a;if(t.charCodeAt(i)===Xu){for(;a++=o?c=!0:(r=t.charCodeAt(a++))===Gu?f=!0:r===Vu&&(f=!0,t.charCodeAt(a)===Gu&&++a),t.slice(i+1,n-1).replace(/""/g,'"')}for(;amc(n,e).then((n=>(new DOMParser).parseFromString(n,t)))}var Sc=Ac("application/xml"),Ec=Ac("text/html"),Nc=Ac("image/svg+xml");function kc(t,n,e,r){if(isNaN(n)||isNaN(e))return t;var i,o,a,u,c,f,s,l,h,d=t._root,p={data:r},g=t._x0,y=t._y0,v=t._x1,_=t._y1;if(!d)return t._root=p,t;for(;d.length;)if((f=n>=(o=(g+v)/2))?g=o:v=o,(s=e>=(a=(y+_)/2))?y=a:_=a,i=d,!(d=d[l=s<<1|f]))return i[l]=p,t;if(u=+t._x.call(null,d.data),c=+t._y.call(null,d.data),n===u&&e===c)return p.next=d,i?i[l]=p:t._root=p,t;do{i=i?i[l]=new Array(4):t._root=new Array(4),(f=n>=(o=(g+v)/2))?g=o:v=o,(s=e>=(a=(y+_)/2))?y=a:_=a}while((l=s<<1|f)==(h=(c>=a)<<1|u>=o));return i[h]=d,i[l]=p,t}function Cc(t,n,e,r,i){this.node=t,this.x0=n,this.y0=e,this.x1=r,this.y1=i}function Pc(t){return t[0]}function zc(t){return t[1]}function $c(t,n,e){var r=new Dc(null==n?Pc:n,null==e?zc:e,NaN,NaN,NaN,NaN);return null==t?r:r.addAll(t)}function Dc(t,n,e,r,i,o){this._x=t,this._y=n,this._x0=e,this._y0=r,this._x1=i,this._y1=o,this._root=void 0}function Rc(t){for(var n={data:t.data},e=n;t=t.next;)e=e.next={data:t.data};return n}var Fc=$c.prototype=Dc.prototype;function qc(t){return function(){return t}}function Uc(t){return 1e-6*(t()-.5)}function Ic(t){return t.x+t.vx}function Oc(t){return t.y+t.vy}function Bc(t){return t.index}function Yc(t,n){var e=t.get(n);if(!e)throw new Error("node not found: "+n);return e}Fc.copy=function(){var t,n,e=new Dc(this._x,this._y,this._x0,this._y0,this._x1,this._y1),r=this._root;if(!r)return e;if(!r.length)return e._root=Rc(r),e;for(t=[{source:r,target:e._root=new Array(4)}];r=t.pop();)for(var i=0;i<4;++i)(n=r.source[i])&&(n.length?t.push({source:n,target:r.target[i]=new Array(4)}):r.target[i]=Rc(n));return e},Fc.add=function(t){const n=+this._x.call(null,t),e=+this._y.call(null,t);return kc(this.cover(n,e),n,e,t)},Fc.addAll=function(t){var n,e,r,i,o=t.length,a=new Array(o),u=new Array(o),c=1/0,f=1/0,s=-1/0,l=-1/0;for(e=0;es&&(s=r),il&&(l=i));if(c>s||f>l)return this;for(this.cover(c,f).cover(s,l),e=0;et||t>=i||r>n||n>=o;)switch(u=(nh||(o=c.y0)>d||(a=c.x1)=v)<<1|t>=y)&&(c=p[p.length-1],p[p.length-1]=p[p.length-1-f],p[p.length-1-f]=c)}else{var _=t-+this._x.call(null,g.data),b=n-+this._y.call(null,g.data),m=_*_+b*b;if(m=(u=(p+y)/2))?p=u:y=u,(s=a>=(c=(g+v)/2))?g=c:v=c,n=d,!(d=d[l=s<<1|f]))return this;if(!d.length)break;(n[l+1&3]||n[l+2&3]||n[l+3&3])&&(e=n,h=l)}for(;d.data!==t;)if(r=d,!(d=d.next))return this;return(i=d.next)&&delete d.next,r?(i?r.next=i:delete r.next,this):n?(i?n[l]=i:delete n[l],(d=n[0]||n[1]||n[2]||n[3])&&d===(n[3]||n[2]||n[1]||n[0])&&!d.length&&(e?e[h]=d:this._root=d),this):(this._root=i,this)},Fc.removeAll=function(t){for(var n=0,e=t.length;n1?r[0]+r.slice(2):r,+t.slice(e+1)]}function Zc(t){return(t=Wc(Math.abs(t)))?t[1]:NaN}var Kc,Qc=/^(?:(.)?([<>=^]))?([+\-( ])?([$#])?(0)?(\d+)?(,)?(\.\d+)?(~)?([a-z%])?$/i;function Jc(t){if(!(n=Qc.exec(t)))throw new Error("invalid format: "+t);var n;return new tf({fill:n[1],align:n[2],sign:n[3],symbol:n[4],zero:n[5],width:n[6],comma:n[7],precision:n[8]&&n[8].slice(1),trim:n[9],type:n[10]})}function tf(t){this.fill=void 0===t.fill?" ":t.fill+"",this.align=void 0===t.align?">":t.align+"",this.sign=void 0===t.sign?"-":t.sign+"",this.symbol=void 0===t.symbol?"":t.symbol+"",this.zero=!!t.zero,this.width=void 0===t.width?void 0:+t.width,this.comma=!!t.comma,this.precision=void 0===t.precision?void 0:+t.precision,this.trim=!!t.trim,this.type=void 0===t.type?"":t.type+""}function nf(t,n){var e=Wc(t,n);if(!e)return t+"";var r=e[0],i=e[1];return i<0?"0."+new Array(-i).join("0")+r:r.length>i+1?r.slice(0,i+1)+"."+r.slice(i+1):r+new Array(i-r.length+2).join("0")}Jc.prototype=tf.prototype,tf.prototype.toString=function(){return this.fill+this.align+this.sign+this.symbol+(this.zero?"0":"")+(void 0===this.width?"":Math.max(1,0|this.width))+(this.comma?",":"")+(void 0===this.precision?"":"."+Math.max(0,0|this.precision))+(this.trim?"~":"")+this.type};var ef={"%":(t,n)=>(100*t).toFixed(n),b:t=>Math.round(t).toString(2),c:t=>t+"",d:function(t){return Math.abs(t=Math.round(t))>=1e21?t.toLocaleString("en").replace(/,/g,""):t.toString(10)},e:(t,n)=>t.toExponential(n),f:(t,n)=>t.toFixed(n),g:(t,n)=>t.toPrecision(n),o:t=>Math.round(t).toString(8),p:(t,n)=>nf(100*t,n),r:nf,s:function(t,n){var e=Wc(t,n);if(!e)return t+"";var r=e[0],i=e[1],o=i-(Kc=3*Math.max(-8,Math.min(8,Math.floor(i/3))))+1,a=r.length;return o===a?r:o>a?r+new Array(o-a+1).join("0"):o>0?r.slice(0,o)+"."+r.slice(o):"0."+new Array(1-o).join("0")+Wc(t,Math.max(0,n+o-1))[0]},X:t=>Math.round(t).toString(16).toUpperCase(),x:t=>Math.round(t).toString(16)};function rf(t){return t}var of,af=Array.prototype.map,uf=["y","z","a","f","p","n","µ","m","","k","M","G","T","P","E","Z","Y"];function cf(t){var n,e,r=void 0===t.grouping||void 0===t.thousands?rf:(n=af.call(t.grouping,Number),e=t.thousands+"",function(t,r){for(var i=t.length,o=[],a=0,u=n[0],c=0;i>0&&u>0&&(c+u+1>r&&(u=Math.max(1,r-c)),o.push(t.substring(i-=u,i+u)),!((c+=u+1)>r));)u=n[a=(a+1)%n.length];return o.reverse().join(e)}),i=void 0===t.currency?"":t.currency[0]+"",o=void 0===t.currency?"":t.currency[1]+"",a=void 0===t.decimal?".":t.decimal+"",u=void 0===t.numerals?rf:function(t){return function(n){return n.replace(/[0-9]/g,(function(n){return t[+n]}))}}(af.call(t.numerals,String)),c=void 0===t.percent?"%":t.percent+"",f=void 0===t.minus?"−":t.minus+"",s=void 0===t.nan?"NaN":t.nan+"";function l(t){var n=(t=Jc(t)).fill,e=t.align,l=t.sign,h=t.symbol,d=t.zero,p=t.width,g=t.comma,y=t.precision,v=t.trim,_=t.type;"n"===_?(g=!0,_="g"):ef[_]||(void 0===y&&(y=12),v=!0,_="g"),(d||"0"===n&&"="===e)&&(d=!0,n="0",e="=");var b="$"===h?i:"#"===h&&/[boxX]/.test(_)?"0"+_.toLowerCase():"",m="$"===h?o:/[%p]/.test(_)?c:"",x=ef[_],w=/[defgprs%]/.test(_);function M(t){var i,o,c,h=b,M=m;if("c"===_)M=x(t)+M,t="";else{var T=(t=+t)<0||1/t<0;if(t=isNaN(t)?s:x(Math.abs(t),y),v&&(t=function(t){t:for(var n,e=t.length,r=1,i=-1;r0&&(i=0)}return i>0?t.slice(0,i)+t.slice(n+1):t}(t)),T&&0==+t&&"+"!==l&&(T=!1),h=(T?"("===l?l:f:"-"===l||"("===l?"":l)+h,M=("s"===_?uf[8+Kc/3]:"")+M+(T&&"("===l?")":""),w)for(i=-1,o=t.length;++i(c=t.charCodeAt(i))||c>57){M=(46===c?a+t.slice(i+1):t.slice(i))+M,t=t.slice(0,i);break}}g&&!d&&(t=r(t,1/0));var A=h.length+t.length+M.length,S=A>1)+h+t+M+S.slice(A);break;default:t=S+h+t+M}return u(t)}return y=void 0===y?6:/[gprs]/.test(_)?Math.max(1,Math.min(21,y)):Math.max(0,Math.min(20,y)),M.toString=function(){return t+""},M}return{format:l,formatPrefix:function(t,n){var e=l(((t=Jc(t)).type="f",t)),r=3*Math.max(-8,Math.min(8,Math.floor(Zc(n)/3))),i=Math.pow(10,-r),o=uf[8+r/3];return function(t){return e(i*t)+o}}}}function ff(n){return of=cf(n),t.format=of.format,t.formatPrefix=of.formatPrefix,of}function sf(t){return Math.max(0,-Zc(Math.abs(t)))}function lf(t,n){return Math.max(0,3*Math.max(-8,Math.min(8,Math.floor(Zc(n)/3)))-Zc(Math.abs(t)))}function hf(t,n){return t=Math.abs(t),n=Math.abs(n)-t,Math.max(0,Zc(n)-Zc(t))+1}t.format=void 0,t.formatPrefix=void 0,ff({thousands:",",grouping:[3],currency:["$",""]});var df=1e-6,pf=1e-12,gf=Math.PI,yf=gf/2,vf=gf/4,_f=2*gf,bf=180/gf,mf=gf/180,xf=Math.abs,wf=Math.atan,Mf=Math.atan2,Tf=Math.cos,Af=Math.ceil,Sf=Math.exp,Ef=Math.hypot,Nf=Math.log,kf=Math.pow,Cf=Math.sin,Pf=Math.sign||function(t){return t>0?1:t<0?-1:0},zf=Math.sqrt,$f=Math.tan;function Df(t){return t>1?0:t<-1?gf:Math.acos(t)}function Rf(t){return t>1?yf:t<-1?-yf:Math.asin(t)}function Ff(t){return(t=Cf(t/2))*t}function qf(){}function Uf(t,n){t&&Of.hasOwnProperty(t.type)&&Of[t.type](t,n)}var If={Feature:function(t,n){Uf(t.geometry,n)},FeatureCollection:function(t,n){for(var e=t.features,r=-1,i=e.length;++r=0?1:-1,i=r*e,o=Tf(n=(n*=mf)/2+vf),a=Cf(n),u=Vf*a,c=Gf*o+u*Tf(i),f=u*r*Cf(i);as.add(Mf(f,c)),Xf=t,Gf=o,Vf=a}function ds(t){return[Mf(t[1],t[0]),Rf(t[2])]}function ps(t){var n=t[0],e=t[1],r=Tf(e);return[r*Tf(n),r*Cf(n),Cf(e)]}function gs(t,n){return t[0]*n[0]+t[1]*n[1]+t[2]*n[2]}function ys(t,n){return[t[1]*n[2]-t[2]*n[1],t[2]*n[0]-t[0]*n[2],t[0]*n[1]-t[1]*n[0]]}function vs(t,n){t[0]+=n[0],t[1]+=n[1],t[2]+=n[2]}function _s(t,n){return[t[0]*n,t[1]*n,t[2]*n]}function bs(t){var n=zf(t[0]*t[0]+t[1]*t[1]+t[2]*t[2]);t[0]/=n,t[1]/=n,t[2]/=n}var ms,xs,ws,Ms,Ts,As,Ss,Es,Ns,ks,Cs,Ps,zs,$s,Ds,Rs,Fs={point:qs,lineStart:Is,lineEnd:Os,polygonStart:function(){Fs.point=Bs,Fs.lineStart=Ys,Fs.lineEnd=Ls,rs=new T,cs.polygonStart()},polygonEnd:function(){cs.polygonEnd(),Fs.point=qs,Fs.lineStart=Is,Fs.lineEnd=Os,as<0?(Wf=-(Kf=180),Zf=-(Qf=90)):rs>df?Qf=90:rs<-df&&(Zf=-90),os[0]=Wf,os[1]=Kf},sphere:function(){Wf=-(Kf=180),Zf=-(Qf=90)}};function qs(t,n){is.push(os=[Wf=t,Kf=t]),nQf&&(Qf=n)}function Us(t,n){var e=ps([t*mf,n*mf]);if(es){var r=ys(es,e),i=ys([r[1],-r[0],0],r);bs(i),i=ds(i);var o,a=t-Jf,u=a>0?1:-1,c=i[0]*bf*u,f=xf(a)>180;f^(u*JfQf&&(Qf=o):f^(u*Jf<(c=(c+360)%360-180)&&cQf&&(Qf=n)),f?tjs(Wf,Kf)&&(Kf=t):js(t,Kf)>js(Wf,Kf)&&(Wf=t):Kf>=Wf?(tKf&&(Kf=t)):t>Jf?js(Wf,t)>js(Wf,Kf)&&(Kf=t):js(t,Kf)>js(Wf,Kf)&&(Wf=t)}else is.push(os=[Wf=t,Kf=t]);nQf&&(Qf=n),es=e,Jf=t}function Is(){Fs.point=Us}function Os(){os[0]=Wf,os[1]=Kf,Fs.point=qs,es=null}function Bs(t,n){if(es){var e=t-Jf;rs.add(xf(e)>180?e+(e>0?360:-360):e)}else ts=t,ns=n;cs.point(t,n),Us(t,n)}function Ys(){cs.lineStart()}function Ls(){Bs(ts,ns),cs.lineEnd(),xf(rs)>df&&(Wf=-(Kf=180)),os[0]=Wf,os[1]=Kf,es=null}function js(t,n){return(n-=t)<0?n+360:n}function Hs(t,n){return t[0]-n[0]}function Xs(t,n){return t[0]<=t[1]?t[0]<=n&&n<=t[1]:ngf&&(t-=Math.round(t/_f)*_f),[t,n]}function ul(t,n,e){return(t%=_f)?n||e?ol(fl(t),sl(n,e)):fl(t):n||e?sl(n,e):al}function cl(t){return function(n,e){return xf(n+=t)>gf&&(n-=Math.round(n/_f)*_f),[n,e]}}function fl(t){var n=cl(t);return n.invert=cl(-t),n}function sl(t,n){var e=Tf(t),r=Cf(t),i=Tf(n),o=Cf(n);function a(t,n){var a=Tf(n),u=Tf(t)*a,c=Cf(t)*a,f=Cf(n),s=f*e+u*r;return[Mf(c*i-s*o,u*e-f*r),Rf(s*i+c*o)]}return a.invert=function(t,n){var a=Tf(n),u=Tf(t)*a,c=Cf(t)*a,f=Cf(n),s=f*i-c*o;return[Mf(c*i+f*o,u*e+s*r),Rf(s*e-u*r)]},a}function ll(t){function n(n){return(n=t(n[0]*mf,n[1]*mf))[0]*=bf,n[1]*=bf,n}return t=ul(t[0]*mf,t[1]*mf,t.length>2?t[2]*mf:0),n.invert=function(n){return(n=t.invert(n[0]*mf,n[1]*mf))[0]*=bf,n[1]*=bf,n},n}function hl(t,n,e,r,i,o){if(e){var a=Tf(n),u=Cf(n),c=r*e;null==i?(i=n+r*_f,o=n-c/2):(i=dl(a,i),o=dl(a,o),(r>0?io)&&(i+=r*_f));for(var f,s=i;r>0?s>o:s1&&n.push(n.pop().concat(n.shift()))},result:function(){var e=n;return n=[],t=null,e}}}function gl(t,n){return xf(t[0]-n[0])=0;--o)i.point((s=f[o])[0],s[1]);else r(h.x,h.p.x,-1,i);h=h.p}f=(h=h.o).z,d=!d}while(!h.v);i.lineEnd()}}}function _l(t){if(n=t.length){for(var n,e,r=0,i=t[0];++r=0?1:-1,E=S*A,N=E>gf,k=y*w;if(c.add(Mf(k*S*Cf(E),v*M+k*Tf(E))),a+=N?A+S*_f:A,N^p>=e^m>=e){var C=ys(ps(d),ps(b));bs(C);var P=ys(o,C);bs(P);var z=(N^A>=0?-1:1)*Rf(P[2]);(r>z||r===z&&(C[0]||C[1]))&&(u+=N^A>=0?1:-1)}}return(a<-df||a0){for(l||(i.polygonStart(),l=!0),i.lineStart(),t=0;t1&&2&c&&h.push(h.pop().concat(h.shift())),a.push(h.filter(wl))}return h}}function wl(t){return t.length>1}function Ml(t,n){return((t=t.x)[0]<0?t[1]-yf-df:yf-t[1])-((n=n.x)[0]<0?n[1]-yf-df:yf-n[1])}al.invert=al;var Tl=xl((function(){return!0}),(function(t){var n,e=NaN,r=NaN,i=NaN;return{lineStart:function(){t.lineStart(),n=1},point:function(o,a){var u=o>0?gf:-gf,c=xf(o-e);xf(c-gf)0?yf:-yf),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(u,r),t.point(o,r),n=0):i!==u&&c>=gf&&(xf(e-i)df?wf((Cf(n)*(o=Tf(r))*Cf(e)-Cf(r)*(i=Tf(n))*Cf(t))/(i*o*a)):(n+r)/2}(e,r,o,a),t.point(i,r),t.lineEnd(),t.lineStart(),t.point(u,r),n=0),t.point(e=o,r=a),i=u},lineEnd:function(){t.lineEnd(),e=r=NaN},clean:function(){return 2-n}}}),(function(t,n,e,r){var i;if(null==t)i=e*yf,r.point(-gf,i),r.point(0,i),r.point(gf,i),r.point(gf,0),r.point(gf,-i),r.point(0,-i),r.point(-gf,-i),r.point(-gf,0),r.point(-gf,i);else if(xf(t[0]-n[0])>df){var o=t[0]0,i=xf(n)>df;function o(t,e){return Tf(t)*Tf(e)>n}function a(t,e,r){var i=[1,0,0],o=ys(ps(t),ps(e)),a=gs(o,o),u=o[0],c=a-u*u;if(!c)return!r&&t;var f=n*a/c,s=-n*u/c,l=ys(i,o),h=_s(i,f);vs(h,_s(o,s));var d=l,p=gs(h,d),g=gs(d,d),y=p*p-g*(gs(h,h)-1);if(!(y<0)){var v=zf(y),_=_s(d,(-p-v)/g);if(vs(_,h),_=ds(_),!r)return _;var b,m=t[0],x=e[0],w=t[1],M=e[1];x0^_[1]<(xf(_[0]-m)gf^(m<=_[0]&&_[0]<=x)){var S=_s(d,(-p+v)/g);return vs(S,h),[_,ds(S)]}}}function u(n,e){var i=r?t:gf-t,o=0;return n<-i?o|=1:n>i&&(o|=2),e<-i?o|=4:e>i&&(o|=8),o}return xl(o,(function(t){var n,e,c,f,s;return{lineStart:function(){f=c=!1,s=1},point:function(l,h){var d,p=[l,h],g=o(l,h),y=r?g?0:u(l,h):g?u(l+(l<0?gf:-gf),h):0;if(!n&&(f=c=g)&&t.lineStart(),g!==c&&(!(d=a(n,p))||gl(n,d)||gl(p,d))&&(p[2]=1),g!==c)s=0,g?(t.lineStart(),d=a(p,n),t.point(d[0],d[1])):(d=a(n,p),t.point(d[0],d[1],2),t.lineEnd()),n=d;else if(i&&n&&r^g){var v;y&e||!(v=a(p,n,!0))||(s=0,r?(t.lineStart(),t.point(v[0][0],v[0][1]),t.point(v[1][0],v[1][1]),t.lineEnd()):(t.point(v[1][0],v[1][1]),t.lineEnd(),t.lineStart(),t.point(v[0][0],v[0][1],3)))}!g||n&&gl(n,p)||t.point(p[0],p[1]),n=p,c=g,e=y},lineEnd:function(){c&&t.lineEnd(),n=null},clean:function(){return s|(f&&c)<<1}}}),(function(n,r,i,o){hl(o,t,e,i,n,r)}),r?[0,-t]:[-gf,t-gf])}var Sl,El,Nl,kl,Cl=1e9,Pl=-Cl;function zl(t,n,e,r){function i(i,o){return t<=i&&i<=e&&n<=o&&o<=r}function o(i,o,u,f){var s=0,l=0;if(null==i||(s=a(i,u))!==(l=a(o,u))||c(i,o)<0^u>0)do{f.point(0===s||3===s?t:e,s>1?r:n)}while((s=(s+u+4)%4)!==l);else f.point(o[0],o[1])}function a(r,i){return xf(r[0]-t)0?0:3:xf(r[0]-e)0?2:1:xf(r[1]-n)0?1:0:i>0?3:2}function u(t,n){return c(t.x,n.x)}function c(t,n){var e=a(t,1),r=a(n,1);return e!==r?e-r:0===e?n[1]-t[1]:1===e?t[0]-n[0]:2===e?t[1]-n[1]:n[0]-t[0]}return function(a){var c,f,s,l,h,d,p,g,y,v,_,b=a,m=pl(),x={point:w,lineStart:function(){x.point=M,f&&f.push(s=[]);v=!0,y=!1,p=g=NaN},lineEnd:function(){c&&(M(l,h),d&&y&&m.rejoin(),c.push(m.result()));x.point=w,y&&b.lineEnd()},polygonStart:function(){b=m,c=[],f=[],_=!0},polygonEnd:function(){var n=function(){for(var n=0,e=0,i=f.length;er&&(h-o)*(r-a)>(d-a)*(t-o)&&++n:d<=r&&(h-o)*(r-a)<(d-a)*(t-o)&&--n;return n}(),e=_&&n,i=(c=ft(c)).length;(e||i)&&(a.polygonStart(),e&&(a.lineStart(),o(null,null,1,a),a.lineEnd()),i&&vl(c,u,n,o,a),a.polygonEnd());b=a,c=f=s=null}};function w(t,n){i(t,n)&&b.point(t,n)}function M(o,a){var u=i(o,a);if(f&&s.push([o,a]),v)l=o,h=a,d=u,v=!1,u&&(b.lineStart(),b.point(o,a));else if(u&&y)b.point(o,a);else{var c=[p=Math.max(Pl,Math.min(Cl,p)),g=Math.max(Pl,Math.min(Cl,g))],m=[o=Math.max(Pl,Math.min(Cl,o)),a=Math.max(Pl,Math.min(Cl,a))];!function(t,n,e,r,i,o){var a,u=t[0],c=t[1],f=0,s=1,l=n[0]-u,h=n[1]-c;if(a=e-u,l||!(a>0)){if(a/=l,l<0){if(a0){if(a>s)return;a>f&&(f=a)}if(a=i-u,l||!(a<0)){if(a/=l,l<0){if(a>s)return;a>f&&(f=a)}else if(l>0){if(a0)){if(a/=h,h<0){if(a0){if(a>s)return;a>f&&(f=a)}if(a=o-c,h||!(a<0)){if(a/=h,h<0){if(a>s)return;a>f&&(f=a)}else if(h>0){if(a0&&(t[0]=u+f*l,t[1]=c+f*h),s<1&&(n[0]=u+s*l,n[1]=c+s*h),!0}}}}}(c,m,t,n,e,r)?u&&(b.lineStart(),b.point(o,a),_=!1):(y||(b.lineStart(),b.point(c[0],c[1])),b.point(m[0],m[1]),u||b.lineEnd(),_=!1)}p=o,g=a,y=u}return x}}var $l={sphere:qf,point:qf,lineStart:function(){$l.point=Rl,$l.lineEnd=Dl},lineEnd:qf,polygonStart:qf,polygonEnd:qf};function Dl(){$l.point=$l.lineEnd=qf}function Rl(t,n){El=t*=mf,Nl=Cf(n*=mf),kl=Tf(n),$l.point=Fl}function Fl(t,n){t*=mf;var e=Cf(n*=mf),r=Tf(n),i=xf(t-El),o=Tf(i),a=r*Cf(i),u=kl*e-Nl*r*o,c=Nl*e+kl*r*o;Sl.add(Mf(zf(a*a+u*u),c)),El=t,Nl=e,kl=r}function ql(t){return Sl=new T,Lf(t,$l),+Sl}var Ul=[null,null],Il={type:"LineString",coordinates:Ul};function Ol(t,n){return Ul[0]=t,Ul[1]=n,ql(Il)}var Bl={Feature:function(t,n){return Ll(t.geometry,n)},FeatureCollection:function(t,n){for(var e=t.features,r=-1,i=e.length;++r0&&(i=Ol(t[o],t[o-1]))>0&&e<=i&&r<=i&&(e+r-i)*(1-Math.pow((e-r)/i,2))df})).map(c)).concat(lt(Af(o/d)*d,i,d).filter((function(t){return xf(t%g)>df})).map(f))}return v.lines=function(){return _().map((function(t){return{type:"LineString",coordinates:t}}))},v.outline=function(){return{type:"Polygon",coordinates:[s(r).concat(l(a).slice(1),s(e).reverse().slice(1),l(u).reverse().slice(1))]}},v.extent=function(t){return arguments.length?v.extentMajor(t).extentMinor(t):v.extentMinor()},v.extentMajor=function(t){return arguments.length?(r=+t[0][0],e=+t[1][0],u=+t[0][1],a=+t[1][1],r>e&&(t=r,r=e,e=t),u>a&&(t=u,u=a,a=t),v.precision(y)):[[r,u],[e,a]]},v.extentMinor=function(e){return arguments.length?(n=+e[0][0],t=+e[1][0],o=+e[0][1],i=+e[1][1],n>t&&(e=n,n=t,t=e),o>i&&(e=o,o=i,i=e),v.precision(y)):[[n,o],[t,i]]},v.step=function(t){return arguments.length?v.stepMajor(t).stepMinor(t):v.stepMinor()},v.stepMajor=function(t){return arguments.length?(p=+t[0],g=+t[1],v):[p,g]},v.stepMinor=function(t){return arguments.length?(h=+t[0],d=+t[1],v):[h,d]},v.precision=function(h){return arguments.length?(y=+h,c=Wl(o,i,90),f=Zl(n,t,y),s=Wl(u,a,90),l=Zl(r,e,y),v):y},v.extentMajor([[-180,-90+df],[180,90-df]]).extentMinor([[-180,-80-df],[180,80+df]])}var Ql,Jl,th,nh,eh=t=>t,rh=new T,ih=new T,oh={point:qf,lineStart:qf,lineEnd:qf,polygonStart:function(){oh.lineStart=ah,oh.lineEnd=fh},polygonEnd:function(){oh.lineStart=oh.lineEnd=oh.point=qf,rh.add(xf(ih)),ih=new T},result:function(){var t=rh/2;return rh=new T,t}};function ah(){oh.point=uh}function uh(t,n){oh.point=ch,Ql=th=t,Jl=nh=n}function ch(t,n){ih.add(nh*t-th*n),th=t,nh=n}function fh(){ch(Ql,Jl)}var sh=oh,lh=1/0,hh=lh,dh=-lh,ph=dh,gh={point:function(t,n){tdh&&(dh=t);nph&&(ph=n)},lineStart:qf,lineEnd:qf,polygonStart:qf,polygonEnd:qf,result:function(){var t=[[lh,hh],[dh,ph]];return dh=ph=-(hh=lh=1/0),t}};var yh,vh,_h,bh,mh=gh,xh=0,wh=0,Mh=0,Th=0,Ah=0,Sh=0,Eh=0,Nh=0,kh=0,Ch={point:Ph,lineStart:zh,lineEnd:Rh,polygonStart:function(){Ch.lineStart=Fh,Ch.lineEnd=qh},polygonEnd:function(){Ch.point=Ph,Ch.lineStart=zh,Ch.lineEnd=Rh},result:function(){var t=kh?[Eh/kh,Nh/kh]:Sh?[Th/Sh,Ah/Sh]:Mh?[xh/Mh,wh/Mh]:[NaN,NaN];return xh=wh=Mh=Th=Ah=Sh=Eh=Nh=kh=0,t}};function Ph(t,n){xh+=t,wh+=n,++Mh}function zh(){Ch.point=$h}function $h(t,n){Ch.point=Dh,Ph(_h=t,bh=n)}function Dh(t,n){var e=t-_h,r=n-bh,i=zf(e*e+r*r);Th+=i*(_h+t)/2,Ah+=i*(bh+n)/2,Sh+=i,Ph(_h=t,bh=n)}function Rh(){Ch.point=Ph}function Fh(){Ch.point=Uh}function qh(){Ih(yh,vh)}function Uh(t,n){Ch.point=Ih,Ph(yh=_h=t,vh=bh=n)}function Ih(t,n){var e=t-_h,r=n-bh,i=zf(e*e+r*r);Th+=i*(_h+t)/2,Ah+=i*(bh+n)/2,Sh+=i,Eh+=(i=bh*t-_h*n)*(_h+t),Nh+=i*(bh+n),kh+=3*i,Ph(_h=t,bh=n)}var Oh=Ch;function Bh(t){this._context=t}Bh.prototype={_radius:4.5,pointRadius:function(t){return this._radius=t,this},polygonStart:function(){this._line=0},polygonEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){0===this._line&&this._context.closePath(),this._point=NaN},point:function(t,n){switch(this._point){case 0:this._context.moveTo(t,n),this._point=1;break;case 1:this._context.lineTo(t,n);break;default:this._context.moveTo(t+this._radius,n),this._context.arc(t,n,this._radius,0,_f)}},result:qf};var Yh,Lh,jh,Hh,Xh,Gh=new T,Vh={point:qf,lineStart:function(){Vh.point=Wh},lineEnd:function(){Yh&&Zh(Lh,jh),Vh.point=qf},polygonStart:function(){Yh=!0},polygonEnd:function(){Yh=null},result:function(){var t=+Gh;return Gh=new T,t}};function Wh(t,n){Vh.point=Zh,Lh=Hh=t,jh=Xh=n}function Zh(t,n){Hh-=t,Xh-=n,Gh.add(zf(Hh*Hh+Xh*Xh)),Hh=t,Xh=n}var Kh=Vh;let Qh,Jh,td,nd;class ed{constructor(t){this._append=null==t?rd:function(t){const n=Math.floor(t);if(!(n>=0))throw new RangeError(`invalid digits: ${t}`);if(n>15)return rd;if(n!==Qh){const t=10**n;Qh=n,Jh=function(n){let e=1;this._+=n[0];for(const r=n.length;e4*n&&g--){var m=a+h,x=u+d,w=c+p,M=zf(m*m+x*x+w*w),T=Rf(w/=M),A=xf(xf(w)-1)n||xf((v*k+_*C)/b-.5)>.3||a*h+u*d+c*p2?t[2]%360*mf:0,k()):[y*bf,v*bf,_*bf]},E.angle=function(t){return arguments.length?(b=t%360*mf,k()):b*bf},E.reflectX=function(t){return arguments.length?(m=t?-1:1,k()):m<0},E.reflectY=function(t){return arguments.length?(x=t?-1:1,k()):x<0},E.precision=function(t){return arguments.length?(a=dd(u,S=t*t),C()):zf(S)},E.fitExtent=function(t,n){return ud(E,t,n)},E.fitSize=function(t,n){return cd(E,t,n)},E.fitWidth=function(t,n){return fd(E,t,n)},E.fitHeight=function(t,n){return sd(E,t,n)},function(){return n=t.apply(this,arguments),E.invert=n.invert&&N,k()}}function _d(t){var n=0,e=gf/3,r=vd(t),i=r(n,e);return i.parallels=function(t){return arguments.length?r(n=t[0]*mf,e=t[1]*mf):[n*bf,e*bf]},i}function bd(t,n){var e=Cf(t),r=(e+Cf(n))/2;if(xf(r)0?n<-yf+df&&(n=-yf+df):n>yf-df&&(n=yf-df);var e=i/kf(Nd(n),r);return[e*Cf(r*t),i-e*Tf(r*t)]}return o.invert=function(t,n){var e=i-n,o=Pf(r)*zf(t*t+e*e),a=Mf(t,xf(e))*Pf(e);return e*r<0&&(a-=gf*Pf(t)*Pf(e)),[a/r,2*wf(kf(i/o,1/r))-yf]},o}function Cd(t,n){return[t,n]}function Pd(t,n){var e=Tf(t),r=t===n?Cf(t):(e-Tf(n))/(n-t),i=e/r+t;if(xf(r)=0;)n+=e[r].value;else n=1;t.value=n}function Gd(t,n){t instanceof Map?(t=[void 0,t],void 0===n&&(n=Wd)):void 0===n&&(n=Vd);for(var e,r,i,o,a,u=new Qd(t),c=[u];e=c.pop();)if((i=n(e.data))&&(a=(i=Array.from(i)).length))for(e.children=i,o=a-1;o>=0;--o)c.push(r=i[o]=new Qd(i[o])),r.parent=e,r.depth=e.depth+1;return u.eachBefore(Kd)}function Vd(t){return t.children}function Wd(t){return Array.isArray(t)?t[1]:null}function Zd(t){void 0!==t.data.value&&(t.value=t.data.value),t.data=t.data.data}function Kd(t){var n=0;do{t.height=n}while((t=t.parent)&&t.height<++n)}function Qd(t){this.data=t,this.depth=this.height=0,this.parent=null}function Jd(t){return null==t?null:tp(t)}function tp(t){if("function"!=typeof t)throw new Error;return t}function np(){return 0}function ep(t){return function(){return t}}qd.invert=function(t,n){for(var e,r=n,i=r*r,o=i*i*i,a=0;a<12&&(o=(i=(r-=e=(r*(zd+$d*i+o*(Dd+Rd*i))-n)/(zd+3*$d*i+o*(7*Dd+9*Rd*i)))*r)*i*i,!(xf(e)df&&--i>0);return[t/(.8707+(o=r*r)*(o*(o*o*o*(.003971-.001529*o)-.013791)-.131979)),r]},Od.invert=Md(Rf),Bd.invert=Md((function(t){return 2*wf(t)})),Yd.invert=function(t,n){return[-n,2*wf(Sf(t))-yf]},Qd.prototype=Gd.prototype={constructor:Qd,count:function(){return this.eachAfter(Xd)},each:function(t,n){let e=-1;for(const r of this)t.call(n,r,++e,this);return this},eachAfter:function(t,n){for(var e,r,i,o=this,a=[o],u=[],c=-1;o=a.pop();)if(u.push(o),e=o.children)for(r=0,i=e.length;r=0;--r)o.push(e[r]);return this},find:function(t,n){let e=-1;for(const r of this)if(t.call(n,r,++e,this))return r},sum:function(t){return this.eachAfter((function(n){for(var e=+t(n.data)||0,r=n.children,i=r&&r.length;--i>=0;)e+=r[i].value;n.value=e}))},sort:function(t){return this.eachBefore((function(n){n.children&&n.children.sort(t)}))},path:function(t){for(var n=this,e=function(t,n){if(t===n)return t;var e=t.ancestors(),r=n.ancestors(),i=null;t=e.pop(),n=r.pop();for(;t===n;)i=t,t=e.pop(),n=r.pop();return i}(n,t),r=[n];n!==e;)n=n.parent,r.push(n);for(var i=r.length;t!==e;)r.splice(i,0,t),t=t.parent;return r},ancestors:function(){for(var t=this,n=[t];t=t.parent;)n.push(t);return n},descendants:function(){return Array.from(this)},leaves:function(){var t=[];return this.eachBefore((function(n){n.children||t.push(n)})),t},links:function(){var t=this,n=[];return t.each((function(e){e!==t&&n.push({source:e.parent,target:e})})),n},copy:function(){return Gd(this).eachBefore(Zd)},[Symbol.iterator]:function*(){var t,n,e,r,i=this,o=[i];do{for(t=o.reverse(),o=[];i=t.pop();)if(yield i,n=i.children)for(e=0,r=n.length;e(t=(rp*t+ip)%op)/op}function up(t,n){for(var e,r,i=0,o=(t=function(t,n){let e,r,i=t.length;for(;i;)r=n()*i--|0,e=t[i],t[i]=t[r],t[r]=e;return t}(Array.from(t),n)).length,a=[];i0&&e*e>r*r+i*i}function lp(t,n){for(var e=0;e1e-6?(E+Math.sqrt(E*E-4*S*N))/(2*S):N/E);return{x:r+w+M*k,y:i+T+A*k,r:k}}function gp(t,n,e){var r,i,o,a,u=t.x-n.x,c=t.y-n.y,f=u*u+c*c;f?(i=n.r+e.r,i*=i,a=t.r+e.r,i>(a*=a)?(r=(f+a-i)/(2*f),o=Math.sqrt(Math.max(0,a/f-r*r)),e.x=t.x-r*u-o*c,e.y=t.y-r*c+o*u):(r=(f+i-a)/(2*f),o=Math.sqrt(Math.max(0,i/f-r*r)),e.x=n.x+r*u-o*c,e.y=n.y+r*c+o*u)):(e.x=n.x+e.r,e.y=n.y)}function yp(t,n){var e=t.r+n.r-1e-6,r=n.x-t.x,i=n.y-t.y;return e>0&&e*e>r*r+i*i}function vp(t){var n=t._,e=t.next._,r=n.r+e.r,i=(n.x*e.r+e.x*n.r)/r,o=(n.y*e.r+e.y*n.r)/r;return i*i+o*o}function _p(t){this._=t,this.next=null,this.previous=null}function bp(t,n){if(!(o=(t=function(t){return"object"==typeof t&&"length"in t?t:Array.from(t)}(t)).length))return 0;var e,r,i,o,a,u,c,f,s,l,h;if((e=t[0]).x=0,e.y=0,!(o>1))return e.r;if(r=t[1],e.x=-r.r,r.x=e.r,r.y=0,!(o>2))return e.r+r.r;gp(r,e,i=t[2]),e=new _p(e),r=new _p(r),i=new _p(i),e.next=i.previous=r,r.next=e.previous=i,i.next=r.previous=e;t:for(c=3;c1&&!zp(t,n););return t.slice(0,n)}function zp(t,n){if("/"===t[n]){let e=0;for(;n>0&&"\\"===t[--n];)++e;if(0==(1&e))return!0}return!1}function $p(t,n){return t.parent===n.parent?1:2}function Dp(t){var n=t.children;return n?n[0]:t.t}function Rp(t){var n=t.children;return n?n[n.length-1]:t.t}function Fp(t,n,e){var r=e/(n.i-t.i);n.c-=r,n.s+=e,t.c+=r,n.z+=e,n.m+=e}function qp(t,n,e){return t.a.parent===n.parent?t.a:e}function Up(t,n){this._=t,this.parent=null,this.children=null,this.A=null,this.a=this,this.z=0,this.m=0,this.c=0,this.s=0,this.t=null,this.i=n}function Ip(t,n,e,r,i){for(var o,a=t.children,u=-1,c=a.length,f=t.value&&(i-e)/t.value;++uh&&(h=u),y=s*s*g,(d=Math.max(h/y,y/l))>p){s-=u;break}p=d}v.push(a={value:s,dice:c1?n:1)},e}(Op);var Lp=function t(n){function e(t,e,r,i,o){if((a=t._squarify)&&a.ratio===n)for(var a,u,c,f,s,l=-1,h=a.length,d=t.value;++l1?n:1)},e}(Op);function jp(t,n,e){return(n[0]-t[0])*(e[1]-t[1])-(n[1]-t[1])*(e[0]-t[0])}function Hp(t,n){return t[0]-n[0]||t[1]-n[1]}function Xp(t){const n=t.length,e=[0,1];let r,i=2;for(r=2;r1&&jp(t[e[i-2]],t[e[i-1]],t[r])<=0;)--i;e[i++]=r}return e.slice(0,i)}var Gp=Math.random,Vp=function t(n){function e(t,e){return t=null==t?0:+t,e=null==e?1:+e,1===arguments.length?(e=t,t=0):e-=t,function(){return n()*e+t}}return e.source=t,e}(Gp),Wp=function t(n){function e(t,e){return arguments.length<2&&(e=t,t=0),t=Math.floor(t),e=Math.floor(e)-t,function(){return Math.floor(n()*e+t)}}return e.source=t,e}(Gp),Zp=function t(n){function e(t,e){var r,i;return t=null==t?0:+t,e=null==e?1:+e,function(){var o;if(null!=r)o=r,r=null;else do{r=2*n()-1,o=2*n()-1,i=r*r+o*o}while(!i||i>1);return t+e*o*Math.sqrt(-2*Math.log(i)/i)}}return e.source=t,e}(Gp),Kp=function t(n){var e=Zp.source(n);function r(){var t=e.apply(this,arguments);return function(){return Math.exp(t())}}return r.source=t,r}(Gp),Qp=function t(n){function e(t){return(t=+t)<=0?()=>0:function(){for(var e=0,r=t;r>1;--r)e+=n();return e+r*n()}}return e.source=t,e}(Gp),Jp=function t(n){var e=Qp.source(n);function r(t){if(0==(t=+t))return n;var r=e(t);return function(){return r()/t}}return r.source=t,r}(Gp),tg=function t(n){function e(t){return function(){return-Math.log1p(-n())/t}}return e.source=t,e}(Gp),ng=function t(n){function e(t){if((t=+t)<0)throw new RangeError("invalid alpha");return t=1/-t,function(){return Math.pow(1-n(),t)}}return e.source=t,e}(Gp),eg=function t(n){function e(t){if((t=+t)<0||t>1)throw new RangeError("invalid p");return function(){return Math.floor(n()+t)}}return e.source=t,e}(Gp),rg=function t(n){function e(t){if((t=+t)<0||t>1)throw new RangeError("invalid p");return 0===t?()=>1/0:1===t?()=>1:(t=Math.log1p(-t),function(){return 1+Math.floor(Math.log1p(-n())/t)})}return e.source=t,e}(Gp),ig=function t(n){var e=Zp.source(n)();function r(t,r){if((t=+t)<0)throw new RangeError("invalid k");if(0===t)return()=>0;if(r=null==r?1:+r,1===t)return()=>-Math.log1p(-n())*r;var i=(t<1?t+1:t)-1/3,o=1/(3*Math.sqrt(i)),a=t<1?()=>Math.pow(n(),1/t):()=>1;return function(){do{do{var t=e(),u=1+o*t}while(u<=0);u*=u*u;var c=1-n()}while(c>=1-.0331*t*t*t*t&&Math.log(c)>=.5*t*t+i*(1-u+Math.log(u)));return i*u*a()*r}}return r.source=t,r}(Gp),og=function t(n){var e=ig.source(n);function r(t,n){var r=e(t),i=e(n);return function(){var t=r();return 0===t?0:t/(t+i())}}return r.source=t,r}(Gp),ag=function t(n){var e=rg.source(n),r=og.source(n);function i(t,n){return t=+t,(n=+n)>=1?()=>t:n<=0?()=>0:function(){for(var i=0,o=t,a=n;o*a>16&&o*(1-a)>16;){var u=Math.floor((o+1)*a),c=r(u,o-u+1)();c<=a?(i+=u,o-=u,a=(a-c)/(1-c)):(o=u-1,a/=c)}for(var f=a<.5,s=e(f?a:1-a),l=s(),h=0;l<=o;++h)l+=s();return i+(f?h:o-h)}}return i.source=t,i}(Gp),ug=function t(n){function e(t,e,r){var i;return 0==(t=+t)?i=t=>-Math.log(t):(t=1/t,i=n=>Math.pow(n,t)),e=null==e?0:+e,r=null==r?1:+r,function(){return e+r*i(-Math.log1p(-n()))}}return e.source=t,e}(Gp),cg=function t(n){function e(t,e){return t=null==t?0:+t,e=null==e?1:+e,function(){return t+e*Math.tan(Math.PI*n())}}return e.source=t,e}(Gp),fg=function t(n){function e(t,e){return t=null==t?0:+t,e=null==e?1:+e,function(){var r=n();return t+e*Math.log(r/(1-r))}}return e.source=t,e}(Gp),sg=function t(n){var e=ig.source(n),r=ag.source(n);function i(t){return function(){for(var i=0,o=t;o>16;){var a=Math.floor(.875*o),u=e(a)();if(u>o)return i+r(a-1,o/u)();i+=a,o-=u}for(var c=-Math.log1p(-n()),f=0;c<=o;++f)c-=Math.log1p(-n());return i+f}}return i.source=t,i}(Gp);const lg=1/4294967296;function hg(t,n){switch(arguments.length){case 0:break;case 1:this.range(t);break;default:this.range(n).domain(t)}return this}function dg(t,n){switch(arguments.length){case 0:break;case 1:"function"==typeof t?this.interpolator(t):this.range(t);break;default:this.domain(t),"function"==typeof n?this.interpolator(n):this.range(n)}return this}const pg=Symbol("implicit");function gg(){var t=new InternMap,n=[],e=[],r=pg;function i(i){let o=t.get(i);if(void 0===o){if(r!==pg)return r;t.set(i,o=n.push(i)-1)}return e[o%e.length]}return i.domain=function(e){if(!arguments.length)return n.slice();n=[],t=new InternMap;for(const r of e)t.has(r)||t.set(r,n.push(r)-1);return i},i.range=function(t){return arguments.length?(e=Array.from(t),i):e.slice()},i.unknown=function(t){return arguments.length?(r=t,i):r},i.copy=function(){return gg(n,e).unknown(r)},hg.apply(i,arguments),i}function yg(){var t,n,e=gg().unknown(void 0),r=e.domain,i=e.range,o=0,a=1,u=!1,c=0,f=0,s=.5;function l(){var e=r().length,l=an&&(e=t,t=n,n=e),function(e){return Math.max(t,Math.min(n,e))}}(a[0],a[t-1])),r=t>2?Mg:wg,i=o=null,l}function l(n){return null==n||isNaN(n=+n)?e:(i||(i=r(a.map(t),u,c)))(t(f(n)))}return l.invert=function(e){return f(n((o||(o=r(u,a.map(t),Yr)))(e)))},l.domain=function(t){return arguments.length?(a=Array.from(t,_g),s()):a.slice()},l.range=function(t){return arguments.length?(u=Array.from(t),s()):u.slice()},l.rangeRound=function(t){return u=Array.from(t),c=Vr,s()},l.clamp=function(t){return arguments.length?(f=!!t||mg,s()):f!==mg},l.interpolate=function(t){return arguments.length?(c=t,s()):c},l.unknown=function(t){return arguments.length?(e=t,l):e},function(e,r){return t=e,n=r,s()}}function Sg(){return Ag()(mg,mg)}function Eg(n,e,r,i){var o,a=W(n,e,r);switch((i=Jc(null==i?",f":i)).type){case"s":var u=Math.max(Math.abs(n),Math.abs(e));return null!=i.precision||isNaN(o=lf(a,u))||(i.precision=o),t.formatPrefix(i,u);case"":case"e":case"g":case"p":case"r":null!=i.precision||isNaN(o=hf(a,Math.max(Math.abs(n),Math.abs(e))))||(i.precision=o-("e"===i.type));break;case"f":case"%":null!=i.precision||isNaN(o=sf(a))||(i.precision=o-2*("%"===i.type))}return t.format(i)}function Ng(t){var n=t.domain;return t.ticks=function(t){var e=n();return G(e[0],e[e.length-1],null==t?10:t)},t.tickFormat=function(t,e){var r=n();return Eg(r[0],r[r.length-1],null==t?10:t,e)},t.nice=function(e){null==e&&(e=10);var r,i,o=n(),a=0,u=o.length-1,c=o[a],f=o[u],s=10;for(f0;){if((i=V(c,f,e))===r)return o[a]=c,o[u]=f,n(o);if(i>0)c=Math.floor(c/i)*i,f=Math.ceil(f/i)*i;else{if(!(i<0))break;c=Math.ceil(c*i)/i,f=Math.floor(f*i)/i}r=i}return t},t}function kg(t,n){var e,r=0,i=(t=t.slice()).length-1,o=t[r],a=t[i];return a-t(-n,e)}function Fg(n){const e=n(Cg,Pg),r=e.domain;let i,o,a=10;function u(){return i=function(t){return t===Math.E?Math.log:10===t&&Math.log10||2===t&&Math.log2||(t=Math.log(t),n=>Math.log(n)/t)}(a),o=function(t){return 10===t?Dg:t===Math.E?Math.exp:n=>Math.pow(t,n)}(a),r()[0]<0?(i=Rg(i),o=Rg(o),n(zg,$g)):n(Cg,Pg),e}return e.base=function(t){return arguments.length?(a=+t,u()):a},e.domain=function(t){return arguments.length?(r(t),u()):r()},e.ticks=t=>{const n=r();let e=n[0],u=n[n.length-1];const c=u0){for(;l<=h;++l)for(f=1;fu)break;p.push(s)}}else for(;l<=h;++l)for(f=a-1;f>=1;--f)if(s=l>0?f/o(-l):f*o(l),!(su)break;p.push(s)}2*p.length{if(null==n&&(n=10),null==r&&(r=10===a?"s":","),"function"!=typeof r&&(a%1||null!=(r=Jc(r)).precision||(r.trim=!0),r=t.format(r)),n===1/0)return r;const u=Math.max(1,a*n/e.ticks().length);return t=>{let n=t/o(Math.round(i(t)));return n*ar(kg(r(),{floor:t=>o(Math.floor(i(t))),ceil:t=>o(Math.ceil(i(t)))})),e}function qg(t){return function(n){return Math.sign(n)*Math.log1p(Math.abs(n/t))}}function Ug(t){return function(n){return Math.sign(n)*Math.expm1(Math.abs(n))*t}}function Ig(t){var n=1,e=t(qg(n),Ug(n));return e.constant=function(e){return arguments.length?t(qg(n=+e),Ug(n)):n},Ng(e)}function Og(t){return function(n){return n<0?-Math.pow(-n,t):Math.pow(n,t)}}function Bg(t){return t<0?-Math.sqrt(-t):Math.sqrt(t)}function Yg(t){return t<0?-t*t:t*t}function Lg(t){var n=t(mg,mg),e=1;return n.exponent=function(n){return arguments.length?1===(e=+n)?t(mg,mg):.5===e?t(Bg,Yg):t(Og(e),Og(1/e)):e},Ng(n)}function jg(){var t=Lg(Ag());return t.copy=function(){return Tg(t,jg()).exponent(t.exponent())},hg.apply(t,arguments),t}function Hg(t){return Math.sign(t)*t*t}const Xg=new Date,Gg=new Date;function Vg(t,n,e,r){function i(n){return t(n=0===arguments.length?new Date:new Date(+n)),n}return i.floor=n=>(t(n=new Date(+n)),n),i.ceil=e=>(t(e=new Date(e-1)),n(e,1),t(e),e),i.round=t=>{const n=i(t),e=i.ceil(t);return t-n(n(t=new Date(+t),null==e?1:Math.floor(e)),t),i.range=(e,r,o)=>{const a=[];if(e=i.ceil(e),o=null==o?1:Math.floor(o),!(e0))return a;let u;do{a.push(u=new Date(+e)),n(e,o),t(e)}while(uVg((n=>{if(n>=n)for(;t(n),!e(n);)n.setTime(n-1)}),((t,r)=>{if(t>=t)if(r<0)for(;++r<=0;)for(;n(t,-1),!e(t););else for(;--r>=0;)for(;n(t,1),!e(t););})),e&&(i.count=(n,r)=>(Xg.setTime(+n),Gg.setTime(+r),t(Xg),t(Gg),Math.floor(e(Xg,Gg))),i.every=t=>(t=Math.floor(t),isFinite(t)&&t>0?t>1?i.filter(r?n=>r(n)%t==0:n=>i.count(0,n)%t==0):i:null)),i}const Wg=Vg((()=>{}),((t,n)=>{t.setTime(+t+n)}),((t,n)=>n-t));Wg.every=t=>(t=Math.floor(t),isFinite(t)&&t>0?t>1?Vg((n=>{n.setTime(Math.floor(n/t)*t)}),((n,e)=>{n.setTime(+n+e*t)}),((n,e)=>(e-n)/t)):Wg:null);const Zg=Wg.range,Kg=1e3,Qg=6e4,Jg=36e5,ty=864e5,ny=6048e5,ey=2592e6,ry=31536e6,iy=Vg((t=>{t.setTime(t-t.getMilliseconds())}),((t,n)=>{t.setTime(+t+n*Kg)}),((t,n)=>(n-t)/Kg),(t=>t.getUTCSeconds())),oy=iy.range,ay=Vg((t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*Kg)}),((t,n)=>{t.setTime(+t+n*Qg)}),((t,n)=>(n-t)/Qg),(t=>t.getMinutes())),uy=ay.range,cy=Vg((t=>{t.setUTCSeconds(0,0)}),((t,n)=>{t.setTime(+t+n*Qg)}),((t,n)=>(n-t)/Qg),(t=>t.getUTCMinutes())),fy=cy.range,sy=Vg((t=>{t.setTime(t-t.getMilliseconds()-t.getSeconds()*Kg-t.getMinutes()*Qg)}),((t,n)=>{t.setTime(+t+n*Jg)}),((t,n)=>(n-t)/Jg),(t=>t.getHours())),ly=sy.range,hy=Vg((t=>{t.setUTCMinutes(0,0,0)}),((t,n)=>{t.setTime(+t+n*Jg)}),((t,n)=>(n-t)/Jg),(t=>t.getUTCHours())),dy=hy.range,py=Vg((t=>t.setHours(0,0,0,0)),((t,n)=>t.setDate(t.getDate()+n)),((t,n)=>(n-t-(n.getTimezoneOffset()-t.getTimezoneOffset())*Qg)/ty),(t=>t.getDate()-1)),gy=py.range,yy=Vg((t=>{t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCDate(t.getUTCDate()+n)}),((t,n)=>(n-t)/ty),(t=>t.getUTCDate()-1)),vy=yy.range,_y=Vg((t=>{t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCDate(t.getUTCDate()+n)}),((t,n)=>(n-t)/ty),(t=>Math.floor(t/ty))),by=_y.range;function my(t){return Vg((n=>{n.setDate(n.getDate()-(n.getDay()+7-t)%7),n.setHours(0,0,0,0)}),((t,n)=>{t.setDate(t.getDate()+7*n)}),((t,n)=>(n-t-(n.getTimezoneOffset()-t.getTimezoneOffset())*Qg)/ny))}const xy=my(0),wy=my(1),My=my(2),Ty=my(3),Ay=my(4),Sy=my(5),Ey=my(6),Ny=xy.range,ky=wy.range,Cy=My.range,Py=Ty.range,zy=Ay.range,$y=Sy.range,Dy=Ey.range;function Ry(t){return Vg((n=>{n.setUTCDate(n.getUTCDate()-(n.getUTCDay()+7-t)%7),n.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCDate(t.getUTCDate()+7*n)}),((t,n)=>(n-t)/ny))}const Fy=Ry(0),qy=Ry(1),Uy=Ry(2),Iy=Ry(3),Oy=Ry(4),By=Ry(5),Yy=Ry(6),Ly=Fy.range,jy=qy.range,Hy=Uy.range,Xy=Iy.range,Gy=Oy.range,Vy=By.range,Wy=Yy.range,Zy=Vg((t=>{t.setDate(1),t.setHours(0,0,0,0)}),((t,n)=>{t.setMonth(t.getMonth()+n)}),((t,n)=>n.getMonth()-t.getMonth()+12*(n.getFullYear()-t.getFullYear())),(t=>t.getMonth())),Ky=Zy.range,Qy=Vg((t=>{t.setUTCDate(1),t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCMonth(t.getUTCMonth()+n)}),((t,n)=>n.getUTCMonth()-t.getUTCMonth()+12*(n.getUTCFullYear()-t.getUTCFullYear())),(t=>t.getUTCMonth())),Jy=Qy.range,tv=Vg((t=>{t.setMonth(0,1),t.setHours(0,0,0,0)}),((t,n)=>{t.setFullYear(t.getFullYear()+n)}),((t,n)=>n.getFullYear()-t.getFullYear()),(t=>t.getFullYear()));tv.every=t=>isFinite(t=Math.floor(t))&&t>0?Vg((n=>{n.setFullYear(Math.floor(n.getFullYear()/t)*t),n.setMonth(0,1),n.setHours(0,0,0,0)}),((n,e)=>{n.setFullYear(n.getFullYear()+e*t)})):null;const nv=tv.range,ev=Vg((t=>{t.setUTCMonth(0,1),t.setUTCHours(0,0,0,0)}),((t,n)=>{t.setUTCFullYear(t.getUTCFullYear()+n)}),((t,n)=>n.getUTCFullYear()-t.getUTCFullYear()),(t=>t.getUTCFullYear()));ev.every=t=>isFinite(t=Math.floor(t))&&t>0?Vg((n=>{n.setUTCFullYear(Math.floor(n.getUTCFullYear()/t)*t),n.setUTCMonth(0,1),n.setUTCHours(0,0,0,0)}),((n,e)=>{n.setUTCFullYear(n.getUTCFullYear()+e*t)})):null;const rv=ev.range;function iv(t,n,e,i,o,a){const u=[[iy,1,Kg],[iy,5,5e3],[iy,15,15e3],[iy,30,3e4],[a,1,Qg],[a,5,3e5],[a,15,9e5],[a,30,18e5],[o,1,Jg],[o,3,108e5],[o,6,216e5],[o,12,432e5],[i,1,ty],[i,2,1728e5],[e,1,ny],[n,1,ey],[n,3,7776e6],[t,1,ry]];function c(n,e,i){const o=Math.abs(e-n)/i,a=r((([,,t])=>t)).right(u,o);if(a===u.length)return t.every(W(n/ry,e/ry,i));if(0===a)return Wg.every(Math.max(W(n,e,i),1));const[c,f]=u[o/u[a-1][2]=12)]},q:function(t){return 1+~~(t.getMonth()/3)},Q:k_,s:C_,S:Zv,u:Kv,U:Qv,V:t_,w:n_,W:e_,x:null,X:null,y:r_,Y:o_,Z:u_,"%":N_},m={a:function(t){return a[t.getUTCDay()]},A:function(t){return o[t.getUTCDay()]},b:function(t){return c[t.getUTCMonth()]},B:function(t){return u[t.getUTCMonth()]},c:null,d:c_,e:c_,f:d_,g:T_,G:S_,H:f_,I:s_,j:l_,L:h_,m:p_,M:g_,p:function(t){return i[+(t.getUTCHours()>=12)]},q:function(t){return 1+~~(t.getUTCMonth()/3)},Q:k_,s:C_,S:y_,u:v_,U:__,V:m_,w:x_,W:w_,x:null,X:null,y:M_,Y:A_,Z:E_,"%":N_},x={a:function(t,n,e){var r=d.exec(n.slice(e));return r?(t.w=p.get(r[0].toLowerCase()),e+r[0].length):-1},A:function(t,n,e){var r=l.exec(n.slice(e));return r?(t.w=h.get(r[0].toLowerCase()),e+r[0].length):-1},b:function(t,n,e){var r=v.exec(n.slice(e));return r?(t.m=_.get(r[0].toLowerCase()),e+r[0].length):-1},B:function(t,n,e){var r=g.exec(n.slice(e));return r?(t.m=y.get(r[0].toLowerCase()),e+r[0].length):-1},c:function(t,e,r){return T(t,n,e,r)},d:zv,e:zv,f:Uv,g:Nv,G:Ev,H:Dv,I:Dv,j:$v,L:qv,m:Pv,M:Rv,p:function(t,n,e){var r=f.exec(n.slice(e));return r?(t.p=s.get(r[0].toLowerCase()),e+r[0].length):-1},q:Cv,Q:Ov,s:Bv,S:Fv,u:Mv,U:Tv,V:Av,w:wv,W:Sv,x:function(t,n,r){return T(t,e,n,r)},X:function(t,n,e){return T(t,r,n,e)},y:Nv,Y:Ev,Z:kv,"%":Iv};function w(t,n){return function(e){var r,i,o,a=[],u=-1,c=0,f=t.length;for(e instanceof Date||(e=new Date(+e));++u53)return null;"w"in o||(o.w=1),"Z"in o?(i=(r=sv(lv(o.y,0,1))).getUTCDay(),r=i>4||0===i?qy.ceil(r):qy(r),r=yy.offset(r,7*(o.V-1)),o.y=r.getUTCFullYear(),o.m=r.getUTCMonth(),o.d=r.getUTCDate()+(o.w+6)%7):(i=(r=fv(lv(o.y,0,1))).getDay(),r=i>4||0===i?wy.ceil(r):wy(r),r=py.offset(r,7*(o.V-1)),o.y=r.getFullYear(),o.m=r.getMonth(),o.d=r.getDate()+(o.w+6)%7)}else("W"in o||"U"in o)&&("w"in o||(o.w="u"in o?o.u%7:"W"in o?1:0),i="Z"in o?sv(lv(o.y,0,1)).getUTCDay():fv(lv(o.y,0,1)).getDay(),o.m=0,o.d="W"in o?(o.w+6)%7+7*o.W-(i+5)%7:o.w+7*o.U-(i+6)%7);return"Z"in o?(o.H+=o.Z/100|0,o.M+=o.Z%100,sv(o)):fv(o)}}function T(t,n,e,r){for(var i,o,a=0,u=n.length,c=e.length;a=c)return-1;if(37===(i=n.charCodeAt(a++))){if(i=n.charAt(a++),!(o=x[i in pv?n.charAt(a++):i])||(r=o(t,e,r))<0)return-1}else if(i!=e.charCodeAt(r++))return-1}return r}return b.x=w(e,b),b.X=w(r,b),b.c=w(n,b),m.x=w(e,m),m.X=w(r,m),m.c=w(n,m),{format:function(t){var n=w(t+="",b);return n.toString=function(){return t},n},parse:function(t){var n=M(t+="",!1);return n.toString=function(){return t},n},utcFormat:function(t){var n=w(t+="",m);return n.toString=function(){return t},n},utcParse:function(t){var n=M(t+="",!0);return n.toString=function(){return t},n}}}var dv,pv={"-":"",_:" ",0:"0"},gv=/^\s*\d+/,yv=/^%/,vv=/[\\^$*+?|[\]().{}]/g;function _v(t,n,e){var r=t<0?"-":"",i=(r?-t:t)+"",o=i.length;return r+(o[t.toLowerCase(),n])))}function wv(t,n,e){var r=gv.exec(n.slice(e,e+1));return r?(t.w=+r[0],e+r[0].length):-1}function Mv(t,n,e){var r=gv.exec(n.slice(e,e+1));return r?(t.u=+r[0],e+r[0].length):-1}function Tv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.U=+r[0],e+r[0].length):-1}function Av(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.V=+r[0],e+r[0].length):-1}function Sv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.W=+r[0],e+r[0].length):-1}function Ev(t,n,e){var r=gv.exec(n.slice(e,e+4));return r?(t.y=+r[0],e+r[0].length):-1}function Nv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.y=+r[0]+(+r[0]>68?1900:2e3),e+r[0].length):-1}function kv(t,n,e){var r=/^(Z)|([+-]\d\d)(?::?(\d\d))?/.exec(n.slice(e,e+6));return r?(t.Z=r[1]?0:-(r[2]+(r[3]||"00")),e+r[0].length):-1}function Cv(t,n,e){var r=gv.exec(n.slice(e,e+1));return r?(t.q=3*r[0]-3,e+r[0].length):-1}function Pv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.m=r[0]-1,e+r[0].length):-1}function zv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.d=+r[0],e+r[0].length):-1}function $v(t,n,e){var r=gv.exec(n.slice(e,e+3));return r?(t.m=0,t.d=+r[0],e+r[0].length):-1}function Dv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.H=+r[0],e+r[0].length):-1}function Rv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.M=+r[0],e+r[0].length):-1}function Fv(t,n,e){var r=gv.exec(n.slice(e,e+2));return r?(t.S=+r[0],e+r[0].length):-1}function qv(t,n,e){var r=gv.exec(n.slice(e,e+3));return r?(t.L=+r[0],e+r[0].length):-1}function Uv(t,n,e){var r=gv.exec(n.slice(e,e+6));return r?(t.L=Math.floor(r[0]/1e3),e+r[0].length):-1}function Iv(t,n,e){var r=yv.exec(n.slice(e,e+1));return r?e+r[0].length:-1}function Ov(t,n,e){var r=gv.exec(n.slice(e));return r?(t.Q=+r[0],e+r[0].length):-1}function Bv(t,n,e){var r=gv.exec(n.slice(e));return r?(t.s=+r[0],e+r[0].length):-1}function Yv(t,n){return _v(t.getDate(),n,2)}function Lv(t,n){return _v(t.getHours(),n,2)}function jv(t,n){return _v(t.getHours()%12||12,n,2)}function Hv(t,n){return _v(1+py.count(tv(t),t),n,3)}function Xv(t,n){return _v(t.getMilliseconds(),n,3)}function Gv(t,n){return Xv(t,n)+"000"}function Vv(t,n){return _v(t.getMonth()+1,n,2)}function Wv(t,n){return _v(t.getMinutes(),n,2)}function Zv(t,n){return _v(t.getSeconds(),n,2)}function Kv(t){var n=t.getDay();return 0===n?7:n}function Qv(t,n){return _v(xy.count(tv(t)-1,t),n,2)}function Jv(t){var n=t.getDay();return n>=4||0===n?Ay(t):Ay.ceil(t)}function t_(t,n){return t=Jv(t),_v(Ay.count(tv(t),t)+(4===tv(t).getDay()),n,2)}function n_(t){return t.getDay()}function e_(t,n){return _v(wy.count(tv(t)-1,t),n,2)}function r_(t,n){return _v(t.getFullYear()%100,n,2)}function i_(t,n){return _v((t=Jv(t)).getFullYear()%100,n,2)}function o_(t,n){return _v(t.getFullYear()%1e4,n,4)}function a_(t,n){var e=t.getDay();return _v((t=e>=4||0===e?Ay(t):Ay.ceil(t)).getFullYear()%1e4,n,4)}function u_(t){var n=t.getTimezoneOffset();return(n>0?"-":(n*=-1,"+"))+_v(n/60|0,"0",2)+_v(n%60,"0",2)}function c_(t,n){return _v(t.getUTCDate(),n,2)}function f_(t,n){return _v(t.getUTCHours(),n,2)}function s_(t,n){return _v(t.getUTCHours()%12||12,n,2)}function l_(t,n){return _v(1+yy.count(ev(t),t),n,3)}function h_(t,n){return _v(t.getUTCMilliseconds(),n,3)}function d_(t,n){return h_(t,n)+"000"}function p_(t,n){return _v(t.getUTCMonth()+1,n,2)}function g_(t,n){return _v(t.getUTCMinutes(),n,2)}function y_(t,n){return _v(t.getUTCSeconds(),n,2)}function v_(t){var n=t.getUTCDay();return 0===n?7:n}function __(t,n){return _v(Fy.count(ev(t)-1,t),n,2)}function b_(t){var n=t.getUTCDay();return n>=4||0===n?Oy(t):Oy.ceil(t)}function m_(t,n){return t=b_(t),_v(Oy.count(ev(t),t)+(4===ev(t).getUTCDay()),n,2)}function x_(t){return t.getUTCDay()}function w_(t,n){return _v(qy.count(ev(t)-1,t),n,2)}function M_(t,n){return _v(t.getUTCFullYear()%100,n,2)}function T_(t,n){return _v((t=b_(t)).getUTCFullYear()%100,n,2)}function A_(t,n){return _v(t.getUTCFullYear()%1e4,n,4)}function S_(t,n){var e=t.getUTCDay();return _v((t=e>=4||0===e?Oy(t):Oy.ceil(t)).getUTCFullYear()%1e4,n,4)}function E_(){return"+0000"}function N_(){return"%"}function k_(t){return+t}function C_(t){return Math.floor(+t/1e3)}function P_(n){return dv=hv(n),t.timeFormat=dv.format,t.timeParse=dv.parse,t.utcFormat=dv.utcFormat,t.utcParse=dv.utcParse,dv}t.timeFormat=void 0,t.timeParse=void 0,t.utcFormat=void 0,t.utcParse=void 0,P_({dateTime:"%x, %X",date:"%-m/%-d/%Y",time:"%-I:%M:%S %p",periods:["AM","PM"],days:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"],shortDays:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],months:["January","February","March","April","May","June","July","August","September","October","November","December"],shortMonths:["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]});var z_="%Y-%m-%dT%H:%M:%S.%LZ";var $_=Date.prototype.toISOString?function(t){return t.toISOString()}:t.utcFormat(z_),D_=$_;var R_=+new Date("2000-01-01T00:00:00.000Z")?function(t){var n=new Date(t);return isNaN(n)?null:n}:t.utcParse(z_),F_=R_;function q_(t){return new Date(t)}function U_(t){return t instanceof Date?+t:+new Date(+t)}function I_(t,n,e,r,i,o,a,u,c,f){var s=Sg(),l=s.invert,h=s.domain,d=f(".%L"),p=f(":%S"),g=f("%I:%M"),y=f("%I %p"),v=f("%a %d"),_=f("%b %d"),b=f("%B"),m=f("%Y");function x(t){return(c(t)Fr(t[t.length-1]),rb=new Array(3).concat("d8b365f5f5f55ab4ac","a6611adfc27d80cdc1018571","a6611adfc27df5f5f580cdc1018571","8c510ad8b365f6e8c3c7eae55ab4ac01665e","8c510ad8b365f6e8c3f5f5f5c7eae55ab4ac01665e","8c510abf812ddfc27df6e8c3c7eae580cdc135978f01665e","8c510abf812ddfc27df6e8c3f5f5f5c7eae580cdc135978f01665e","5430058c510abf812ddfc27df6e8c3c7eae580cdc135978f01665e003c30","5430058c510abf812ddfc27df6e8c3f5f5f5c7eae580cdc135978f01665e003c30").map(H_),ib=eb(rb),ob=new Array(3).concat("af8dc3f7f7f77fbf7b","7b3294c2a5cfa6dba0008837","7b3294c2a5cff7f7f7a6dba0008837","762a83af8dc3e7d4e8d9f0d37fbf7b1b7837","762a83af8dc3e7d4e8f7f7f7d9f0d37fbf7b1b7837","762a839970abc2a5cfe7d4e8d9f0d3a6dba05aae611b7837","762a839970abc2a5cfe7d4e8f7f7f7d9f0d3a6dba05aae611b7837","40004b762a839970abc2a5cfe7d4e8d9f0d3a6dba05aae611b783700441b","40004b762a839970abc2a5cfe7d4e8f7f7f7d9f0d3a6dba05aae611b783700441b").map(H_),ab=eb(ob),ub=new Array(3).concat("e9a3c9f7f7f7a1d76a","d01c8bf1b6dab8e1864dac26","d01c8bf1b6daf7f7f7b8e1864dac26","c51b7de9a3c9fde0efe6f5d0a1d76a4d9221","c51b7de9a3c9fde0eff7f7f7e6f5d0a1d76a4d9221","c51b7dde77aef1b6dafde0efe6f5d0b8e1867fbc414d9221","c51b7dde77aef1b6dafde0eff7f7f7e6f5d0b8e1867fbc414d9221","8e0152c51b7dde77aef1b6dafde0efe6f5d0b8e1867fbc414d9221276419","8e0152c51b7dde77aef1b6dafde0eff7f7f7e6f5d0b8e1867fbc414d9221276419").map(H_),cb=eb(ub),fb=new Array(3).concat("998ec3f7f7f7f1a340","5e3c99b2abd2fdb863e66101","5e3c99b2abd2f7f7f7fdb863e66101","542788998ec3d8daebfee0b6f1a340b35806","542788998ec3d8daebf7f7f7fee0b6f1a340b35806","5427888073acb2abd2d8daebfee0b6fdb863e08214b35806","5427888073acb2abd2d8daebf7f7f7fee0b6fdb863e08214b35806","2d004b5427888073acb2abd2d8daebfee0b6fdb863e08214b358067f3b08","2d004b5427888073acb2abd2d8daebf7f7f7fee0b6fdb863e08214b358067f3b08").map(H_),sb=eb(fb),lb=new Array(3).concat("ef8a62f7f7f767a9cf","ca0020f4a58292c5de0571b0","ca0020f4a582f7f7f792c5de0571b0","b2182bef8a62fddbc7d1e5f067a9cf2166ac","b2182bef8a62fddbc7f7f7f7d1e5f067a9cf2166ac","b2182bd6604df4a582fddbc7d1e5f092c5de4393c32166ac","b2182bd6604df4a582fddbc7f7f7f7d1e5f092c5de4393c32166ac","67001fb2182bd6604df4a582fddbc7d1e5f092c5de4393c32166ac053061","67001fb2182bd6604df4a582fddbc7f7f7f7d1e5f092c5de4393c32166ac053061").map(H_),hb=eb(lb),db=new Array(3).concat("ef8a62ffffff999999","ca0020f4a582bababa404040","ca0020f4a582ffffffbababa404040","b2182bef8a62fddbc7e0e0e09999994d4d4d","b2182bef8a62fddbc7ffffffe0e0e09999994d4d4d","b2182bd6604df4a582fddbc7e0e0e0bababa8787874d4d4d","b2182bd6604df4a582fddbc7ffffffe0e0e0bababa8787874d4d4d","67001fb2182bd6604df4a582fddbc7e0e0e0bababa8787874d4d4d1a1a1a","67001fb2182bd6604df4a582fddbc7ffffffe0e0e0bababa8787874d4d4d1a1a1a").map(H_),pb=eb(db),gb=new Array(3).concat("fc8d59ffffbf91bfdb","d7191cfdae61abd9e92c7bb6","d7191cfdae61ffffbfabd9e92c7bb6","d73027fc8d59fee090e0f3f891bfdb4575b4","d73027fc8d59fee090ffffbfe0f3f891bfdb4575b4","d73027f46d43fdae61fee090e0f3f8abd9e974add14575b4","d73027f46d43fdae61fee090ffffbfe0f3f8abd9e974add14575b4","a50026d73027f46d43fdae61fee090e0f3f8abd9e974add14575b4313695","a50026d73027f46d43fdae61fee090ffffbfe0f3f8abd9e974add14575b4313695").map(H_),yb=eb(gb),vb=new Array(3).concat("fc8d59ffffbf91cf60","d7191cfdae61a6d96a1a9641","d7191cfdae61ffffbfa6d96a1a9641","d73027fc8d59fee08bd9ef8b91cf601a9850","d73027fc8d59fee08bffffbfd9ef8b91cf601a9850","d73027f46d43fdae61fee08bd9ef8ba6d96a66bd631a9850","d73027f46d43fdae61fee08bffffbfd9ef8ba6d96a66bd631a9850","a50026d73027f46d43fdae61fee08bd9ef8ba6d96a66bd631a9850006837","a50026d73027f46d43fdae61fee08bffffbfd9ef8ba6d96a66bd631a9850006837").map(H_),_b=eb(vb),bb=new Array(3).concat("fc8d59ffffbf99d594","d7191cfdae61abdda42b83ba","d7191cfdae61ffffbfabdda42b83ba","d53e4ffc8d59fee08be6f59899d5943288bd","d53e4ffc8d59fee08bffffbfe6f59899d5943288bd","d53e4ff46d43fdae61fee08be6f598abdda466c2a53288bd","d53e4ff46d43fdae61fee08bffffbfe6f598abdda466c2a53288bd","9e0142d53e4ff46d43fdae61fee08be6f598abdda466c2a53288bd5e4fa2","9e0142d53e4ff46d43fdae61fee08bffffbfe6f598abdda466c2a53288bd5e4fa2").map(H_),mb=eb(bb),xb=new Array(3).concat("e5f5f999d8c92ca25f","edf8fbb2e2e266c2a4238b45","edf8fbb2e2e266c2a42ca25f006d2c","edf8fbccece699d8c966c2a42ca25f006d2c","edf8fbccece699d8c966c2a441ae76238b45005824","f7fcfde5f5f9ccece699d8c966c2a441ae76238b45005824","f7fcfde5f5f9ccece699d8c966c2a441ae76238b45006d2c00441b").map(H_),wb=eb(xb),Mb=new Array(3).concat("e0ecf49ebcda8856a7","edf8fbb3cde38c96c688419d","edf8fbb3cde38c96c68856a7810f7c","edf8fbbfd3e69ebcda8c96c68856a7810f7c","edf8fbbfd3e69ebcda8c96c68c6bb188419d6e016b","f7fcfde0ecf4bfd3e69ebcda8c96c68c6bb188419d6e016b","f7fcfde0ecf4bfd3e69ebcda8c96c68c6bb188419d810f7c4d004b").map(H_),Tb=eb(Mb),Ab=new Array(3).concat("e0f3dba8ddb543a2ca","f0f9e8bae4bc7bccc42b8cbe","f0f9e8bae4bc7bccc443a2ca0868ac","f0f9e8ccebc5a8ddb57bccc443a2ca0868ac","f0f9e8ccebc5a8ddb57bccc44eb3d32b8cbe08589e","f7fcf0e0f3dbccebc5a8ddb57bccc44eb3d32b8cbe08589e","f7fcf0e0f3dbccebc5a8ddb57bccc44eb3d32b8cbe0868ac084081").map(H_),Sb=eb(Ab),Eb=new Array(3).concat("fee8c8fdbb84e34a33","fef0d9fdcc8afc8d59d7301f","fef0d9fdcc8afc8d59e34a33b30000","fef0d9fdd49efdbb84fc8d59e34a33b30000","fef0d9fdd49efdbb84fc8d59ef6548d7301f990000","fff7ecfee8c8fdd49efdbb84fc8d59ef6548d7301f990000","fff7ecfee8c8fdd49efdbb84fc8d59ef6548d7301fb300007f0000").map(H_),Nb=eb(Eb),kb=new Array(3).concat("ece2f0a6bddb1c9099","f6eff7bdc9e167a9cf02818a","f6eff7bdc9e167a9cf1c9099016c59","f6eff7d0d1e6a6bddb67a9cf1c9099016c59","f6eff7d0d1e6a6bddb67a9cf3690c002818a016450","fff7fbece2f0d0d1e6a6bddb67a9cf3690c002818a016450","fff7fbece2f0d0d1e6a6bddb67a9cf3690c002818a016c59014636").map(H_),Cb=eb(kb),Pb=new Array(3).concat("ece7f2a6bddb2b8cbe","f1eef6bdc9e174a9cf0570b0","f1eef6bdc9e174a9cf2b8cbe045a8d","f1eef6d0d1e6a6bddb74a9cf2b8cbe045a8d","f1eef6d0d1e6a6bddb74a9cf3690c00570b0034e7b","fff7fbece7f2d0d1e6a6bddb74a9cf3690c00570b0034e7b","fff7fbece7f2d0d1e6a6bddb74a9cf3690c00570b0045a8d023858").map(H_),zb=eb(Pb),$b=new Array(3).concat("e7e1efc994c7dd1c77","f1eef6d7b5d8df65b0ce1256","f1eef6d7b5d8df65b0dd1c77980043","f1eef6d4b9dac994c7df65b0dd1c77980043","f1eef6d4b9dac994c7df65b0e7298ace125691003f","f7f4f9e7e1efd4b9dac994c7df65b0e7298ace125691003f","f7f4f9e7e1efd4b9dac994c7df65b0e7298ace125698004367001f").map(H_),Db=eb($b),Rb=new Array(3).concat("fde0ddfa9fb5c51b8a","feebe2fbb4b9f768a1ae017e","feebe2fbb4b9f768a1c51b8a7a0177","feebe2fcc5c0fa9fb5f768a1c51b8a7a0177","feebe2fcc5c0fa9fb5f768a1dd3497ae017e7a0177","fff7f3fde0ddfcc5c0fa9fb5f768a1dd3497ae017e7a0177","fff7f3fde0ddfcc5c0fa9fb5f768a1dd3497ae017e7a017749006a").map(H_),Fb=eb(Rb),qb=new Array(3).concat("edf8b17fcdbb2c7fb8","ffffcca1dab441b6c4225ea8","ffffcca1dab441b6c42c7fb8253494","ffffccc7e9b47fcdbb41b6c42c7fb8253494","ffffccc7e9b47fcdbb41b6c41d91c0225ea80c2c84","ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea80c2c84","ffffd9edf8b1c7e9b47fcdbb41b6c41d91c0225ea8253494081d58").map(H_),Ub=eb(qb),Ib=new Array(3).concat("f7fcb9addd8e31a354","ffffccc2e69978c679238443","ffffccc2e69978c67931a354006837","ffffccd9f0a3addd8e78c67931a354006837","ffffccd9f0a3addd8e78c67941ab5d238443005a32","ffffe5f7fcb9d9f0a3addd8e78c67941ab5d238443005a32","ffffe5f7fcb9d9f0a3addd8e78c67941ab5d238443006837004529").map(H_),Ob=eb(Ib),Bb=new Array(3).concat("fff7bcfec44fd95f0e","ffffd4fed98efe9929cc4c02","ffffd4fed98efe9929d95f0e993404","ffffd4fee391fec44ffe9929d95f0e993404","ffffd4fee391fec44ffe9929ec7014cc4c028c2d04","ffffe5fff7bcfee391fec44ffe9929ec7014cc4c028c2d04","ffffe5fff7bcfee391fec44ffe9929ec7014cc4c02993404662506").map(H_),Yb=eb(Bb),Lb=new Array(3).concat("ffeda0feb24cf03b20","ffffb2fecc5cfd8d3ce31a1c","ffffb2fecc5cfd8d3cf03b20bd0026","ffffb2fed976feb24cfd8d3cf03b20bd0026","ffffb2fed976feb24cfd8d3cfc4e2ae31a1cb10026","ffffccffeda0fed976feb24cfd8d3cfc4e2ae31a1cb10026","ffffccffeda0fed976feb24cfd8d3cfc4e2ae31a1cbd0026800026").map(H_),jb=eb(Lb),Hb=new Array(3).concat("deebf79ecae13182bd","eff3ffbdd7e76baed62171b5","eff3ffbdd7e76baed63182bd08519c","eff3ffc6dbef9ecae16baed63182bd08519c","eff3ffc6dbef9ecae16baed64292c62171b5084594","f7fbffdeebf7c6dbef9ecae16baed64292c62171b5084594","f7fbffdeebf7c6dbef9ecae16baed64292c62171b508519c08306b").map(H_),Xb=eb(Hb),Gb=new Array(3).concat("e5f5e0a1d99b31a354","edf8e9bae4b374c476238b45","edf8e9bae4b374c47631a354006d2c","edf8e9c7e9c0a1d99b74c47631a354006d2c","edf8e9c7e9c0a1d99b74c47641ab5d238b45005a32","f7fcf5e5f5e0c7e9c0a1d99b74c47641ab5d238b45005a32","f7fcf5e5f5e0c7e9c0a1d99b74c47641ab5d238b45006d2c00441b").map(H_),Vb=eb(Gb),Wb=new Array(3).concat("f0f0f0bdbdbd636363","f7f7f7cccccc969696525252","f7f7f7cccccc969696636363252525","f7f7f7d9d9d9bdbdbd969696636363252525","f7f7f7d9d9d9bdbdbd969696737373525252252525","fffffff0f0f0d9d9d9bdbdbd969696737373525252252525","fffffff0f0f0d9d9d9bdbdbd969696737373525252252525000000").map(H_),Zb=eb(Wb),Kb=new Array(3).concat("efedf5bcbddc756bb1","f2f0f7cbc9e29e9ac86a51a3","f2f0f7cbc9e29e9ac8756bb154278f","f2f0f7dadaebbcbddc9e9ac8756bb154278f","f2f0f7dadaebbcbddc9e9ac8807dba6a51a34a1486","fcfbfdefedf5dadaebbcbddc9e9ac8807dba6a51a34a1486","fcfbfdefedf5dadaebbcbddc9e9ac8807dba6a51a354278f3f007d").map(H_),Qb=eb(Kb),Jb=new Array(3).concat("fee0d2fc9272de2d26","fee5d9fcae91fb6a4acb181d","fee5d9fcae91fb6a4ade2d26a50f15","fee5d9fcbba1fc9272fb6a4ade2d26a50f15","fee5d9fcbba1fc9272fb6a4aef3b2ccb181d99000d","fff5f0fee0d2fcbba1fc9272fb6a4aef3b2ccb181d99000d","fff5f0fee0d2fcbba1fc9272fb6a4aef3b2ccb181da50f1567000d").map(H_),tm=eb(Jb),nm=new Array(3).concat("fee6cefdae6be6550d","feeddefdbe85fd8d3cd94701","feeddefdbe85fd8d3ce6550da63603","feeddefdd0a2fdae6bfd8d3ce6550da63603","feeddefdd0a2fdae6bfd8d3cf16913d948018c2d04","fff5ebfee6cefdd0a2fdae6bfd8d3cf16913d948018c2d04","fff5ebfee6cefdd0a2fdae6bfd8d3cf16913d94801a636037f2704").map(H_),em=eb(nm);var rm=hi(Tr(300,.5,0),Tr(-240,.5,1)),im=hi(Tr(-100,.75,.35),Tr(80,1.5,.8)),om=hi(Tr(260,.75,.35),Tr(80,1.5,.8)),am=Tr();var um=Fe(),cm=Math.PI/3,fm=2*Math.PI/3;function sm(t){var n=t.length;return function(e){return t[Math.max(0,Math.min(n-1,Math.floor(e*n)))]}}var lm=sm(H_("44015444025645045745055946075a46085c460a5d460b5e470d60470e6147106347116447136548146748166848176948186a481a6c481b6d481c6e481d6f481f70482071482173482374482475482576482677482878482979472a7a472c7a472d7b472e7c472f7d46307e46327e46337f463480453581453781453882443983443a83443b84433d84433e85423f854240864241864142874144874045884046883f47883f48893e49893e4a893e4c8a3d4d8a3d4e8a3c4f8a3c508b3b518b3b528b3a538b3a548c39558c39568c38588c38598c375a8c375b8d365c8d365d8d355e8d355f8d34608d34618d33628d33638d32648e32658e31668e31678e31688e30698e306a8e2f6b8e2f6c8e2e6d8e2e6e8e2e6f8e2d708e2d718e2c718e2c728e2c738e2b748e2b758e2a768e2a778e2a788e29798e297a8e297b8e287c8e287d8e277e8e277f8e27808e26818e26828e26828e25838e25848e25858e24868e24878e23888e23898e238a8d228b8d228c8d228d8d218e8d218f8d21908d21918c20928c20928c20938c1f948c1f958b1f968b1f978b1f988b1f998a1f9a8a1e9b8a1e9c891e9d891f9e891f9f881fa0881fa1881fa1871fa28720a38620a48621a58521a68522a78522a88423a98324aa8325ab8225ac8226ad8127ad8128ae8029af7f2ab07f2cb17e2db27d2eb37c2fb47c31b57b32b67a34b67935b77937b87838b9773aba763bbb753dbc743fbc7340bd7242be7144bf7046c06f48c16e4ac16d4cc26c4ec36b50c46a52c56954c56856c66758c7655ac8645cc8635ec96260ca6063cb5f65cb5e67cc5c69cd5b6ccd5a6ece5870cf5773d05675d05477d1537ad1517cd2507fd34e81d34d84d44b86d54989d5488bd6468ed64590d74393d74195d84098d83e9bd93c9dd93ba0da39a2da37a5db36a8db34aadc32addc30b0dd2fb2dd2db5de2bb8de29bade28bddf26c0df25c2df23c5e021c8e020cae11fcde11dd0e11cd2e21bd5e21ad8e219dae319dde318dfe318e2e418e5e419e7e419eae51aece51befe51cf1e51df4e61ef6e620f8e621fbe723fde725")),hm=sm(H_("00000401000501010601010802010902020b02020d03030f03031204041405041606051806051a07061c08071e0907200a08220b09240c09260d0a290e0b2b100b2d110c2f120d31130d34140e36150e38160f3b180f3d19103f1a10421c10441d11471e114920114b21114e22115024125325125527125829115a2a115c2c115f2d11612f116331116533106734106936106b38106c390f6e3b0f703d0f713f0f72400f74420f75440f764510774710784910784a10794c117a4e117b4f127b51127c52137c54137d56147d57157e59157e5a167e5c167f5d177f5f187f601880621980641a80651a80671b80681c816a1c816b1d816d1d816e1e81701f81721f817320817521817621817822817922827b23827c23827e24828025828125818326818426818627818827818928818b29818c29818e2a81902a81912b81932b80942c80962c80982d80992d809b2e7f9c2e7f9e2f7fa02f7fa1307ea3307ea5317ea6317da8327daa337dab337cad347cae347bb0357bb2357bb3367ab5367ab73779b83779ba3878bc3978bd3977bf3a77c03a76c23b75c43c75c53c74c73d73c83e73ca3e72cc3f71cd4071cf4070d0416fd2426fd3436ed5446dd6456cd8456cd9466bdb476adc4869de4968df4a68e04c67e24d66e34e65e44f64e55064e75263e85362e95462ea5661eb5760ec5860ed5a5fee5b5eef5d5ef05f5ef1605df2625df2645cf3655cf4675cf4695cf56b5cf66c5cf66e5cf7705cf7725cf8745cf8765cf9785df9795df97b5dfa7d5efa7f5efa815ffb835ffb8560fb8761fc8961fc8a62fc8c63fc8e64fc9065fd9266fd9467fd9668fd9869fd9a6afd9b6bfe9d6cfe9f6dfea16efea36ffea571fea772fea973feaa74feac76feae77feb078feb27afeb47bfeb67cfeb77efeb97ffebb81febd82febf84fec185fec287fec488fec68afec88cfeca8dfecc8ffecd90fecf92fed194fed395fed597fed799fed89afdda9cfddc9efddea0fde0a1fde2a3fde3a5fde5a7fde7a9fde9aafdebacfcecaefceeb0fcf0b2fcf2b4fcf4b6fcf6b8fcf7b9fcf9bbfcfbbdfcfdbf")),dm=sm(H_("00000401000501010601010802010a02020c02020e03021004031204031405041706041907051b08051d09061f0a07220b07240c08260d08290e092b10092d110a30120a32140b34150b37160b39180c3c190c3e1b0c411c0c431e0c451f0c48210c4a230c4c240c4f260c51280b53290b552b0b572d0b592f0a5b310a5c320a5e340a5f3609613809623909633b09643d09653e0966400a67420a68440a68450a69470b6a490b6a4a0c6b4c0c6b4d0d6c4f0d6c510e6c520e6d540f6d550f6d57106e59106e5a116e5c126e5d126e5f136e61136e62146e64156e65156e67166e69166e6a176e6c186e6d186e6f196e71196e721a6e741a6e751b6e771c6d781c6d7a1d6d7c1d6d7d1e6d7f1e6c801f6c82206c84206b85216b87216b88226a8a226a8c23698d23698f24699025689225689326679526679727669827669a28659b29649d29649f2a63a02a63a22b62a32c61a52c60a62d60a82e5fa92e5eab2f5ead305dae305cb0315bb1325ab3325ab43359b63458b73557b93556ba3655bc3754bd3853bf3952c03a51c13a50c33b4fc43c4ec63d4dc73e4cc83f4bca404acb4149cc4248ce4347cf4446d04545d24644d34743d44842d54a41d74b3fd84c3ed94d3dda4e3cdb503bdd513ade5238df5337e05536e15635e25734e35933e45a31e55c30e65d2fe75e2ee8602de9612bea632aeb6429eb6628ec6726ed6925ee6a24ef6c23ef6e21f06f20f1711ff1731df2741cf3761bf37819f47918f57b17f57d15f67e14f68013f78212f78410f8850ff8870ef8890cf98b0bf98c0af98e09fa9008fa9207fa9407fb9606fb9706fb9906fb9b06fb9d07fc9f07fca108fca309fca50afca60cfca80dfcaa0ffcac11fcae12fcb014fcb216fcb418fbb61afbb81dfbba1ffbbc21fbbe23fac026fac228fac42afac62df9c72ff9c932f9cb35f8cd37f8cf3af7d13df7d340f6d543f6d746f5d949f5db4cf4dd4ff4df53f4e156f3e35af3e55df2e661f2e865f2ea69f1ec6df1ed71f1ef75f1f179f2f27df2f482f3f586f3f68af4f88ef5f992f6fa96f8fb9af9fc9dfafda1fcffa4")),pm=sm(H_("0d088710078813078916078a19068c1b068d1d068e20068f2206902406912605912805922a05932c05942e05952f059631059733059735049837049938049a3a049a3c049b3e049c3f049c41049d43039e44039e46039f48039f4903a04b03a14c02a14e02a25002a25102a35302a35502a45601a45801a45901a55b01a55c01a65e01a66001a66100a76300a76400a76600a76700a86900a86a00a86c00a86e00a86f00a87100a87201a87401a87501a87701a87801a87a02a87b02a87d03a87e03a88004a88104a78305a78405a78606a68707a68808a68a09a58b0aa58d0ba58e0ca48f0da4910ea3920fa39410a29511a19613a19814a099159f9a169f9c179e9d189d9e199da01a9ca11b9ba21d9aa31e9aa51f99a62098a72197a82296aa2395ab2494ac2694ad2793ae2892b02991b12a90b22b8fb32c8eb42e8db52f8cb6308bb7318ab83289ba3388bb3488bc3587bd3786be3885bf3984c03a83c13b82c23c81c33d80c43e7fc5407ec6417dc7427cc8437bc9447aca457acb4679cc4778cc4977cd4a76ce4b75cf4c74d04d73d14e72d24f71d35171d45270d5536fd5546ed6556dd7566cd8576bd9586ada5a6ada5b69db5c68dc5d67dd5e66de5f65de6164df6263e06363e16462e26561e26660e3685fe4695ee56a5de56b5de66c5ce76e5be76f5ae87059e97158e97257ea7457eb7556eb7655ec7754ed7953ed7a52ee7b51ef7c51ef7e50f07f4ff0804ef1814df1834cf2844bf3854bf3874af48849f48948f58b47f58c46f68d45f68f44f79044f79143f79342f89441f89540f9973ff9983ef99a3efa9b3dfa9c3cfa9e3bfb9f3afba139fba238fca338fca537fca636fca835fca934fdab33fdac33fdae32fdaf31fdb130fdb22ffdb42ffdb52efeb72dfeb82cfeba2cfebb2bfebd2afebe2afec029fdc229fdc328fdc527fdc627fdc827fdca26fdcb26fccd25fcce25fcd025fcd225fbd324fbd524fbd724fad824fada24f9dc24f9dd25f8df25f8e125f7e225f7e425f6e626f6e826f5e926f5eb27f4ed27f3ee27f3f027f2f227f1f426f1f525f0f724f0f921"));function gm(t){return function(){return t}}const ym=Math.abs,vm=Math.atan2,_m=Math.cos,bm=Math.max,mm=Math.min,xm=Math.sin,wm=Math.sqrt,Mm=1e-12,Tm=Math.PI,Am=Tm/2,Sm=2*Tm;function Em(t){return t>=1?Am:t<=-1?-Am:Math.asin(t)}function Nm(t){let n=3;return t.digits=function(e){if(!arguments.length)return n;if(null==e)n=null;else{const t=Math.floor(e);if(!(t>=0))throw new RangeError(`invalid digits: ${e}`);n=t}return t},()=>new Ua(n)}function km(t){return t.innerRadius}function Cm(t){return t.outerRadius}function Pm(t){return t.startAngle}function zm(t){return t.endAngle}function $m(t){return t&&t.padAngle}function Dm(t,n,e,r,i,o,a){var u=t-e,c=n-r,f=(a?o:-o)/wm(u*u+c*c),s=f*c,l=-f*u,h=t+s,d=n+l,p=e+s,g=r+l,y=(h+p)/2,v=(d+g)/2,_=p-h,b=g-d,m=_*_+b*b,x=i-o,w=h*g-p*d,M=(b<0?-1:1)*wm(bm(0,x*x*m-w*w)),T=(w*b-_*M)/m,A=(-w*_-b*M)/m,S=(w*b+_*M)/m,E=(-w*_+b*M)/m,N=T-y,k=A-v,C=S-y,P=E-v;return N*N+k*k>C*C+P*P&&(T=S,A=E),{cx:T,cy:A,x01:-s,y01:-l,x11:T*(i/x-1),y11:A*(i/x-1)}}var Rm=Array.prototype.slice;function Fm(t){return"object"==typeof t&&"length"in t?t:Array.from(t)}function qm(t){this._context=t}function Um(t){return new qm(t)}function Im(t){return t[0]}function Om(t){return t[1]}function Bm(t,n){var e=gm(!0),r=null,i=Um,o=null,a=Nm(u);function u(u){var c,f,s,l=(u=Fm(u)).length,h=!1;for(null==r&&(o=i(s=a())),c=0;c<=l;++c)!(c=l;--h)u.point(v[h],_[h]);u.lineEnd(),u.areaEnd()}y&&(v[s]=+t(d,s,f),_[s]=+n(d,s,f),u.point(r?+r(d,s,f):v[s],e?+e(d,s,f):_[s]))}if(p)return u=null,p+""||null}function s(){return Bm().defined(i).curve(a).context(o)}return t="function"==typeof t?t:void 0===t?Im:gm(+t),n="function"==typeof n?n:gm(void 0===n?0:+n),e="function"==typeof e?e:void 0===e?Om:gm(+e),f.x=function(n){return arguments.length?(t="function"==typeof n?n:gm(+n),r=null,f):t},f.x0=function(n){return arguments.length?(t="function"==typeof n?n:gm(+n),f):t},f.x1=function(t){return arguments.length?(r=null==t?null:"function"==typeof t?t:gm(+t),f):r},f.y=function(t){return arguments.length?(n="function"==typeof t?t:gm(+t),e=null,f):n},f.y0=function(t){return arguments.length?(n="function"==typeof t?t:gm(+t),f):n},f.y1=function(t){return arguments.length?(e=null==t?null:"function"==typeof t?t:gm(+t),f):e},f.lineX0=f.lineY0=function(){return s().x(t).y(n)},f.lineY1=function(){return s().x(t).y(e)},f.lineX1=function(){return s().x(r).y(n)},f.defined=function(t){return arguments.length?(i="function"==typeof t?t:gm(!!t),f):i},f.curve=function(t){return arguments.length?(a=t,null!=o&&(u=a(o)),f):a},f.context=function(t){return arguments.length?(null==t?o=u=null:u=a(o=t),f):o},f}function Lm(t,n){return nt?1:n>=t?0:NaN}function jm(t){return t}qm.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._point=0},lineEnd:function(){(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:this._context.lineTo(t,n)}}};var Hm=Gm(Um);function Xm(t){this._curve=t}function Gm(t){function n(n){return new Xm(t(n))}return n._curve=t,n}function Vm(t){var n=t.curve;return t.angle=t.x,delete t.x,t.radius=t.y,delete t.y,t.curve=function(t){return arguments.length?n(Gm(t)):n()._curve},t}function Wm(){return Vm(Bm().curve(Hm))}function Zm(){var t=Ym().curve(Hm),n=t.curve,e=t.lineX0,r=t.lineX1,i=t.lineY0,o=t.lineY1;return t.angle=t.x,delete t.x,t.startAngle=t.x0,delete t.x0,t.endAngle=t.x1,delete t.x1,t.radius=t.y,delete t.y,t.innerRadius=t.y0,delete t.y0,t.outerRadius=t.y1,delete t.y1,t.lineStartAngle=function(){return Vm(e())},delete t.lineX0,t.lineEndAngle=function(){return Vm(r())},delete t.lineX1,t.lineInnerRadius=function(){return Vm(i())},delete t.lineY0,t.lineOuterRadius=function(){return Vm(o())},delete t.lineY1,t.curve=function(t){return arguments.length?n(Gm(t)):n()._curve},t}function Km(t,n){return[(n=+n)*Math.cos(t-=Math.PI/2),n*Math.sin(t)]}Xm.prototype={areaStart:function(){this._curve.areaStart()},areaEnd:function(){this._curve.areaEnd()},lineStart:function(){this._curve.lineStart()},lineEnd:function(){this._curve.lineEnd()},point:function(t,n){this._curve.point(n*Math.sin(t),n*-Math.cos(t))}};class Qm{constructor(t,n){this._context=t,this._x=n}areaStart(){this._line=0}areaEnd(){this._line=NaN}lineStart(){this._point=0}lineEnd(){(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line}point(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:this._x?this._context.bezierCurveTo(this._x0=(this._x0+t)/2,this._y0,this._x0,n,t,n):this._context.bezierCurveTo(this._x0,this._y0=(this._y0+n)/2,t,this._y0,t,n)}this._x0=t,this._y0=n}}class Jm{constructor(t){this._context=t}lineStart(){this._point=0}lineEnd(){}point(t,n){if(t=+t,n=+n,0===this._point)this._point=1;else{const e=Km(this._x0,this._y0),r=Km(this._x0,this._y0=(this._y0+n)/2),i=Km(t,this._y0),o=Km(t,n);this._context.moveTo(...e),this._context.bezierCurveTo(...r,...i,...o)}this._x0=t,this._y0=n}}function tx(t){return new Qm(t,!0)}function nx(t){return new Qm(t,!1)}function ex(t){return new Jm(t)}function rx(t){return t.source}function ix(t){return t.target}function ox(t){let n=rx,e=ix,r=Im,i=Om,o=null,a=null,u=Nm(c);function c(){let c;const f=Rm.call(arguments),s=n.apply(this,f),l=e.apply(this,f);if(null==o&&(a=t(c=u())),a.lineStart(),f[0]=s,a.point(+r.apply(this,f),+i.apply(this,f)),f[0]=l,a.point(+r.apply(this,f),+i.apply(this,f)),a.lineEnd(),c)return a=null,c+""||null}return c.source=function(t){return arguments.length?(n=t,c):n},c.target=function(t){return arguments.length?(e=t,c):e},c.x=function(t){return arguments.length?(r="function"==typeof t?t:gm(+t),c):r},c.y=function(t){return arguments.length?(i="function"==typeof t?t:gm(+t),c):i},c.context=function(n){return arguments.length?(null==n?o=a=null:a=t(o=n),c):o},c}const ax=wm(3);var ux={draw(t,n){const e=.59436*wm(n+mm(n/28,.75)),r=e/2,i=r*ax;t.moveTo(0,e),t.lineTo(0,-e),t.moveTo(-i,-r),t.lineTo(i,r),t.moveTo(-i,r),t.lineTo(i,-r)}},cx={draw(t,n){const e=wm(n/Tm);t.moveTo(e,0),t.arc(0,0,e,0,Sm)}},fx={draw(t,n){const e=wm(n/5)/2;t.moveTo(-3*e,-e),t.lineTo(-e,-e),t.lineTo(-e,-3*e),t.lineTo(e,-3*e),t.lineTo(e,-e),t.lineTo(3*e,-e),t.lineTo(3*e,e),t.lineTo(e,e),t.lineTo(e,3*e),t.lineTo(-e,3*e),t.lineTo(-e,e),t.lineTo(-3*e,e),t.closePath()}};const sx=wm(1/3),lx=2*sx;var hx={draw(t,n){const e=wm(n/lx),r=e*sx;t.moveTo(0,-e),t.lineTo(r,0),t.lineTo(0,e),t.lineTo(-r,0),t.closePath()}},dx={draw(t,n){const e=.62625*wm(n);t.moveTo(0,-e),t.lineTo(e,0),t.lineTo(0,e),t.lineTo(-e,0),t.closePath()}},px={draw(t,n){const e=.87559*wm(n-mm(n/7,2));t.moveTo(-e,0),t.lineTo(e,0),t.moveTo(0,e),t.lineTo(0,-e)}},gx={draw(t,n){const e=wm(n),r=-e/2;t.rect(r,r,e,e)}},yx={draw(t,n){const e=.4431*wm(n);t.moveTo(e,e),t.lineTo(e,-e),t.lineTo(-e,-e),t.lineTo(-e,e),t.closePath()}};const vx=xm(Tm/10)/xm(7*Tm/10),_x=xm(Sm/10)*vx,bx=-_m(Sm/10)*vx;var mx={draw(t,n){const e=wm(.8908130915292852*n),r=_x*e,i=bx*e;t.moveTo(0,-e),t.lineTo(r,i);for(let n=1;n<5;++n){const o=Sm*n/5,a=_m(o),u=xm(o);t.lineTo(u*e,-a*e),t.lineTo(a*r-u*i,u*r+a*i)}t.closePath()}};const xx=wm(3);var wx={draw(t,n){const e=-wm(n/(3*xx));t.moveTo(0,2*e),t.lineTo(-xx*e,-e),t.lineTo(xx*e,-e),t.closePath()}};const Mx=wm(3);var Tx={draw(t,n){const e=.6824*wm(n),r=e/2,i=e*Mx/2;t.moveTo(0,-e),t.lineTo(i,r),t.lineTo(-i,r),t.closePath()}};const Ax=-.5,Sx=wm(3)/2,Ex=1/wm(12),Nx=3*(Ex/2+1);var kx={draw(t,n){const e=wm(n/Nx),r=e/2,i=e*Ex,o=r,a=e*Ex+e,u=-o,c=a;t.moveTo(r,i),t.lineTo(o,a),t.lineTo(u,c),t.lineTo(Ax*r-Sx*i,Sx*r+Ax*i),t.lineTo(Ax*o-Sx*a,Sx*o+Ax*a),t.lineTo(Ax*u-Sx*c,Sx*u+Ax*c),t.lineTo(Ax*r+Sx*i,Ax*i-Sx*r),t.lineTo(Ax*o+Sx*a,Ax*a-Sx*o),t.lineTo(Ax*u+Sx*c,Ax*c-Sx*u),t.closePath()}},Cx={draw(t,n){const e=.6189*wm(n-mm(n/6,1.7));t.moveTo(-e,-e),t.lineTo(e,e),t.moveTo(-e,e),t.lineTo(e,-e)}};const Px=[cx,fx,hx,gx,mx,wx,kx],zx=[cx,px,Cx,Tx,ux,yx,dx];function $x(){}function Dx(t,n,e){t._context.bezierCurveTo((2*t._x0+t._x1)/3,(2*t._y0+t._y1)/3,(t._x0+2*t._x1)/3,(t._y0+2*t._y1)/3,(t._x0+4*t._x1+n)/6,(t._y0+4*t._y1+e)/6)}function Rx(t){this._context=t}function Fx(t){this._context=t}function qx(t){this._context=t}function Ux(t,n){this._basis=new Rx(t),this._beta=n}Rx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){switch(this._point){case 3:Dx(this,this._x1,this._y1);case 2:this._context.lineTo(this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3,this._context.lineTo((5*this._x0+this._x1)/6,(5*this._y0+this._y1)/6);default:Dx(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},Fx.prototype={areaStart:$x,areaEnd:$x,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._y0=this._y1=this._y2=this._y3=this._y4=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x2,this._y2),this._context.closePath();break;case 2:this._context.moveTo((this._x2+2*this._x3)/3,(this._y2+2*this._y3)/3),this._context.lineTo((this._x3+2*this._x2)/3,(this._y3+2*this._y2)/3),this._context.closePath();break;case 3:this.point(this._x2,this._y2),this.point(this._x3,this._y3),this.point(this._x4,this._y4)}},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._x2=t,this._y2=n;break;case 1:this._point=2,this._x3=t,this._y3=n;break;case 2:this._point=3,this._x4=t,this._y4=n,this._context.moveTo((this._x0+4*this._x1+t)/6,(this._y0+4*this._y1+n)/6);break;default:Dx(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},qx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._y0=this._y1=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3;var e=(this._x0+4*this._x1+t)/6,r=(this._y0+4*this._y1+n)/6;this._line?this._context.lineTo(e,r):this._context.moveTo(e,r);break;case 3:this._point=4;default:Dx(this,t,n)}this._x0=this._x1,this._x1=t,this._y0=this._y1,this._y1=n}},Ux.prototype={lineStart:function(){this._x=[],this._y=[],this._basis.lineStart()},lineEnd:function(){var t=this._x,n=this._y,e=t.length-1;if(e>0)for(var r,i=t[0],o=n[0],a=t[e]-i,u=n[e]-o,c=-1;++c<=e;)r=c/e,this._basis.point(this._beta*t[c]+(1-this._beta)*(i+r*a),this._beta*n[c]+(1-this._beta)*(o+r*u));this._x=this._y=null,this._basis.lineEnd()},point:function(t,n){this._x.push(+t),this._y.push(+n)}};var Ix=function t(n){function e(t){return 1===n?new Rx(t):new Ux(t,n)}return e.beta=function(n){return t(+n)},e}(.85);function Ox(t,n,e){t._context.bezierCurveTo(t._x1+t._k*(t._x2-t._x0),t._y1+t._k*(t._y2-t._y0),t._x2+t._k*(t._x1-n),t._y2+t._k*(t._y1-e),t._x2,t._y2)}function Bx(t,n){this._context=t,this._k=(1-n)/6}Bx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:Ox(this,this._x1,this._y1)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2,this._x1=t,this._y1=n;break;case 2:this._point=3;default:Ox(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Yx=function t(n){function e(t){return new Bx(t,n)}return e.tension=function(n){return t(+n)},e}(0);function Lx(t,n){this._context=t,this._k=(1-n)/6}Lx.prototype={areaStart:$x,areaEnd:$x,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._x3=t,this._y3=n;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=n);break;case 2:this._point=3,this._x5=t,this._y5=n;break;default:Ox(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var jx=function t(n){function e(t){return new Lx(t,n)}return e.tension=function(n){return t(+n)},e}(0);function Hx(t,n){this._context=t,this._k=(1-n)/6}Hx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Ox(this,t,n)}this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Xx=function t(n){function e(t){return new Hx(t,n)}return e.tension=function(n){return t(+n)},e}(0);function Gx(t,n,e){var r=t._x1,i=t._y1,o=t._x2,a=t._y2;if(t._l01_a>Mm){var u=2*t._l01_2a+3*t._l01_a*t._l12_a+t._l12_2a,c=3*t._l01_a*(t._l01_a+t._l12_a);r=(r*u-t._x0*t._l12_2a+t._x2*t._l01_2a)/c,i=(i*u-t._y0*t._l12_2a+t._y2*t._l01_2a)/c}if(t._l23_a>Mm){var f=2*t._l23_2a+3*t._l23_a*t._l12_a+t._l12_2a,s=3*t._l23_a*(t._l23_a+t._l12_a);o=(o*f+t._x1*t._l23_2a-n*t._l12_2a)/s,a=(a*f+t._y1*t._l23_2a-e*t._l12_2a)/s}t._context.bezierCurveTo(r,i,o,a,t._x2,t._y2)}function Vx(t,n){this._context=t,this._alpha=n}Vx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 2:this._context.lineTo(this._x2,this._y2);break;case 3:this.point(this._x2,this._y2)}(this._line||0!==this._line&&1===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;break;case 2:this._point=3;default:Gx(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Wx=function t(n){function e(t){return n?new Vx(t,n):new Bx(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function Zx(t,n){this._context=t,this._alpha=n}Zx.prototype={areaStart:$x,areaEnd:$x,lineStart:function(){this._x0=this._x1=this._x2=this._x3=this._x4=this._x5=this._y0=this._y1=this._y2=this._y3=this._y4=this._y5=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){switch(this._point){case 1:this._context.moveTo(this._x3,this._y3),this._context.closePath();break;case 2:this._context.lineTo(this._x3,this._y3),this._context.closePath();break;case 3:this.point(this._x3,this._y3),this.point(this._x4,this._y4),this.point(this._x5,this._y5)}},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1,this._x3=t,this._y3=n;break;case 1:this._point=2,this._context.moveTo(this._x4=t,this._y4=n);break;case 2:this._point=3,this._x5=t,this._y5=n;break;default:Gx(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Kx=function t(n){function e(t){return n?new Zx(t,n):new Lx(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function Qx(t,n){this._context=t,this._alpha=n}Qx.prototype={areaStart:function(){this._line=0},areaEnd:function(){this._line=NaN},lineStart:function(){this._x0=this._x1=this._x2=this._y0=this._y1=this._y2=NaN,this._l01_a=this._l12_a=this._l23_a=this._l01_2a=this._l12_2a=this._l23_2a=this._point=0},lineEnd:function(){(this._line||0!==this._line&&3===this._point)&&this._context.closePath(),this._line=1-this._line},point:function(t,n){if(t=+t,n=+n,this._point){var e=this._x2-t,r=this._y2-n;this._l23_a=Math.sqrt(this._l23_2a=Math.pow(e*e+r*r,this._alpha))}switch(this._point){case 0:this._point=1;break;case 1:this._point=2;break;case 2:this._point=3,this._line?this._context.lineTo(this._x2,this._y2):this._context.moveTo(this._x2,this._y2);break;case 3:this._point=4;default:Gx(this,t,n)}this._l01_a=this._l12_a,this._l12_a=this._l23_a,this._l01_2a=this._l12_2a,this._l12_2a=this._l23_2a,this._x0=this._x1,this._x1=this._x2,this._x2=t,this._y0=this._y1,this._y1=this._y2,this._y2=n}};var Jx=function t(n){function e(t){return n?new Qx(t,n):new Hx(t,0)}return e.alpha=function(n){return t(+n)},e}(.5);function tw(t){this._context=t}function nw(t){return t<0?-1:1}function ew(t,n,e){var r=t._x1-t._x0,i=n-t._x1,o=(t._y1-t._y0)/(r||i<0&&-0),a=(e-t._y1)/(i||r<0&&-0),u=(o*i+a*r)/(r+i);return(nw(o)+nw(a))*Math.min(Math.abs(o),Math.abs(a),.5*Math.abs(u))||0}function rw(t,n){var e=t._x1-t._x0;return e?(3*(t._y1-t._y0)/e-n)/2:n}function iw(t,n,e){var r=t._x0,i=t._y0,o=t._x1,a=t._y1,u=(o-r)/3;t._context.bezierCurveTo(r+u,i+u*n,o-u,a-u*e,o,a)}function ow(t){this._context=t}function aw(t){this._context=new uw(t)}function uw(t){this._context=t}function cw(t){this._context=t}function fw(t){var n,e,r=t.length-1,i=new Array(r),o=new Array(r),a=new Array(r);for(i[0]=0,o[0]=2,a[0]=t[0]+2*t[1],n=1;n=0;--n)i[n]=(a[n]-i[n+1])/o[n];for(o[r-1]=(t[r]+i[r-1])/2,n=0;n1)for(var e,r,i,o=1,a=t[n[0]],u=a.length;o=0;)e[n]=n;return e}function dw(t,n){return t[n]}function pw(t){const n=[];return n.key=t,n}function gw(t){var n=t.map(yw);return hw(t).sort((function(t,e){return n[t]-n[e]}))}function yw(t){for(var n,e=-1,r=0,i=t.length,o=-1/0;++eo&&(o=n,r=e);return r}function vw(t){var n=t.map(_w);return hw(t).sort((function(t,e){return n[t]-n[e]}))}function _w(t){for(var n,e=0,r=-1,i=t.length;++r=0&&(this._t=1-this._t,this._line=1-this._line)},point:function(t,n){switch(t=+t,n=+n,this._point){case 0:this._point=1,this._line?this._context.lineTo(t,n):this._context.moveTo(t,n);break;case 1:this._point=2;default:if(this._t<=0)this._context.lineTo(this._x,n),this._context.lineTo(t,n);else{var e=this._x*(1-this._t)+t*this._t;this._context.lineTo(e,this._y),this._context.lineTo(e,n)}}this._x=t,this._y=n}};var bw=t=>()=>t;function mw(t,{sourceEvent:n,target:e,transform:r,dispatch:i}){Object.defineProperties(this,{type:{value:t,enumerable:!0,configurable:!0},sourceEvent:{value:n,enumerable:!0,configurable:!0},target:{value:e,enumerable:!0,configurable:!0},transform:{value:r,enumerable:!0,configurable:!0},_:{value:i}})}function xw(t,n,e){this.k=t,this.x=n,this.y=e}xw.prototype={constructor:xw,scale:function(t){return 1===t?this:new xw(this.k*t,this.x,this.y)},translate:function(t,n){return 0===t&0===n?this:new xw(this.k,this.x+this.k*t,this.y+this.k*n)},apply:function(t){return[t[0]*this.k+this.x,t[1]*this.k+this.y]},applyX:function(t){return t*this.k+this.x},applyY:function(t){return t*this.k+this.y},invert:function(t){return[(t[0]-this.x)/this.k,(t[1]-this.y)/this.k]},invertX:function(t){return(t-this.x)/this.k},invertY:function(t){return(t-this.y)/this.k},rescaleX:function(t){return t.copy().domain(t.range().map(this.invertX,this).map(t.invert,t))},rescaleY:function(t){return t.copy().domain(t.range().map(this.invertY,this).map(t.invert,t))},toString:function(){return"translate("+this.x+","+this.y+") scale("+this.k+")"}};var ww=new xw(1,0,0);function Mw(t){for(;!t.__zoom;)if(!(t=t.parentNode))return ww;return t.__zoom}function Tw(t){t.stopImmediatePropagation()}function Aw(t){t.preventDefault(),t.stopImmediatePropagation()}function Sw(t){return!(t.ctrlKey&&"wheel"!==t.type||t.button)}function Ew(){var t=this;return t instanceof SVGElement?(t=t.ownerSVGElement||t).hasAttribute("viewBox")?[[(t=t.viewBox.baseVal).x,t.y],[t.x+t.width,t.y+t.height]]:[[0,0],[t.width.baseVal.value,t.height.baseVal.value]]:[[0,0],[t.clientWidth,t.clientHeight]]}function Nw(){return this.__zoom||ww}function kw(t){return-t.deltaY*(1===t.deltaMode?.05:t.deltaMode?1:.002)*(t.ctrlKey?10:1)}function Cw(){return navigator.maxTouchPoints||"ontouchstart"in this}function Pw(t,n,e){var r=t.invertX(n[0][0])-e[0][0],i=t.invertX(n[1][0])-e[1][0],o=t.invertY(n[0][1])-e[0][1],a=t.invertY(n[1][1])-e[1][1];return t.translate(i>r?(r+i)/2:Math.min(0,r)||Math.max(0,i),a>o?(o+a)/2:Math.min(0,o)||Math.max(0,a))}Mw.prototype=xw.prototype,t.Adder=T,t.Delaunay=Lu,t.FormatSpecifier=tf,t.InternMap=InternMap,t.InternSet=InternSet,t.Node=Qd,t.Path=Ua,t.Voronoi=qu,t.ZoomTransform=xw,t.active=function(t,n){var e,r,i=t.__transition;if(i)for(r in n=null==n?null:n+"",i)if((e=i[r]).state>qi&&e.name===n)return new po([[t]],Zo,n,+r);return null},t.arc=function(){var t=km,n=Cm,e=gm(0),r=null,i=Pm,o=zm,a=$m,u=null,c=Nm(f);function f(){var f,s,l=+t.apply(this,arguments),h=+n.apply(this,arguments),d=i.apply(this,arguments)-Am,p=o.apply(this,arguments)-Am,g=ym(p-d),y=p>d;if(u||(u=f=c()),hMm)if(g>Sm-Mm)u.moveTo(h*_m(d),h*xm(d)),u.arc(0,0,h,d,p,!y),l>Mm&&(u.moveTo(l*_m(p),l*xm(p)),u.arc(0,0,l,p,d,y));else{var v,_,b=d,m=p,x=d,w=p,M=g,T=g,A=a.apply(this,arguments)/2,S=A>Mm&&(r?+r.apply(this,arguments):wm(l*l+h*h)),E=mm(ym(h-l)/2,+e.apply(this,arguments)),N=E,k=E;if(S>Mm){var C=Em(S/l*xm(A)),P=Em(S/h*xm(A));(M-=2*C)>Mm?(x+=C*=y?1:-1,w-=C):(M=0,x=w=(d+p)/2),(T-=2*P)>Mm?(b+=P*=y?1:-1,m-=P):(T=0,b=m=(d+p)/2)}var z=h*_m(b),$=h*xm(b),D=l*_m(w),R=l*xm(w);if(E>Mm){var F,q=h*_m(m),U=h*xm(m),I=l*_m(x),O=l*xm(x);if(g1?0:t<-1?Tm:Math.acos(t)}((B*L+Y*j)/(wm(B*B+Y*Y)*wm(L*L+j*j)))/2),X=wm(F[0]*F[0]+F[1]*F[1]);N=mm(E,(l-X)/(H-1)),k=mm(E,(h-X)/(H+1))}else N=k=0}T>Mm?k>Mm?(v=Dm(I,O,z,$,h,k,y),_=Dm(q,U,D,R,h,k,y),u.moveTo(v.cx+v.x01,v.cy+v.y01),kMm&&M>Mm?N>Mm?(v=Dm(D,R,q,U,l,-N,y),_=Dm(z,$,I,O,l,-N,y),u.lineTo(v.cx+v.x01,v.cy+v.y01),N=0))throw new RangeError("invalid r");let e=t.length;if(!((e=Math.floor(e))>=0))throw new RangeError("invalid length");if(!e||!n)return t;const r=y(n),i=t.slice();return r(t,i,0,e,1),r(i,t,0,e,1),r(t,i,0,e,1),t},t.blur2=l,t.blurImage=h,t.brush=function(){return wa(la)},t.brushSelection=function(t){var n=t.__brush;return n?n.dim.output(n.selection):null},t.brushX=function(){return wa(fa)},t.brushY=function(){return wa(sa)},t.buffer=function(t,n){return fetch(t,n).then(_c)},t.chord=function(){return za(!1,!1)},t.chordDirected=function(){return za(!0,!1)},t.chordTranspose=function(){return za(!1,!0)},t.cluster=function(){var t=Ld,n=1,e=1,r=!1;function i(i){var o,a=0;i.eachAfter((function(n){var e=n.children;e?(n.x=function(t){return t.reduce(jd,0)/t.length}(e),n.y=function(t){return 1+t.reduce(Hd,0)}(e)):(n.x=o?a+=t(n,o):0,n.y=0,o=n)}));var u=function(t){for(var n;n=t.children;)t=n[0];return t}(i),c=function(t){for(var n;n=t.children;)t=n[n.length-1];return t}(i),f=u.x-t(u,c)/2,s=c.x+t(c,u)/2;return i.eachAfter(r?function(t){t.x=(t.x-i.x)*n,t.y=(i.y-t.y)*e}:function(t){t.x=(t.x-f)/(s-f)*n,t.y=(1-(i.y?t.y/i.y:1))*e})}return i.separation=function(n){return arguments.length?(t=n,i):t},i.size=function(t){return arguments.length?(r=!1,n=+t[0],e=+t[1],i):r?null:[n,e]},i.nodeSize=function(t){return arguments.length?(r=!0,n=+t[0],e=+t[1],i):r?[n,e]:null},i},t.color=ze,t.contourDensity=function(){var t=fu,n=su,e=lu,r=960,i=500,o=20,a=2,u=3*o,c=r+2*u>>a,f=i+2*u>>a,s=Qa(20);function h(r){var i=new Float32Array(c*f),s=Math.pow(2,-a),h=-1;for(const o of r){var d=(t(o,++h,r)+u)*s,p=(n(o,h,r)+u)*s,g=+e(o,h,r);if(g&&d>=0&&d=0&&pt*r)))(n).map(((t,n)=>(t.value=+e[n],p(t))))}function p(t){return t.coordinates.forEach(g),t}function g(t){t.forEach(y)}function y(t){t.forEach(v)}function v(t){t[0]=t[0]*Math.pow(2,a)-u,t[1]=t[1]*Math.pow(2,a)-u}function _(){return c=r+2*(u=3*o)>>a,f=i+2*u>>a,d}return d.contours=function(t){var n=h(t),e=iu().size([c,f]),r=Math.pow(2,2*a),i=t=>{t=+t;var i=p(e.contour(n,t*r));return i.value=t,i};return Object.defineProperty(i,"max",{get:()=>J(n)/r}),i},d.x=function(n){return arguments.length?(t="function"==typeof n?n:Qa(+n),d):t},d.y=function(t){return arguments.length?(n="function"==typeof t?t:Qa(+t),d):n},d.weight=function(t){return arguments.length?(e="function"==typeof t?t:Qa(+t),d):e},d.size=function(t){if(!arguments.length)return[r,i];var n=+t[0],e=+t[1];if(!(n>=0&&e>=0))throw new Error("invalid size");return r=n,i=e,_()},d.cellSize=function(t){if(!arguments.length)return 1<=1))throw new Error("invalid cell size");return a=Math.floor(Math.log(t)/Math.LN2),_()},d.thresholds=function(t){return arguments.length?(s="function"==typeof t?t:Array.isArray(t)?Qa(Za.call(t)):Qa(t),d):s},d.bandwidth=function(t){if(!arguments.length)return Math.sqrt(o*(o+1));if(!((t=+t)>=0))throw new Error("invalid bandwidth");return o=(Math.sqrt(4*t*t+1)-1)/2,_()},d},t.contours=iu,t.count=v,t.create=function(t){return Zn(Yt(t).call(document.documentElement))},t.creator=Yt,t.cross=function(...t){const n="function"==typeof t[t.length-1]&&function(t){return n=>t(...n)}(t.pop()),e=(t=t.map(m)).map(_),r=t.length-1,i=new Array(r+1).fill(0),o=[];if(r<0||e.some(b))return o;for(;;){o.push(i.map(((n,e)=>t[e][n])));let a=r;for(;++i[a]===e[a];){if(0===a)return n?o.map(n):o;i[a--]=0}}},t.csv=wc,t.csvFormat=rc,t.csvFormatBody=ic,t.csvFormatRow=ac,t.csvFormatRows=oc,t.csvFormatValue=uc,t.csvParse=nc,t.csvParseRows=ec,t.cubehelix=Tr,t.cumsum=function(t,n){var e=0,r=0;return Float64Array.from(t,void 0===n?t=>e+=+t||0:i=>e+=+n(i,r++,t)||0)},t.curveBasis=function(t){return new Rx(t)},t.curveBasisClosed=function(t){return new Fx(t)},t.curveBasisOpen=function(t){return new qx(t)},t.curveBumpX=tx,t.curveBumpY=nx,t.curveBundle=Ix,t.curveCardinal=Yx,t.curveCardinalClosed=jx,t.curveCardinalOpen=Xx,t.curveCatmullRom=Wx,t.curveCatmullRomClosed=Kx,t.curveCatmullRomOpen=Jx,t.curveLinear=Um,t.curveLinearClosed=function(t){return new tw(t)},t.curveMonotoneX=function(t){return new ow(t)},t.curveMonotoneY=function(t){return new aw(t)},t.curveNatural=function(t){return new cw(t)},t.curveStep=function(t){return new sw(t,.5)},t.curveStepAfter=function(t){return new sw(t,1)},t.curveStepBefore=function(t){return new sw(t,0)},t.descending=e,t.deviation=w,t.difference=function(t,...n){t=new InternSet(t);for(const e of n)for(const n of e)t.delete(n);return t},t.disjoint=function(t,n){const e=n[Symbol.iterator](),r=new InternSet;for(const n of t){if(r.has(n))return!1;let t,i;for(;({value:t,done:i}=e.next())&&!i;){if(Object.is(n,t))return!1;r.add(t)}}return!0},t.dispatch=$t,t.drag=function(){var t,n,e,r,i=se,o=le,a=he,u=de,c={},f=$t("start","drag","end"),s=0,l=0;function h(t){t.on("mousedown.drag",d).filter(u).on("touchstart.drag",y).on("touchmove.drag",v,ee).on("touchend.drag touchcancel.drag",_).style("touch-action","none").style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function d(a,u){if(!r&&i.call(this,a,u)){var c=b(this,o.call(this,a,u),a,u,"mouse");c&&(Zn(a.view).on("mousemove.drag",p,re).on("mouseup.drag",g,re),ae(a.view),ie(a),e=!1,t=a.clientX,n=a.clientY,c("start",a))}}function p(r){if(oe(r),!e){var i=r.clientX-t,o=r.clientY-n;e=i*i+o*o>l}c.mouse("drag",r)}function g(t){Zn(t.view).on("mousemove.drag mouseup.drag",null),ue(t.view,e),oe(t),c.mouse("end",t)}function y(t,n){if(i.call(this,t,n)){var e,r,a=t.changedTouches,u=o.call(this,t,n),c=a.length;for(e=0;e+t,t.easePoly=wo,t.easePolyIn=mo,t.easePolyInOut=wo,t.easePolyOut=xo,t.easeQuad=_o,t.easeQuadIn=function(t){return t*t},t.easeQuadInOut=_o,t.easeQuadOut=function(t){return t*(2-t)},t.easeSin=Ao,t.easeSinIn=function(t){return 1==+t?1:1-Math.cos(t*To)},t.easeSinInOut=Ao,t.easeSinOut=function(t){return Math.sin(t*To)},t.every=function(t,n){if("function"!=typeof n)throw new TypeError("test is not a function");let e=-1;for(const r of t)if(!n(r,++e,t))return!1;return!0},t.extent=M,t.fcumsum=function(t,n){const e=new T;let r=-1;return Float64Array.from(t,void 0===n?t=>e.add(+t||0):i=>e.add(+n(i,++r,t)||0))},t.filter=function(t,n){if("function"!=typeof n)throw new TypeError("test is not a function");const e=[];let r=-1;for(const i of t)n(i,++r,t)&&e.push(i);return e},t.flatGroup=function(t,...n){return z(P(t,...n),n)},t.flatRollup=function(t,n,...e){return z(D(t,n,...e),e)},t.forceCenter=function(t,n){var e,r=1;function i(){var i,o,a=e.length,u=0,c=0;for(i=0;if+p||os+p||ac.index){var g=f-u.x-u.vx,y=s-u.y-u.vy,v=g*g+y*y;vt.r&&(t.r=t[n].r)}function c(){if(n){var r,i,o=n.length;for(e=new Array(o),r=0;r[u(t,n,r),t])));for(a=0,i=new Array(f);a=u)){(t.data!==n||t.next)&&(0===l&&(p+=(l=Uc(e))*l),0===h&&(p+=(h=Uc(e))*h),p(t=(Lc*t+jc)%Hc)/Hc}();function l(){h(),f.call("tick",n),e1?(null==e?u.delete(t):u.set(t,p(e)),n):u.get(t)},find:function(n,e,r){var i,o,a,u,c,f=0,s=t.length;for(null==r?r=1/0:r*=r,f=0;f1?(f.on(t,e),n):f.on(t)}}},t.forceX=function(t){var n,e,r,i=qc(.1);function o(t){for(var i,o=0,a=n.length;o=.12&&i<.234&&r>=-.425&&r<-.214?u:i>=.166&&i<.234&&r>=-.214&&r<-.115?c:a).invert(t)},s.stream=function(e){return t&&n===e?t:(r=[a.stream(n=e),u.stream(e),c.stream(e)],i=r.length,t={point:function(t,n){for(var e=-1;++ejs(r[0],r[1])&&(r[1]=i[1]),js(i[0],r[1])>js(r[0],r[1])&&(r[0]=i[0])):o.push(r=i);for(a=-1/0,n=0,r=o[e=o.length-1];n<=e;r=i,++n)i=o[n],(u=js(r[1],i[0]))>a&&(a=u,Wf=i[0],Kf=r[1])}return is=os=null,Wf===1/0||Zf===1/0?[[NaN,NaN],[NaN,NaN]]:[[Wf,Zf],[Kf,Qf]]},t.geoCentroid=function(t){ms=xs=ws=Ms=Ts=As=Ss=Es=0,Ns=new T,ks=new T,Cs=new T,Lf(t,Gs);var n=+Ns,e=+ks,r=+Cs,i=Ef(n,e,r);return i=0))throw new RangeError(`invalid digits: ${t}`);i=n}return null===n&&(r=new ed(i)),a},a.projection(t).digits(i).context(n)},t.geoProjection=yd,t.geoProjectionMutator=vd,t.geoRotation=ll,t.geoStereographic=function(){return yd(Bd).scale(250).clipAngle(142)},t.geoStereographicRaw=Bd,t.geoStream=Lf,t.geoTransform=function(t){return{stream:id(t)}},t.geoTransverseMercator=function(){var t=Ed(Yd),n=t.center,e=t.rotate;return t.center=function(t){return arguments.length?n([-t[1],t[0]]):[(t=n())[1],-t[0]]},t.rotate=function(t){return arguments.length?e([t[0],t[1],t.length>2?t[2]+90:90]):[(t=e())[0],t[1],t[2]-90]},e([0,0,90]).scale(159.155)},t.geoTransverseMercatorRaw=Yd,t.gray=function(t,n){return new ur(t,0,0,null==n?1:n)},t.greatest=ot,t.greatestIndex=function(t,e=n){if(1===e.length)return tt(t,e);let r,i=-1,o=-1;for(const n of t)++o,(i<0?0===e(n,n):e(n,r)>0)&&(r=n,i=o);return i},t.group=C,t.groupSort=function(t,e,r){return(2!==e.length?U($(t,e,r),(([t,e],[r,i])=>n(e,i)||n(t,r))):U(C(t,r),(([t,r],[i,o])=>e(r,o)||n(t,i)))).map((([t])=>t))},t.groups=P,t.hcl=dr,t.hierarchy=Gd,t.histogram=Q,t.hsl=He,t.html=Ec,t.image=function(t,n){return new Promise((function(e,r){var i=new Image;for(var o in n)i[o]=n[o];i.onerror=r,i.onload=function(){e(i)},i.src=t}))},t.index=function(t,...n){return F(t,k,R,n)},t.indexes=function(t,...n){return F(t,Array.from,R,n)},t.interpolate=Gr,t.interpolateArray=function(t,n){return(Ir(n)?Ur:Or)(t,n)},t.interpolateBasis=Er,t.interpolateBasisClosed=Nr,t.interpolateBlues=Xb,t.interpolateBrBG=ib,t.interpolateBuGn=wb,t.interpolateBuPu=Tb,t.interpolateCividis=function(t){return t=Math.max(0,Math.min(1,t)),"rgb("+Math.max(0,Math.min(255,Math.round(-4.54-t*(35.34-t*(2381.73-t*(6402.7-t*(7024.72-2710.57*t)))))))+", "+Math.max(0,Math.min(255,Math.round(32.49+t*(170.73+t*(52.82-t*(131.46-t*(176.58-67.37*t)))))))+", "+Math.max(0,Math.min(255,Math.round(81.24+t*(442.36-t*(2482.43-t*(6167.24-t*(6614.94-2475.67*t)))))))+")"},t.interpolateCool=om,t.interpolateCubehelix=li,t.interpolateCubehelixDefault=rm,t.interpolateCubehelixLong=hi,t.interpolateDate=Br,t.interpolateDiscrete=function(t){var n=t.length;return function(e){return t[Math.max(0,Math.min(n-1,Math.floor(e*n)))]}},t.interpolateGnBu=Sb,t.interpolateGreens=Vb,t.interpolateGreys=Zb,t.interpolateHcl=ci,t.interpolateHclLong=fi,t.interpolateHsl=oi,t.interpolateHslLong=ai,t.interpolateHue=function(t,n){var e=Pr(+t,+n);return function(t){var n=e(t);return n-360*Math.floor(n/360)}},t.interpolateInferno=dm,t.interpolateLab=function(t,n){var e=$r((t=ar(t)).l,(n=ar(n)).l),r=$r(t.a,n.a),i=$r(t.b,n.b),o=$r(t.opacity,n.opacity);return function(n){return t.l=e(n),t.a=r(n),t.b=i(n),t.opacity=o(n),t+""}},t.interpolateMagma=hm,t.interpolateNumber=Yr,t.interpolateNumberArray=Ur,t.interpolateObject=Lr,t.interpolateOrRd=Nb,t.interpolateOranges=em,t.interpolatePRGn=ab,t.interpolatePiYG=cb,t.interpolatePlasma=pm,t.interpolatePuBu=zb,t.interpolatePuBuGn=Cb,t.interpolatePuOr=sb,t.interpolatePuRd=Db,t.interpolatePurples=Qb,t.interpolateRainbow=function(t){(t<0||t>1)&&(t-=Math.floor(t));var n=Math.abs(t-.5);return am.h=360*t-100,am.s=1.5-1.5*n,am.l=.8-.9*n,am+""},t.interpolateRdBu=hb,t.interpolateRdGy=pb,t.interpolateRdPu=Fb,t.interpolateRdYlBu=yb,t.interpolateRdYlGn=_b,t.interpolateReds=tm,t.interpolateRgb=Dr,t.interpolateRgbBasis=Fr,t.interpolateRgbBasisClosed=qr,t.interpolateRound=Vr,t.interpolateSinebow=function(t){var n;return t=(.5-t)*Math.PI,um.r=255*(n=Math.sin(t))*n,um.g=255*(n=Math.sin(t+cm))*n,um.b=255*(n=Math.sin(t+fm))*n,um+""},t.interpolateSpectral=mb,t.interpolateString=Xr,t.interpolateTransformCss=ti,t.interpolateTransformSvg=ni,t.interpolateTurbo=function(t){return t=Math.max(0,Math.min(1,t)),"rgb("+Math.max(0,Math.min(255,Math.round(34.61+t*(1172.33-t*(10793.56-t*(33300.12-t*(38394.49-14825.05*t)))))))+", "+Math.max(0,Math.min(255,Math.round(23.31+t*(557.33+t*(1225.33-t*(3574.96-t*(1073.77+707.56*t)))))))+", "+Math.max(0,Math.min(255,Math.round(27.2+t*(3211.1-t*(15327.97-t*(27814-t*(22569.18-6838.66*t)))))))+")"},t.interpolateViridis=lm,t.interpolateWarm=im,t.interpolateYlGn=Ob,t.interpolateYlGnBu=Ub,t.interpolateYlOrBr=Yb,t.interpolateYlOrRd=jb,t.interpolateZoom=ri,t.interrupt=Gi,t.intersection=function(t,...n){t=new InternSet(t),n=n.map(vt);t:for(const e of t)for(const r of n)if(!r.has(e)){t.delete(e);continue t}return t},t.interval=function(t,n,e){var r=new Ei,i=n;return null==n?(r.restart(t,n,e),r):(r._restart=r.restart,r.restart=function(t,n,e){n=+n,e=null==e?Ai():+e,r._restart((function o(a){a+=i,r._restart(o,i+=n,e),t(a)}),n,e)},r.restart(t,n,e),r)},t.isoFormat=D_,t.isoParse=F_,t.json=function(t,n){return fetch(t,n).then(Tc)},t.lab=ar,t.lch=function(t,n,e,r){return 1===arguments.length?hr(t):new pr(e,n,t,null==r?1:r)},t.least=function(t,e=n){let r,i=!1;if(1===e.length){let o;for(const a of t){const t=e(a);(i?n(t,o)<0:0===n(t,t))&&(r=a,o=t,i=!0)}}else for(const n of t)(i?e(n,r)<0:0===e(n,n))&&(r=n,i=!0);return r},t.leastIndex=ht,t.line=Bm,t.lineRadial=Wm,t.link=ox,t.linkHorizontal=function(){return ox(tx)},t.linkRadial=function(){const t=ox(ex);return t.angle=t.x,delete t.x,t.radius=t.y,delete t.y,t},t.linkVertical=function(){return ox(nx)},t.local=Qn,t.map=function(t,n){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");if("function"!=typeof n)throw new TypeError("mapper is not a function");return Array.from(t,((e,r)=>n(e,r,t)))},t.matcher=Vt,t.max=J,t.maxIndex=tt,t.mean=function(t,n){let e=0,r=0;if(void 0===n)for(let n of t)null!=n&&(n=+n)>=n&&(++e,r+=n);else{let i=-1;for(let o of t)null!=(o=n(o,++i,t))&&(o=+o)>=o&&(++e,r+=o)}if(e)return r/e},t.median=function(t,n){return at(t,.5,n)},t.medianIndex=function(t,n){return ct(t,.5,n)},t.merge=ft,t.min=nt,t.minIndex=et,t.mode=function(t,n){const e=new InternMap;if(void 0===n)for(let n of t)null!=n&&n>=n&&e.set(n,(e.get(n)||0)+1);else{let r=-1;for(let i of t)null!=(i=n(i,++r,t))&&i>=i&&e.set(i,(e.get(i)||0)+1)}let r,i=0;for(const[t,n]of e)n>i&&(i=n,r=t);return r},t.namespace=It,t.namespaces=Ut,t.nice=Z,t.now=Ai,t.pack=function(){var t=null,n=1,e=1,r=np;function i(i){const o=ap();return i.x=n/2,i.y=e/2,t?i.eachBefore(xp(t)).eachAfter(wp(r,.5,o)).eachBefore(Mp(1)):i.eachBefore(xp(mp)).eachAfter(wp(np,1,o)).eachAfter(wp(r,i.r/Math.min(n,e),o)).eachBefore(Mp(Math.min(n,e)/(2*i.r))),i}return i.radius=function(n){return arguments.length?(t=Jd(n),i):t},i.size=function(t){return arguments.length?(n=+t[0],e=+t[1],i):[n,e]},i.padding=function(t){return arguments.length?(r="function"==typeof t?t:ep(+t),i):r},i},t.packEnclose=function(t){return up(t,ap())},t.packSiblings=function(t){return bp(t,ap()),t},t.pairs=function(t,n=st){const e=[];let r,i=!1;for(const o of t)i&&e.push(n(r,o)),r=o,i=!0;return e},t.partition=function(){var t=1,n=1,e=0,r=!1;function i(i){var o=i.height+1;return i.x0=i.y0=e,i.x1=t,i.y1=n/o,i.eachBefore(function(t,n){return function(r){r.children&&Ap(r,r.x0,t*(r.depth+1)/n,r.x1,t*(r.depth+2)/n);var i=r.x0,o=r.y0,a=r.x1-e,u=r.y1-e;a0&&(d+=l);for(null!=n?p.sort((function(t,e){return n(g[t],g[e])})):null!=e&&p.sort((function(t,n){return e(a[t],a[n])})),u=0,f=d?(v-h*b)/d:0;u0?l*f:0)+b,g[c]={data:a[c],index:u,value:l,startAngle:y,endAngle:s,padAngle:_};return g}return a.value=function(n){return arguments.length?(t="function"==typeof n?n:gm(+n),a):t},a.sortValues=function(t){return arguments.length?(n=t,e=null,a):n},a.sort=function(t){return arguments.length?(e=t,n=null,a):e},a.startAngle=function(t){return arguments.length?(r="function"==typeof t?t:gm(+t),a):r},a.endAngle=function(t){return arguments.length?(i="function"==typeof t?t:gm(+t),a):i},a.padAngle=function(t){return arguments.length?(o="function"==typeof t?t:gm(+t),a):o},a},t.piecewise=di,t.pointRadial=Km,t.pointer=ne,t.pointers=function(t,n){return t.target&&(t=te(t),void 0===n&&(n=t.currentTarget),t=t.touches||[t]),Array.from(t,(t=>ne(t,n)))},t.polygonArea=function(t){for(var n,e=-1,r=t.length,i=t[r-1],o=0;++eu!=f>u&&a<(c-e)*(u-r)/(f-r)+e&&(s=!s),c=e,f=r;return s},t.polygonHull=function(t){if((e=t.length)<3)return null;var n,e,r=new Array(e),i=new Array(e);for(n=0;n=0;--n)f.push(t[r[o[n]][2]]);for(n=+u;n(n=1664525*n+1013904223|0,lg*(n>>>0))},t.randomLogNormal=Kp,t.randomLogistic=fg,t.randomNormal=Zp,t.randomPareto=ng,t.randomPoisson=sg,t.randomUniform=Vp,t.randomWeibull=ug,t.range=lt,t.rank=function(t,e=n){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");let r=Array.from(t);const i=new Float64Array(r.length);2!==e.length&&(r=r.map(e),e=n);const o=(t,n)=>e(r[t],r[n]);let a,u;return(t=Uint32Array.from(r,((t,n)=>n))).sort(e===n?(t,n)=>O(r[t],r[n]):I(o)),t.forEach(((t,n)=>{const e=o(t,void 0===a?t:a);e>=0?((void 0===a||e>0)&&(a=t,u=n),i[t]=u):i[t]=NaN})),i},t.reduce=function(t,n,e){if("function"!=typeof n)throw new TypeError("reducer is not a function");const r=t[Symbol.iterator]();let i,o,a=-1;if(arguments.length<3){if(({done:i,value:e}=r.next()),i)return;++a}for(;({done:i,value:o}=r.next()),!i;)e=n(e,o,++a,t);return e},t.reverse=function(t){if("function"!=typeof t[Symbol.iterator])throw new TypeError("values is not iterable");return Array.from(t).reverse()},t.rgb=Fe,t.ribbon=function(){return Wa()},t.ribbonArrow=function(){return Wa(Va)},t.rollup=$,t.rollups=D,t.scaleBand=yg,t.scaleDiverging=function t(){var n=Ng(L_()(mg));return n.copy=function(){return B_(n,t())},dg.apply(n,arguments)},t.scaleDivergingLog=function t(){var n=Fg(L_()).domain([.1,1,10]);return n.copy=function(){return B_(n,t()).base(n.base())},dg.apply(n,arguments)},t.scaleDivergingPow=j_,t.scaleDivergingSqrt=function(){return j_.apply(null,arguments).exponent(.5)},t.scaleDivergingSymlog=function t(){var n=Ig(L_());return n.copy=function(){return B_(n,t()).constant(n.constant())},dg.apply(n,arguments)},t.scaleIdentity=function t(n){var e;function r(t){return null==t||isNaN(t=+t)?e:t}return r.invert=r,r.domain=r.range=function(t){return arguments.length?(n=Array.from(t,_g),r):n.slice()},r.unknown=function(t){return arguments.length?(e=t,r):e},r.copy=function(){return t(n).unknown(e)},n=arguments.length?Array.from(n,_g):[0,1],Ng(r)},t.scaleImplicit=pg,t.scaleLinear=function t(){var n=Sg();return n.copy=function(){return Tg(n,t())},hg.apply(n,arguments),Ng(n)},t.scaleLog=function t(){const n=Fg(Ag()).domain([1,10]);return n.copy=()=>Tg(n,t()).base(n.base()),hg.apply(n,arguments),n},t.scaleOrdinal=gg,t.scalePoint=function(){return vg(yg.apply(null,arguments).paddingInner(1))},t.scalePow=jg,t.scaleQuantile=function t(){var e,r=[],i=[],o=[];function a(){var t=0,n=Math.max(1,i.length);for(o=new Array(n-1);++t0?o[n-1]:r[0],n=i?[o[i-1],r]:[o[n-1],o[n]]},u.unknown=function(t){return arguments.length?(n=t,u):u},u.thresholds=function(){return o.slice()},u.copy=function(){return t().domain([e,r]).range(a).unknown(n)},hg.apply(Ng(u),arguments)},t.scaleRadial=function t(){var n,e=Sg(),r=[0,1],i=!1;function o(t){var r=function(t){return Math.sign(t)*Math.sqrt(Math.abs(t))}(e(t));return isNaN(r)?n:i?Math.round(r):r}return o.invert=function(t){return e.invert(Hg(t))},o.domain=function(t){return arguments.length?(e.domain(t),o):e.domain()},o.range=function(t){return arguments.length?(e.range((r=Array.from(t,_g)).map(Hg)),o):r.slice()},o.rangeRound=function(t){return o.range(t).round(!0)},o.round=function(t){return arguments.length?(i=!!t,o):i},o.clamp=function(t){return arguments.length?(e.clamp(t),o):e.clamp()},o.unknown=function(t){return arguments.length?(n=t,o):n},o.copy=function(){return t(e.domain(),r).round(i).clamp(e.clamp()).unknown(n)},hg.apply(o,arguments),Ng(o)},t.scaleSequential=function t(){var n=Ng(O_()(mg));return n.copy=function(){return B_(n,t())},dg.apply(n,arguments)},t.scaleSequentialLog=function t(){var n=Fg(O_()).domain([1,10]);return n.copy=function(){return B_(n,t()).base(n.base())},dg.apply(n,arguments)},t.scaleSequentialPow=Y_,t.scaleSequentialQuantile=function t(){var e=[],r=mg;function i(t){if(null!=t&&!isNaN(t=+t))return r((s(e,t,1)-1)/(e.length-1))}return i.domain=function(t){if(!arguments.length)return e.slice();e=[];for(let n of t)null==n||isNaN(n=+n)||e.push(n);return e.sort(n),i},i.interpolator=function(t){return arguments.length?(r=t,i):r},i.range=function(){return e.map(((t,n)=>r(n/(e.length-1))))},i.quantiles=function(t){return Array.from({length:t+1},((n,r)=>at(e,r/t)))},i.copy=function(){return t(r).domain(e)},dg.apply(i,arguments)},t.scaleSequentialSqrt=function(){return Y_.apply(null,arguments).exponent(.5)},t.scaleSequentialSymlog=function t(){var n=Ig(O_());return n.copy=function(){return B_(n,t()).constant(n.constant())},dg.apply(n,arguments)},t.scaleSqrt=function(){return jg.apply(null,arguments).exponent(.5)},t.scaleSymlog=function t(){var n=Ig(Ag());return n.copy=function(){return Tg(n,t()).constant(n.constant())},hg.apply(n,arguments)},t.scaleThreshold=function t(){var n,e=[.5],r=[0,1],i=1;function o(t){return null!=t&&t<=t?r[s(e,t,0,i)]:n}return o.domain=function(t){return arguments.length?(e=Array.from(t),i=Math.min(e.length,r.length-1),o):e.slice()},o.range=function(t){return arguments.length?(r=Array.from(t),i=Math.min(e.length,r.length-1),o):r.slice()},o.invertExtent=function(t){var n=r.indexOf(t);return[e[n-1],e[n]]},o.unknown=function(t){return arguments.length?(n=t,o):n},o.copy=function(){return t().domain(e).range(r).unknown(n)},hg.apply(o,arguments)},t.scaleTime=function(){return hg.apply(I_(uv,cv,tv,Zy,xy,py,sy,ay,iy,t.timeFormat).domain([new Date(2e3,0,1),new Date(2e3,0,2)]),arguments)},t.scaleUtc=function(){return hg.apply(I_(ov,av,ev,Qy,Fy,yy,hy,cy,iy,t.utcFormat).domain([Date.UTC(2e3,0,1),Date.UTC(2e3,0,2)]),arguments)},t.scan=function(t,n){const e=ht(t,n);return e<0?void 0:e},t.schemeAccent=G_,t.schemeBlues=Hb,t.schemeBrBG=rb,t.schemeBuGn=xb,t.schemeBuPu=Mb,t.schemeCategory10=X_,t.schemeDark2=V_,t.schemeGnBu=Ab,t.schemeGreens=Gb,t.schemeGreys=Wb,t.schemeOrRd=Eb,t.schemeOranges=nm,t.schemePRGn=ob,t.schemePaired=W_,t.schemePastel1=Z_,t.schemePastel2=K_,t.schemePiYG=ub,t.schemePuBu=Pb,t.schemePuBuGn=kb,t.schemePuOr=fb,t.schemePuRd=$b,t.schemePurples=Kb,t.schemeRdBu=lb,t.schemeRdGy=db,t.schemeRdPu=Rb,t.schemeRdYlBu=gb,t.schemeRdYlGn=vb,t.schemeReds=Jb,t.schemeSet1=Q_,t.schemeSet2=J_,t.schemeSet3=tb,t.schemeSpectral=bb,t.schemeTableau10=nb,t.schemeYlGn=Ib,t.schemeYlGnBu=qb,t.schemeYlOrBr=Bb,t.schemeYlOrRd=Lb,t.select=Zn,t.selectAll=function(t){return"string"==typeof t?new Vn([document.querySelectorAll(t)],[document.documentElement]):new Vn([Ht(t)],Gn)},t.selection=Wn,t.selector=jt,t.selectorAll=Gt,t.shuffle=dt,t.shuffler=pt,t.some=function(t,n){if("function"!=typeof n)throw new TypeError("test is not a function");let e=-1;for(const r of t)if(n(r,++e,t))return!0;return!1},t.sort=U,t.stack=function(){var t=gm([]),n=hw,e=lw,r=dw;function i(i){var o,a,u=Array.from(t.apply(this,arguments),pw),c=u.length,f=-1;for(const t of i)for(o=0,++f;o0)for(var e,r,i,o,a,u,c=0,f=t[n[0]].length;c0?(r[0]=o,r[1]=o+=i):i<0?(r[1]=a,r[0]=a+=i):(r[0]=0,r[1]=i)},t.stackOffsetExpand=function(t,n){if((r=t.length)>0){for(var e,r,i,o=0,a=t[0].length;o0){for(var e,r=0,i=t[n[0]],o=i.length;r0&&(r=(e=t[n[0]]).length)>0){for(var e,r,i,o=0,a=1;afunction(t){t=`${t}`;let n=t.length;zp(t,n-1)&&!zp(t,n-2)&&(t=t.slice(0,-1));return"/"===t[0]?t:`/${t}`}(t(n,e,r)))),e=n.map(Pp),i=new Set(n).add("");for(const t of e)i.has(t)||(i.add(t),n.push(t),e.push(Pp(t)),h.push(Np));d=(t,e)=>n[e],p=(t,n)=>e[n]}for(a=0,i=h.length;a=0&&(f=h[t]).data===Np;--t)f.data=null}if(u.parent=Sp,u.eachBefore((function(t){t.depth=t.parent.depth+1,--i})).eachBefore(Kd),u.parent=null,i>0)throw new Error("cycle");return u}return r.id=function(t){return arguments.length?(n=Jd(t),r):n},r.parentId=function(t){return arguments.length?(e=Jd(t),r):e},r.path=function(n){return arguments.length?(t=Jd(n),r):t},r},t.style=_n,t.subset=function(t,n){return _t(n,t)},t.sum=function(t,n){let e=0;if(void 0===n)for(let n of t)(n=+n)&&(e+=n);else{let r=-1;for(let i of t)(i=+n(i,++r,t))&&(e+=i)}return e},t.superset=_t,t.svg=Nc,t.symbol=function(t,n){let e=null,r=Nm(i);function i(){let i;if(e||(e=i=r()),t.apply(this,arguments).draw(e,+n.apply(this,arguments)),i)return e=null,i+""||null}return t="function"==typeof t?t:gm(t||cx),n="function"==typeof n?n:gm(void 0===n?64:+n),i.type=function(n){return arguments.length?(t="function"==typeof n?n:gm(n),i):t},i.size=function(t){return arguments.length?(n="function"==typeof t?t:gm(+t),i):n},i.context=function(t){return arguments.length?(e=null==t?null:t,i):e},i},t.symbolAsterisk=ux,t.symbolCircle=cx,t.symbolCross=fx,t.symbolDiamond=hx,t.symbolDiamond2=dx,t.symbolPlus=px,t.symbolSquare=gx,t.symbolSquare2=yx,t.symbolStar=mx,t.symbolTimes=Cx,t.symbolTriangle=wx,t.symbolTriangle2=Tx,t.symbolWye=kx,t.symbolX=Cx,t.symbols=Px,t.symbolsFill=Px,t.symbolsStroke=zx,t.text=mc,t.thresholdFreedmanDiaconis=function(t,n,e){const r=v(t),i=at(t,.75)-at(t,.25);return r&&i?Math.ceil((e-n)/(2*i*Math.pow(r,-1/3))):1},t.thresholdScott=function(t,n,e){const r=v(t),i=w(t);return r&&i?Math.ceil((e-n)*Math.cbrt(r)/(3.49*i)):1},t.thresholdSturges=K,t.tickFormat=Eg,t.tickIncrement=V,t.tickStep=W,t.ticks=G,t.timeDay=py,t.timeDays=gy,t.timeFormatDefaultLocale=P_,t.timeFormatLocale=hv,t.timeFriday=Sy,t.timeFridays=$y,t.timeHour=sy,t.timeHours=ly,t.timeInterval=Vg,t.timeMillisecond=Wg,t.timeMilliseconds=Zg,t.timeMinute=ay,t.timeMinutes=uy,t.timeMonday=wy,t.timeMondays=ky,t.timeMonth=Zy,t.timeMonths=Ky,t.timeSaturday=Ey,t.timeSaturdays=Dy,t.timeSecond=iy,t.timeSeconds=oy,t.timeSunday=xy,t.timeSundays=Ny,t.timeThursday=Ay,t.timeThursdays=zy,t.timeTickInterval=cv,t.timeTicks=uv,t.timeTuesday=My,t.timeTuesdays=Cy,t.timeWednesday=Ty,t.timeWednesdays=Py,t.timeWeek=xy,t.timeWeeks=Ny,t.timeYear=tv,t.timeYears=nv,t.timeout=$i,t.timer=Ni,t.timerFlush=ki,t.transition=go,t.transpose=gt,t.tree=function(){var t=$p,n=1,e=1,r=null;function i(i){var c=function(t){for(var n,e,r,i,o,a=new Up(t,0),u=[a];n=u.pop();)if(r=n._.children)for(n.children=new Array(o=r.length),i=o-1;i>=0;--i)u.push(e=n.children[i]=new Up(r[i],i)),e.parent=n;return(a.parent=new Up(null,0)).children=[a],a}(i);if(c.eachAfter(o),c.parent.m=-c.z,c.eachBefore(a),r)i.eachBefore(u);else{var f=i,s=i,l=i;i.eachBefore((function(t){t.xs.x&&(s=t),t.depth>l.depth&&(l=t)}));var h=f===s?1:t(f,s)/2,d=h-f.x,p=n/(s.x+h+d),g=e/(l.depth||1);i.eachBefore((function(t){t.x=(t.x+d)*p,t.y=t.depth*g}))}return i}function o(n){var e=n.children,r=n.parent.children,i=n.i?r[n.i-1]:null;if(e){!function(t){for(var n,e=0,r=0,i=t.children,o=i.length;--o>=0;)(n=i[o]).z+=e,n.m+=e,e+=n.s+(r+=n.c)}(n);var o=(e[0].z+e[e.length-1].z)/2;i?(n.z=i.z+t(n._,i._),n.m=n.z-o):n.z=o}else i&&(n.z=i.z+t(n._,i._));n.parent.A=function(n,e,r){if(e){for(var i,o=n,a=n,u=e,c=o.parent.children[0],f=o.m,s=a.m,l=u.m,h=c.m;u=Rp(u),o=Dp(o),u&&o;)c=Dp(c),(a=Rp(a)).a=n,(i=u.z+l-o.z-f+t(u._,o._))>0&&(Fp(qp(u,n,r),n,i),f+=i,s+=i),l+=u.m,f+=o.m,h+=c.m,s+=a.m;u&&!Rp(a)&&(a.t=u,a.m+=l-s),o&&!Dp(c)&&(c.t=o,c.m+=f-h,r=n)}return r}(n,i,n.parent.A||r[0])}function a(t){t._.x=t.z+t.parent.m,t.m+=t.parent.m}function u(t){t.x*=n,t.y=t.depth*e}return i.separation=function(n){return arguments.length?(t=n,i):t},i.size=function(t){return arguments.length?(r=!1,n=+t[0],e=+t[1],i):r?null:[n,e]},i.nodeSize=function(t){return arguments.length?(r=!0,n=+t[0],e=+t[1],i):r?[n,e]:null},i},t.treemap=function(){var t=Yp,n=!1,e=1,r=1,i=[0],o=np,a=np,u=np,c=np,f=np;function s(t){return t.x0=t.y0=0,t.x1=e,t.y1=r,t.eachBefore(l),i=[0],n&&t.eachBefore(Tp),t}function l(n){var e=i[n.depth],r=n.x0+e,s=n.y0+e,l=n.x1-e,h=n.y1-e;l=e-1){var s=u[n];return s.x0=i,s.y0=o,s.x1=a,void(s.y1=c)}var l=f[n],h=r/2+l,d=n+1,p=e-1;for(;d>>1;f[g]c-o){var _=r?(i*v+a*y)/r:a;t(n,d,y,i,o,_,c),t(d,e,v,_,o,a,c)}else{var b=r?(o*v+c*y)/r:c;t(n,d,y,i,o,a,b),t(d,e,v,i,b,a,c)}}(0,c,t.value,n,e,r,i)},t.treemapDice=Ap,t.treemapResquarify=Lp,t.treemapSlice=Ip,t.treemapSliceDice=function(t,n,e,r,i){(1&t.depth?Ip:Ap)(t,n,e,r,i)},t.treemapSquarify=Yp,t.tsv=Mc,t.tsvFormat=lc,t.tsvFormatBody=hc,t.tsvFormatRow=pc,t.tsvFormatRows=dc,t.tsvFormatValue=gc,t.tsvParse=fc,t.tsvParseRows=sc,t.union=function(...t){const n=new InternSet;for(const e of t)for(const t of e)n.add(t);return n},t.unixDay=_y,t.unixDays=by,t.utcDay=yy,t.utcDays=vy,t.utcFriday=By,t.utcFridays=Vy,t.utcHour=hy,t.utcHours=dy,t.utcMillisecond=Wg,t.utcMilliseconds=Zg,t.utcMinute=cy,t.utcMinutes=fy,t.utcMonday=qy,t.utcMondays=jy,t.utcMonth=Qy,t.utcMonths=Jy,t.utcSaturday=Yy,t.utcSaturdays=Wy,t.utcSecond=iy,t.utcSeconds=oy,t.utcSunday=Fy,t.utcSundays=Ly,t.utcThursday=Oy,t.utcThursdays=Gy,t.utcTickInterval=av,t.utcTicks=ov,t.utcTuesday=Uy,t.utcTuesdays=Hy,t.utcWednesday=Iy,t.utcWednesdays=Xy,t.utcWeek=Fy,t.utcWeeks=Ly,t.utcYear=ev,t.utcYears=rv,t.variance=x,t.version="7.8.5",t.window=pn,t.xml=Sc,t.zip=function(){return gt(arguments)},t.zoom=function(){var t,n,e,r=Sw,i=Ew,o=Pw,a=kw,u=Cw,c=[0,1/0],f=[[-1/0,-1/0],[1/0,1/0]],s=250,l=ri,h=$t("start","zoom","end"),d=500,p=150,g=0,y=10;function v(t){t.property("__zoom",Nw).on("wheel.zoom",T,{passive:!1}).on("mousedown.zoom",A).on("dblclick.zoom",S).filter(u).on("touchstart.zoom",E).on("touchmove.zoom",N).on("touchend.zoom touchcancel.zoom",k).style("-webkit-tap-highlight-color","rgba(0,0,0,0)")}function _(t,n){return(n=Math.max(c[0],Math.min(c[1],n)))===t.k?t:new xw(n,t.x,t.y)}function b(t,n,e){var r=n[0]-e[0]*t.k,i=n[1]-e[1]*t.k;return r===t.x&&i===t.y?t:new xw(t.k,r,i)}function m(t){return[(+t[0][0]+ +t[1][0])/2,(+t[0][1]+ +t[1][1])/2]}function x(t,n,e,r){t.on("start.zoom",(function(){w(this,arguments).event(r).start()})).on("interrupt.zoom end.zoom",(function(){w(this,arguments).event(r).end()})).tween("zoom",(function(){var t=this,o=arguments,a=w(t,o).event(r),u=i.apply(t,o),c=null==e?m(u):"function"==typeof e?e.apply(t,o):e,f=Math.max(u[1][0]-u[0][0],u[1][1]-u[0][1]),s=t.__zoom,h="function"==typeof n?n.apply(t,o):n,d=l(s.invert(c).concat(f/s.k),h.invert(c).concat(f/h.k));return function(t){if(1===t)t=h;else{var n=d(t),e=f/n[2];t=new xw(e,c[0]-n[0]*e,c[1]-n[1]*e)}a.zoom(null,t)}}))}function w(t,n,e){return!e&&t.__zooming||new M(t,n)}function M(t,n){this.that=t,this.args=n,this.active=0,this.sourceEvent=null,this.extent=i.apply(t,n),this.taps=0}function T(t,...n){if(r.apply(this,arguments)){var e=w(this,n).event(t),i=this.__zoom,u=Math.max(c[0],Math.min(c[1],i.k*Math.pow(2,a.apply(this,arguments)))),s=ne(t);if(e.wheel)e.mouse[0][0]===s[0]&&e.mouse[0][1]===s[1]||(e.mouse[1]=i.invert(e.mouse[0]=s)),clearTimeout(e.wheel);else{if(i.k===u)return;e.mouse=[s,i.invert(s)],Gi(this),e.start()}Aw(t),e.wheel=setTimeout((function(){e.wheel=null,e.end()}),p),e.zoom("mouse",o(b(_(i,u),e.mouse[0],e.mouse[1]),e.extent,f))}}function A(t,...n){if(!e&&r.apply(this,arguments)){var i=t.currentTarget,a=w(this,n,!0).event(t),u=Zn(t.view).on("mousemove.zoom",(function(t){if(Aw(t),!a.moved){var n=t.clientX-s,e=t.clientY-l;a.moved=n*n+e*e>g}a.event(t).zoom("mouse",o(b(a.that.__zoom,a.mouse[0]=ne(t,i),a.mouse[1]),a.extent,f))}),!0).on("mouseup.zoom",(function(t){u.on("mousemove.zoom mouseup.zoom",null),ue(t.view,a.moved),Aw(t),a.event(t).end()}),!0),c=ne(t,i),s=t.clientX,l=t.clientY;ae(t.view),Tw(t),a.mouse=[c,this.__zoom.invert(c)],Gi(this),a.start()}}function S(t,...n){if(r.apply(this,arguments)){var e=this.__zoom,a=ne(t.changedTouches?t.changedTouches[0]:t,this),u=e.invert(a),c=e.k*(t.shiftKey?.5:2),l=o(b(_(e,c),a,u),i.apply(this,n),f);Aw(t),s>0?Zn(this).transition().duration(s).call(x,l,a,t):Zn(this).call(v.transform,l,a,t)}}function E(e,...i){if(r.apply(this,arguments)){var o,a,u,c,f=e.touches,s=f.length,l=w(this,i,e.changedTouches.length===s).event(e);for(Tw(e),a=0;a (selected_parent_id, parent_count) + all_task_ids = set() + all_parent_ids = set() + + for awaited_info in awaited_info_list: + thread_id = awaited_info.thread_id + for task_info in awaited_info.awaited_by: + task_id = task_info.task_id + task_map[task_id] = (task_info, thread_id) + all_task_ids.add(task_id) + + if task_info.awaited_by: + parent_ids = [p.task_name for p in task_info.awaited_by] + parent_count = len(parent_ids) + all_parent_ids.update(parent_ids) + selected_parent = min(parent_ids) if parent_count > 1 else parent_ids[0] + child_to_parent[task_id] = (selected_parent, parent_count) + + return task_map, child_to_parent, all_task_ids, all_parent_ids + + +def _find_leaf_tasks(all_task_ids, all_parent_ids): + return all_task_ids - all_parent_ids + + +def _build_linear_stacks(leaf_task_ids, task_map, child_to_parent): + for leaf_id in leaf_task_ids: + frames = [] + visited = set() + current_id = leaf_id + thread_id = None + + while current_id is not None: + if current_id in visited: + break + visited.add(current_id) + + if current_id not in task_map: + break + + task_info, tid = task_map[current_id] + + if thread_id is None: + thread_id = tid + + if task_info.coroutine_stack: + for coro_info in task_info.coroutine_stack: + for frame in coro_info.call_stack: + frames.append(frame) + + parent_info = child_to_parent.get(current_id) + task_name = task_info.task_name or "Task-" + str(task_info.task_id) + if parent_info: + selected_parent, parent_count = parent_info + if parent_count > 1: + task_name = f"{task_name} ({parent_count} parents)" + frames.append(FrameInfo(("", None, task_name, None))) + current_id = selected_parent + else: + frames.append(FrameInfo(("", None, task_name, None))) + current_id = None + + if frames and thread_id is not None: + yield frames, thread_id, leaf_id + + +def iter_async_frames(awaited_info_list): + task_map, child_to_parent, all_task_ids, all_parent_ids = _build_task_graph(awaited_info_list) + leaf_task_ids = _find_leaf_tasks(all_task_ids, all_parent_ids) + yield from _build_linear_stacks(leaf_task_ids, task_map, child_to_parent) + + +class Collector(ABC): + aggregating = False + + @abstractmethod + def collect(self, stack_frames, timestamps_us=None): + """Collect profiling data from stack frames. + + Args: + stack_frames: List of InterpreterInfo objects + timestamps_us: Optional list of timestamps in microseconds. If provided + (from binary replay with RLE batching), use these instead of current + time. If None, collectors should use time.monotonic() or similar. + The list may contain multiple timestamps when samples are batched + together (same stack, different times). + """ + + def collect_failed_sample(self): + """Collect data about a failed sample attempt.""" + + @abstractmethod + def export(self, filename): + """Export collected data. + + Returns: + bool: True if output was generated, False if there was no data to export. + """ + + @staticmethod + def _filter_internal_frames(frames): + return filter_internal_frames(frames) + + def _iter_all_frames(self, stack_frames, skip_idle=False): + for interpreter_info in stack_frames: + for thread_info in interpreter_info.threads: + # skip_idle now means: skip if thread is not actively running + # A thread is "active" if it has the GIL OR is on CPU + if skip_idle: + status_flags = thread_info.status + has_gil = bool(status_flags & THREAD_STATUS_HAS_GIL) + on_cpu = bool(status_flags & THREAD_STATUS_ON_CPU) + if not (has_gil or on_cpu): + continue + frames = thread_info.frame_info + if frames: + # Filter out internal profiler frames from the bottom of the stack + frames = self._filter_internal_frames(frames) + if frames: + yield frames, thread_info.thread_id + + def _iter_async_frames(self, awaited_info_list): + yield from iter_async_frames(awaited_info_list) + + def _iter_stacks(self, stack_frames, skip_idle=False): + """Yield (frames, thread_id) for all stacks, handling both sync and async modes.""" + if stack_frames and hasattr(stack_frames[0], "awaited_by"): + for frames, thread_id, _ in iter_async_frames(stack_frames): + if frames: + yield frames, thread_id + else: + for frames, thread_id in self._iter_all_frames(stack_frames, skip_idle=skip_idle): + if frames: + yield frames, thread_id + + def _is_gc_frame(self, frame): + if isinstance(frame, tuple): + funcname = frame[2] if len(frame) >= 3 else "" + else: + funcname = getattr(frame, "funcname", "") + + return "" in funcname or "gc_collect" in funcname + + def _collect_thread_status_stats(self, stack_frames): + """Collect aggregate and per-thread status statistics from a sample. + + Returns: + tuple: (aggregate_status_counts, has_gc_frame, per_thread_stats) + - aggregate_status_counts: dict with has_gil, on_cpu, has_exception, etc. + - has_gc_frame: bool indicating if any thread has GC frames + - per_thread_stats: dict mapping thread_id to per-thread counts + """ + status_counts = { + "has_gil": 0, + "on_cpu": 0, + "gil_requested": 0, + "unknown": 0, + "has_exception": 0, + "total": 0, + } + has_gc_frame = False + per_thread_stats = {} + + for interpreter_info in stack_frames: + threads = getattr(interpreter_info, "threads", []) + for thread_info in threads: + status_counts["total"] += 1 + + # Track thread status using bit flags + status_flags = getattr(thread_info, "status", 0) + + if status_flags & THREAD_STATUS_HAS_GIL: + status_counts["has_gil"] += 1 + if status_flags & THREAD_STATUS_ON_CPU: + status_counts["on_cpu"] += 1 + if status_flags & THREAD_STATUS_GIL_REQUESTED: + status_counts["gil_requested"] += 1 + if status_flags & THREAD_STATUS_UNKNOWN: + status_counts["unknown"] += 1 + if status_flags & THREAD_STATUS_HAS_EXCEPTION: + status_counts["has_exception"] += 1 + + # Track per-thread statistics + thread_id = getattr(thread_info, "thread_id", None) + if thread_id is not None: + if thread_id not in per_thread_stats: + per_thread_stats[thread_id] = { + "has_gil": 0, + "on_cpu": 0, + "gil_requested": 0, + "unknown": 0, + "has_exception": 0, + "total": 0, + "gc_samples": 0, + } + + thread_stats = per_thread_stats[thread_id] + thread_stats["total"] += 1 + + if status_flags & THREAD_STATUS_HAS_GIL: + thread_stats["has_gil"] += 1 + if status_flags & THREAD_STATUS_ON_CPU: + thread_stats["on_cpu"] += 1 + if status_flags & THREAD_STATUS_GIL_REQUESTED: + thread_stats["gil_requested"] += 1 + if status_flags & THREAD_STATUS_UNKNOWN: + thread_stats["unknown"] += 1 + if status_flags & THREAD_STATUS_HAS_EXCEPTION: + thread_stats["has_exception"] += 1 + + # Check for GC frames in this thread + frames = getattr(thread_info, "frame_info", None) + if frames: + for frame in frames: + if self._is_gc_frame(frame): + thread_stats["gc_samples"] += 1 + has_gc_frame = True + break + + return status_counts, has_gc_frame, per_thread_stats diff --git a/Lib/profiling/sampling/constants.py b/Lib/profiling/sampling/constants.py new file mode 100644 index 000000000..d7c710f94 --- /dev/null +++ b/Lib/profiling/sampling/constants.py @@ -0,0 +1,57 @@ +"""Constants for the sampling profiler.""" + +# Time unit conversion constants +MICROSECONDS_PER_SECOND = 1_000_000 +MILLISECONDS_PER_SECOND = 1_000 + +# Profiling mode constants +PROFILING_MODE_WALL = 0 +PROFILING_MODE_CPU = 1 +PROFILING_MODE_GIL = 2 +PROFILING_MODE_ALL = 3 # Combines GIL + CPU checks +PROFILING_MODE_EXCEPTION = 4 # Only samples when thread has an active exception + +PROFILING_MODE_NAMES = { + PROFILING_MODE_WALL: "wall", + PROFILING_MODE_CPU: "cpu", + PROFILING_MODE_GIL: "gil", + PROFILING_MODE_ALL: "all", + PROFILING_MODE_EXCEPTION: "exception", +} + +# Sort mode constants +SORT_MODE_NSAMPLES = 0 +SORT_MODE_TOTTIME = 1 +SORT_MODE_CUMTIME = 2 +SORT_MODE_SAMPLE_PCT = 3 +SORT_MODE_CUMUL_PCT = 4 +SORT_MODE_NSAMPLES_CUMUL = 5 + +# Default location for synthetic frames (native, GC) that have no source location +# Format: (lineno, end_lineno, col_offset, end_col_offset) +DEFAULT_LOCATION = (0, 0, -1, -1) + +# Internal frame path suffixes to filter from profiling output +# These are internal profiler modules that should not appear in user-facing output +_INTERNAL_FRAME_SUFFIXES = ( + "_sync_coordinator.py", +) + +# Thread status flags +try: + from _remote_debugging import ( + THREAD_STATUS_HAS_GIL, + THREAD_STATUS_ON_CPU, + THREAD_STATUS_UNKNOWN, + THREAD_STATUS_GIL_REQUESTED, + THREAD_STATUS_HAS_EXCEPTION, + THREAD_STATUS_MAIN_THREAD, + ) +except ImportError: + # Fallback for tests or when module is not available + THREAD_STATUS_HAS_GIL = (1 << 0) + THREAD_STATUS_ON_CPU = (1 << 1) + THREAD_STATUS_UNKNOWN = (1 << 2) + THREAD_STATUS_GIL_REQUESTED = (1 << 3) + THREAD_STATUS_HAS_EXCEPTION = (1 << 4) + THREAD_STATUS_MAIN_THREAD = (1 << 5) diff --git a/Lib/profiling/sampling/gecko_collector.py b/Lib/profiling/sampling/gecko_collector.py new file mode 100644 index 000000000..2de8cce38 --- /dev/null +++ b/Lib/profiling/sampling/gecko_collector.py @@ -0,0 +1,1006 @@ +import itertools +import io +import json +import os +import platform +import sys +import tempfile +import threading +import time + +from .collector import Collector, filter_internal_frames +from .opcode_utils import get_opcode_info, format_opcode +try: + from _remote_debugging import THREAD_STATUS_HAS_GIL, THREAD_STATUS_ON_CPU, THREAD_STATUS_UNKNOWN, THREAD_STATUS_GIL_REQUESTED, THREAD_STATUS_HAS_EXCEPTION, THREAD_STATUS_MAIN_THREAD +except ImportError: + # Fallback if module not available (shouldn't happen in normal use) + THREAD_STATUS_HAS_GIL = (1 << 0) + THREAD_STATUS_ON_CPU = (1 << 1) + THREAD_STATUS_UNKNOWN = (1 << 2) + THREAD_STATUS_GIL_REQUESTED = (1 << 3) + THREAD_STATUS_HAS_EXCEPTION = (1 << 4) + THREAD_STATUS_MAIN_THREAD = (1 << 5) + + +# Categories matching Firefox Profiler expectations +GECKO_CATEGORIES = [ + {"name": "Other", "color": "grey", "subcategories": ["Other"]}, + {"name": "Python", "color": "yellow", "subcategories": ["Other"]}, + {"name": "Native", "color": "blue", "subcategories": ["Other"]}, + {"name": "GC", "color": "orange", "subcategories": ["Other"]}, + {"name": "GIL", "color": "green", "subcategories": ["Other"]}, + {"name": "CPU", "color": "purple", "subcategories": ["Other"]}, + {"name": "Code Type", "color": "red", "subcategories": ["Other"]}, + {"name": "Opcodes", "color": "magenta", "subcategories": ["Other"]}, + {"name": "Exception", "color": "lightblue", "subcategories": ["Other"]}, +] + +# Category indices +CATEGORY_OTHER = 0 +CATEGORY_PYTHON = 1 +CATEGORY_NATIVE = 2 +CATEGORY_GC = 3 +CATEGORY_GIL = 4 +CATEGORY_CPU = 5 +CATEGORY_CODE_TYPE = 6 +CATEGORY_OPCODES = 7 +CATEGORY_EXCEPTION = 8 + +# Subcategory indices +DEFAULT_SUBCATEGORY = 0 + +GECKO_FORMAT_VERSION = 32 +GECKO_PREPROCESSED_VERSION = 57 + +# Resource type constants +RESOURCE_TYPE_LIBRARY = 1 + +# Frame constants +FRAME_ADDRESS_NONE = -1 +FRAME_INLINE_DEPTH_ROOT = 0 + +# Process constants +PROCESS_TYPE_MAIN = 0 +STACKWALK_DISABLED = 0 + +# In-memory buffer before spilling to disk +DEFAULT_SPILL_BUFFER_BYTES = 128 * 1024 +_JSON_SEPARATORS = (",", ":") +_JSON_ENCODER = json.JSONEncoder( + separators=_JSON_SEPARATORS, allow_nan=False +) + + +class SpillColumn: + def __init__(self, directory, basename, *, + buffer_bytes=None): + self.path = os.path.join(directory, basename) + self.buffer = bytearray() + self._buffer_bytes = ( + DEFAULT_SPILL_BUFFER_BYTES if buffer_bytes is None + else buffer_bytes + ) + + def append(self, value): + self.buffer += (_JSON_ENCODER.encode(value) + "\n").encode("utf-8") + if len(self.buffer) >= self._buffer_bytes: + self.flush() + + def flush(self): + with open(self.path, "ab") as file: + file.write(self.buffer) + self.buffer.clear() + + def iter_tokens(self): + with open(self.path, encoding="utf-8") as file: + for line in file: + yield line.rstrip("\n") + + +class GeckoThreadSpill: + _COLUMNS = ( + ("samples_stack", "samples-stack.json"), + ("samples_time", "samples-time.json"), + ("markers_name", "markers-name.json"), + ("markers_start_time", "markers-start-time.json"), + ("markers_end_time", "markers-end-time.json"), + ("markers_phase", "markers-phase.json"), + ("markers_category", "markers-category.json"), + ("markers_data", "markers-data.json"), + ) + + def __init__(self, directory, tid): + prefix = f"thread-{tid}-" + for attr, basename in self._COLUMNS: + setattr(self, attr, SpillColumn(directory, prefix + basename)) + self.sample_count = 0 + self.marker_count = 0 + + def append_sample(self, stack_index, time_ms): + self.samples_stack.append(stack_index) + self.samples_time.append(time_ms) + self.sample_count += 1 + + def append_marker(self, name_idx, start_time, end_time, phase, category, data): + self.markers_name.append(name_idx) + self.markers_start_time.append(start_time) + self.markers_end_time.append(end_time) + self.markers_phase.append(phase) + self.markers_category.append(category) + self.markers_data.append(data) + self.marker_count += 1 + + def prepare_read(self): + for attr, _basename in self._COLUMNS: + getattr(self, attr).flush() + + +class GeckoCollector(Collector): + aggregating = True + + def __init__(self, sample_interval_usec, *, skip_idle=False, opcodes=False): + self.sample_interval_usec = sample_interval_usec + self.skip_idle = skip_idle + self.opcodes_enabled = opcodes + self.start_time = time.monotonic() * 1000 # milliseconds since start + + # Global string table (shared across all threads) + self.global_strings = ["(root)"] # Start with root + self.global_string_map = {"(root)": 0} + + # Per-thread data structures + self.threads = {} # tid -> thread data + self.spill_dir = None + self.exported = False + + # Global tables + self.libs = [] + + # Sampling interval tracking + self.sample_count = 0 + self.last_sample_time = 0 + self.interval = 1.0 # Will be calculated from actual sampling + + # State tracking for interval markers (tid -> start_time) + self.has_gil_start = {} # Thread has the GIL + self.no_gil_start = {} # Thread doesn't have the GIL + self.on_cpu_start = {} # Thread is running on CPU + self.off_cpu_start = {} # Thread is off CPU + self.python_code_start = {} # Thread running Python code (has GIL) + self.native_code_start = {} # Thread running native code (on CPU without GIL) + self.gil_wait_start = {} # Thread waiting for GIL + self.exception_start = {} # Thread has an exception set + self.no_exception_start = {} # Thread has no exception set + + # GC event tracking: track GC start time per thread + self.gc_start_per_thread = {} # tid -> start_time + + # Track which threads have been initialized for state tracking + self.initialized_threads = set() + + # Opcode state tracking per thread: tid -> (opcode, lineno, col_offset, funcname, filename, start_time) + self.opcode_state = {} + + # For binary replay: track base timestamp (first sample's timestamp) + self._replay_base_timestamp_us = None + + def _track_state_transition(self, tid, condition, active_dict, inactive_dict, + active_name, inactive_name, category, current_time): + """Track binary state transitions and emit markers. + + Args: + tid: Thread ID + condition: Whether the active state is true + active_dict: Dict tracking start time of active state + inactive_dict: Dict tracking start time of inactive state + active_name: Name for active state marker + inactive_name: Name for inactive state marker + category: Gecko category for the markers + current_time: Current timestamp + """ + # On first observation of a thread, just record the current state + # without creating a marker (we don't know what the previous state was) + if tid not in self.initialized_threads: + if condition: + active_dict[tid] = current_time + else: + inactive_dict[tid] = current_time + return + + # For already-initialized threads, track transitions + if condition: + active_dict.setdefault(tid, current_time) + if tid in inactive_dict: + self._add_marker(tid, inactive_name, inactive_dict.pop(tid), + current_time, category) + else: + inactive_dict.setdefault(tid, current_time) + if tid in active_dict: + self._add_marker(tid, active_name, active_dict.pop(tid), + current_time, category) + + def collect(self, stack_frames, timestamps_us=None): + """Collect samples from stack frames. + + Args: + stack_frames: List of interpreter/thread frame info + timestamps_us: List of timestamps in microseconds (None for live sampling) + """ + if self.exported: + raise RuntimeError("cannot append to GeckoCollector after export") + + # Handle live sampling (no timestamps provided) + if timestamps_us is None: + current_time = (time.monotonic() * 1000) - self.start_time + times = [current_time] + else: + if not timestamps_us: + return + # Initialize base timestamp if needed + if self._replay_base_timestamp_us is None: + self._replay_base_timestamp_us = timestamps_us[0] + # Convert all timestamps to times (ms relative to first sample) + base = self._replay_base_timestamp_us + times = [(ts - base) / 1000 for ts in timestamps_us] + + first_time = times[0] + + # Update interval calculation + if self.sample_count > 0 and self.last_sample_time > 0: + self.interval = (times[-1] - self.last_sample_time) / self.sample_count + self.last_sample_time = times[-1] + + # Process async tasks + if stack_frames and hasattr(stack_frames[0], "awaited_by"): + for frames, thread_id, _ in self._iter_async_frames(stack_frames): + frames = filter_internal_frames(frames) + if not frames: + continue + + if thread_id not in self.threads: + self.threads[thread_id] = self._create_thread( + thread_id, False + ) + + self._record_stack_sample( + self.threads[thread_id], frames, thread_id, times, first_time + ) + + self.sample_count += len(times) + return + + # Process threads + for interpreter_info in stack_frames: + for thread_info in interpreter_info.threads: + frames = filter_internal_frames(thread_info.frame_info) + tid = thread_info.thread_id + status_flags = thread_info.status + is_main_thread = bool(status_flags & THREAD_STATUS_MAIN_THREAD) + + # Initialize thread if needed + if tid not in self.threads: + self.threads[tid] = self._create_thread(tid, is_main_thread) + + thread_data = self.threads[tid] + + # Decode status flags + has_gil = bool(status_flags & THREAD_STATUS_HAS_GIL) + on_cpu = bool(status_flags & THREAD_STATUS_ON_CPU) + gil_requested = bool(status_flags & THREAD_STATUS_GIL_REQUESTED) + + # Track state transitions using first timestamp + self._track_state_transition( + tid, has_gil, self.has_gil_start, self.no_gil_start, + "Has GIL", "No GIL", CATEGORY_GIL, first_time + ) + self._track_state_transition( + tid, on_cpu, self.on_cpu_start, self.off_cpu_start, + "On CPU", "Off CPU", CATEGORY_CPU, first_time + ) + + # Track code type + if has_gil: + self._track_state_transition( + tid, True, self.python_code_start, self.native_code_start, + "Python Code", "Native Code", CATEGORY_CODE_TYPE, first_time + ) + elif on_cpu: + self._track_state_transition( + tid, True, self.native_code_start, self.python_code_start, + "Native Code", "Python Code", CATEGORY_CODE_TYPE, first_time + ) + else: + if tid in self.initialized_threads: + if tid in self.python_code_start: + self._add_marker(tid, "Python Code", self.python_code_start.pop(tid), + first_time, CATEGORY_CODE_TYPE) + if tid in self.native_code_start: + self._add_marker(tid, "Native Code", self.native_code_start.pop(tid), + first_time, CATEGORY_CODE_TYPE) + + # Track GIL wait + if gil_requested: + self.gil_wait_start.setdefault(tid, first_time) + elif tid in self.gil_wait_start: + self._add_marker(tid, "Waiting for GIL", self.gil_wait_start.pop(tid), + first_time, CATEGORY_GIL) + + # Track exception state + has_exception = bool(status_flags & THREAD_STATUS_HAS_EXCEPTION) + self._track_state_transition( + tid, has_exception, self.exception_start, self.no_exception_start, + "Has Exception", "No Exception", CATEGORY_EXCEPTION, first_time + ) + + # Track GC events + has_gc_frame = any(frame[2] == "" for frame in frames) + if has_gc_frame: + if tid not in self.gc_start_per_thread: + self.gc_start_per_thread[tid] = first_time + elif tid in self.gc_start_per_thread: + self._add_marker(tid, "GC Collecting", self.gc_start_per_thread.pop(tid), + first_time, CATEGORY_GC) + + # Mark thread as initialized + self.initialized_threads.add(tid) + + # Skip idle threads if requested + is_idle = not has_gil and not on_cpu + if self.skip_idle and is_idle: + continue + + if not frames: + continue + + self._record_stack_sample( + thread_data, frames, tid, times, first_time + ) + + self.sample_count += len(times) + + def _record_stack_sample(self, thread_data, frames, tid, times, first_time): + stack_index = self._process_stack(thread_data, frames) + + thread_spill = thread_data["_spill"] + for t in times: + thread_spill.append_sample(stack_index, t) + + if self.opcodes_enabled and frames: + leaf_frame = frames[0] + filename, location, funcname, opcode = leaf_frame + if isinstance(location, tuple): + lineno, _, col_offset, _ = location + else: + lineno = location + col_offset = -1 + + current_state = (opcode, lineno, col_offset, funcname, filename) + + if tid not in self.opcode_state: + self.opcode_state[tid] = (*current_state, first_time) + elif self.opcode_state[tid][:5] != current_state: + ( + prev_opcode, prev_lineno, prev_col, prev_funcname, + prev_filename, prev_start + ) = self.opcode_state[tid] + self._add_opcode_interval_marker( + tid, prev_opcode, prev_lineno, prev_col, prev_funcname, + prev_start, first_time + ) + self.opcode_state[tid] = (*current_state, first_time) + + def _create_thread(self, tid, is_main_thread): + """Create a new thread structure with processed profile format.""" + if self.spill_dir is None: + self.spill_dir = tempfile.TemporaryDirectory() + + thread = { + "name": f"Thread-{tid}", + "isMainThread": is_main_thread, + "processStartupTime": 0, + "processShutdownTime": None, + "registerTime": 0, + "unregisterTime": None, + "pausedRanges": [], + "pid": str(os.getpid()), + "tid": tid, + "processType": "default", + "processName": "Python Process", + # Stack table - processed format + "stackTable": { + "frame": [], + "category": [], + "subcategory": [], + "prefix": [], + "length": 0, # Will be updated on export + }, + # Frame table - processed format + "frameTable": { + "address": [], + "category": [], + "subcategory": [], + "func": [], + "innerWindowID": [], + "implementation": [], + "optimizations": [], + "line": [], + "column": [], + "inlineDepth": [], + "nativeSymbol": [], + "length": 0, # Will be updated on export + }, + # Function table - processed format + "funcTable": { + "name": [], + "isJS": [], + "relevantForJS": [], + "resource": [], + "fileName": [], + "lineNumber": [], + "columnNumber": [], + "length": 0, # Will be updated on export + }, + # Resource table - processed format + "resourceTable": { + "lib": [], + "name": [], + "host": [], + "type": [], + "length": 0, # Will be updated on export + }, + # Native symbols table (empty for Python) + "nativeSymbols": { + "libIndex": [], + "address": [], + "name": [], + "functionSize": [], + "length": 0, + }, + # Caches for deduplication + "_stackCache": {}, + "_frameCache": {}, + "_funcCache": {}, + "_resourceCache": {}, + "_spill": GeckoThreadSpill(self.spill_dir.name, tid), + } + + return thread + + def _is_python(self, filename: str) -> bool: + return not filename.startswith("<") or filename in ["", ""] + + def _get_category(self, filename: str) -> int: + return CATEGORY_PYTHON if self._is_python(filename) else CATEGORY_NATIVE + + def _intern_string(self, s): + """Intern a string in the global string table.""" + if s in self.global_string_map: + return self.global_string_map[s] + idx = len(self.global_strings) + self.global_strings.append(s) + self.global_string_map[s] = idx + return idx + + def _add_marker(self, tid, name, start_time, end_time, category): + """Add an interval marker for a specific thread.""" + if tid not in self.threads: + return + + duration = end_time - start_time + + name_idx = self._intern_string(name) + self.threads[tid]["_spill"].append_marker( + name_idx, start_time, end_time, 1, category, { + "type": name.replace(" ", ""), + "duration": duration, + "tid": tid, + } + ) + + def _add_opcode_interval_marker(self, tid, opcode, lineno, col_offset, + funcname, start_time, end_time): + """Add an interval marker for opcode execution span.""" + if tid not in self.threads or opcode is None: + return + + opcode_info = get_opcode_info(opcode) + # Use formatted opcode name (with base opcode for specialized ones) + formatted_opname = format_opcode(opcode) + + name_idx = self._intern_string(formatted_opname) + + self.threads[tid]["_spill"].append_marker( + name_idx, start_time, end_time, 1, CATEGORY_OPCODES, { + "type": "Opcode", + "opcode": opcode, + "opname": formatted_opname, + "base_opname": opcode_info["base_opname"], + "is_specialized": opcode_info["is_specialized"], + "line": lineno, + "column": col_offset if col_offset >= 0 else None, + "function": funcname, + "duration": end_time - start_time, + } + ) + + def _process_stack(self, thread_data, frames): + """Process a stack and return the stack index.""" + if not frames: + return None + + # Cache references to avoid repeated dictionary lookups + stack_cache = thread_data["_stackCache"] + stack_table = thread_data["stackTable"] + stack_frames = stack_table["frame"] + stack_prefix = stack_table["prefix"] + stack_category = stack_table["category"] + stack_subcategory = stack_table["subcategory"] + + # Build stack bottom-up (from root to leaf) + prefix_stack_idx = None + + for frame_tuple in reversed(frames): + # frame_tuple is (filename, location, funcname, opcode) + # location is (lineno, end_lineno, col_offset, end_col_offset) or just lineno + filename, location, funcname, opcode = frame_tuple + if isinstance(location, tuple): + lineno, end_lineno, col_offset, end_col_offset = location + else: + # Legacy format: location is just lineno + lineno = location + col_offset = -1 + end_col_offset = -1 + + # Get or create function + func_idx = self._get_or_create_func( + thread_data, filename, funcname, lineno + ) + + # Get or create frame (include column for precise source location) + frame_idx = self._get_or_create_frame( + thread_data, func_idx, lineno, col_offset + ) + + # Check stack cache + stack_key = (frame_idx, prefix_stack_idx) + if stack_key in stack_cache: + prefix_stack_idx = stack_cache[stack_key] + else: + # Create new stack entry + stack_idx = len(stack_frames) + stack_frames.append(frame_idx) + stack_prefix.append(prefix_stack_idx) + + # Determine category + category = self._get_category(filename) + stack_category.append(category) + stack_subcategory.append(DEFAULT_SUBCATEGORY) + + stack_cache[stack_key] = stack_idx + prefix_stack_idx = stack_idx + + return prefix_stack_idx + + def _get_or_create_func(self, thread_data, filename, funcname, lineno): + """Get or create a function entry.""" + func_cache = thread_data["_funcCache"] + func_key = (filename, funcname) + + if func_key in func_cache: + return func_cache[func_key] + + # Cache references for func table + func_table = thread_data["funcTable"] + func_names = func_table["name"] + func_is_js = func_table["isJS"] + func_relevant = func_table["relevantForJS"] + func_resources = func_table["resource"] + func_filenames = func_table["fileName"] + func_line_numbers = func_table["lineNumber"] + func_column_numbers = func_table["columnNumber"] + + func_idx = len(func_names) + + # Intern strings in global table + name_idx = self._intern_string(funcname) + + # Determine if Python + is_python = self._is_python(filename) + + # Create resource + resource_idx = self._get_or_create_resource(thread_data, filename) + + # Add function + func_names.append(name_idx) + func_is_js.append(is_python) + func_relevant.append(is_python) + func_resources.append(resource_idx) + + if is_python: + filename_idx = self._intern_string(os.path.basename(filename)) + func_filenames.append(filename_idx) + func_line_numbers.append(lineno) + else: + func_filenames.append(None) + func_line_numbers.append(None) + func_column_numbers.append(None) + + func_cache[func_key] = func_idx + return func_idx + + def _get_or_create_resource(self, thread_data, filename): + """Get or create a resource entry.""" + resource_cache = thread_data["_resourceCache"] + + if filename in resource_cache: + return resource_cache[filename] + + # Cache references for resource table + resource_table = thread_data["resourceTable"] + resource_libs = resource_table["lib"] + resource_names = resource_table["name"] + resource_hosts = resource_table["host"] + resource_types = resource_table["type"] + + resource_idx = len(resource_names) + resource_name = ( + os.path.basename(filename) if "/" in filename else filename + ) + name_idx = self._intern_string(resource_name) + + resource_libs.append(None) + resource_names.append(name_idx) + resource_hosts.append(None) + resource_types.append(RESOURCE_TYPE_LIBRARY) + + resource_cache[filename] = resource_idx + return resource_idx + + def _get_or_create_frame(self, thread_data, func_idx, lineno, col_offset=-1): + """Get or create a frame entry.""" + frame_cache = thread_data["_frameCache"] + # Include column in cache key for precise frame identification + frame_key = (func_idx, lineno, col_offset if col_offset >= 0 else None) + + if frame_key in frame_cache: + return frame_cache[frame_key] + + # Cache references for frame table + frame_table = thread_data["frameTable"] + frame_addresses = frame_table["address"] + frame_inline_depths = frame_table["inlineDepth"] + frame_categories = frame_table["category"] + frame_subcategories = frame_table["subcategory"] + frame_funcs = frame_table["func"] + frame_native_symbols = frame_table["nativeSymbol"] + frame_inner_window_ids = frame_table["innerWindowID"] + frame_implementations = frame_table["implementation"] + frame_lines = frame_table["line"] + frame_columns = frame_table["column"] + frame_optimizations = frame_table["optimizations"] + + frame_idx = len(frame_funcs) + + # Determine category based on function - use cached func table reference + is_python = thread_data["funcTable"]["isJS"][func_idx] + category = CATEGORY_PYTHON if is_python else CATEGORY_NATIVE + + frame_addresses.append(FRAME_ADDRESS_NONE) + frame_inline_depths.append(FRAME_INLINE_DEPTH_ROOT) + frame_categories.append(category) + frame_subcategories.append(DEFAULT_SUBCATEGORY) + frame_funcs.append(func_idx) + frame_native_symbols.append(None) + frame_inner_window_ids.append(None) + frame_implementations.append(None) + frame_lines.append(lineno if lineno else None) + # Store column offset if available (>= 0), otherwise None + frame_columns.append(col_offset if col_offset >= 0 else None) + frame_optimizations.append(None) + + frame_cache[frame_key] = frame_idx + return frame_idx + + def _finalize_markers(self): + """Close any open markers at the end of profiling.""" + end_time = self.last_sample_time + + # Close all open markers for each thread using a generic approach + marker_states = [ + (self.has_gil_start, "Has GIL", CATEGORY_GIL), + (self.no_gil_start, "No GIL", CATEGORY_GIL), + (self.on_cpu_start, "On CPU", CATEGORY_CPU), + (self.off_cpu_start, "Off CPU", CATEGORY_CPU), + (self.python_code_start, "Python Code", CATEGORY_CODE_TYPE), + (self.native_code_start, "Native Code", CATEGORY_CODE_TYPE), + (self.gil_wait_start, "Waiting for GIL", CATEGORY_GIL), + (self.gc_start_per_thread, "GC Collecting", CATEGORY_GC), + (self.exception_start, "Has Exception", CATEGORY_EXCEPTION), + (self.no_exception_start, "No Exception", CATEGORY_EXCEPTION), + ] + + for state_dict, marker_name, category in marker_states: + for tid in list(state_dict.keys()): + self._add_marker(tid, marker_name, state_dict[tid], end_time, category) + del state_dict[tid] + + # Close any open opcode markers + for tid, state in list(self.opcode_state.items()): + opcode, lineno, col_offset, funcname, filename, start_time = state + self._add_opcode_interval_marker(tid, opcode, lineno, col_offset, funcname, start_time, end_time) + self.opcode_state.clear() + + def export(self, filename): + """Export the profile to a Gecko JSON file.""" + if self.sample_count > 0 and self.last_sample_time > 0: + self.interval = self.last_sample_time / self.sample_count + + # Spinner for progress indication + spinner = itertools.cycle(['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']) + stop_spinner = threading.Event() + + def spin(): + message = 'Building Gecko profile...' + while not stop_spinner.is_set(): + sys.stderr.write(f'\r{next(spinner)} {message}') + sys.stderr.flush() + time.sleep(0.1) + # Clear the spinner line + sys.stderr.write('\r' + ' ' * (len(message) + 3) + '\r') + sys.stderr.flush() + + spinner_thread = threading.Thread(target=spin, daemon=True) + spinner_thread.start() + + temp_path = None + replaced = False + try: + self._prepare_for_serialization() + output_dir = os.path.dirname(os.path.abspath(filename)) or "." + with tempfile.NamedTemporaryFile( + "w", dir=output_dir, delete=False + ) as file: + temp_path = file.name + self._stream_profile(file) + os.replace(temp_path, filename) + replaced = True + finally: + self.exported = True + stop_spinner.set() + spinner_thread.join(timeout=1.0) + # Small delay to ensure the clear happens + time.sleep(0.01) + if temp_path is not None and not replaced: + try: + os.unlink(temp_path) + except FileNotFoundError: + pass + self._cleanup_spills() + + print(f"Gecko profile written to {filename}") + print( + f"Open in Firefox Profiler: https://profiler.firefox.com/" + ) + return True + + def _build_marker_schema(self): + """Build marker schema definitions for Firefox Profiler.""" + schema = [] + + # Opcode marker schema (only if opcodes enabled) + if self.opcodes_enabled: + schema.append({ + "name": "Opcode", + "display": ["marker-table", "marker-chart"], + "tooltipLabel": "{marker.data.opname}", + "tableLabel": "{marker.data.opname} at line {marker.data.line}", + "chartLabel": "{marker.data.opname}", + "fields": [ + {"key": "opname", "label": "Opcode", "format": "string", "searchable": True}, + {"key": "base_opname", "label": "Base Opcode", "format": "string"}, + {"key": "is_specialized", "label": "Specialized", "format": "string"}, + {"key": "line", "label": "Line", "format": "integer"}, + {"key": "column", "label": "Column", "format": "integer"}, + {"key": "function", "label": "Function", "format": "string"}, + {"key": "duration", "label": "Duration", "format": "duration"}, + ], + }) + + return schema + + def _build_profile(self): + """Build the complete profile structure in processed format.""" + try: + self._prepare_for_serialization() + file = io.StringIO() + self._stream_profile(file) + return json.loads(file.getvalue()) + finally: + self.exported = True + self._cleanup_spills() + + def _profile_head(self): + return { + "meta": { + "interval": self.interval, + "startTime": self.start_time, + "abi": platform.machine(), + "misc": "Python profiler", + "oscpu": platform.machine(), + "platform": platform.system(), + "processType": PROCESS_TYPE_MAIN, + "categories": GECKO_CATEGORIES, + "stackwalk": STACKWALK_DISABLED, + "toolkit": "", + "version": GECKO_FORMAT_VERSION, + "preprocessedProfileVersion": GECKO_PREPROCESSED_VERSION, + "appBuildID": "", + "physicalCPUs": os.cpu_count() or 0, + "logicalCPUs": os.cpu_count() or 0, + "CPUName": "", + "product": "Python", + "symbolicated": True, + "markerSchema": self._build_marker_schema(), + "importedFrom": "Tachyon Sampling Profiler", + "extensions": { + "id": [], + "name": [], + "baseURL": [], + "length": 0, + }, + }, + "libs": self.libs, + } + + def _profile_tail(self): + return { + "pages": [], + "shared": { + "stringArray": self.global_strings, + "sources": {"length": 0, "uuid": [], "filename": []}, + }, + } + + def _prepare_for_serialization(self): + if self.exported: + raise RuntimeError("GeckoCollector has already been exported") + self._finalize_markers() + for thread_data in self.threads.values(): + thread_data["_spill"].prepare_read() + thread_data["stackTable"]["length"] = len(thread_data["stackTable"]["frame"]) + thread_data["frameTable"]["length"] = len(thread_data["frameTable"]["func"]) + thread_data["funcTable"]["length"] = len(thread_data["funcTable"]["name"]) + thread_data["resourceTable"]["length"] = len(thread_data["resourceTable"]["name"]) + + def _cleanup_spills(self): + if self.spill_dir is not None: + self.spill_dir.cleanup() + self.spill_dir = None + + def _stream_profile(self, file): + file.write("{") + first = True + for key, value in self._profile_head().items(): + first = _write_json_member(file, key, value, first) + + first = _write_member_name(file, "threads", first) + file.write("[") + for index, (tid, thread_data) in enumerate(self.threads.items()): + if index: + file.write(",") + self._stream_thread(file, tid, thread_data) + file.write("]") + + for key, value in self._profile_tail().items(): + first = _write_json_member(file, key, value, first) + file.write("}") + + def _stream_thread(self, file, tid, thread_data): + spill = thread_data["_spill"] + metadata = { + "name": thread_data["name"], + "isMainThread": thread_data["isMainThread"], + "processStartupTime": thread_data["processStartupTime"], + "processShutdownTime": thread_data["processShutdownTime"], + "registerTime": thread_data["registerTime"], + "unregisterTime": thread_data["unregisterTime"], + "pausedRanges": thread_data["pausedRanges"], + "pid": thread_data["pid"], + "tid": thread_data["tid"], + "processType": thread_data["processType"], + "processName": thread_data["processName"], + } + file.write("{") + first = True + for key, value in metadata.items(): + first = _write_json_member(file, key, value, first) + + first = _write_member_name(file, "samples", first) + self._stream_samples(file, spill) + for key in ( + "stackTable", + "frameTable", + "funcTable", + "resourceTable", + "nativeSymbols", + ): + first = _write_json_member(file, key, thread_data[key], first) + first = _write_member_name(file, "markers", first) + self._stream_markers(file, spill) + file.write("}") + + def _stream_samples(self, file, spill): + _stream_column_table( + file, + ( + ("stack", spill.samples_stack.iter_tokens()), + ("time", spill.samples_time.iter_tokens()), + ("eventDelay", ("null" for _ in range(spill.sample_count))), + ), + spill.sample_count, + ( + ("weight", None), + ("weightType", "samples"), + ("length", spill.sample_count), + ), + ) + + def _stream_markers(self, file, spill): + _stream_column_table( + file, + ( + ("data", spill.markers_data.iter_tokens()), + ("name", spill.markers_name.iter_tokens()), + ("startTime", spill.markers_start_time.iter_tokens()), + ("endTime", spill.markers_end_time.iter_tokens()), + ("phase", spill.markers_phase.iter_tokens()), + ("category", spill.markers_category.iter_tokens()), + ), + spill.marker_count, + (("length", spill.marker_count),), + ) + + +def _write_json(file, value): + for chunk in _JSON_ENCODER.iterencode(value): + file.write(chunk) + + +def _write_member_name(file, name, first): + if not first: + file.write(",") + _write_json(file, name) + file.write(":") + return False + + +def _write_json_member(file, name, value, first): + first = _write_member_name(file, name, first) + _write_json(file, value) + return first + + +def _stream_column_table(file, columns, expected_count, trailing_members=()): + file.write("{") + first = True + for name, token_iter in columns: + first = _write_member_name(file, name, first) + _stream_array(file, token_iter, expected_count, name) + for name, value in trailing_members: + first = _write_json_member(file, name, value, first) + file.write("}") + + +def _stream_array(file, token_iter, expected_count, label="array"): + file.write("[") + count = 0 + for token in token_iter: + if count: + file.write(",") + file.write(token) + count += 1 + if count != expected_count: + raise RuntimeError( + f"streamed {count} {label} items, expected {expected_count}" + ) + file.write("]") diff --git a/Lib/profiling/sampling/heatmap_collector.py b/Lib/profiling/sampling/heatmap_collector.py new file mode 100644 index 000000000..0361fd357 --- /dev/null +++ b/Lib/profiling/sampling/heatmap_collector.py @@ -0,0 +1,1145 @@ +"""Heatmap collector for Python profiling with line-level execution heat visualization.""" + +import base64 +import collections +import html +import importlib.resources +import json +import locale +import math +import os +import platform +import site +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import Dict, List, Tuple + +from ._css_utils import get_combined_css +from ._format_utils import fmt +from .collector import normalize_location, extract_lineno +from .opcode_utils import get_opcode_info, format_opcode +from .stack_collector import StackTraceCollector +from .module_utils import extract_module_name, get_python_path_info + + +# ============================================================================ +# Data Classes +# ============================================================================ + +@dataclass +class FileStats: + """Statistics for a single profiled file.""" + filename: str + module_name: str + module_type: str + total_samples: int + total_self_samples: int + num_lines: int + max_samples: int + max_self_samples: int + percentage: float = 0.0 + + +@dataclass +class TreeNode: + """Node in the hierarchical file tree structure.""" + files: List[FileStats] = field(default_factory=list) + samples: int = 0 + count: int = 0 + children: Dict[str, 'TreeNode'] = field(default_factory=dict) + + +# ============================================================================ +# Helper Classes +# ============================================================================ + +class _TemplateLoader: + """Loads and caches HTML/CSS/JS templates for heatmap generation.""" + + def __init__(self): + """Load all templates and assets once.""" + self.index_template = None + self.file_template = None + self.index_css = None + self.index_js = None + self.file_css = None + self.file_js = None + self.logo_html = None + + self._load_templates() + + def _load_templates(self): + """Load all template files from _heatmap_assets.""" + try: + template_dir = importlib.resources.files(__package__) + assets_dir = template_dir / "_heatmap_assets" + + # Load HTML templates + self.index_template = (assets_dir / "heatmap_index_template.html").read_text(encoding="utf-8") + self.file_template = (assets_dir / "heatmap_pyfile_template.html").read_text(encoding="utf-8") + + # Load CSS (same file used for both index and file pages) + css_content = get_combined_css("heatmap") + self.index_css = css_content + self.file_css = css_content + + # Load JS + base_js = (template_dir / "_shared_assets" / "base.js").read_text(encoding="utf-8") + heatmap_shared_js = (assets_dir / "heatmap_shared.js").read_text(encoding="utf-8") + shared_js = f"{base_js}\n{heatmap_shared_js}" + self.index_js = f"{shared_js}\n{(assets_dir / 'heatmap_index.js').read_text(encoding='utf-8')}" + self.file_js = f"{shared_js}\n{(assets_dir / 'heatmap.js').read_text(encoding='utf-8')}" + + # Load Tachyon logo + logo_dir = template_dir / "_assets" + try: + png_path = logo_dir / "tachyon-logo.png" + b64_logo = base64.b64encode(png_path.read_bytes()).decode("ascii") + self.logo_html = f'' + except (FileNotFoundError, IOError) as e: + self.logo_html = '
' + print(f"Warning: Could not load Tachyon logo: {e}") + + except (FileNotFoundError, IOError) as e: + raise RuntimeError(f"Failed to load heatmap template files: {e}") from e + + +class _TreeBuilder: + """Builds hierarchical tree structure from file statistics.""" + + @staticmethod + def build_file_tree(file_stats: List[FileStats]) -> Dict[str, TreeNode]: + """Build hierarchical tree grouped by module type, then by module structure. + + Args: + file_stats: List of FileStats objects + + Returns: + Dictionary mapping module types to their tree roots + """ + # Group by module type first + type_groups = {'stdlib': [], 'site-packages': [], 'project': [], 'other': []} + for stat in file_stats: + type_groups[stat.module_type].append(stat) + + # Build tree for each type + trees = {} + for module_type, stats in type_groups.items(): + if not stats: + continue + + root_node = TreeNode() + + for stat in stats: + module_name = stat.module_name + parts = module_name.split('.') + + # Navigate/create tree structure + current_node = root_node + for i, part in enumerate(parts): + if i == len(parts) - 1: + # Last part - store the file + current_node.files.append(stat) + else: + # Intermediate part - create or navigate + if part not in current_node.children: + current_node.children[part] = TreeNode() + current_node = current_node.children[part] + + # Calculate aggregate stats for this type's tree + _TreeBuilder._calculate_node_stats(root_node) + trees[module_type] = root_node + + return trees + + @staticmethod + def _calculate_node_stats(node: TreeNode) -> Tuple[int, int]: + """Recursively calculate aggregate statistics for tree nodes. + + Args: + node: TreeNode to calculate stats for + + Returns: + Tuple of (total_samples, file_count) + """ + total_samples = 0 + file_count = 0 + + # Count files at this level + for file_stat in node.files: + total_samples += file_stat.total_samples + file_count += 1 + + # Recursively process children + for child in node.children.values(): + child_samples, child_count = _TreeBuilder._calculate_node_stats(child) + total_samples += child_samples + file_count += child_count + + node.samples = total_samples + node.count = file_count + return total_samples, file_count + + +class _HtmlRenderer: + """Renders hierarchical tree structures as HTML.""" + + def __init__(self, file_index: Dict[str, str]): + """Initialize renderer with file index. + + Args: + file_index: Mapping from filenames to HTML file names + """ + self.file_index = file_index + self.heatmap_bar_height = 16 + + def render_hierarchical_html(self, trees: Dict[str, TreeNode]) -> str: + """Build hierarchical HTML with type sections and collapsible module folders. + + Args: + trees: Dictionary mapping module types to tree roots + + Returns: + Complete HTML string for all sections + """ + type_names = { + 'stdlib': '📚 Standard Library', + 'site-packages': '📦 Site Packages', + 'project': '🏗️ Project Files', + 'other': '📄 Other Files' + } + + sections = [] + for module_type in ['project', 'stdlib', 'site-packages', 'other']: + if module_type not in trees: + continue + + tree = trees[module_type] + + # Project starts expanded, others start collapsed + is_collapsed = module_type in {'stdlib', 'site-packages', 'other'} + icon = '▶' if is_collapsed else '▼' + content_style = ' style="display: none;"' if is_collapsed else '' + + file_word = "file" if tree.count == 1 else "files" + sample_word = "sample" if tree.samples == 1 else "samples" + section_html = f''' +
+
+ {icon} + {type_names[module_type]} + ({tree.count} {file_word}, {tree.samples:n} {sample_word}) +
+
+''' + + # Render root folders + root_folders = sorted(tree.children.items(), + key=lambda x: x[1].samples, reverse=True) + + for folder_name, folder_node in root_folders: + section_html += self._render_folder(folder_node, folder_name, level=1) + + # Render root files (files not in any module) + if tree.files: + sorted_files = sorted(tree.files, key=lambda x: x.total_samples, reverse=True) + section_html += '
\n' + for stat in sorted_files: + section_html += self._render_file_item(stat, indent=' ') + section_html += '
\n' + + section_html += '
\n
\n' + sections.append(section_html) + + return '\n'.join(sections) + + def _render_folder(self, node: TreeNode, name: str, level: int = 1) -> str: + """Render a single folder node recursively. + + Args: + node: TreeNode to render + name: Display name for the folder + level: Nesting level for indentation + + Returns: + HTML string for this folder and its contents + """ + indent = ' ' * level + parts = [] + + # Render folder header (collapsed by default) + file_word = "file" if node.count == 1 else "files" + sample_word = "sample" if node.samples == 1 else "samples" + parts.append(f'{indent}') + + return '\n'.join(parts) + + def _render_file_item(self, stat: FileStats, indent: str = '') -> str: + """Render a single file item with heatmap bar. + + Args: + stat: FileStats object + indent: Indentation string + + Returns: + HTML string for file item + """ + full_path = html.escape(stat.filename) + module_name = html.escape(stat.module_name) + + intensity = stat.percentage / 100.0 + bar_width = min(stat.percentage, 100) + + html_file = self.file_index[stat.filename] + s = "" if stat.total_samples == 1 else "s" + + return (f'{indent}
\n' + f'{indent} 📄 {module_name}\n' + f'{indent} {stat.total_samples:n} sample{s}\n' + f'{indent}
\n' + f'{indent}
\n') + + +# ============================================================================ +# Main Collector Class +# ============================================================================ + +class HeatmapCollector(StackTraceCollector): + """Collector that generates coverage.py-style heatmap HTML output with line intensity. + + This collector creates detailed HTML reports showing which lines of code + were executed most frequently during profiling, similar to coverage.py + but showing execution "heat" rather than just coverage. + """ + + # File naming and formatting constants + FILE_INDEX_FORMAT = "file_{:04d}.html" + + def __init__(self, *args, **kwargs): + """Initialize the heatmap collector with data structures for analysis.""" + super().__init__(*args, **kwargs) + + # Sample counting data structures + self.line_samples = collections.Counter() + self.file_samples = collections.defaultdict(collections.Counter) + self.line_self_samples = collections.Counter() + self.file_self_samples = collections.defaultdict(collections.Counter) + + # Call graph data structures for navigation (sets for O(1) deduplication) + self.call_graph = collections.defaultdict(set) + self.callers_graph = collections.defaultdict(set) + self.function_definitions = {} + + # Map each sampled line to its function for proper caller lookup + # (filename, lineno) -> funcname + self.line_to_function = {} + + # Edge counting for call path analysis + self.edge_samples = collections.Counter() + + # Bytecode-level tracking data structures + # Track samples per (file, lineno) -> {opcode: {'count': N, 'locations': set()}} + # Locations are deduplicated via set to minimize memory usage + self.line_opcodes = collections.defaultdict(dict) + + # Statistics and metadata + self._total_samples = 0 + self._path_info = get_python_path_info() + self.stats = {} + + # Opcode collection flag + self.opcodes_enabled = False + + # Template loader (loads all templates once) + self._template_loader = _TemplateLoader() + + # File index (populated during export) + self.file_index = {} + + # Reusable set for deduplicating line locations within a single sample. + # This avoids over-counting recursive functions in cumulative stats. + self._seen_lines = set() + + def set_stats(self, sample_interval_usec, duration_sec, sample_rate, error_rate=None, missed_samples=None, **kwargs): + """Set profiling statistics to include in heatmap output. + + Args: + sample_interval_usec: Sampling interval in microseconds + duration_sec: Total profiling duration in seconds + sample_rate: Effective sampling rate + error_rate: Optional error rate during profiling + missed_samples: Optional percentage of missed samples + **kwargs: Additional statistics to include + """ + self.stats = { + "sample_interval_usec": sample_interval_usec, + "duration_sec": duration_sec, + "sample_rate": sample_rate, + "error_rate": error_rate, + "missed_samples": missed_samples, + "python_version": sys.version, + "python_implementation": platform.python_implementation(), + "platform": platform.platform(), + } + self.stats.update(kwargs) + + def process_frames(self, frames, thread_id, weight=1): + """Process stack frames and count samples per line. + + Args: + frames: List of (filename, location, funcname, opcode) tuples in + leaf-to-root order. location is (lineno, end_lineno, col_offset, end_col_offset). + opcode is None if not gathered. + thread_id: Thread ID for this stack trace + weight: Number of samples this stack represents (for batched RLE) + """ + self._total_samples += weight + self._seen_lines.clear() + + for i, (filename, location, funcname, opcode) in enumerate(frames): + # Normalize location to 4-tuple format + lineno, end_lineno, col_offset, end_col_offset = normalize_location(location) + + if not self._is_valid_frame(filename, lineno): + continue + + # frames[0] is the leaf - where execution is actually happening + is_leaf = (i == 0) + line_key = (filename, lineno) + count_cumulative = line_key not in self._seen_lines + if count_cumulative: + self._seen_lines.add(line_key) + + self._record_line_sample(filename, lineno, funcname, is_leaf=is_leaf, + count_cumulative=count_cumulative, weight=weight) + + if opcode is not None: + # Set opcodes_enabled flag when we first encounter opcode data + self.opcodes_enabled = True + self._record_bytecode_sample(filename, lineno, opcode, + end_lineno, col_offset, end_col_offset, + weight=weight) + + # Build call graph for adjacent frames (relationships are deduplicated anyway) + if i + 1 < len(frames): + next_frame = frames[i + 1] + next_lineno = extract_lineno(next_frame[1]) + self._record_call_relationship( + (filename, lineno, funcname), + (next_frame[0], next_lineno, next_frame[2]), + weight=weight, + ) + + def _is_valid_frame(self, filename, lineno): + """Check if a frame should be included in the heatmap.""" + # Skip internal or invalid files + if not filename or filename.startswith('<') or filename.startswith('['): + return False + + # Skip invalid frames with corrupted filename data + if filename == "__init__" and lineno == 0: + return False + + return True + + def _record_line_sample(self, filename, lineno, funcname, is_leaf=False, + count_cumulative=True, weight=1): + """Record a sample for a specific line.""" + # Track cumulative samples (all occurrences in stack) + if count_cumulative: + self.line_samples[(filename, lineno)] += weight + self.file_samples[filename][lineno] += weight + + # Track self/leaf samples (only when at top of stack) + if is_leaf: + self.line_self_samples[(filename, lineno)] += weight + self.file_self_samples[filename][lineno] += weight + + # Record function definition location + if funcname and (filename, funcname) not in self.function_definitions: + self.function_definitions[(filename, funcname)] = lineno + + # Map this line to its function for caller/callee navigation + if funcname: + self.line_to_function[(filename, lineno)] = funcname + + def _record_bytecode_sample(self, filename, lineno, opcode, + end_lineno=None, col_offset=None, end_col_offset=None, + weight=1): + """Record a sample for a specific bytecode instruction. + + Args: + filename: Source filename + lineno: Line number + opcode: Opcode number being executed + end_lineno: End line number (may be -1 if not available) + col_offset: Column offset in UTF-8 bytes (may be -1 if not available) + end_col_offset: End column offset in UTF-8 bytes (may be -1 if not available) + weight: Number of samples this represents (for batched RLE) + """ + key = (filename, lineno) + + # Initialize opcode entry if needed - use set for location deduplication + if opcode not in self.line_opcodes[key]: + self.line_opcodes[key][opcode] = {'count': 0, 'locations': set()} + + self.line_opcodes[key][opcode]['count'] += weight + + # Store unique location info if column offset is available (not -1) + if col_offset is not None and col_offset >= 0: + # Use tuple as set key for deduplication + loc_key = (end_lineno, col_offset, end_col_offset) + self.line_opcodes[key][opcode]['locations'].add(loc_key) + + def _get_bytecode_data_for_line(self, filename, lineno): + """Get bytecode disassembly data for instructions on a specific line. + + Args: + filename: Source filename + lineno: Line number + + Returns: + List of dicts with instruction info, sorted by samples descending + """ + key = (filename, lineno) + opcode_data = self.line_opcodes.get(key, {}) + + result = [] + for opcode, data in opcode_data.items(): + info = get_opcode_info(opcode) + # Handle both old format (int count) and new format (dict with count/locations) + if isinstance(data, dict): + count = data.get('count', 0) + raw_locations = data.get('locations', set()) + # Convert set of tuples to list of dicts for JSON serialization + if isinstance(raw_locations, set): + locations = [ + {'end_lineno': loc[0], 'col_offset': loc[1], 'end_col_offset': loc[2]} + for loc in raw_locations + ] + else: + locations = raw_locations + else: + count = data + locations = [] + + result.append({ + 'opcode': opcode, + 'opname': format_opcode(opcode), + 'base_opname': info['base_opname'], + 'is_specialized': info['is_specialized'], + 'samples': count, + 'locations': locations, + }) + + # Sort by samples descending, then by opcode number + result.sort(key=lambda x: (-x['samples'], x['opcode'])) + return result + + def _record_call_relationship(self, callee_frame, caller_frame, weight=1): + """Record caller/callee relationship between adjacent frames.""" + callee_filename, callee_lineno, callee_funcname = callee_frame + caller_filename, caller_lineno, caller_funcname = caller_frame + + # Skip internal files for call graph + if callee_filename.startswith('<') or callee_filename.startswith('['): + return + + # Get the callee's function definition line + callee_def_line = self.function_definitions.get( + (callee_filename, callee_funcname), callee_lineno + ) + + # Record caller -> callee relationship (set handles deduplication) + caller_key = (caller_filename, caller_lineno) + callee_info = (callee_filename, callee_def_line, callee_funcname) + self.call_graph[caller_key].add(callee_info) + + # Record callee <- caller relationship (set handles deduplication) + callee_key = (callee_filename, callee_def_line) + caller_info = (caller_filename, caller_lineno, caller_funcname) + self.callers_graph[callee_key].add(caller_info) + + # Count this call edge for path analysis + edge_key = (caller_key, callee_key) + self.edge_samples[edge_key] += weight + + def export(self, output_path): + """Export heatmap data as HTML files in a directory. + + Args: + output_path: Path where to create the heatmap output directory + """ + if not self.file_samples: + print("Warning: No heatmap data to export") + return False + + try: + output_dir = self._prepare_output_directory(output_path) + file_stats = self._calculate_file_stats() + self._create_file_index(file_stats) + + # Generate individual file reports + self._generate_file_reports(output_dir, file_stats) + + # Generate index page + self._generate_index_html(output_dir / 'index.html', file_stats) + + self._print_export_summary(output_dir, file_stats) + return True + + except Exception as e: + print(f"Error: Failed to export heatmap: {e}") + raise + + def _prepare_output_directory(self, output_path): + """Create output directory for heatmap files.""" + output_dir = Path(output_path) + if output_dir.suffix == '.html': + output_dir = output_dir.with_suffix('') + + try: + output_dir.mkdir(exist_ok=True, parents=True) + except (IOError, OSError) as e: + raise RuntimeError(f"Failed to create output directory {output_dir}: {e}") from e + + return output_dir + + def _create_file_index(self, file_stats: List[FileStats]): + """Create mapping from filenames to HTML file names.""" + self.file_index = { + stat.filename: self.FILE_INDEX_FORMAT.format(i) + for i, stat in enumerate(file_stats) + } + + def _generate_file_reports(self, output_dir, file_stats: List[FileStats]): + """Generate HTML report for each source file.""" + for stat in file_stats: + file_path = output_dir / self.file_index[stat.filename] + line_counts = self.file_samples[stat.filename] + valid_line_counts = {line: count for line, count in line_counts.items() if line >= 0} + + self_counts = self.file_self_samples.get(stat.filename, {}) + valid_self_counts = {line: count for line, count in self_counts.items() if line >= 0} + + self._generate_file_html( + file_path, + stat.filename, + valid_line_counts, + valid_self_counts, + stat + ) + + def _print_export_summary(self, output_dir, file_stats: List[FileStats]): + """Print summary of exported heatmap.""" + print(f"Heatmap output written to {output_dir}/") + print(f" - Index: {output_dir / 'index.html'}") + s = "" if len(file_stats) == 1 else "s" + print(f" - {len(file_stats)} source file{s} analyzed") + + def _calculate_file_stats(self) -> List[FileStats]: + """Calculate statistics for each file. + + Returns: + List of FileStats objects sorted by total samples + """ + file_stats = [] + for filename, line_counts in self.file_samples.items(): + # Skip special frames + if filename in ('~', '...', '.') or filename.startswith('<') or filename.startswith('['): + continue + + # Filter out lines with -1 (special frames) + valid_line_counts = {line: count for line, count in line_counts.items() if line >= 0} + if not valid_line_counts: + continue + + # Get self samples for this file + self_line_counts = self.file_self_samples.get(filename, {}) + valid_self_counts = {line: count for line, count in self_line_counts.items() if line >= 0} + + total_samples = sum(valid_line_counts.values()) + total_self_samples = sum(valid_self_counts.values()) + num_lines = len(valid_line_counts) + max_samples = max(valid_line_counts.values()) + max_self_samples = max(valid_self_counts.values()) if valid_self_counts else 0 + module_name, module_type = extract_module_name(filename, self._path_info) + + file_stats.append(FileStats( + filename=filename, + module_name=module_name, + module_type=module_type, + total_samples=total_samples, + total_self_samples=total_self_samples, + num_lines=num_lines, + max_samples=max_samples, + max_self_samples=max_self_samples, + percentage=0.0 + )) + + # Sort by total samples and calculate percentages + file_stats.sort(key=lambda x: x.total_samples, reverse=True) + if file_stats: + max_total = file_stats[0].total_samples + for stat in file_stats: + stat.percentage = (stat.total_samples / max_total * 100) if max_total > 0 else 0 + + return file_stats + + def _generate_index_html(self, index_path: Path, file_stats: List[FileStats]): + """Generate index.html with list of all profiled files.""" + # Build hierarchical tree + tree = _TreeBuilder.build_file_tree(file_stats) + + # Render tree as HTML + renderer = _HtmlRenderer(self.file_index) + sections_html = renderer.render_hierarchical_html(tree) + + # Format error rate and missed samples with bar classes + error_rate = self.stats.get('error_rate') + if error_rate is not None: + error_rate_str = f"{fmt(error_rate)}%" + error_rate_width = min(error_rate, 100) + # Determine bar color class based on rate + if error_rate < 5: + error_rate_class = "good" + elif error_rate < 15: + error_rate_class = "warning" + else: + error_rate_class = "error" + else: + error_rate_str = "N/A" + error_rate_width = 0 + error_rate_class = "good" + + missed_samples = self.stats.get('missed_samples') + if missed_samples is not None: + missed_samples_str = f"{fmt(missed_samples)}%" + missed_samples_width = min(missed_samples, 100) + if missed_samples < 5: + missed_samples_class = "good" + elif missed_samples < 15: + missed_samples_class = "warning" + else: + missed_samples_class = "error" + else: + missed_samples_str = "N/A" + missed_samples_width = 0 + missed_samples_class = "good" + + # Populate template + replacements = { + "": f"", + "": f"", + "": self._template_loader.logo_html, + "": f"{sys.version_info.major}.{sys.version_info.minor}", + "": f"{len(file_stats):n}", + "": f"{self._total_samples:n}", + "": fmt(self.stats.get('duration_sec', 0)), + "": fmt(self.stats.get('sample_rate', 0)), + "": error_rate_str, + "": str(error_rate_width), + "": error_rate_class, + "": missed_samples_str, + "": str(missed_samples_width), + "": missed_samples_class, + "": sections_html, + } + + html_content = self._template_loader.index_template + for placeholder, value in replacements.items(): + html_content = html_content.replace(placeholder, value) + + try: + index_path.write_text(html_content, encoding='utf-8') + except (IOError, OSError) as e: + raise RuntimeError(f"Failed to write index file {index_path}: {e}") from e + + def _generate_file_html(self, output_path: Path, filename: str, + line_counts: Dict[int, int], self_counts: Dict[int, int], + file_stat: FileStats): + """Generate HTML for a single source file with heatmap coloring.""" + # Read source file + try: + source_lines = Path(filename).read_text(encoding='utf-8', errors='replace').splitlines() + except (IOError, OSError) as e: + if not (filename.startswith('<') or filename.startswith('[') or + filename in ('~', '...', '.') or len(filename) < 2): + print(f"Warning: Could not read source file {filename}: {e}") + source_lines = [f"# Source file not available: {filename}"] + + # Generate HTML for each line + max_samples = max(line_counts.values()) if line_counts else 1 + max_self_samples = max(self_counts.values()) if self_counts else 1 + code_lines_html = [ + self._build_line_html(line_num, line_content, line_counts, self_counts, + max_samples, max_self_samples, filename) + for line_num, line_content in enumerate(source_lines, start=1) + ] + + # Populate template + replacements = { + "": html.escape(filename), + "": f"{file_stat.total_samples:n}", + "": f"{file_stat.total_self_samples:n}", + "": f"{file_stat.num_lines:n}", + "": fmt(file_stat.percentage, 2), + "": f"{file_stat.max_samples:n}", + "": f"{file_stat.max_self_samples:n}", + "": ''.join(code_lines_html), + "": f"", + "": f"", + "": self._template_loader.logo_html, + "": f"{sys.version_info.major}.{sys.version_info.minor}", + } + + html_content = self._template_loader.file_template + for placeholder, value in replacements.items(): + html_content = html_content.replace(placeholder, value) + + try: + output_path.write_text(html_content, encoding='utf-8') + except (IOError, OSError) as e: + raise RuntimeError(f"Failed to write file {output_path}: {e}") from e + + def _build_line_html(self, line_num: int, line_content: str, + line_counts: Dict[int, int], self_counts: Dict[int, int], + max_samples: int, max_self_samples: int, filename: str) -> str: + """Build HTML for a single line of source code.""" + cumulative_samples = line_counts.get(line_num, 0) + self_samples = self_counts.get(line_num, 0) + + # Calculate colors for both self and cumulative modes + if cumulative_samples > 0: + log_cumulative = math.log(cumulative_samples + 1) + log_max = math.log(max_samples + 1) + cumulative_intensity = log_cumulative / log_max if log_max > 0 else 0 + + if self_samples > 0 and max_self_samples > 0: + log_self = math.log(self_samples + 1) + log_max_self = math.log(max_self_samples + 1) + self_intensity = log_self / log_max_self if log_max_self > 0 else 0 + else: + self_intensity = 0 + + self_display = f"{self_samples:n}" if self_samples > 0 else "" + cumulative_display = f"{cumulative_samples:n}" + tooltip = f"Self: {self_samples:n}, Total: {cumulative_samples:n}" + else: + cumulative_intensity = 0 + self_intensity = 0 + self_display = "" + cumulative_display = "" + tooltip = "" + + # Get bytecode data for this line (if any) + bytecode_data = self._get_bytecode_data_for_line(filename, line_num) + has_bytecode = len(bytecode_data) > 0 and cumulative_samples > 0 + + # Build bytecode toggle button if data is available + bytecode_btn_html = '' + bytecode_panel_html = '' + if has_bytecode: + bytecode_json = html.escape(json.dumps(bytecode_data)) + + # Calculate specialization percentage + total_samples = sum(d['samples'] for d in bytecode_data) + specialized_samples = sum(d['samples'] for d in bytecode_data if d['is_specialized']) + spec_pct = int(100 * specialized_samples / total_samples) if total_samples > 0 else 0 + + bytecode_btn_html = ( + f'' + ) + # Wrapper contains columns + content panel + bytecode_panel_html = ( + f'
\n' + f'
' + f'
' + f'
' + f'
' + f'
\n' + f'
\n' + f'
\n' + ) + elif self.opcodes_enabled: + # Add invisible spacer to maintain consistent indentation when opcodes are enabled + bytecode_btn_html = '
' + + # Get navigation buttons + nav_buttons_html = self._build_navigation_buttons(filename, line_num) + + # Build line HTML with instruction highlights if available + line_html = self._render_source_with_highlights(line_content, line_num, + filename, bytecode_data) + title_attr = f' title="{html.escape(tooltip)}"' if tooltip else "" + + # Specialization color for toggle mode (green gradient based on spec %) + spec_color_attr = '' + if has_bytecode: + spec_color = self._format_specialization_color(spec_pct) + spec_color_attr = f'data-spec-color="{spec_color}" ' + + return ( + f'
\n' + f'
{line_num}
\n' + f'
{self_display}
\n' + f'
{cumulative_display}
\n' + f' {bytecode_btn_html}\n' + f'
{line_html}
\n' + f' {nav_buttons_html}\n' + f'
\n' + f'{bytecode_panel_html}' + ) + + def _render_source_with_highlights(self, line_content: str, line_num: int, + filename: str, bytecode_data: list) -> str: + """Render source line with instruction highlight spans. + + Simple: collect ranges with sample counts, assign each byte position to + smallest covering range, then emit spans for contiguous runs with sample data. + """ + content = line_content.rstrip('\n') + if not content: + return '' + + # Collect all (start, end) -> {samples, opcodes} mapping from instructions + # Multiple instructions may share the same range, so we sum samples and collect opcodes + range_data = {} + for instr in bytecode_data: + samples = instr.get('samples', 0) + opname = instr.get('opname', '') + for loc in instr.get('locations', []): + if loc.get('end_lineno', line_num) == line_num: + start, end = loc.get('col_offset', -1), loc.get('end_col_offset', -1) + if start >= 0 and end >= 0: + key = (start, end) + if key not in range_data: + range_data[key] = {'samples': 0, 'opcodes': []} + range_data[key]['samples'] += samples + if opname and opname not in range_data[key]['opcodes']: + range_data[key]['opcodes'].append(opname) + + if not range_data: + return html.escape(content) + + # For each byte position, find the smallest covering range + byte_to_range = {} + for (start, end) in range_data.keys(): + for pos in range(start, end): + if pos not in byte_to_range: + byte_to_range[pos] = (start, end) + else: + # Keep smaller range + old_start, old_end = byte_to_range[pos] + if (end - start) < (old_end - old_start): + byte_to_range[pos] = (start, end) + + # Calculate totals for percentage and intensity + total_line_samples = sum(d['samples'] for d in range_data.values()) + max_range_samples = max(d['samples'] for d in range_data.values()) if range_data else 1 + + # Render character by character + result = [] + byte_offset = 0 + char_idx = 0 + current_range = None + span_chars = [] + + def flush_span(): + nonlocal span_chars, current_range + if span_chars: + text = html.escape(''.join(span_chars)) + if current_range: + data = range_data.get(current_range, {'samples': 0, 'opcodes': []}) + samples = data['samples'] + opcodes = ', '.join(data['opcodes'][:3]) # Top 3 opcodes + if len(data['opcodes']) > 3: + opcodes += f" +{len(data['opcodes']) - 3} more" + pct = int(100 * samples / total_line_samples) if total_line_samples > 0 else 0 + result.append(f'{text}') + else: + result.append(text) + span_chars = [] + + while char_idx < len(content): + char = content[char_idx] + char_bytes = len(char.encode('utf-8')) + char_range = byte_to_range.get(byte_offset) + + if char_range != current_range: + flush_span() + current_range = char_range + + span_chars.append(char) + byte_offset += char_bytes + char_idx += 1 + + flush_span() + return ''.join(result) + + def _format_specialization_color(self, spec_pct: int) -> str: + """Format specialization color based on percentage. + + Uses a gradient from gray (0%) through orange (50%) to green (100%). + """ + # Normalize to 0-1 + ratio = spec_pct / 100.0 + + if ratio >= 0.5: + # Orange to green (50-100%) + t = (ratio - 0.5) * 2 # 0 to 1 + r = int(255 * (1 - t)) # 255 -> 0 + g = int(180 + 75 * t) # 180 -> 255 + b = int(50 * (1 - t)) # 50 -> 0 + else: + # Gray to orange (0-50%) + t = ratio * 2 # 0 to 1 + r = int(158 + 97 * t) # 158 -> 255 + g = int(158 + 22 * t) # 158 -> 180 + b = int(158 - 108 * t) # 158 -> 50 + + alpha = 0.15 + 0.25 * ratio # 0.15 to 0.4 + return f"rgba({r}, {g}, {b}, {alpha})" + + def _build_navigation_buttons(self, filename: str, line_num: int) -> str: + """Build navigation buttons for callers/callees. + + - Callers: All lines in a function show who calls this function + - Callees: Only actual call site lines show what they call + """ + line_key = (filename, line_num) + + funcname = self.line_to_function.get(line_key) + + # Get callers: look up by function definition line, not current line + # This ensures all lines in a function show who calls this function + if funcname: + func_def_line = self.function_definitions.get((filename, funcname), line_num) + func_def_key = (filename, func_def_line) + caller_list = self._deduplicate_by_function(self.callers_graph.get(func_def_key, set())) + else: + caller_list = self._deduplicate_by_function(self.callers_graph.get(line_key, set())) + + # Get callees: only show for actual call site lines (not every line in function) + callee_list = self._deduplicate_by_function(self.call_graph.get(line_key, set())) + + # Get edge counts for each caller/callee + # For callers, use the function definition key for edge lookup + if funcname: + func_def_line = self.function_definitions.get((filename, funcname), line_num) + caller_edge_key = (filename, func_def_line) + else: + caller_edge_key = line_key + callers_with_counts = self._get_edge_counts(caller_edge_key, caller_list, is_caller=True) + # For callees, use the actual line key since that's where the call happens + callees_with_counts = self._get_edge_counts(line_key, callee_list, is_caller=False) + + # Build navigation buttons with counts + caller_btn = self._create_navigation_button(callers_with_counts, 'caller', '▲') + callee_btn = self._create_navigation_button(callees_with_counts, 'callee', '▼') + + if caller_btn or callee_btn: + return f'
{caller_btn}{callee_btn}
' + return '' + + def _get_edge_counts(self, line_key: Tuple[str, int], + items: List[Tuple[str, int, str]], + is_caller: bool) -> List[Tuple[str, int, str, int]]: + """Get sample counts for each caller/callee edge.""" + result = [] + for file, line, func in items: + edge_line_key = (file, line) + if is_caller: + edge_key = (edge_line_key, line_key) + else: + edge_key = (line_key, edge_line_key) + + count = self.edge_samples.get(edge_key, 0) + result.append((file, line, func, count)) + + result.sort(key=lambda x: x[3], reverse=True) + return result + + def _deduplicate_by_function(self, items) -> List[Tuple[str, int, str]]: + """Remove duplicate entries based on (file, function) key. + + Args: + items: Iterable of (file, line, func) tuples (set or list) + """ + seen = {} + result = [] + for file, line, func in items: + key = (file, func) + if key not in seen: + seen[key] = True + result.append((file, line, func)) + return result + + def _create_navigation_button(self, items_with_counts: List[Tuple[str, int, str, int]], + btn_class: str, arrow: str) -> str: + """Create HTML for a navigation button with sample counts.""" + # Filter valid items + valid_items = [(f, l, fn, cnt) for f, l, fn, cnt in items_with_counts + if f in self.file_index and l > 0] + if not valid_items: + return "" + + if len(valid_items) == 1: + file, line, func, count = valid_items[0] + target_html = self.file_index[file] + nav_data = json.dumps({'link': f"{target_html}#line-{line}", 'func': func}) + title = f"Go to {btn_class}: {html.escape(func)} ({count:n} samples)" + return f'' + + # Multiple items - create menu + total_samples = sum(cnt for _, _, _, cnt in valid_items) + items_data = [ + { + 'file': os.path.basename(file), + 'func': func, + 'count': count, + 'link': f"{self.file_index[file]}#line-{line}" + } + for file, line, func, count in valid_items + ] + items_json = html.escape(json.dumps(items_data)) + title = f"{len(items_data)} {btn_class}s ({total_samples:n} samples)" + return f'' diff --git a/Lib/profiling/sampling/jsonl_collector.py b/Lib/profiling/sampling/jsonl_collector.py new file mode 100644 index 000000000..41b0456d6 --- /dev/null +++ b/Lib/profiling/sampling/jsonl_collector.py @@ -0,0 +1,268 @@ +"""JSON Lines (JSONL) collector for the sampling profiler. + +Emits a normalized newline-delimited JSON record stream suitable for +programmatic consumption by external tools, scripts, and agents. Each line +is one JSON object; consumers can parse the file incrementally line by +line, but the producer writes the whole file at the end of the run (it is +not a live/streaming producer). + +Record schema +============= + +Every record is a JSON object with at least ``"type"``, ``"v"`` (record +schema version), and ``"run_id"`` (UUID4 hex tagging the run; allows +demultiplexing concatenated streams). Records appear in this fixed order: + +1. ``meta`` (exactly one, first line):: + + {"type":"meta","v":0,"run_id":"", + "sample_interval_usec":,"mode":"wall|cpu|gil|all|exception"} + + ``mode`` is omitted when not provided. + +2. ``string_table`` (zero or more):: + + {"type":"string_table","v":0,"run_id":"", + "strings":[{"str_id":,"value":""}, ...]} + + Strings (filenames, function names) are interned to keep repeated values + compact. IDs are zero-based. Each chunk holds up to ``_CHUNK_SIZE`` + entries, and each entry carries its explicit ``str_id`` so consumers do + not need to infer offsets across chunks. + +3. ``frame_table`` (zero or more):: + + {"type":"frame_table","v":0,"run_id":"", + "frames":[{"frame_id":,"path_str_id":,"func_str_id":, + "line":,"end_line":,"col":, + "end_col":}, ...]} + + ``end_line``/``col``/``end_col`` are *omitted* when source location data + is unavailable (a missing key means "not available", not zero or null). + ``line`` is ``0`` for synthetic frames (for example, internal marker + frames whose source location is None). Frame IDs are zero-based. + +4. ``agg`` (zero or more):: + + {"type":"agg","v":0,"run_id":"","kind":"frame","scope":"final", + "samples_total":, + "entries":[{"frame_id":,"self":,"cumulative":}, ...]} + + ``self`` counts samples where the frame was the leaf (currently + executing); ``cumulative`` counts samples where the frame appeared + anywhere in the stack (deduped per sample so recursion does not + double-count). ``samples_total`` is the run-wide total, repeated on + each chunk so a streaming consumer always knows the denominator. + +5. ``end`` (exactly one, last line):: + + {"type":"end","v":0,"run_id":"","samples_total":} + + Presence of ``end`` is the consumer's signal that the file is complete. + +Forward compatibility +===================== + +Consumers MUST ignore unknown record ``"type"`` values and unknown object +fields. New fields will be added by adding optional keys; an incompatible +schema change will bump the per-record ``"v"``. +""" + +from collections import Counter +import json +import uuid +from itertools import batched + +from .constants import PROFILING_MODE_NAMES +from .collector import normalize_location +from .stack_collector import StackTraceCollector + + +_CHUNK_SIZE = 256 +_SCHEMA_VERSION = 0 + + +class JsonlCollector(StackTraceCollector): + """Collector that exports finalized profiling data as JSONL. + + See the module docstring for the full record schema. The collector + accumulates samples in memory and writes the complete file at + ``export()`` time. + """ + + def __init__(self, sample_interval_usec, *, skip_idle=False, mode=None): + super().__init__(sample_interval_usec, skip_idle=skip_idle) + self.run_id = uuid.uuid4().hex + + self._string_to_id = {} + self._strings = [] + + self._frame_to_id = {} + self._frames = [] + + self._frame_self = Counter() + self._frame_cumulative = Counter() + self._samples_total = 0 + self._seen_frame_ids = set() + + self._mode = mode + + def process_frames(self, frames, _thread_id, weight=1): + self._samples_total += weight + self._seen_frame_ids.clear() + + for i, (filename, location, funcname, _opcode) in enumerate(frames): + frame_id = self._get_or_create_frame_id( + filename, location, funcname + ) + is_leaf = i == 0 + count_cumulative = frame_id not in self._seen_frame_ids + + if count_cumulative: + self._seen_frame_ids.add(frame_id) + + if is_leaf: + self._frame_self[frame_id] += weight + + if count_cumulative: + self._frame_cumulative[frame_id] += weight + + def export(self, filename): + with open(filename, "w", encoding="utf-8") as output: + self._write_message(output, self._build_meta_record()) + self._write_chunked_records( + output, + { + "type": "string_table", + "v": _SCHEMA_VERSION, + "run_id": self.run_id, + }, + "strings", + self._strings, + ) + self._write_chunked_records( + output, + { + "type": "frame_table", + "v": _SCHEMA_VERSION, + "run_id": self.run_id, + }, + "frames", + self._frames, + ) + self._write_chunked_records( + output, + { + "type": "agg", + "v": _SCHEMA_VERSION, + "run_id": self.run_id, + "kind": "frame", + "scope": "final", + "samples_total": self._samples_total, + }, + "entries", + self._iter_final_agg_entries(), + ) + self._write_message(output, self._build_end_record()) + print(f"JSONL profile written to {filename}") + return True + + def _build_meta_record(self): + record = { + "type": "meta", + "v": _SCHEMA_VERSION, + "run_id": self.run_id, + "sample_interval_usec": self.sample_interval_usec, + } + + if self._mode is not None: + record["mode"] = PROFILING_MODE_NAMES.get( + self._mode, str(self._mode) + ) + + return record + + def _build_end_record(self): + record = { + "type": "end", + "v": _SCHEMA_VERSION, + "run_id": self.run_id, + "samples_total": self._samples_total, + } + + return record + + def _iter_final_agg_entries(self): + for frame_record in self._frames: + frame_id = frame_record["frame_id"] + yield { + "frame_id": frame_id, + "self": self._frame_self[frame_id], + "cumulative": self._frame_cumulative[frame_id], + } + + def _get_or_create_frame_id(self, filename, location, funcname): + location_fields = self._location_to_export_fields(location) + func_str_id = self._intern_string(funcname) + path_str_id = self._intern_string(filename) + + frame_key = ( + path_str_id, + func_str_id, + location_fields["line"], + location_fields.get("end_line"), + location_fields.get("col"), + location_fields.get("end_col"), + ) + + if (frame_id := self._frame_to_id.get(frame_key)) is not None: + return frame_id + + frame_id = len(self._frames) + frame_record = { + "frame_id": frame_id, + "path_str_id": path_str_id, + "func_str_id": func_str_id, + **location_fields, + } + + self._frame_to_id[frame_key] = frame_id + self._frames.append(frame_record) + return frame_id + + def _intern_string(self, value): + value = str(value) + + if (string_id := self._string_to_id.get(value)) is not None: + return string_id + + string_id = len(self._strings) + self._string_to_id[value] = string_id + self._strings.append({"str_id": string_id, "value": value}) + return string_id + + @staticmethod + def _location_to_export_fields(location): + lineno, end_lineno, col_offset, end_col_offset = normalize_location( + location + ) + + fields = {"line": lineno} + if end_lineno > 0: + fields["end_line"] = end_lineno + if col_offset >= 0: + fields["col"] = col_offset + if end_col_offset >= 0: + fields["end_col"] = end_col_offset + return fields + + def _write_chunked_records( + self, output, base_record, chunk_field, entries + ): + for chunk in batched(entries, _CHUNK_SIZE): + self._write_message(output, {**base_record, chunk_field: chunk}) + + @staticmethod + def _write_message(output, record): + output.write(json.dumps(record, separators=(",", ":"))) + output.write("\n") diff --git a/Lib/profiling/sampling/module_utils.py b/Lib/profiling/sampling/module_utils.py new file mode 100644 index 000000000..dfde2b28a --- /dev/null +++ b/Lib/profiling/sampling/module_utils.py @@ -0,0 +1,102 @@ +"""Utilities for extracting module names from file paths.""" + +import os +import site +import sys +from pathlib import Path + + +def get_python_path_info(): + """Get information about Python's search paths. + + Returns: + dict: Dictionary containing stdlib path, site-packages paths, and sys.path entries. + """ + info = { + 'stdlib': None, + 'site_packages': [], + 'sys_path': [] + } + + # Get standard library path from os module location + try: + if hasattr(os, '__file__') and os.__file__: + info['stdlib'] = Path(os.__file__).parent + except (AttributeError, OSError): + pass # Silently continue if we can't determine stdlib path + + # Get site-packages directories + site_packages = [] + try: + site_packages.extend(Path(p) for p in site.getsitepackages()) + except (AttributeError, OSError): + pass # Continue without site packages if unavailable + + # Get user site-packages + try: + user_site = site.getusersitepackages() + if user_site and Path(user_site).exists(): + site_packages.append(Path(user_site)) + except (AttributeError, OSError): + pass # Continue without user site packages + + info['site_packages'] = site_packages + info['sys_path'] = [Path(p) for p in sys.path if p] + + return info + + +def extract_module_name(filename, path_info): + """Extract Python module name and type from file path. + + Args: + filename: Path to the Python file + path_info: Dictionary from get_python_path_info() + + Returns: + tuple: (module_name, module_type) where module_type is one of: + 'stdlib', 'site-packages', 'project', or 'other' + """ + if not filename: + return ('unknown', 'other') + + try: + file_path = Path(filename) + except (ValueError, OSError): + return (str(filename), 'other') + + # Check if it's in stdlib + if path_info['stdlib'] and file_path.is_relative_to(path_info['stdlib']): + return (_path_to_module(file_path.relative_to(path_info['stdlib'])), 'stdlib') + + # Check site-packages + for site_pkg in path_info['site_packages']: + if file_path.is_relative_to(site_pkg): + return (_path_to_module(file_path.relative_to(site_pkg)), 'site-packages') + + # Check other sys.path entries (project files) + if not str(file_path).startswith(('<', '[')): # Skip special files + for path_entry in path_info['sys_path']: + if file_path.is_relative_to(path_entry): + return (_path_to_module(file_path.relative_to(path_entry)), 'project') + + # Fallback: just use the filename + return (_path_to_module(file_path), 'other') + + +def _path_to_module(path): + if isinstance(path, str): + path = Path(path) + + # Remove .py extension + if path.suffix == '.py': + path = path.with_suffix('') + + # Convert path separators to dots, stripping root/drive (e.g. "/" or "C:\") + parts = [p for p in path.parts if p != path.root and p != path.drive] + + # Handle __init__ files - they represent the package itself + if parts and parts[-1] == '__init__': + parts = parts[:-1] + + return '.'.join(parts) if parts else path.stem diff --git a/Lib/profiling/sampling/opcode_utils.py b/Lib/profiling/sampling/opcode_utils.py new file mode 100644 index 000000000..71b35383d --- /dev/null +++ b/Lib/profiling/sampling/opcode_utils.py @@ -0,0 +1,94 @@ +"""Opcode utilities for bytecode-level profiler visualization. + +This module provides utilities to get opcode names and detect specialization +status using the opcode module's metadata. Used by heatmap and flamegraph +collectors to display which bytecode instructions are executing at each +source line, including Python's adaptive specialization optimizations. +""" + +import opcode + +# Build opcode name mapping: opcode number -> opcode name +# This includes both standard opcodes and specialized variants (Python 3.11+) +_OPCODE_NAMES = dict(enumerate(opcode.opname)) +if hasattr(opcode, "_specialized_opmap"): + for name, op in opcode._specialized_opmap.items(): + _OPCODE_NAMES[op] = name + +# Build deopt mapping: specialized opcode number -> base opcode number +# Python 3.11+ uses adaptive specialization where generic opcodes like +# LOAD_ATTR can be replaced at runtime with specialized variants like +# LOAD_ATTR_INSTANCE_VALUE. This mapping lets us show both forms. +_DEOPT_MAP = {} +if hasattr(opcode, "_specializations") and hasattr( + opcode, "_specialized_opmap" +): + for base_name, variant_names in opcode._specializations.items(): + base_opcode = opcode.opmap.get(base_name) + if base_opcode is not None: + for variant_name in variant_names: + variant_opcode = opcode._specialized_opmap.get(variant_name) + if variant_opcode is not None: + _DEOPT_MAP[variant_opcode] = base_opcode + + +def get_opcode_info(opcode_num): + """Get opcode name and specialization info from an opcode number. + + Args: + opcode_num: The opcode number (0-255 or higher for specialized) + + Returns: + A dict with keys: + - 'opname': The opcode name (e.g., 'LOAD_ATTR_INSTANCE_VALUE') + - 'base_opname': The base opcode name (e.g., 'LOAD_ATTR') + - 'is_specialized': True if this is a specialized instruction + """ + opname = _OPCODE_NAMES.get(opcode_num) + if opname is None: + return { + "opname": f"<{opcode_num}>", + "base_opname": f"<{opcode_num}>", + "is_specialized": False, + } + + base_opcode = _DEOPT_MAP.get(opcode_num) + if base_opcode is not None: + base_opname = _OPCODE_NAMES.get(base_opcode, f"<{base_opcode}>") + return { + "opname": opname, + "base_opname": base_opname, + "is_specialized": True, + } + + return { + "opname": opname, + "base_opname": opname, + "is_specialized": False, + } + + +def format_opcode(opcode_num): + """Format an opcode for display, showing base opcode for specialized ones. + + Args: + opcode_num: The opcode number (0-255 or higher for specialized) + + Returns: + A formatted string like 'LOAD_ATTR' or 'LOAD_ATTR_INSTANCE_VALUE (LOAD_ATTR)' + """ + info = get_opcode_info(opcode_num) + if info["is_specialized"]: + return f"{info['opname']} ({info['base_opname']})" + return info["opname"] + + +def get_opcode_mapping(): + """Get opcode name and deopt mappings for JavaScript consumption. + + Returns: + A dict with keys: + - 'names': Dict mapping opcode numbers to opcode names + - 'deopt': Dict mapping specialized opcode numbers to base opcode numbers + """ + return {"names": _OPCODE_NAMES, "deopt": _DEOPT_MAP} diff --git a/Lib/profiling/sampling/pstats_collector.py b/Lib/profiling/sampling/pstats_collector.py new file mode 100644 index 000000000..0f31d9358 --- /dev/null +++ b/Lib/profiling/sampling/pstats_collector.py @@ -0,0 +1,429 @@ +import collections +import marshal +import pstats +lazy from _colorize import get_colors + +from .collector import Collector, extract_lineno +from .constants import MICROSECONDS_PER_SECOND, PROFILING_MODE_CPU + + +class PstatsCollector(Collector): + aggregating = True + + def __init__(self, sample_interval_usec, *, skip_idle=False): + self.result = collections.defaultdict( + lambda: dict(total_rec_calls=0, direct_calls=0, cumulative_calls=0) + ) + self.stats = {} + self.sample_interval_usec = sample_interval_usec + self.callers = collections.defaultdict( + lambda: collections.defaultdict(int) + ) + self.skip_idle = skip_idle + self._seen_locations = set() + + def _process_frames(self, frames, weight=1): + """Process a single thread's frame stack.""" + if not frames: + return + + self._seen_locations.clear() + + # Process each frame in the stack to track cumulative calls + # frame.location is int, tuple (lineno, end_lineno, col_offset, end_col_offset), or None + for frame in frames: + lineno = extract_lineno(frame.location) + location = (frame.filename, lineno, frame.funcname) + if location not in self._seen_locations: + self._seen_locations.add(location) + self.result[location]["cumulative_calls"] += weight + + # The top frame gets counted as an inline call (directly executing) + top_lineno = extract_lineno(frames[0].location) + top_location = (frames[0].filename, top_lineno, frames[0].funcname) + self.result[top_location]["direct_calls"] += weight + + # Track caller-callee relationships for call graph + for i in range(1, len(frames)): + callee_frame = frames[i - 1] + caller_frame = frames[i] + + callee_lineno = extract_lineno(callee_frame.location) + caller_lineno = extract_lineno(caller_frame.location) + callee = (callee_frame.filename, callee_lineno, callee_frame.funcname) + caller = (caller_frame.filename, caller_lineno, caller_frame.funcname) + + self.callers[callee][caller] += weight + + def collect(self, stack_frames, timestamps_us=None): + weight = len(timestamps_us) if timestamps_us else 1 + for frames, _ in self._iter_stacks(stack_frames, skip_idle=self.skip_idle): + self._process_frames(frames, weight=weight) + + def export(self, filename): + self.create_stats() + self._dump_stats(filename) + return True + + def _dump_stats(self, file): + stats_with_marker = dict(self.stats) + stats_with_marker[("__sampled__",)] = True + with open(file, "wb") as f: + marshal.dump(stats_with_marker, f) + + # Needed for compatibility with pstats.Stats + def create_stats(self): + sample_interval_sec = self.sample_interval_usec / MICROSECONDS_PER_SECOND + callers = {} + for fname, call_counts in self.result.items(): + total = call_counts["direct_calls"] * sample_interval_sec + cumulative_calls = call_counts["cumulative_calls"] + cumulative = cumulative_calls * sample_interval_sec + callers = dict(self.callers.get(fname, {})) + self.stats[fname] = ( + call_counts["direct_calls"], # cc = direct calls for sample percentage + cumulative_calls, # nc = cumulative calls for cumulative percentage + total, + cumulative, + callers, + ) + + def print_stats(self, sort=-1, limit=None, show_summary=True, mode=None): + """Print formatted statistics to stdout.""" + # Create stats object + stats = pstats.SampledStats(self).strip_dirs() + if not stats.stats: + print("No samples were collected.") + if mode == PROFILING_MODE_CPU: + print("This can happen in CPU mode when all threads are idle.") + return + + # Get the stats data + stats_list = [] + for func, ( + direct_calls, + cumulative_calls, + total_time, + cumulative_time, + callers, + ) in stats.stats.items(): + stats_list.append( + ( + func, + direct_calls, + cumulative_calls, + total_time, + cumulative_time, + callers, + ) + ) + + # Calculate total samples for percentage calculations (using direct_calls) + total_samples = sum( + direct_calls for _, direct_calls, _, _, _, _ in stats_list + ) + + # Sort based on the requested field + sort_field = sort + if sort_field == -1: # stdname + stats_list.sort(key=lambda x: str(x[0])) + elif sort_field == 0: # nsamples (direct samples) + stats_list.sort(key=lambda x: x[1], reverse=True) # direct_calls + elif sort_field == 1: # tottime + stats_list.sort(key=lambda x: x[3], reverse=True) # total_time + elif sort_field == 2: # cumtime + stats_list.sort(key=lambda x: x[4], reverse=True) # cumulative_time + elif sort_field == 3: # sample% + stats_list.sort( + key=lambda x: (x[1] / total_samples * 100) + if total_samples > 0 + else 0, + reverse=True, # direct_calls percentage + ) + elif sort_field == 4: # cumul% + stats_list.sort( + key=lambda x: (x[2] / total_samples * 100) + if total_samples > 0 + else 0, + reverse=True, # cumulative_calls percentage + ) + elif sort_field == 5: # nsamples (cumulative samples) + stats_list.sort(key=lambda x: x[2], reverse=True) # cumulative_calls + + # Apply limit if specified + if limit is not None: + stats_list = stats_list[:limit] + + # Determine the best unit for time columns based on maximum values + max_total_time = max( + (total_time for _, _, _, total_time, _, _ in stats_list), default=0 + ) + max_cumulative_time = max( + (cumulative_time for _, _, _, _, cumulative_time, _ in stats_list), + default=0, + ) + + total_time_unit, total_time_scale = self._determine_best_unit(max_total_time) + cumulative_time_unit, cumulative_time_scale = self._determine_best_unit( + max_cumulative_time + ) + + # Define column widths for consistent alignment + col_widths = { + "nsamples": 15, # "nsamples" column (inline/cumulative format) + "sample_pct": 8, # "sample%" column + "tottime": max(12, len(f"tottime ({total_time_unit})")), + "cum_pct": 8, # "cumul%" column + "cumtime": max(12, len(f"cumtime ({cumulative_time_unit})")), + } + + # Print header with colors and proper alignment + ANSIColors = get_colors() + + print(f"{ANSIColors.BOLD_BLUE}Profile Stats:{ANSIColors.RESET}") + + header_nsamples = f"{ANSIColors.BOLD_BLUE}{'nsamples':>{col_widths['nsamples']}}{ANSIColors.RESET}" + header_sample_pct = f"{ANSIColors.BOLD_BLUE}{'sample%':>{col_widths['sample_pct']}}{ANSIColors.RESET}" + header_tottime = f"{ANSIColors.BOLD_BLUE}{f'tottime ({total_time_unit})':>{col_widths['tottime']}}{ANSIColors.RESET}" + header_cum_pct = f"{ANSIColors.BOLD_BLUE}{'cumul%':>{col_widths['cum_pct']}}{ANSIColors.RESET}" + header_cumtime = f"{ANSIColors.BOLD_BLUE}{f'cumtime ({cumulative_time_unit})':>{col_widths['cumtime']}}{ANSIColors.RESET}" + header_filename = ( + f"{ANSIColors.BOLD_BLUE}filename:lineno(function){ANSIColors.RESET}" + ) + + print( + f"{header_nsamples} {header_sample_pct} {header_tottime} {header_cum_pct} {header_cumtime} {header_filename}" + ) + + # Print each line with proper alignment + for ( + func, + direct_calls, + cumulative_calls, + total_time, + cumulative_time, + callers, + ) in stats_list: + # Calculate percentages + sample_pct = ( + (direct_calls / total_samples * 100) if total_samples > 0 else 0 + ) + cum_pct = ( + (cumulative_calls / total_samples * 100) + if total_samples > 0 + else 0 + ) + + # Format values with proper alignment - always use A/B format + nsamples_str = f"{direct_calls}/{cumulative_calls}" + nsamples_str = f"{nsamples_str:>{col_widths['nsamples']}}" + sample_pct_str = f"{sample_pct:{col_widths['sample_pct']}.1f}" + tottime = f"{total_time * total_time_scale:{col_widths['tottime']}.3f}" + cum_pct_str = f"{cum_pct:{col_widths['cum_pct']}.1f}" + cumtime = f"{cumulative_time * cumulative_time_scale:{col_widths['cumtime']}.3f}" + + # Format the function name with colors + func_name = ( + f"{ANSIColors.GREEN}{func[0]}{ANSIColors.RESET}:" + f"{ANSIColors.YELLOW}{func[1]}{ANSIColors.RESET}(" + f"{ANSIColors.CYAN}{func[2]}{ANSIColors.RESET})" + ) + + # Print the formatted line with consistent spacing + print( + f"{nsamples_str} {sample_pct_str} {tottime} {cum_pct_str} {cumtime} {func_name}" + ) + + # Print legend + print(f"\n{ANSIColors.BOLD_BLUE}Legend:{ANSIColors.RESET}") + print( + f" {ANSIColors.YELLOW}nsamples{ANSIColors.RESET}: Direct/Cumulative samples (direct executing / on call stack)" + ) + print( + f" {ANSIColors.YELLOW}sample%{ANSIColors.RESET}: Percentage of total samples this function was directly executing" + ) + print( + f" {ANSIColors.YELLOW}tottime{ANSIColors.RESET}: Estimated total time spent directly in this function" + ) + print( + f" {ANSIColors.YELLOW}cumul%{ANSIColors.RESET}: Percentage of total samples when this function was on the call stack" + ) + print( + f" {ANSIColors.YELLOW}cumtime{ANSIColors.RESET}: Estimated cumulative time (including time in called functions)" + ) + print( + f" {ANSIColors.YELLOW}filename:lineno(function){ANSIColors.RESET}: Function location and name" + ) + + # Print summary of interesting functions if enabled + if show_summary and stats_list: + self._print_summary(stats_list, total_samples) + + @staticmethod + def _determine_best_unit(max_value): + """Determine the best unit (s, ms, μs) and scale factor for a maximum value.""" + if max_value >= 1.0: + return "s", 1.0 + elif max_value >= 0.001: + return "ms", 1000.0 + else: + return "μs", float(MICROSECONDS_PER_SECOND) + + def _print_summary(self, stats_list, total_samples): + """Print summary of interesting functions.""" + ANSIColors = get_colors() + + print( + f"\n{ANSIColors.BOLD_BLUE}Summary of Interesting Functions:{ANSIColors.RESET}" + ) + + # Aggregate stats by fully qualified function name (ignoring line numbers) + func_aggregated = {} + for ( + func, + direct_calls, + cumulative_calls, + total_time, + cumulative_time, + callers, + ) in stats_list: + # Use filename:function_name as the key to get fully qualified name + qualified_name = f"{func[0]}:{func[2]}" + if qualified_name not in func_aggregated: + func_aggregated[qualified_name] = [ + 0, + 0, + 0, + 0, + ] # direct_calls, cumulative_calls, total_time, cumulative_time + func_aggregated[qualified_name][0] += direct_calls + func_aggregated[qualified_name][1] += cumulative_calls + func_aggregated[qualified_name][2] += total_time + func_aggregated[qualified_name][3] += cumulative_time + + # Convert aggregated data back to list format for processing + aggregated_stats = [] + for qualified_name, ( + prim_calls, + total_calls, + total_time, + cumulative_time, + ) in func_aggregated.items(): + # Parse the qualified name back to filename and function name + if ":" in qualified_name: + filename, func_name = qualified_name.rsplit(":", 1) + else: + filename, func_name = "", qualified_name + # Create a dummy func tuple with filename and function name for display + dummy_func = (filename, "", func_name) + aggregated_stats.append( + ( + dummy_func, + prim_calls, + total_calls, + total_time, + cumulative_time, + {}, + ) + ) + + # Determine best units for summary metrics + max_total_time = max( + (total_time for _, _, _, total_time, _, _ in aggregated_stats), + default=0, + ) + max_cumulative_time = max( + ( + cumulative_time + for _, _, _, _, cumulative_time, _ in aggregated_stats + ), + default=0, + ) + + total_unit, total_scale = self._determine_best_unit(max_total_time) + cumulative_unit, cumulative_scale = self._determine_best_unit( + max_cumulative_time + ) + + def _format_func_name(func): + """Format function name with colors.""" + return ( + f"{ANSIColors.GREEN}{func[0]}{ANSIColors.RESET}:" + f"{ANSIColors.YELLOW}{func[1]}{ANSIColors.RESET}(" + f"{ANSIColors.CYAN}{func[2]}{ANSIColors.RESET})" + ) + + def _print_top_functions(stats_list, title, key_func, format_line, n=3): + """Print top N functions sorted by key_func with formatted output.""" + print(f"\n{ANSIColors.BOLD_BLUE}{title}:{ANSIColors.RESET}") + sorted_stats = sorted(stats_list, key=key_func, reverse=True) + for stat in sorted_stats[:n]: + if line := format_line(stat): + print(f" {line}") + + # Functions with highest direct/cumulative ratio (hot spots) + def format_hotspots(stat): + func, direct_calls, cumulative_calls, total_time, _, _ = stat + if direct_calls > 0 and cumulative_calls > 0: + ratio = direct_calls / cumulative_calls + direct_pct = ( + (direct_calls / total_samples * 100) + if total_samples > 0 + else 0 + ) + return ( + f"{ratio:.3f} direct/cumulative ratio, " + f"{direct_pct:.1f}% direct samples: {_format_func_name(func)}" + ) + return None + + _print_top_functions( + aggregated_stats, + "Functions with Highest Direct/Cumulative Ratio (Hot Spots)", + key_func=lambda x: (x[1] / x[2]) if x[2] > 0 else 0, + format_line=format_hotspots, + ) + + # Functions with highest call frequency (cumulative/direct difference) + def format_call_frequency(stat): + func, direct_calls, cumulative_calls, total_time, _, _ = stat + if cumulative_calls > direct_calls: + call_frequency = cumulative_calls - direct_calls + cum_pct = ( + (cumulative_calls / total_samples * 100) + if total_samples > 0 + else 0 + ) + return ( + f"{call_frequency:d} indirect calls, " + f"{cum_pct:.1f}% total stack presence: {_format_func_name(func)}" + ) + return None + + _print_top_functions( + aggregated_stats, + "Functions with Highest Call Frequency (Indirect Calls)", + key_func=lambda x: x[2] - x[1], # Sort by (cumulative - direct) + format_line=format_call_frequency, + ) + + # Functions with highest cumulative-to-direct multiplier (call magnification) + def format_call_magnification(stat): + func, direct_calls, cumulative_calls, total_time, _, _ = stat + if direct_calls > 0 and cumulative_calls > direct_calls: + multiplier = cumulative_calls / direct_calls + indirect_calls = cumulative_calls - direct_calls + return ( + f"{multiplier:.1f}x call magnification, " + f"{indirect_calls:d} indirect calls from {direct_calls:d} direct: {_format_func_name(func)}" + ) + return None + + _print_top_functions( + aggregated_stats, + "Functions with Highest Call Magnification (Cumulative/Direct)", + key_func=lambda x: (x[2] / x[1]) + if x[1] > 0 + else 0, # Sort by cumulative/direct ratio + format_line=format_call_magnification, + ) diff --git a/Lib/profiling/sampling/sample.py b/Lib/profiling/sampling/sample.py new file mode 100644 index 000000000..702e00c87 --- /dev/null +++ b/Lib/profiling/sampling/sample.py @@ -0,0 +1,665 @@ +import _remote_debugging +import contextlib +import os +import statistics +import sys +import sysconfig +import time +from collections import deque +lazy from _colorize import get_colors + +from .pstats_collector import PstatsCollector +from .stack_collector import CollapsedStackCollector, FlamegraphCollector +from .heatmap_collector import HeatmapCollector +from .gecko_collector import GeckoCollector +from .binary_collector import BinaryCollector + + +@contextlib.contextmanager +def _pause_threads(unwinder, blocking): + """Context manager to pause/resume threads around sampling if blocking is True.""" + if blocking: + unwinder.pause_threads() + try: + yield + finally: + unwinder.resume_threads() + else: + yield + + +from .constants import ( + PROFILING_MODE_WALL, + PROFILING_MODE_CPU, + PROFILING_MODE_GIL, + PROFILING_MODE_ALL, + PROFILING_MODE_EXCEPTION, +) +from ._format_utils import fmt +try: + from .live_collector import LiveStatsCollector +except ImportError: + LiveStatsCollector = None + +_FREE_THREADED_BUILD = sysconfig.get_config_var("Py_GIL_DISABLED") is not None + +# Minimum number of samples required before showing the TUI +# If fewer samples are collected, we skip the TUI and just print a message +MIN_SAMPLES_FOR_TUI = 200 + +# Maximum number of consecutive identical samples to keep before flushing. +MAX_PENDING_SAMPLES = 8192 + + +def _resolve_python_pid(pid): + """On Windows, if pid is a venvlauncher process, return the child Python PID. + + The venvlauncher (used as python.exe in venvs) spawns the real Python + interpreter as a child process via CreateProcessW. The RemoteUnwinder + needs the child's PID, not the launcher's. + + Returns the original pid if not on Windows, not a venv launcher, + or no child process is found. + """ + if os.name != "nt" or sys.prefix == sys.base_prefix: + return pid + try: + children = _remote_debugging.get_child_pids(pid, recursive=False) + python_children = [ + child for child in children + if _remote_debugging.is_python_process(child) + ] + if len(python_children) == 1: + return python_children[0] + except (OSError, RuntimeError) as err: + raise SystemExit( + f"Failed to initialize profiler from virtualenv: {err}\n" + f"Try running with the base interpreter: {sys._base_executable}" + ) from err + return pid + + +class SampleProfiler: + def __init__(self, pid, sample_interval_usec, all_threads, *, mode=PROFILING_MODE_WALL, native=False, gc=True, opcodes=False, skip_non_matching_threads=True, collect_stats=False, blocking=False): + self.pid = _resolve_python_pid(pid) + self.sample_interval_usec = sample_interval_usec + self.all_threads = all_threads + self.mode = mode # Store mode for later use + self.collect_stats = collect_stats + self.blocking = blocking + try: + self.unwinder = self._new_unwinder(native, gc, opcodes, skip_non_matching_threads) + except RuntimeError as err: + raise SystemExit(err) from err + # Track sample intervals and total sample count + self.sample_intervals = deque(maxlen=100) + self.total_samples = 0 + self.realtime_stats = False + + def _new_unwinder(self, native, gc, opcodes, skip_non_matching_threads): + kwargs = {} + if _FREE_THREADED_BUILD or self.all_threads: + kwargs['all_threads'] = self.all_threads + else: + kwargs['only_active_thread'] = bool(self.all_threads) + + return _remote_debugging.RemoteUnwinder( + self.pid, + mode=self.mode, + native=native, + gc=gc, + opcodes=opcodes, + skip_non_matching_threads=skip_non_matching_threads, + cache_frames=True, + stats=self.collect_stats, + **kwargs + ) + + def _get_stack_trace(self, async_aware=None): + with _pause_threads(self.unwinder, self.blocking): + if async_aware == "all": + return self.unwinder.get_all_awaited_by() + if async_aware == "running": + return self.unwinder.get_async_stack_trace() + return self.unwinder.get_stack_trace() + + def dump_stack(self, *, async_aware=None): + """Return a single stack snapshot from the target process.""" + return self._get_stack_trace(async_aware=async_aware) + + def sample(self, collector, duration_sec=None, *, async_aware=False): + sample_interval_sec = self.sample_interval_usec / 1_000_000 + num_samples = 0 + errors = 0 + interrupted = False + running_time_sec = 0 + start_time = next_time = time.perf_counter() + last_sample_time = start_time + realtime_update_interval = 1.0 # Update every second + last_realtime_update = start_time + aggregating = getattr(collector, 'aggregating', False) is True + prev_stack = None + pending_count = 0 + pending_timestamps = [] if aggregating else None + + def flush_pending(): + nonlocal pending_count, pending_timestamps + if pending_count == 0: + return + pending_count = 0 + ts = pending_timestamps + pending_timestamps = [] + collector.collect(prev_stack, timestamps_us=ts) + + try: + while duration_sec is None or running_time_sec < duration_sec: + # Check if live collector wants to stop + if hasattr(collector, 'running') and not collector.running: + break + + current_time = time.perf_counter() + current_time_us = int(current_time * 1_000_000) + if next_time > current_time: + sleep_time = (next_time - current_time) * 0.9 + if sleep_time > 0.0001: + time.sleep(sleep_time) + elif next_time < current_time: + try: + stack_frames = self._get_stack_trace( + async_aware=async_aware + ) + if aggregating: + if stack_frames != prev_stack: + flush_pending() + prev_stack = stack_frames + pending_count += 1 + pending_timestamps.append(current_time_us) + if pending_count >= MAX_PENDING_SAMPLES: + flush_pending() + else: + collector.collect(stack_frames) + except ProcessLookupError as e: + running_time_sec = current_time - start_time + break + except (RuntimeError, UnicodeDecodeError, MemoryError, OSError): + flush_pending() + collector.collect_failed_sample() + errors += 1 + prev_stack = None + except Exception as e: + if not _is_process_running(self.pid): + break + raise e from None + + # Track actual sampling intervals for real-time stats + if num_samples > 0: + actual_interval = current_time - last_sample_time + self.sample_intervals.append( + 1.0 / actual_interval + ) # Convert to Hz + self.total_samples += 1 + + # Print real-time statistics if enabled + if ( + self.realtime_stats + and (current_time - last_realtime_update) + >= realtime_update_interval + ): + self._print_realtime_stats() + last_realtime_update = current_time + + last_sample_time = current_time + num_samples += 1 + next_time += sample_interval_sec + + running_time_sec = time.perf_counter() - start_time + except KeyboardInterrupt: + interrupted = True + running_time_sec = time.perf_counter() - start_time + print("Interrupted by user.") + finally: + flush_pending() + + # Clear real-time stats line if it was being displayed + if self.realtime_stats and len(self.sample_intervals) > 0: + print() # Add newline after real-time stats + + sample_rate = num_samples / running_time_sec if running_time_sec > 0 else 0 + error_rate = (errors / num_samples) * 100 if num_samples > 0 else 0 + expected_samples = int(running_time_sec / sample_interval_sec) + missed_samples = (expected_samples - num_samples) / expected_samples * 100 if expected_samples > 0 else 0 + + # Don't print stats for live mode (curses is handling display) + is_live_mode = LiveStatsCollector is not None and isinstance(collector, LiveStatsCollector) + if not is_live_mode: + s = "" if num_samples == 1 else "s" + print(f"Captured {num_samples:n} sample{s} in {fmt(running_time_sec, 2)} seconds") + print(f"Sample rate: {fmt(sample_rate, 2)} samples/sec") + print(f"Error rate: {fmt(error_rate, 2)}") + + # Print unwinder stats if stats collection is enabled + if self.collect_stats: + self._print_unwinder_stats() + + if isinstance(collector, BinaryCollector): + self._print_binary_stats(collector) + + # Pass stats to flamegraph collector if it's the right type + if hasattr(collector, 'set_stats'): + collector.set_stats(self.sample_interval_usec, running_time_sec, sample_rate, error_rate, missed_samples, mode=self.mode) + + if num_samples < expected_samples and not is_live_mode and not interrupted: + print( + f"Warning: missed {expected_samples - num_samples} samples " + f"from the expected total of {expected_samples} " + f"({fmt((expected_samples - num_samples) / expected_samples * 100, 2)}%)" + ) + + def _print_realtime_stats(self): + """Print real-time sampling statistics.""" + if len(self.sample_intervals) < 2: + return + + # Calculate statistics on the Hz values (deque automatically maintains rolling window) + hz_values = list(self.sample_intervals) + mean_hz = statistics.mean(hz_values) + min_hz = min(hz_values) + max_hz = max(hz_values) + + # Calculate microseconds per sample for all metrics (1/Hz * 1,000,000) + mean_us_per_sample = (1.0 / mean_hz) * 1_000_000 if mean_hz > 0 else 0 + min_us_per_sample = ( + (1.0 / max_hz) * 1_000_000 if max_hz > 0 else 0 + ) # Min time = Max Hz + max_us_per_sample = ( + (1.0 / min_hz) * 1_000_000 if min_hz > 0 else 0 + ) # Max time = Min Hz + + # Build cache stats string if stats collection is enabled + ANSIColors = get_colors() + + cache_stats_str = "" + if self.collect_stats: + try: + stats = self.unwinder.get_stats() + hits = stats.get('frame_cache_hits', 0) + partial = stats.get('frame_cache_partial_hits', 0) + misses = stats.get('frame_cache_misses', 0) + total = hits + partial + misses + if total > 0: + hit_pct = (hits + partial) / total * 100 + cache_stats_str = f" {ANSIColors.MAGENTA}Cache: {fmt(hit_pct)}% ({hits}+{partial}/{misses}){ANSIColors.RESET}" + except RuntimeError: + pass + + # Clear line and print stats + print( + f"\r\033[K{ANSIColors.BOLD_BLUE}Stats:{ANSIColors.RESET} " + f"{ANSIColors.YELLOW}{fmt(mean_hz)}Hz ({fmt(mean_us_per_sample)}µs){ANSIColors.RESET} " + f"{ANSIColors.GREEN}Min: {fmt(min_hz)}Hz{ANSIColors.RESET} " + f"{ANSIColors.RED}Max: {fmt(max_hz)}Hz{ANSIColors.RESET} " + f"{ANSIColors.CYAN}N={self.total_samples}{ANSIColors.RESET}" + f"{cache_stats_str}", + end="", + flush=True, + ) + + def _print_unwinder_stats(self): + """Print unwinder statistics including cache performance.""" + try: + stats = self.unwinder.get_stats() + except RuntimeError: + return # Stats not enabled + + ANSIColors = get_colors() + + print(f"\n{ANSIColors.BOLD_BLUE}{'='*50}{ANSIColors.RESET}") + print(f"{ANSIColors.BOLD_BLUE}Unwinder Statistics:{ANSIColors.RESET}") + + # Frame cache stats + total_samples = stats.get('total_samples', 0) + frame_cache_hits = stats.get('frame_cache_hits', 0) + frame_cache_partial_hits = stats.get('frame_cache_partial_hits', 0) + frame_cache_misses = stats.get('frame_cache_misses', 0) + total_lookups = frame_cache_hits + frame_cache_partial_hits + frame_cache_misses + + # Calculate percentages + hits_pct = (frame_cache_hits / total_lookups * 100) if total_lookups > 0 else 0 + partial_pct = (frame_cache_partial_hits / total_lookups * 100) if total_lookups > 0 else 0 + misses_pct = (frame_cache_misses / total_lookups * 100) if total_lookups > 0 else 0 + + print(f" {ANSIColors.CYAN}Frame Cache:{ANSIColors.RESET}") + print(f" Total samples: {total_samples:n}") + print(f" Full hits: {frame_cache_hits:n} ({ANSIColors.GREEN}{fmt(hits_pct)}%{ANSIColors.RESET})") + print(f" Partial hits: {frame_cache_partial_hits:n} ({ANSIColors.YELLOW}{fmt(partial_pct)}%{ANSIColors.RESET})") + print(f" Misses: {frame_cache_misses:n} ({ANSIColors.RED}{fmt(misses_pct)}%{ANSIColors.RESET})") + + # Frame read stats + frames_from_cache = stats.get('frames_read_from_cache', 0) + frames_from_memory = stats.get('frames_read_from_memory', 0) + total_frames = frames_from_cache + frames_from_memory + cache_frame_pct = (frames_from_cache / total_frames * 100) if total_frames > 0 else 0 + memory_frame_pct = (frames_from_memory / total_frames * 100) if total_frames > 0 else 0 + + print(f" {ANSIColors.CYAN}Frame Reads:{ANSIColors.RESET}") + print(f" From cache: {frames_from_cache:n} ({ANSIColors.GREEN}{fmt(cache_frame_pct)}%{ANSIColors.RESET})") + print(f" From memory: {frames_from_memory:n} ({ANSIColors.RED}{fmt(memory_frame_pct)}%{ANSIColors.RESET})") + + # Code object cache stats + code_hits = stats.get('code_object_cache_hits', 0) + code_misses = stats.get('code_object_cache_misses', 0) + total_code = code_hits + code_misses + code_hits_pct = (code_hits / total_code * 100) if total_code > 0 else 0 + code_misses_pct = (code_misses / total_code * 100) if total_code > 0 else 0 + + print(f" {ANSIColors.CYAN}Code Object Cache:{ANSIColors.RESET}") + print(f" Hits: {code_hits:n} ({ANSIColors.GREEN}{fmt(code_hits_pct)}%{ANSIColors.RESET})") + print(f" Misses: {code_misses:n} ({ANSIColors.RED}{fmt(code_misses_pct)}%{ANSIColors.RESET})") + + batched_attempts = stats.get('batched_read_attempts', 0) + batched_successes = stats.get('batched_read_successes', 0) + batched_misses = stats.get('batched_read_misses', 0) + segments_requested = stats.get('batched_read_segments_requested', 0) + segments_completed = stats.get('batched_read_segments_completed', 0) + if batched_attempts > 0: + batched_success_rate = stats.get('batched_read_success_rate', 0.0) + batched_miss_rate = 100.0 - batched_success_rate + segment_completion_rate = stats.get( + 'batched_read_segment_completion_rate', 0.0 + ) + + print(f" {ANSIColors.CYAN}Batched Reads:{ANSIColors.RESET}") + print(f" Attempts: {batched_attempts:n}") + print( + f" Successes: {batched_successes:n} " + f"({ANSIColors.GREEN}{fmt(batched_success_rate)}%{ANSIColors.RESET})" + ) + print( + f" Misses: {batched_misses:n} " + f"({ANSIColors.RED}{fmt(batched_miss_rate)}%{ANSIColors.RESET})" + ) + print( + f" Segments read: {segments_completed:n}/{segments_requested:n} " + f"({ANSIColors.GREEN}{fmt(segment_completion_rate)}%{ANSIColors.RESET})" + ) + + # Memory operations + memory_reads = stats.get('memory_reads', 0) + memory_bytes = stats.get('memory_bytes_read', 0) + if memory_bytes >= 1024 * 1024: + memory_str = f"{fmt(memory_bytes / (1024 * 1024))} MB" + elif memory_bytes >= 1024: + memory_str = f"{fmt(memory_bytes / 1024)} KB" + else: + memory_str = f"{memory_bytes} B" + print(f" {ANSIColors.CYAN}Memory:{ANSIColors.RESET}") + print(f" Read operations: {memory_reads:n} ({memory_str})") + + # Stale invalidations + stale_invalidations = stats.get('stale_cache_invalidations', 0) + if stale_invalidations > 0: + print(f" {ANSIColors.YELLOW}Stale cache invalidations: {stale_invalidations}{ANSIColors.RESET}") + + def _print_binary_stats(self, collector): + """Print binary I/O encoding statistics.""" + try: + stats = collector.get_stats() + except (ValueError, RuntimeError): + return # Collector closed or stats unavailable + + ANSIColors = get_colors() + + print(f" {ANSIColors.CYAN}Binary Encoding:{ANSIColors.RESET}") + + repeat_records = stats.get('repeat_records', 0) + repeat_samples = stats.get('repeat_samples', 0) + full_records = stats.get('full_records', 0) + suffix_records = stats.get('suffix_records', 0) + pop_push_records = stats.get('pop_push_records', 0) + total_records = stats.get('total_records', 0) + + if total_records > 0: + repeat_pct = repeat_records / total_records * 100 + full_pct = full_records / total_records * 100 + suffix_pct = suffix_records / total_records * 100 + pop_push_pct = pop_push_records / total_records * 100 + else: + repeat_pct = full_pct = suffix_pct = pop_push_pct = 0 + + print(f" Records: {total_records:,}") + print(f" RLE repeat: {repeat_records:,} ({ANSIColors.GREEN}{repeat_pct:.1f}%{ANSIColors.RESET}) [{repeat_samples:,} samples]") + print(f" Full stack: {full_records:,} ({full_pct:.1f}%)") + print(f" Suffix match: {suffix_records:,} ({suffix_pct:.1f}%)") + print(f" Pop-push: {pop_push_records:,} ({pop_push_pct:.1f}%)") + + frames_written = stats.get('total_frames_written', 0) + frames_saved = stats.get('frames_saved', 0) + compression_pct = stats.get('frame_compression_pct', 0) + + print(f" {ANSIColors.CYAN}Frame Efficiency:{ANSIColors.RESET}") + print(f" Frames written: {frames_written:,}") + print(f" Frames saved: {frames_saved:,} ({ANSIColors.GREEN}{compression_pct:.1f}%{ANSIColors.RESET})") + + bytes_written = stats.get('bytes_written', 0) + if bytes_written >= 1024 * 1024: + bytes_str = f"{bytes_written / (1024 * 1024):.1f} MB" + elif bytes_written >= 1024: + bytes_str = f"{bytes_written / 1024:.1f} KB" + else: + bytes_str = f"{bytes_written} B" + print(f" Bytes (pre-zstd): {bytes_str}") + + +def _is_process_running(pid): + if pid <= 0: + return False + if os.name == "posix": + try: + os.kill(pid, 0) + return True + except ProcessLookupError: + return False + except PermissionError: + # EPERM means process exists but we can't signal it + return True + elif sys.platform == "win32": + try: + _remote_debugging.RemoteUnwinder(pid) + except Exception: + return False + return True + else: + raise ValueError(f"Unsupported platform: {sys.platform}") + + +def sample( + pid, + collector, + *, + duration_sec=None, + all_threads=False, + realtime_stats=False, + mode=PROFILING_MODE_WALL, + async_aware=None, + native=False, + gc=True, + opcodes=False, + blocking=False, +): + """Sample a process using the provided collector. + + Args: + pid: Process ID to sample + collector: Collector instance to use for gathering samples + duration_sec: How long to sample for (seconds), or None to run until + the process exits or interrupted + all_threads: Whether to sample all threads + realtime_stats: Whether to print real-time sampling statistics + mode: Profiling mode - WALL (all samples), CPU (only when on CPU), + GIL (only when holding GIL), ALL (includes GIL and CPU status), + EXCEPTION (only when thread has an active exception) + native: Whether to include native frames + gc: Whether to include GC frames + opcodes: Whether to include opcode information + blocking: Whether to stop all threads before sampling for consistent snapshots + + Returns: + The collector with collected samples + """ + # Get sample interval from collector + sample_interval_usec = collector.sample_interval_usec + + # PROFILING_MODE_ALL implies no skipping at all + if mode == PROFILING_MODE_ALL: + skip_non_matching_threads = False + else: + # For most modes, skip non-matching threads + # Gecko collector overrides this by setting skip_idle=False + skip_non_matching_threads = True + + profiler = SampleProfiler( + pid, + sample_interval_usec, + all_threads=all_threads, + mode=mode, + native=native, + gc=gc, + opcodes=opcodes, + skip_non_matching_threads=skip_non_matching_threads, + collect_stats=realtime_stats, + blocking=blocking, + ) + profiler.realtime_stats = realtime_stats + + # Run the sampling + profiler.sample(collector, duration_sec, async_aware=async_aware) + + return collector + + +def dump_stack( + pid, + *, + all_threads=False, + mode=PROFILING_MODE_ALL, + async_aware=None, + native=False, + gc=True, + opcodes=False, + blocking=False, +): + """Return a single stack snapshot from a process.""" + if mode == PROFILING_MODE_ALL: + skip_non_matching_threads = False + else: + skip_non_matching_threads = True + + profiler = SampleProfiler( + pid, + sample_interval_usec=1, + all_threads=all_threads, + mode=mode, + native=native, + gc=gc, + opcodes=opcodes, + skip_non_matching_threads=skip_non_matching_threads, + blocking=blocking, + ) + return profiler.dump_stack(async_aware=async_aware) + + +def sample_live( + pid, + collector, + *, + duration_sec=None, + all_threads=False, + realtime_stats=False, + mode=PROFILING_MODE_WALL, + async_aware=None, + native=False, + gc=True, + opcodes=False, + blocking=False, +): + """Sample a process in live/interactive mode with curses TUI. + + Args: + pid: Process ID to sample + collector: LiveStatsCollector instance + duration_sec: How long to sample for (seconds) + all_threads: Whether to sample all threads + realtime_stats: Whether to print real-time sampling statistics + mode: Profiling mode - WALL (all samples), CPU (only when on CPU), + GIL (only when holding GIL), ALL (includes GIL and CPU status), + EXCEPTION (only when thread has an active exception) + native: Whether to include native frames + gc: Whether to include GC frames + opcodes: Whether to include opcode information + blocking: Whether to stop all threads before sampling for consistent snapshots + + Returns: + The collector with collected samples + """ + import curses + + # Check if process is alive before doing any heavy initialization + if not _is_process_running(pid): + print(f"No samples collected - process {pid} exited before profiling could begin.", file=sys.stderr) + return collector + + # Get sample interval from collector + sample_interval_usec = collector.sample_interval_usec + + # PROFILING_MODE_ALL implies no skipping at all + if mode == PROFILING_MODE_ALL: + skip_non_matching_threads = False + else: + skip_non_matching_threads = True + + profiler = SampleProfiler( + pid, + sample_interval_usec, + all_threads=all_threads, + mode=mode, + native=native, + gc=gc, + opcodes=opcodes, + skip_non_matching_threads=skip_non_matching_threads, + collect_stats=realtime_stats, + blocking=blocking, + ) + profiler.realtime_stats = realtime_stats + + def curses_wrapper_func(stdscr): + collector.init_curses(stdscr) + try: + profiler.sample(collector, duration_sec, async_aware=async_aware) + # If too few samples were collected, exit cleanly without showing TUI + if collector.successful_samples < MIN_SAMPLES_FOR_TUI: + # Clear screen before exiting to avoid visual artifacts + stdscr.clear() + stdscr.refresh() + return + # Mark as finished and keep the TUI running until user presses 'q' + collector.mark_finished() + # Keep processing input until user quits + while collector.running: + collector._handle_input() + time.sleep(0.05) # Small sleep to avoid busy waiting + finally: + collector.cleanup_curses() + + try: + curses.wrapper(curses_wrapper_func) + except KeyboardInterrupt: + pass + + # If too few samples were collected, print a message + if collector.successful_samples < MIN_SAMPLES_FOR_TUI: + if collector.successful_samples == 0: + print(f"No samples collected - process {pid} exited before profiling could begin.", file=sys.stderr) + else: + print(f"Only {collector.successful_samples} sample(s) collected (minimum {MIN_SAMPLES_FOR_TUI} required for TUI) - process {pid} exited too quickly.", file=sys.stderr) + + return collector diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py new file mode 100644 index 000000000..1610f4a36 --- /dev/null +++ b/Lib/profiling/sampling/stack_collector.py @@ -0,0 +1,907 @@ +import base64 +import collections +import functools +import importlib.resources +import json +import linecache +import os +import sys +import sysconfig + +from ._css_utils import get_combined_css +from .collector import Collector, extract_lineno +from .opcode_utils import get_opcode_mapping +from .string_table import StringTable +from .module_utils import extract_module_name, get_python_path_info + + +class StackTraceCollector(Collector): + aggregating = True + + def __init__(self, sample_interval_usec, *, skip_idle=False): + self.sample_interval_usec = sample_interval_usec + self.skip_idle = skip_idle + + def collect(self, stack_frames, timestamps_us=None): + weight = len(timestamps_us) if timestamps_us else 1 + for frames, thread_id in self._iter_stacks(stack_frames, skip_idle=self.skip_idle): + self.process_frames(frames, thread_id, weight=weight) + + def process_frames(self, frames, thread_id, weight=1): + pass + + +class CollapsedStackCollector(StackTraceCollector): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.stack_counter = collections.Counter() + + def process_frames(self, frames, thread_id, weight=1): + # Extract only (filename, lineno, funcname) - opcode not needed for collapsed stacks + # frame is (filename, location, funcname, opcode) + call_tree = tuple( + (f[0], extract_lineno(f[1]), f[2]) for f in reversed(frames) + ) + self.stack_counter[(call_tree, thread_id)] += weight + + def export(self, filename): + lines = [] + for (call_tree, thread_id), count in self.stack_counter.items(): + parts = [f"tid:{thread_id}"] + for file, line, func in call_tree: + # This is what pstats does for "special" frames: + if file == "~" and line == 0: + part = func + else: + part = f"{os.path.basename(file)}:{func}:{line}" + parts.append(part) + stack_str = ";".join(parts) + lines.append((stack_str, count)) + + lines.sort(key=lambda x: (-x[1], x[0])) + + with open(filename, "w") as f: + for stack, count in lines: + f.write(f"{stack} {count}\n") + print(f"Collapsed stack output written to {filename}") + return True + + +class FlamegraphCollector(StackTraceCollector): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.stats = {"sample_interval_usec": self.sample_interval_usec} + self._root = { + "samples": 0, + "children": {}, + "threads": set(), + "thread_samples": collections.Counter(), + "thread_self": collections.Counter(), + } + self._total_samples = 0 + self._sample_count = 0 # Track actual number of samples (not thread traces) + self._func_intern = {} + self._string_table = StringTable() + self._module_cache = {} + self._all_threads = set() + + # Thread status statistics (similar to LiveStatsCollector) + self.thread_status_counts = { + "has_gil": 0, + "on_cpu": 0, + "gil_requested": 0, + "unknown": 0, + "has_exception": 0, + "total": 0, + } + self.samples_with_gc_frames = 0 + + # Per-thread statistics + self.per_thread_stats = {} # {thread_id: {has_gil, on_cpu, gil_requested, unknown, has_exception, total, gc_samples}} + + def collect(self, stack_frames, timestamps_us=None): + """Override to track thread status statistics before processing frames.""" + # Weight is number of timestamps (samples with identical stack) + weight = len(timestamps_us) if timestamps_us else 1 + # Increment sample count by weight + self._sample_count += weight + + # Collect both aggregate and per-thread statistics using base method + status_counts, has_gc_frame, per_thread_stats = self._collect_thread_status_stats(stack_frames) + + # Merge aggregate status counts (multiply by weight) + for key in status_counts: + self.thread_status_counts[key] += status_counts[key] * weight + + # Update aggregate GC frame count + if has_gc_frame: + self.samples_with_gc_frames += weight + + # Merge per-thread statistics (multiply by weight) + for thread_id, stats in per_thread_stats.items(): + if thread_id not in self.per_thread_stats: + self.per_thread_stats[thread_id] = { + "has_gil": 0, + "on_cpu": 0, + "gil_requested": 0, + "unknown": 0, + "has_exception": 0, + "total": 0, + "gc_samples": 0, + } + for key, value in stats.items(): + self.per_thread_stats[thread_id][key] += value * weight + + # Call parent collect to process frames + super().collect(stack_frames, timestamps_us) + + def set_stats(self, sample_interval_usec, duration_sec, sample_rate, + error_rate=None, missed_samples=None, mode=None): + """Set profiling statistics to include in flamegraph data.""" + self.stats = { + "sample_interval_usec": sample_interval_usec, + "duration_sec": duration_sec, + "sample_rate": sample_rate, + "error_rate": error_rate, + "missed_samples": missed_samples, + "mode": mode + } + + def set_replay_stats(self, info): + """Restore measured statistics stored in a binary profile.""" + duration_sec = info.get("duration_sec") + sample_rate = info.get("sample_rate") + if duration_sec is None or sample_rate is None: + return + self.set_stats( + self.sample_interval_usec, + duration_sec, + sample_rate, + error_rate=info.get("error_rate"), + missed_samples=info.get("missed_samples"), + mode=self.stats.get("mode"), + ) + def set_mode(self, mode): + self.stats["mode"] = mode + + def export(self, filename): + flamegraph_data = self._convert_to_flamegraph_format() + + # Debug output with string table statistics + num_functions = len(flamegraph_data.get("children", [])) + total_time = flamegraph_data.get("value", 0) + string_count = len(self._string_table) + s1 = "" if num_functions == 1 else "s" + s2 = "" if total_time == 1 else "s" + s3 = "" if string_count == 1 else "s" + print( + f"Flamegraph data: {num_functions} root function{s1}, " + f"{total_time} total sample{s2}, " + f"{string_count} unique string{s3}" + ) + + if num_functions == 0: + print( + "Warning: No functions found in profiling data. Check if sampling captured any data." + ) + return False + + html_content = self._create_flamegraph_html(flamegraph_data) + + with open(filename, "w", encoding="utf-8") as f: + f.write(html_content) + + print(f"Flamegraph saved to: {filename}") + return True + + @staticmethod + @functools.lru_cache(maxsize=None) + def _format_function_name(func): + filename, lineno, funcname = func + + # Special frames like and should not show file:line + if filename == "~" and lineno == 0: + return funcname + + if len(filename) > 50: + parts = filename.split("/") + if len(parts) > 2: + filename = f".../{'/'.join(parts[-2:])}" + + return f"{funcname} ({filename}:{lineno})" + + @staticmethod + @functools.lru_cache(maxsize=None) + def _format_module_name(func, module_name): + filename, lineno, funcname = func + + # Special frames like and should not show file:line + if filename == "~" and lineno == 0: + return funcname + + return f"{funcname} ({module_name}:{lineno})" + + def _get_module_name(self, filename, path_info): + module_name = self._module_cache.get(filename) + if module_name is None: + module_name, _ = extract_module_name(filename, path_info) + self._module_cache[filename] = module_name + return module_name + + def _convert_to_flamegraph_format(self, *, min_samples=None): + if self._total_samples == 0: + return { + "name": self._string_table.intern("No Data"), + "value": 0, + "children": [], + "threads": [], + "strings": self._string_table.get_strings() + } + + def convert_children(children, min_samples, path_info): + out = [] + for func, node in children.items(): + samples = node["samples"] + significant_for_thread = any( + thread_samples >= max( + 1, + int( + self._root["thread_samples"][thread_id] + * 0.001 + ), + ) + for thread_id, thread_samples + in node["thread_samples"].items() + ) + if samples < min_samples and not significant_for_thread: + continue + + # Intern all string components for maximum efficiency + filename_idx = self._string_table.intern(func[0]) + funcname_idx = self._string_table.intern(func[2]) + module_name = self._get_module_name(func[0], path_info) + + module_idx = self._string_table.intern(module_name) + name_idx = self._string_table.intern(self._format_function_name(func)) + label_idx = self._string_table.intern(self._format_module_name(func, module_name)) + + child_entry = { + "name": name_idx, + "label": label_idx, + "value": samples, + "self": node.get("self", 0), + "children": [], + "filename": filename_idx, + "module": module_idx, + "lineno": func[1], + "funcname": funcname_idx, + "threads": sorted(list(node.get("threads", set()))), + "thread_values": { + thread_id: [ + samples, + node["thread_self"].get(thread_id, 0), + ] + for thread_id, samples in sorted( + node["thread_samples"].items() + ) + }, + } + + source = self._get_source_lines(func) + if source: + # Intern source lines for memory efficiency + source_indices = [self._string_table.intern(line) for line in source] + child_entry["source"] = source_indices + + # Include opcode data if available + opcodes = node.get("opcodes", {}) + if opcodes: + child_entry["opcodes"] = dict(opcodes) + thread_opcodes = node.get("thread_opcodes") + if thread_opcodes: + child_entry["thread_opcodes"] = { + thread_id: dict(counts) + for thread_id, counts in sorted( + thread_opcodes.items() + ) + } + + # Recurse + child_entry["children"] = convert_children( + node["children"], min_samples, path_info + ) + out.append(child_entry) + + # Sort by value (descending) then by name index for consistent ordering + out.sort(key=lambda x: (-x["value"], x["name"])) + return out + + # Filter out very small functions (less than 0.1% of total samples) + total_samples = self._total_samples + if min_samples is None: + min_samples = max(1, int(total_samples * 0.001)) + path_info = get_python_path_info() + + root_children = convert_children(self._root["children"], min_samples, path_info) + if not root_children: + return { + "name": self._string_table.intern("No significant data"), + "value": 0, + "children": [], + "strings": self._string_table.get_strings() + } + + # Calculate thread status percentages for display + is_free_threaded = bool(sysconfig.get_config_var("Py_GIL_DISABLED")) + total_threads = max(1, self.thread_status_counts["total"]) + thread_stats = { + "has_gil_pct": (self.thread_status_counts["has_gil"] / total_threads) * 100, + "on_cpu_pct": (self.thread_status_counts["on_cpu"] / total_threads) * 100, + "gil_requested_pct": (self.thread_status_counts["gil_requested"] / total_threads) * 100, + "has_exception_pct": (self.thread_status_counts["has_exception"] / total_threads) * 100, + "gc_pct": (self.samples_with_gc_frames / max(1, self._sample_count)) * 100, + "free_threaded": is_free_threaded, + **self.thread_status_counts + } + + # Calculate per-thread statistics with percentages + per_thread_stats_with_pct = {} + total_samples_denominator = max(1, self._sample_count) + for thread_id, stats in self.per_thread_stats.items(): + total = max(1, stats["total"]) + per_thread_stats_with_pct[thread_id] = { + "has_gil_pct": (stats["has_gil"] / total) * 100, + "on_cpu_pct": (stats["on_cpu"] / total) * 100, + "gil_requested_pct": (stats["gil_requested"] / total) * 100, + "has_exception_pct": (stats["has_exception"] / total) * 100, + "gc_pct": (stats["gc_samples"] / total_samples_denominator) * 100, + **stats + } + + # Build opcode mapping for JS + opcode_mapping = get_opcode_mapping() + + # If we only have one root child, make it the root to avoid redundant level + root_thread_values = { + thread_id: [samples, 0] + for thread_id, samples in sorted( + self._root["thread_samples"].items() + ) + } + sole_root_covers_profile = ( + len(root_children) == 1 + and root_children[0]["value"] == total_samples + and { + thread_id: values[0] + for thread_id, values + in root_children[0]["thread_values"].items() + } == { + thread_id: values[0] + for thread_id, values in root_thread_values.items() + } + ) + if sole_root_covers_profile: + main_child = root_children[0] + # Update name and label to indicate it's the program root + old_name = self._string_table.get_string(main_child["name"]) + main_child["name"] = self._string_table.intern(f"Program Root: {old_name}") + old_label = self._string_table.get_string(main_child["label"]) + main_child["label"] = self._string_table.intern(f"Program Root: {old_label}") + main_child["stats"] = { + **self.stats, + "thread_stats": thread_stats, + "per_thread_stats": per_thread_stats_with_pct + } + main_child["threads"] = sorted(list(self._all_threads)) + main_child["strings"] = self._string_table.get_strings() + main_child["opcode_mapping"] = opcode_mapping + return main_child + + program_root_idx = self._string_table.intern("Program Root") + return { + "name": program_root_idx, + "label": program_root_idx, + "value": total_samples, + "children": root_children, + "stats": { + **self.stats, + "thread_stats": thread_stats, + "per_thread_stats": per_thread_stats_with_pct + }, + "threads": sorted(list(self._all_threads)), + "thread_values": root_thread_values, + "strings": self._string_table.get_strings(), + "opcode_mapping": opcode_mapping + } + + def process_frames(self, frames, thread_id, weight=1): + """Process stack frames into flamegraph tree structure. + + Args: + frames: List of (filename, location, funcname, opcode) tuples in + leaf-to-root order. location is (lineno, end_lineno, col_offset, end_col_offset). + opcode is None if not gathered. + thread_id: Thread ID for this stack trace + weight: Number of samples this stack represents (for batched RLE) + """ + # Reverse to root->leaf order for tree building + self._root["samples"] += weight + self._root["thread_samples"][thread_id] += weight + self._total_samples += weight + self._root["threads"].add(thread_id) + self._all_threads.add(thread_id) + + current = self._root + for filename, location, funcname, opcode in reversed(frames): + lineno = extract_lineno(location) + func = (filename, lineno, funcname) + func = self._func_intern.setdefault(func, func) + + node = current["children"].get(func) + if node is None: + node = { + "samples": 0, + "children": {}, + "threads": set(), + "thread_samples": collections.Counter(), + "thread_self": collections.Counter(), + "opcodes": collections.Counter(), + "self": 0, + } + current["children"][func] = node + node["samples"] += weight + node["thread_samples"][thread_id] += weight + node["threads"].add(thread_id) + + if opcode is not None: + node["opcodes"][opcode] += weight + thread_opcodes = node.setdefault("thread_opcodes", {}) + thread_opcodes.setdefault( + thread_id, collections.Counter() + )[opcode] += weight + + current = node + + if current is not self._root: + current["self"] += weight + current["thread_self"][thread_id] += weight + + def _get_source_lines(self, func): + filename, lineno, _ = func + + try: + lines = [] + start_line = max(1, lineno - 2) + end_line = lineno + 3 + + for line_num in range(start_line, end_line): + line = linecache.getline(filename, line_num) + if line.strip(): + marker = "→ " if line_num == lineno else " " + lines.append(f"{marker}{line_num}: {line.rstrip()}") + + return lines if lines else None + + except Exception: + return None + + def _create_flamegraph_html(self, data): + data_json = json.dumps(data) + + template_dir = importlib.resources.files(__package__) + vendor_dir = template_dir / "_vendor" + assets_dir = template_dir / "_assets" + + d3_path = vendor_dir / "d3" / "7.8.5" / "d3.min.js" + d3_flame_graph_dir = vendor_dir / "d3-flame-graph" / "4.1.3" + fg_css_path = d3_flame_graph_dir / "d3-flamegraph.css" + fg_js_path = d3_flame_graph_dir / "d3-flamegraph.min.js" + fg_tooltip_js_path = d3_flame_graph_dir / "d3-flamegraph-tooltip.min.js" + + html_template = (template_dir / "_flamegraph_assets" / "flamegraph_template.html").read_text(encoding="utf-8") + css_content = get_combined_css("flamegraph") + base_js = (template_dir / "_shared_assets" / "base.js").read_text(encoding="utf-8") + component_js = (template_dir / "_flamegraph_assets" / "flamegraph.js").read_text(encoding="utf-8") + js_content = f"{base_js}\n{component_js}" + + # Set title and subtitle based on whether this is a differential flamegraph + is_differential = data.get("stats", {}).get("is_differential", False) + if is_differential: + title = "Tachyon Profiler - Differential Flamegraph Report" + subtitle = "Differential Flamegraph Report" + else: + title = "Tachyon Profiler - Flamegraph Report" + subtitle = "Flamegraph Report" + + html_template = html_template.replace("{{TITLE}}", title) + html_template = html_template.replace("{{SUBTITLE}}", subtitle) + + # Inline first-party CSS/JS + html_template = html_template.replace( + "", f"" + ) + html_template = html_template.replace( + "", f"" + ) + + png_path = assets_dir / "tachyon-logo.png" + b64_logo = base64.b64encode(png_path.read_bytes()).decode("ascii") + + # Let CSS control size; keep markup simple + logo_html = f'Tachyon logo' + html_template = html_template.replace("", logo_html) + html_template = html_template.replace( + "", f"{sys.version_info.major}.{sys.version_info.minor}" + ) + + d3_js = d3_path.read_text(encoding="utf-8") + fg_css = fg_css_path.read_text(encoding="utf-8") + fg_js = fg_js_path.read_text(encoding="utf-8") + fg_tooltip_js = fg_tooltip_js_path.read_text(encoding="utf-8") + + html_template = html_template.replace( + "", + f"", + ) + html_template = html_template.replace( + "", + f"", + ) + html_template = html_template.replace( + "", + f"", + ) + html_template = html_template.replace( + "", + f"", + ) + + # Replace the placeholder with actual data + html_content = html_template.replace( + "{{FLAMEGRAPH_DATA}}", data_json + ) + + return html_content + + +class DiffFlamegraphCollector(FlamegraphCollector): + """Differential flamegraph collector that compares against a baseline binary profile.""" + + def __init__(self, sample_interval_usec, *, baseline_binary_path, + skip_idle=False, mode=None, capture_config=None): + super().__init__(sample_interval_usec, skip_idle=skip_idle) + if not os.path.exists(baseline_binary_path): + raise ValueError(f"Baseline file not found: {baseline_binary_path}") + self.baseline_binary_path = baseline_binary_path + self._baseline_collector = None + self._elided_paths = set() + self.mode = mode + self.capture_config = capture_config + + def _load_baseline(self): + """Load baseline profile from binary file.""" + from .binary_reader import BinaryReader + + with BinaryReader(self.baseline_binary_path) as reader: + info = reader.get_info() + + baseline_mode = info.get("mode") + if ( + baseline_mode is not None + and self.mode is not None + and baseline_mode != self.mode + ): + raise ValueError( + "Baseline profiling mode does not match current mode" + ) + + baseline_config = info.get("capture_config") + if baseline_config is not None and self.capture_config is not None: + names = baseline_config.keys() | self.capture_config.keys() + mismatches = [ + name for name in names + if baseline_config.get(name, False) + != self.capture_config.get(name, False) + ] + else: + mismatches = [] + if mismatches: + raise ValueError( + "Baseline capture configuration does not match current " + f"configuration: {', '.join(sorted(mismatches))}" + ) + + baseline_collector = FlamegraphCollector( + sample_interval_usec=info['sample_interval_us'], + skip_idle=self.skip_idle + ) + + reader.replay_samples(baseline_collector) + + self._baseline_collector = baseline_collector + + def _aggregate_path_samples(self, root_node, path=None): + """Aggregate samples by stack path, excluding line numbers for cross-profile matching.""" + if path is None: + path = () + + stats = {} + + for func, node in root_node["children"].items(): + filename, _lineno, funcname = func + func_key = (filename, funcname) + path_key = path + (func_key,) + + total_samples = node.get("samples", 0) + self_samples = node.get("self", 0) + + if path_key in stats: + stats[path_key]["total"] += total_samples + stats[path_key]["self"] += self_samples + else: + stats[path_key] = { + "total": total_samples, + "self": self_samples + } + + child_stats = self._aggregate_path_samples(node, path_key) + for key, data in child_stats.items(): + if key in stats: + stats[key]["total"] += data["total"] + stats[key]["self"] += data["self"] + else: + stats[key] = data + + return stats + + def _convert_to_flamegraph_format(self): + """Convert to flamegraph format with differential annotations.""" + if self._baseline_collector is None: + self._load_baseline() + + current_flamegraph = super()._convert_to_flamegraph_format() + + current_stats = self._aggregate_path_samples(self._root) + baseline_stats = self._aggregate_path_samples(self._baseline_collector._root) + + # Scale baseline values to make them comparable, accounting for both + # sample count differences and sample interval differences. + baseline_total = self._baseline_collector._total_samples + if baseline_total > 0 and self._total_samples > 0: + current_time = self._total_samples * self.sample_interval_usec + baseline_time = baseline_total * self._baseline_collector.sample_interval_usec + scale = current_time / baseline_time + elif baseline_total > 0: + # Current profile is empty - use interval-based scale for elided display + scale = self.sample_interval_usec / self._baseline_collector.sample_interval_usec + else: + scale = 1.0 + + self._annotate_nodes_with_diff(current_flamegraph, current_stats, baseline_stats, scale) + self._add_elided_flamegraph(current_flamegraph, current_stats, baseline_stats, scale) + + return current_flamegraph + + def _annotate_nodes_with_diff(self, current_flamegraph, current_stats, baseline_stats, scale): + """Annotate each node in the tree with diff metadata.""" + if "stats" not in current_flamegraph: + current_flamegraph["stats"] = {} + + current_flamegraph["stats"]["baseline_samples"] = self._baseline_collector._total_samples + current_flamegraph["stats"]["current_samples"] = self._total_samples + current_flamegraph["stats"]["baseline_scale"] = scale + current_flamegraph["stats"]["is_differential"] = True + + if self._is_promoted_root(current_flamegraph): + self._add_diff_data_to_node(current_flamegraph, (), current_stats, baseline_stats, scale) + else: + for child in current_flamegraph["children"]: + self._add_diff_data_to_node(child, (), current_stats, baseline_stats, scale) + + def _add_diff_data_to_node(self, node, path, current_stats, baseline_stats, scale): + """Recursively add diff metadata to nodes.""" + func_key = self._extract_func_key(node, self._string_table) + path_key = path + (func_key,) if func_key else path + + current_data = current_stats.get(path_key, {"total": 0, "self": 0}) + baseline_data = baseline_stats.get(path_key, {"total": 0, "self": 0}) + + current_self = node.get("self", 0) + current_total = node.get("value", 0) + + current_nonself = current_total - current_self + aggregate_nonself = current_data["total"] - current_data["self"] + + # Allocate self and descendant samples separately. Line-number + # changes can split one function path into several rendered nodes, + # and using independent weights for self and inclusive totals could + # otherwise assign a node more self samples than total samples. + self_weight = self._sample_weight( + current_self, + current_data["self"], + current_total, + current_data["total"], + ) + nonself_weight = self._sample_weight( + current_nonself, + aggregate_nonself, + current_total, + current_data["total"], + ) + baseline_self = baseline_data["self"] * scale * self_weight + baseline_nonself = ( + baseline_data["total"] - baseline_data["self"] + ) * scale * nonself_weight + baseline_total = baseline_self + baseline_nonself + + diff = current_self - baseline_self + if baseline_self > 0: + diff_pct = (diff / baseline_self) * 100.0 + elif current_self > 0: + diff_pct = 100.0 + else: + diff_pct = 0.0 + + node["baseline"] = baseline_self + node["baseline_total"] = baseline_total + node["self_time"] = current_self + node["diff"] = diff + node["diff_pct"] = diff_pct + + if "children" in node and node["children"]: + for child in node["children"]: + self._add_diff_data_to_node(child, path_key, current_stats, baseline_stats, scale) + + @staticmethod + def _sample_weight(value, aggregate, fallback_value, fallback_aggregate): + if aggregate > 0: + return value / aggregate + if fallback_aggregate > 0: + return fallback_value / fallback_aggregate + return 0 + + def _is_promoted_root(self, data): + """Check if the data represents a promoted root node.""" + return "filename" in data and "funcname" in data + + def _add_elided_flamegraph(self, current_flamegraph, current_stats, baseline_stats, scale): + """Calculate elided paths and add elided flamegraph to stats.""" + self._elided_paths = baseline_stats.keys() - current_stats.keys() + + # A sampled stack can end at an internal path that also has elided + # descendants. Count every disappeared path with self samples, not + # just the leaves of the elided path tree. + elided_stacks = { + path + for path in self._elided_paths + if baseline_stats[path]["self"] > 0 + } + current_flamegraph["stats"]["elided_count"] = len(elided_stacks) + + if self._elided_paths: + elided_flamegraph = self._build_elided_flamegraph(baseline_stats, scale) + if elided_flamegraph: + current_flamegraph["stats"]["elided_flamegraph"] = elided_flamegraph + + def _build_elided_flamegraph(self, baseline_stats, scale): + """Build flamegraph containing only elided paths from baseline. + + This re-runs the base conversion pipeline on the baseline collector + to produce a complete formatted flamegraph, then prunes it to keep + only elided paths. + """ + if not self._baseline_collector or not self._elided_paths: + return None + + # Suppress source line collection for elided nodes - these functions + # no longer exist in the current profile, so source lines from the + # current machine's filesystem would be misleading or unavailable. + orig_get_source = self._baseline_collector._get_source_lines + self._baseline_collector._get_source_lines = lambda func: None + try: + baseline_data = self._baseline_collector._convert_to_flamegraph_format( + min_samples=1 + ) + finally: + self._baseline_collector._get_source_lines = orig_get_source + + # Remove non-elided nodes and recalculate values + if not self._extract_elided_nodes(baseline_data, path=()): + return None + + self._add_elided_metadata(baseline_data, baseline_stats, scale, path=()) + + # Merge only profiling metadata, not thread-level stats + for key in ("sample_interval_usec", "duration_sec", "sample_rate", + "error_rate", "missed_samples", "mode"): + if key in self.stats: + baseline_data["stats"][key] = self.stats[key] + baseline_data["stats"]["is_differential"] = True + baseline_data["stats"]["baseline_samples"] = self._baseline_collector._total_samples + baseline_data["stats"]["current_samples"] = self._total_samples + + return baseline_data + + def _extract_elided_nodes(self, node, path): + """Remove non-elided nodes and recalculate values bottom-up.""" + if not node: + return False + + func_key = self._extract_func_key(node, self._baseline_collector._string_table) + current_path = path + (func_key,) if func_key else path + + is_elided = current_path in self._elided_paths if func_key else False + + if "children" in node: + # Filter children, keeping only those with elided descendants + elided_children = [] + total_value = 0 + for child in node["children"]: + if self._extract_elided_nodes(child, current_path): + elided_children.append(child) + total_value += child.get("value", 0) + node["children"] = elided_children + + # Recalculate value for structural (non-elided) ancestor nodes; + # elided nodes keep their original value to preserve self-samples + if elided_children and not is_elided: + node["value"] = total_value + node["self"] = 0 + node.pop("opcodes", None) + node.pop("thread_opcodes", None) + + # Keep this node if it's elided or has elided descendants + return is_elided or bool(node.get("children")) + + def _add_elided_metadata(self, node, baseline_stats, scale, path): + """Add differential metadata showing this path disappeared.""" + if not node: + return + + func_key = self._extract_func_key(node, self._baseline_collector._string_table) + current_path = path + (func_key,) if func_key else path + + baseline_self = 0 + baseline_total = 0 + if func_key and current_path in baseline_stats: + baseline_total = node.get("value", 0) * scale + + # Matched nodes are retained only as structural ancestors. Their + # own samples are still present in the current profile and must + # not be reported as disappeared. + if current_path in self._elided_paths: + baseline_self = node.get("self", 0) * scale + + node["baseline"] = baseline_self + node["baseline_total"] = baseline_total + node["diff"] = -baseline_self + else: + node["baseline"] = 0 + node["baseline_total"] = 0 + node["diff"] = 0 + + node["self_time"] = 0 + # Elided paths have zero current self-time, so the change is always + # -100% when there was actual baseline self-time to lose. + # For internal nodes with no baseline self-time, use 0% to avoid + # misleading tooltips. + if baseline_self > 0: + node["diff_pct"] = -100.0 + else: + node["diff_pct"] = 0.0 + + if "children" in node and node["children"]: + for child in node["children"]: + self._add_elided_metadata(child, baseline_stats, scale, current_path) + + def _extract_func_key(self, node, string_table): + """Extract (filename, funcname) key from node, excluding line numbers. + + Line numbers are excluded to match functions even if they moved. + Returns None for root nodes that don't have function information. + """ + if "filename" not in node or "funcname" not in node: + return None + filename = string_table.get_string(node["filename"]) + funcname = string_table.get_string(node["funcname"]) + return (filename, funcname) diff --git a/Lib/profiling/sampling/string_table.py b/Lib/profiling/sampling/string_table.py new file mode 100644 index 000000000..25c347f7f --- /dev/null +++ b/Lib/profiling/sampling/string_table.py @@ -0,0 +1,53 @@ +"""String table implementation for memory-efficient string storage in profiling data.""" + +class StringTable: + """A string table for interning strings and reducing memory usage.""" + + def __init__(self): + self._strings = [] + self._string_to_index = {} + + def intern(self, string): + """Intern a string and return its index. + + Args: + string: The string to intern + + Returns: + int: The index of the string in the table + """ + if not isinstance(string, str): + string = str(string) + + if string in self._string_to_index: + return self._string_to_index[string] + + index = len(self._strings) + self._strings.append(string) + self._string_to_index[string] = index + return index + + def get_string(self, index): + """Get a string by its index. + + Args: + index: The index of the string + + Returns: + str: The string at the given index, or empty string if invalid + """ + if 0 <= index < len(self._strings): + return self._strings[index] + return "" + + def get_strings(self): + """Get the list of all strings in the table. + + Returns: + list: A copy of the strings list + """ + return self._strings.copy() + + def __len__(self): + """Return the number of strings in the table.""" + return len(self._strings) diff --git a/Lib/profiling/tracing/__init__.py b/Lib/profiling/tracing/__init__.py new file mode 100644 index 000000000..165665304 --- /dev/null +++ b/Lib/profiling/tracing/__init__.py @@ -0,0 +1,220 @@ +"""Tracing profiler for Python. + +This module provides deterministic profiling of Python programs by tracing +every function call and return. +""" + +__all__ = ("run", "runctx", "Profile") + +import _lsprof +import importlib.machinery +import importlib.util +import io +from profiling.tracing._utils import _Utils + +# ____________________________________________________________ +# Simple interface + +def run(statement, filename=None, sort=-1): + """Run statement under profiler optionally saving results in filename + + This function takes a single argument that can be passed to the + "exec" statement, and an optional file name. In all cases this + routine attempts to "exec" its first argument and gather profiling + statistics from the execution. If no file name is present, then this + function automatically prints a simple profiling report, sorted by the + standard name string (file/line/function-name) that is presented in + each line. + """ + return _Utils(Profile).run(statement, filename, sort) + +def runctx(statement, globals, locals, filename=None, sort=-1): + """Run statement under profiler, supplying your own globals and locals, + optionally saving results in filename. + + statement and filename have the same semantics as profile.run + """ + return _Utils(Profile).runctx(statement, globals, locals, + filename, sort) + +# ____________________________________________________________ + +class Profile(_lsprof.Profiler): + """Profile(timer=None, timeunit=None, subcalls=True, builtins=True) + + Builds a profiler object using the specified timer function. + The default timer is a fast built-in one based on real time. + For custom timer functions returning integers, timeunit can + be a float specifying a scale (i.e. how long each integer unit + is, in seconds). + """ + + # Most of the functionality is in the base class. + # This subclass only adds convenient and backward-compatible methods. + + def print_stats(self, sort=-1): + import pstats + if not isinstance(sort, tuple): + sort = (sort,) + pstats.Stats(self).strip_dirs().sort_stats(*sort).print_stats() + + def dump_stats(self, file): + import marshal + with open(file, 'wb') as f: + self.create_stats() + marshal.dump(self.stats, f) + + def create_stats(self): + self.disable() + self.snapshot_stats() + + def snapshot_stats(self): + entries = self.getstats() + self.stats = {} + callersdicts = {} + # call information + for entry in entries: + func = label(entry.code) + nc = entry.callcount # ncalls column of pstats (before '/') + cc = nc - entry.reccallcount # ncalls column of pstats (after '/') + tt = entry.inlinetime # tottime column of pstats + ct = entry.totaltime # cumtime column of pstats + callers = {} + callersdicts[id(entry.code)] = callers + self.stats[func] = cc, nc, tt, ct, callers + # subcall information + for entry in entries: + if entry.calls: + func = label(entry.code) + for subentry in entry.calls: + try: + callers = callersdicts[id(subentry.code)] + except KeyError: + continue + nc = subentry.callcount + cc = nc - subentry.reccallcount + tt = subentry.inlinetime + ct = subentry.totaltime + if func in callers: + prev = callers[func] + nc += prev[0] + cc += prev[1] + tt += prev[2] + ct += prev[3] + callers[func] = nc, cc, tt, ct + + # The following two methods can be called by clients to use + # a profiler to profile a statement, given as a string. + + def run(self, cmd): + import __main__ + dict = __main__.__dict__ + return self.runctx(cmd, dict, dict) + + def runctx(self, cmd, globals, locals): + self.enable() + try: + exec(cmd, globals, locals) + finally: + self.disable() + return self + + # This method is more useful to profile a single function call. + def runcall(self, func, /, *args, **kw): + self.enable() + try: + return func(*args, **kw) + finally: + self.disable() + + def __enter__(self): + self.enable() + return self + + def __exit__(self, *exc_info): + self.disable() + +# ____________________________________________________________ + +def label(code): + if isinstance(code, str): + return ('~', 0, code) # built-in functions ('~' sorts at the end) + else: + return (code.co_filename, code.co_firstlineno, code.co_name) + +# ____________________________________________________________ + +def main(): + import os + import sys + import runpy + import pstats + from optparse import OptionParser + usage = "cProfile.py [-o output_file_path] [-s sort] [-m module | scriptfile] [arg] ..." + parser = OptionParser(usage=usage) + parser.allow_interspersed_args = False + parser.add_option('-o', '--outfile', dest="outfile", + help="Save stats to ", default=None) + parser.add_option('-s', '--sort', dest="sort", + help="Sort order when printing to stdout, based on pstats.Stats class", + default=2, + choices=sorted(pstats.Stats.sort_arg_dict_default)) + parser.add_option('-m', dest="module", action="store_true", + help="Profile a library module", default=False) + + if not sys.argv[1:]: + parser.print_usage() + sys.exit(2) + + (options, args) = parser.parse_args() + sys.argv[:] = args + + # The script that we're profiling may chdir, so capture the absolute path + # to the output file at startup. + if options.outfile is not None: + options.outfile = os.path.abspath(options.outfile) + + if len(args) > 0: + if options.module: + code = "run_module(modname, run_name='__main__')" + globs = { + 'run_module': runpy.run_module, + 'modname': args[0] + } + else: + progname = args[0] + sys.path.insert(0, os.path.dirname(progname)) + with io.open_code(progname) as fp: + code = compile(fp.read(), progname, 'exec', module='__main__') + spec = importlib.machinery.ModuleSpec(name='__main__', loader=None, + origin=progname) + module = importlib.util.module_from_spec(spec) + # Set __main__ so that importing __main__ in the profiled code will + # return the same namespace that the code is executing under. + sys.modules['__main__'] = module + # Ensure that we're using the same __dict__ instance as the module + # for the global variables so that updates to globals are reflected + # in the module's namespace. + globs = module.__dict__ + globs.update({ + # Set __spec__ to None so the profiled program behaves like a + # script run directly (gh-140729). + '__spec__': None, + '__file__': spec.origin, + '__name__': spec.name, + '__package__': None, + }) + + try: + runctx(code, globs, None, options.outfile, options.sort) + except BrokenPipeError as exc: + # Prevent "Exception ignored" during interpreter shutdown. + sys.stdout = None + sys.exit(exc.errno) + else: + parser.print_usage() + return parser + +# When invoked as main program, invoke the profiler on a script +if __name__ == '__main__': + main() diff --git a/Lib/profiling/tracing/__main__.py b/Lib/profiling/tracing/__main__.py new file mode 100644 index 000000000..95041d136 --- /dev/null +++ b/Lib/profiling/tracing/__main__.py @@ -0,0 +1,6 @@ +"""Run the tracing profiler from the command line.""" + +from profiling.tracing import main + +if __name__ == '__main__': + main() diff --git a/Lib/profiling/tracing/_utils.py b/Lib/profiling/tracing/_utils.py new file mode 100644 index 000000000..8c4b65889 --- /dev/null +++ b/Lib/profiling/tracing/_utils.py @@ -0,0 +1,32 @@ +class _Utils: + """Support class for utility functions which are shared by + profile.py and cProfile.py modules. + Not supposed to be used directly. + """ + + def __init__(self, profiler): + self.profiler = profiler + + def run(self, statement, filename, sort): + prof = self.profiler() + try: + prof.run(statement) + except SystemExit: + pass + finally: + self._show(prof, filename, sort) + + def runctx(self, statement, globals, locals, filename, sort): + prof = self.profiler() + try: + prof.runctx(statement, globals, locals) + except SystemExit: + pass + finally: + self._show(prof, filename, sort) + + def _show(self, prof, filename, sort): + if filename is not None: + prof.dump_stats(filename) + else: + prof.print_stats(sort) diff --git a/Lib/pstats.py b/Lib/pstats.py index becaf3558..07ecda077 100644 --- a/Lib/pstats.py +++ b/Lib/pstats.py @@ -139,7 +139,11 @@ class Stats: return elif isinstance(arg, str): with open(arg, 'rb') as f: - self.stats = marshal.load(f) + stats = marshal.load(f) + if (('__sampled__',)) in stats: + stats.pop((('__sampled__',))) + self.__class__ = SampledStats + self.stats = stats try: file_stats = os.stat(arg) arg = time.ctime(file_stats.st_mtime) + " " + arg @@ -150,6 +154,7 @@ class Stats: arg.create_stats() self.stats = arg.stats arg.stats = {} + return if not self.stats: raise TypeError("Cannot create or construct a %r object from %r" % (self.__class__, arg)) @@ -467,7 +472,10 @@ class Stats: subheader = isinstance(value, tuple) break if subheader: - print(" "*name_size + " ncalls tottime cumtime", file=self.stream) + self.print_call_subheading(name_size) + + def print_call_subheading(self, name_size): + print(" "*name_size + " ncalls tottime cumtime", file=self.stream) def print_call_line(self, name_size, source, call_dict, arrow="->"): print(func_std_string(source).ljust(name_size) + arrow, end=' ', file=self.stream) @@ -516,6 +524,35 @@ class Stats: print(f8(ct/cc), end=' ', file=self.stream) print(func_std_string(func), file=self.stream) + +class SampledStats(Stats): + def __init__(self, *args, stream=None): + super().__init__(*args, stream=stream) + + self.sort_arg_dict = { + "samples" : (((1,-1), ), "sample count"), + "nsamples" : (((1,-1), ), "sample count"), + "cumtime" : (((3,-1), ), "cumulative time"), + "cumulative": (((3,-1), ), "cumulative time"), + "filename" : (((4, 1), ), "file name"), + "line" : (((5, 1), ), "line number"), + "module" : (((4, 1), ), "file name"), + "name" : (((6, 1), ), "function name"), + "nfl" : (((6, 1),(4, 1),(5, 1),), "name/file/line"), + "psamples" : (((0,-1), ), "primitive call count"), + "stdname" : (((7, 1), ), "standard name"), + "time" : (((2,-1), ), "internal time"), + "tottime" : (((2,-1), ), "internal time"), + } + + def print_call_subheading(self, name_size): + print(" "*name_size + " nsamples tottime cumtime", file=self.stream) + + def print_title(self): + print(' nsamples tottime persample cumtime persample', end=' ', file=self.stream) + print('filename:lineno(function)', file=self.stream) + + class TupleComp: """This class provides a generic function for comparing any two tuples. Each instance records a list of tuple-indices (from most significant @@ -607,6 +644,24 @@ def f8(x): # Statistics browser added by ESR, April 2001 #************************************************************************** +class StatsLoaderShim: + """Compatibility shim implementing 'create_stats' needed by Stats classes + to handle already unmarshalled data.""" + def __init__(self, raw_stats): + self.stats = raw_stats + + def create_stats(self): + pass + +def stats_factory(raw_stats): + """Return a Stats or SampledStats instance based on the marker in raw_stats.""" + if (('__sampled__',)) in raw_stats: + raw_stats = dict(raw_stats) # avoid mutating caller's dict + raw_stats.pop((('__sampled__',))) + return SampledStats(StatsLoaderShim(raw_stats)) + else: + return Stats(StatsLoaderShim(raw_stats)) + if __name__ == '__main__': import cmd try: @@ -693,7 +748,15 @@ if __name__ == '__main__': def do_read(self, line): if line: try: - self.stats = Stats(line) + with open(line, 'rb') as f: + raw_stats = marshal.load(f) + self.stats = stats_factory(raw_stats) + try: + file_stats = os.stat(line) + arg = time.ctime(file_stats.st_mtime) + " " + line + except Exception: + arg = line + self.stats.files = [arg] except OSError as err: print(err.args[1], file=self.stream) return diff --git a/Lib/test/test_asyncio/test_tools.py b/Lib/test/test_asyncio/test_tools.py index 34e948302..df934164e 100644 --- a/Lib/test/test_asyncio/test_tools.py +++ b/Lib/test/test_asyncio/test_tools.py @@ -2,12 +2,27 @@ import unittest from asyncio import tools -from collections import namedtuple +import _remote_debugging -FrameInfo = namedtuple('FrameInfo', ['funcname', 'filename', 'lineno']) -CoroInfo = namedtuple('CoroInfo', ['call_stack', 'task_name']) -TaskInfo = namedtuple('TaskInfo', ['task_id', 'task_name', 'coroutine_stack', 'awaited_by']) -AwaitedInfo = namedtuple('AwaitedInfo', ['thread_id', 'awaited_by']) + +def LocationInfo(lineno, end_lineno=None, col_offset=None, end_col_offset=None): + return _remote_debugging.LocationInfo((lineno, end_lineno, col_offset, end_col_offset)) + + +def FrameInfo(funcname, filename, location, opcode=None): + return _remote_debugging.FrameInfo((filename, location, funcname, opcode)) + + +def CoroInfo(call_stack, task_name): + return _remote_debugging.CoroInfo((call_stack, task_name)) + + +def TaskInfo(task_id, task_name, coroutine_stack, awaited_by): + return _remote_debugging.TaskInfo((task_id, task_name, coroutine_stack, awaited_by)) + + +def AwaitedInfo(thread_id, awaited_by): + return _remote_debugging.AwaitedInfo((thread_id, awaited_by)) # mock output of get_all_awaited_by function. @@ -33,33 +48,33 @@ TEST_INPUTS_TREE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("awaiter3", "/path/to/app.py", 130), - FrameInfo("awaiter2", "/path/to/app.py", 120), - FrameInfo("awaiter", "/path/to/app.py", 110) + FrameInfo("awaiter3", "/path/to/app.py", LocationInfo(130)), + FrameInfo("awaiter2", "/path/to/app.py", LocationInfo(120)), + FrameInfo("awaiter", "/path/to/app.py", LocationInfo(110)) ], task_name=4 ), CoroInfo( call_stack=[ - FrameInfo("awaiterB3", "/path/to/app.py", 190), - FrameInfo("awaiterB2", "/path/to/app.py", 180), - FrameInfo("awaiterB", "/path/to/app.py", 170) + FrameInfo("awaiterB3", "/path/to/app.py", LocationInfo(190)), + FrameInfo("awaiterB2", "/path/to/app.py", LocationInfo(180)), + FrameInfo("awaiterB", "/path/to/app.py", LocationInfo(170)) ], task_name=5 ), CoroInfo( call_stack=[ - FrameInfo("awaiterB3", "/path/to/app.py", 190), - FrameInfo("awaiterB2", "/path/to/app.py", 180), - FrameInfo("awaiterB", "/path/to/app.py", 170) + FrameInfo("awaiterB3", "/path/to/app.py", LocationInfo(190)), + FrameInfo("awaiterB2", "/path/to/app.py", LocationInfo(180)), + FrameInfo("awaiterB", "/path/to/app.py", LocationInfo(170)) ], task_name=6 ), CoroInfo( call_stack=[ - FrameInfo("awaiter3", "/path/to/app.py", 130), - FrameInfo("awaiter2", "/path/to/app.py", 120), - FrameInfo("awaiter", "/path/to/app.py", 110) + FrameInfo("awaiter3", "/path/to/app.py", LocationInfo(130)), + FrameInfo("awaiter2", "/path/to/app.py", LocationInfo(120)), + FrameInfo("awaiter", "/path/to/app.py", LocationInfo(110)) ], task_name=7 ) @@ -72,9 +87,9 @@ TEST_INPUTS_TREE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("_aexit", "", 0), - FrameInfo("__aexit__", "", 0), - FrameInfo("main", "", 0) + FrameInfo("_aexit", "", LocationInfo(0)), + FrameInfo("__aexit__", "", LocationInfo(0)), + FrameInfo("main", "", LocationInfo(0)) ], task_name=2 ) @@ -87,9 +102,9 @@ TEST_INPUTS_TREE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("_aexit", "", 0), - FrameInfo("__aexit__", "", 0), - FrameInfo("main", "", 0) + FrameInfo("_aexit", "", LocationInfo(0)), + FrameInfo("__aexit__", "", LocationInfo(0)), + FrameInfo("main", "", LocationInfo(0)) ], task_name=2 ) @@ -102,10 +117,10 @@ TEST_INPUTS_TREE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("_aexit", "", 0), - FrameInfo("__aexit__", "", 0), - FrameInfo("blocho_caller", "", 0), - FrameInfo("bloch", "", 0) + FrameInfo("_aexit", "", LocationInfo(0)), + FrameInfo("__aexit__", "", LocationInfo(0)), + FrameInfo("blocho_caller", "", LocationInfo(0)), + FrameInfo("bloch", "", LocationInfo(0)) ], task_name=8 ) @@ -118,10 +133,10 @@ TEST_INPUTS_TREE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("_aexit", "", 0), - FrameInfo("__aexit__", "", 0), - FrameInfo("blocho_caller", "", 0), - FrameInfo("bloch", "", 0) + FrameInfo("_aexit", "", LocationInfo(0)), + FrameInfo("__aexit__", "", LocationInfo(0)), + FrameInfo("blocho_caller", "", LocationInfo(0)), + FrameInfo("bloch", "", LocationInfo(0)) ], task_name=8 ) @@ -134,10 +149,10 @@ TEST_INPUTS_TREE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("_aexit", "", 0), - FrameInfo("__aexit__", "", 0), - FrameInfo("blocho_caller", "", 0), - FrameInfo("bloch", "", 0) + FrameInfo("_aexit", "", LocationInfo(0)), + FrameInfo("__aexit__", "", LocationInfo(0)), + FrameInfo("blocho_caller", "", LocationInfo(0)), + FrameInfo("bloch", "", LocationInfo(0)) ], task_name=9 ) @@ -150,10 +165,10 @@ TEST_INPUTS_TREE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("_aexit", "", 0), - FrameInfo("__aexit__", "", 0), - FrameInfo("blocho_caller", "", 0), - FrameInfo("bloch", "", 0) + FrameInfo("_aexit", "", LocationInfo(0)), + FrameInfo("__aexit__", "", LocationInfo(0)), + FrameInfo("blocho_caller", "", LocationInfo(0)), + FrameInfo("bloch", "", LocationInfo(0)) ], task_name=9 ) @@ -222,7 +237,7 @@ TEST_INPUTS_TREE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main2", "", 0)], + call_stack=[FrameInfo("main2", "", LocationInfo(0))], task_name=5 ) ] @@ -233,7 +248,7 @@ TEST_INPUTS_TREE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main2", "", 0)], + call_stack=[FrameInfo("main2", "", LocationInfo(0))], task_name=5 ) ] @@ -244,7 +259,7 @@ TEST_INPUTS_TREE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main2", "", 0)], + call_stack=[FrameInfo("main2", "", LocationInfo(0))], task_name=5 ) ] @@ -266,7 +281,7 @@ TEST_INPUTS_TREE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=1 ) ] @@ -277,7 +292,7 @@ TEST_INPUTS_TREE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=1 ) ] @@ -288,7 +303,7 @@ TEST_INPUTS_TREE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=1 ) ] @@ -347,7 +362,7 @@ TEST_INPUTS_TREE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=4 ) ] @@ -358,7 +373,7 @@ TEST_INPUTS_TREE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=4 ) ] @@ -369,7 +384,7 @@ TEST_INPUTS_TREE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=4 ) ] @@ -415,11 +430,11 @@ TEST_INPUTS_CYCLES_TREE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("awaiter2", "", 0)], + call_stack=[FrameInfo("awaiter2", "", LocationInfo(0))], task_name=4 ), CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=2 ) ] @@ -430,7 +445,7 @@ TEST_INPUTS_CYCLES_TREE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("awaiter", "", 0)], + call_stack=[FrameInfo("awaiter", "", LocationInfo(0))], task_name=3 ) ] @@ -462,9 +477,9 @@ TEST_INPUTS_CYCLES_TREE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("nested", "", 0), - FrameInfo("nested", "", 0), - FrameInfo("task_b", "", 0) + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("task_b", "", LocationInfo(0)) ], task_name=4 ) @@ -477,17 +492,17 @@ TEST_INPUTS_CYCLES_TREE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("nested", "", 0), - FrameInfo("nested", "", 0), - FrameInfo("task_c", "", 0) + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("task_c", "", LocationInfo(0)) ], task_name=5 ), CoroInfo( call_stack=[ - FrameInfo("nested", "", 0), - FrameInfo("nested", "", 0), - FrameInfo("task_a", "", 0) + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("task_a", "", LocationInfo(0)) ], task_name=3 ) @@ -500,8 +515,8 @@ TEST_INPUTS_CYCLES_TREE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("nested", "", 0), - FrameInfo("nested", "", 0) + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("nested", "", LocationInfo(0)) ], task_name=6 ) @@ -514,9 +529,9 @@ TEST_INPUTS_CYCLES_TREE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("nested", "", 0), - FrameInfo("nested", "", 0), - FrameInfo("task_b", "", 0) + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("task_b", "", LocationInfo(0)) ], task_name=4 ) @@ -553,33 +568,33 @@ TEST_INPUTS_TABLE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("awaiter3", "", 0), - FrameInfo("awaiter2", "", 0), - FrameInfo("awaiter", "", 0) + FrameInfo("awaiter3", "", LocationInfo(0)), + FrameInfo("awaiter2", "", LocationInfo(0)), + FrameInfo("awaiter", "", LocationInfo(0)) ], task_name=4 ), CoroInfo( call_stack=[ - FrameInfo("awaiter1_3", "", 0), - FrameInfo("awaiter1_2", "", 0), - FrameInfo("awaiter1", "", 0) + FrameInfo("awaiter1_3", "", LocationInfo(0)), + FrameInfo("awaiter1_2", "", LocationInfo(0)), + FrameInfo("awaiter1", "", LocationInfo(0)) ], task_name=5 ), CoroInfo( call_stack=[ - FrameInfo("awaiter1_3", "", 0), - FrameInfo("awaiter1_2", "", 0), - FrameInfo("awaiter1", "", 0) + FrameInfo("awaiter1_3", "", LocationInfo(0)), + FrameInfo("awaiter1_2", "", LocationInfo(0)), + FrameInfo("awaiter1", "", LocationInfo(0)) ], task_name=6 ), CoroInfo( call_stack=[ - FrameInfo("awaiter3", "", 0), - FrameInfo("awaiter2", "", 0), - FrameInfo("awaiter", "", 0) + FrameInfo("awaiter3", "", LocationInfo(0)), + FrameInfo("awaiter2", "", LocationInfo(0)), + FrameInfo("awaiter", "", LocationInfo(0)) ], task_name=7 ) @@ -592,9 +607,9 @@ TEST_INPUTS_TABLE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("_aexit", "", 0), - FrameInfo("__aexit__", "", 0), - FrameInfo("main", "", 0) + FrameInfo("_aexit", "", LocationInfo(0)), + FrameInfo("__aexit__", "", LocationInfo(0)), + FrameInfo("main", "", LocationInfo(0)) ], task_name=2 ) @@ -607,9 +622,9 @@ TEST_INPUTS_TABLE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("_aexit", "", 0), - FrameInfo("__aexit__", "", 0), - FrameInfo("main", "", 0) + FrameInfo("_aexit", "", LocationInfo(0)), + FrameInfo("__aexit__", "", LocationInfo(0)), + FrameInfo("main", "", LocationInfo(0)) ], task_name=2 ) @@ -622,10 +637,10 @@ TEST_INPUTS_TABLE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("_aexit", "", 0), - FrameInfo("__aexit__", "", 0), - FrameInfo("blocho_caller", "", 0), - FrameInfo("bloch", "", 0) + FrameInfo("_aexit", "", LocationInfo(0)), + FrameInfo("__aexit__", "", LocationInfo(0)), + FrameInfo("blocho_caller", "", LocationInfo(0)), + FrameInfo("bloch", "", LocationInfo(0)) ], task_name=8 ) @@ -638,10 +653,10 @@ TEST_INPUTS_TABLE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("_aexit", "", 0), - FrameInfo("__aexit__", "", 0), - FrameInfo("blocho_caller", "", 0), - FrameInfo("bloch", "", 0) + FrameInfo("_aexit", "", LocationInfo(0)), + FrameInfo("__aexit__", "", LocationInfo(0)), + FrameInfo("blocho_caller", "", LocationInfo(0)), + FrameInfo("bloch", "", LocationInfo(0)) ], task_name=8 ) @@ -654,10 +669,10 @@ TEST_INPUTS_TABLE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("_aexit", "", 0), - FrameInfo("__aexit__", "", 0), - FrameInfo("blocho_caller", "", 0), - FrameInfo("bloch", "", 0) + FrameInfo("_aexit", "", LocationInfo(0)), + FrameInfo("__aexit__", "", LocationInfo(0)), + FrameInfo("blocho_caller", "", LocationInfo(0)), + FrameInfo("bloch", "", LocationInfo(0)) ], task_name=9 ) @@ -670,10 +685,10 @@ TEST_INPUTS_TABLE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("_aexit", "", 0), - FrameInfo("__aexit__", "", 0), - FrameInfo("blocho_caller", "", 0), - FrameInfo("bloch", "", 0) + FrameInfo("_aexit", "", LocationInfo(0)), + FrameInfo("__aexit__", "", LocationInfo(0)), + FrameInfo("blocho_caller", "", LocationInfo(0)), + FrameInfo("bloch", "", LocationInfo(0)) ], task_name=9 ) @@ -797,7 +812,7 @@ TEST_INPUTS_TABLE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main2", "", 0)], + call_stack=[FrameInfo("main2", "", LocationInfo(0))], task_name=5 ) ] @@ -808,7 +823,7 @@ TEST_INPUTS_TABLE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main2", "", 0)], + call_stack=[FrameInfo("main2", "", LocationInfo(0))], task_name=5 ) ] @@ -819,7 +834,7 @@ TEST_INPUTS_TABLE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main2", "", 0)], + call_stack=[FrameInfo("main2", "", LocationInfo(0))], task_name=5 ) ] @@ -841,7 +856,7 @@ TEST_INPUTS_TABLE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=1 ) ] @@ -852,7 +867,7 @@ TEST_INPUTS_TABLE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=1 ) ] @@ -863,7 +878,7 @@ TEST_INPUTS_TABLE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=1 ) ] @@ -916,7 +931,7 @@ TEST_INPUTS_TABLE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=4 ) ] @@ -927,7 +942,7 @@ TEST_INPUTS_TABLE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=4 ) ] @@ -938,7 +953,7 @@ TEST_INPUTS_TABLE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=4 ) ] @@ -979,11 +994,11 @@ TEST_INPUTS_TABLE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("awaiter2", "", 0)], + call_stack=[FrameInfo("awaiter2", "", LocationInfo(0))], task_name=4 ), CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=2 ) ] @@ -994,7 +1009,7 @@ TEST_INPUTS_TABLE = [ coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("awaiter", "", 0)], + call_stack=[FrameInfo("awaiter", "", LocationInfo(0))], task_name=3 ) ] @@ -1033,9 +1048,9 @@ TEST_INPUTS_TABLE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("nested", "", 0), - FrameInfo("nested", "", 0), - FrameInfo("task_b", "", 0) + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("task_b", "", LocationInfo(0)) ], task_name=4 ) @@ -1048,17 +1063,17 @@ TEST_INPUTS_TABLE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("nested", "", 0), - FrameInfo("nested", "", 0), - FrameInfo("task_c", "", 0) + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("task_c", "", LocationInfo(0)) ], task_name=5 ), CoroInfo( call_stack=[ - FrameInfo("nested", "", 0), - FrameInfo("nested", "", 0), - FrameInfo("task_a", "", 0) + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("task_a", "", LocationInfo(0)) ], task_name=3 ) @@ -1071,8 +1086,8 @@ TEST_INPUTS_TABLE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("nested", "", 0), - FrameInfo("nested", "", 0) + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("nested", "", LocationInfo(0)) ], task_name=6 ) @@ -1085,9 +1100,9 @@ TEST_INPUTS_TABLE = [ awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("nested", "", 0), - FrameInfo("nested", "", 0), - FrameInfo("task_b", "", 0) + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("nested", "", LocationInfo(0)), + FrameInfo("task_b", "", LocationInfo(0)) ], task_name=4 ) @@ -1290,7 +1305,7 @@ class TestAsyncioToolsBasic(unittest.TestCase): coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=3 ) ] @@ -1301,7 +1316,7 @@ class TestAsyncioToolsBasic(unittest.TestCase): coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=2 ) ] @@ -1331,7 +1346,7 @@ class TestAsyncioToolsBasic(unittest.TestCase): coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=2 ) ] @@ -1342,7 +1357,7 @@ class TestAsyncioToolsBasic(unittest.TestCase): coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=3 ) ] @@ -1379,7 +1394,7 @@ class TestAsyncioToolsBasic(unittest.TestCase): coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=2 ) ] @@ -1390,7 +1405,7 @@ class TestAsyncioToolsBasic(unittest.TestCase): coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("main", "", 0)], + call_stack=[FrameInfo("main", "", LocationInfo(0))], task_name=3 ) ] @@ -1417,11 +1432,11 @@ class TestAsyncioToolsBasic(unittest.TestCase): awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("c1", "", 0), - FrameInfo("c2", "", 0), - FrameInfo("c3", "", 0), - FrameInfo("c4", "", 0), - FrameInfo("c5", "", 0) + FrameInfo("c1", "", LocationInfo(0)), + FrameInfo("c2", "", LocationInfo(0)), + FrameInfo("c3", "", LocationInfo(0)), + FrameInfo("c4", "", LocationInfo(0)), + FrameInfo("c5", "", LocationInfo(0)) ], task_name=11 ) @@ -1461,7 +1476,7 @@ class TestAsyncioToolsBasic(unittest.TestCase): coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("call1", "", 0)], + call_stack=[FrameInfo("call1", "", LocationInfo(0))], task_name=2 ) ] @@ -1472,7 +1487,7 @@ class TestAsyncioToolsBasic(unittest.TestCase): coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("call2", "", 0)], + call_stack=[FrameInfo("call2", "", LocationInfo(0))], task_name=3 ) ] @@ -1483,11 +1498,11 @@ class TestAsyncioToolsBasic(unittest.TestCase): coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("call3", "", 0)], + call_stack=[FrameInfo("call3", "", LocationInfo(0))], task_name=1 ), CoroInfo( - call_stack=[FrameInfo("call4", "", 0)], + call_stack=[FrameInfo("call4", "", LocationInfo(0))], task_name=2 ) ] @@ -1511,7 +1526,7 @@ class TestAsyncioToolsBasic(unittest.TestCase): coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("foo", "", 0)], + call_stack=[FrameInfo("foo", "", LocationInfo(0))], task_name=2 ) ] @@ -1555,7 +1570,7 @@ class TestAsyncioToolsEdgeCases(unittest.TestCase): coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("loopback", "", 0)], + call_stack=[FrameInfo("loopback", "", LocationInfo(0))], task_name=1 ) ] @@ -1579,7 +1594,7 @@ class TestAsyncioToolsEdgeCases(unittest.TestCase): coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("coro", "", 0)], + call_stack=[FrameInfo("coro", "", LocationInfo(0))], task_name=999 ) ] @@ -1603,11 +1618,11 @@ class TestAsyncioToolsEdgeCases(unittest.TestCase): coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("frameA", "", 0)], + call_stack=[FrameInfo("frameA", "", LocationInfo(0))], task_name=2 ), CoroInfo( - call_stack=[FrameInfo("frameA", "", 0)], + call_stack=[FrameInfo("frameA", "", LocationInfo(0))], task_name=3 ) ] @@ -1651,7 +1666,7 @@ class TestAsyncioToolsEdgeCases(unittest.TestCase): coroutine_stack=[], awaited_by=[ CoroInfo( - call_stack=[FrameInfo("f1", "", 0)], + call_stack=[FrameInfo("f1", "", LocationInfo(0))], task_name=2 ) ] @@ -1682,8 +1697,8 @@ class TestAsyncioToolsEdgeCases(unittest.TestCase): awaited_by=[ CoroInfo( call_stack=[ - FrameInfo("f1", "", 0), - FrameInfo("f2", "", 0) + FrameInfo("f1", "", LocationInfo(0)), + FrameInfo("f2", "", LocationInfo(0)) ], task_name=2 ) diff --git a/Lib/test/test_profile.py b/Lib/test/test_profile.py index 56ccc84a2..4e3961919 100644 --- a/Lib/test/test_profile.py +++ b/Lib/test/test_profile.py @@ -4,12 +4,16 @@ import sys import pstats import unittest import os +import warnings from difflib import unified_diff from io import StringIO from test.support.os_helper import TESTFN, unlink, temp_dir, change_cwd from contextlib import contextmanager, redirect_stdout -import profile +# Suppress deprecation warning for profile module (PEP 799) +with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + import profile from test.profilee import testfunc, timer from test.support.script_helper import assert_python_failure, assert_python_ok diff --git a/Lib/test/test_profiling/__init__.py b/Lib/test/test_profiling/__init__.py new file mode 100644 index 000000000..4b16ecc31 --- /dev/null +++ b/Lib/test/test_profiling/__init__.py @@ -0,0 +1,5 @@ +import os +from test.support import load_package_tests + +def load_tests(*args): + return load_package_tests(os.path.dirname(__file__), *args) diff --git a/Lib/test/test_profiling/__main__.py b/Lib/test/test_profiling/__main__.py new file mode 100644 index 000000000..40a23a297 --- /dev/null +++ b/Lib/test/test_profiling/__main__.py @@ -0,0 +1,4 @@ +from . import load_tests +import unittest + +unittest.main() diff --git a/Lib/test/test_profiling/test_heatmap.py b/Lib/test/test_profiling/test_heatmap.py new file mode 100644 index 000000000..ee27fdd3f --- /dev/null +++ b/Lib/test/test_profiling/test_heatmap.py @@ -0,0 +1,862 @@ +"""Tests for the heatmap collector (profiling.sampling).""" + +import os +import shutil +import tempfile +import unittest +from collections import namedtuple +from pathlib import Path + +# Matches the C structseq LocationInfo from _remote_debugging +LocationInfo = namedtuple('LocationInfo', ['lineno', 'end_lineno', 'col_offset', 'end_col_offset']) + +from profiling.sampling.heatmap_collector import ( + HeatmapCollector, + get_python_path_info, + extract_module_name, +) + +from test.support import captured_stdout, captured_stderr + + +# ============================================================================= +# Unit Tests for Public Helper Functions +# ============================================================================= + +class TestPathInfoFunctions(unittest.TestCase): + """Test public helper functions for path information.""" + + def test_get_python_path_info_returns_dict(self): + """Test that get_python_path_info returns a dictionary with expected keys.""" + path_info = get_python_path_info() + + self.assertIsInstance(path_info, dict) + self.assertIn('stdlib', path_info) + self.assertIn('site_packages', path_info) + self.assertIn('sys_path', path_info) + + def test_get_python_path_info_stdlib_is_path_or_none(self): + """Test that stdlib is either a Path object or None.""" + path_info = get_python_path_info() + + if path_info['stdlib'] is not None: + self.assertIsInstance(path_info['stdlib'], Path) + + def test_get_python_path_info_site_packages_is_list(self): + """Test that site_packages is a list.""" + path_info = get_python_path_info() + + self.assertIsInstance(path_info['site_packages'], list) + for item in path_info['site_packages']: + self.assertIsInstance(item, Path) + + def test_get_python_path_info_sys_path_is_list(self): + """Test that sys_path is a list of Path objects.""" + path_info = get_python_path_info() + + self.assertIsInstance(path_info['sys_path'], list) + for item in path_info['sys_path']: + self.assertIsInstance(item, Path) + + def test_extract_module_name_with_none(self): + """Test extract_module_name with None filename.""" + path_info = get_python_path_info() + module_name, module_type = extract_module_name(None, path_info) + + self.assertEqual(module_name, 'unknown') + self.assertEqual(module_type, 'other') + + def test_extract_module_name_with_empty_string(self): + """Test extract_module_name with empty filename.""" + path_info = get_python_path_info() + module_name, module_type = extract_module_name('', path_info) + + self.assertEqual(module_name, 'unknown') + self.assertEqual(module_type, 'other') + + def test_extract_module_name_with_stdlib_file(self): + """Test extract_module_name with a standard library file.""" + path_info = get_python_path_info() + + # Use os module as a known stdlib file + if path_info['stdlib']: + stdlib_file = str(path_info['stdlib'] / 'os.py') + module_name, module_type = extract_module_name(stdlib_file, path_info) + + self.assertEqual(module_type, 'stdlib') + self.assertIn('os', module_name) + + def test_extract_module_name_with_project_file(self): + """Test extract_module_name with a project file.""" + path_info = get_python_path_info() + + # Create a mock project file path + if path_info['sys_path']: + # Use current directory as project path + project_file = '/some/project/path/mymodule.py' + module_name, module_type = extract_module_name(project_file, path_info) + + # Should classify as 'other' if not in sys.path + self.assertIn(module_type, ['project', 'other']) + + def test_extract_module_name_removes_py_extension(self): + """Test that .py extension is removed from module names.""" + path_info = get_python_path_info() + + # Test with a simple .py file + module_name, module_type = extract_module_name('/path/to/test.py', path_info) + + # Module name should not contain .py + self.assertNotIn('.py', module_name) + + def test_extract_module_name_with_special_files(self): + """Test extract_module_name with special filenames like .""" + path_info = get_python_path_info() + + special_files = ['', '', '[eval]'] + for special_file in special_files: + module_name, module_type = extract_module_name(special_file, path_info) + self.assertEqual(module_type, 'other') + + +# ============================================================================= +# Unit Tests for HeatmapCollector Public API +# ============================================================================= + +class TestHeatmapCollectorInit(unittest.TestCase): + """Test HeatmapCollector initialization.""" + + def test_init_creates_empty_data_structures(self): + """Test that __init__ creates empty data structures.""" + collector = HeatmapCollector(sample_interval_usec=100) + + # Check that data structures are initialized + self.assertIsInstance(collector.line_samples, dict) + self.assertIsInstance(collector.file_samples, dict) + self.assertIsInstance(collector.line_self_samples, dict) + self.assertIsInstance(collector.file_self_samples, dict) + self.assertIsInstance(collector.call_graph, dict) + self.assertIsInstance(collector.callers_graph, dict) + self.assertIsInstance(collector.function_definitions, dict) + self.assertIsInstance(collector.edge_samples, dict) + + # Check that they're empty + self.assertEqual(len(collector.line_samples), 0) + self.assertEqual(len(collector.file_samples), 0) + self.assertEqual(len(collector.line_self_samples), 0) + self.assertEqual(len(collector.file_self_samples), 0) + + def test_init_sets_total_samples_to_zero(self): + """Test that total samples starts at zero.""" + collector = HeatmapCollector(sample_interval_usec=100) + self.assertEqual(collector._total_samples, 0) + + def test_init_gets_path_info(self): + """Test that path info is retrieved during init.""" + collector = HeatmapCollector(sample_interval_usec=100) + self.assertIsNotNone(collector._path_info) + self.assertIn('stdlib', collector._path_info) + + +class TestHeatmapCollectorSetStats(unittest.TestCase): + """Test HeatmapCollector.set_stats() method.""" + + def test_set_stats_stores_all_parameters(self): + """Test that set_stats stores all provided parameters.""" + collector = HeatmapCollector(sample_interval_usec=100) + + collector.set_stats( + sample_interval_usec=500, + duration_sec=10.5, + sample_rate=99.5, + error_rate=0.5 + ) + + self.assertEqual(collector.stats['sample_interval_usec'], 500) + self.assertEqual(collector.stats['duration_sec'], 10.5) + self.assertEqual(collector.stats['sample_rate'], 99.5) + self.assertEqual(collector.stats['error_rate'], 0.5) + + def test_set_stats_includes_system_info(self): + """Test that set_stats includes Python and platform info.""" + collector = HeatmapCollector(sample_interval_usec=100) + collector.set_stats(sample_interval_usec=100, duration_sec=1.0, sample_rate=100.0) + + self.assertIn('python_version', collector.stats) + self.assertIn('python_implementation', collector.stats) + self.assertIn('platform', collector.stats) + + def test_set_stats_accepts_kwargs(self): + """Test that set_stats accepts additional kwargs.""" + collector = HeatmapCollector(sample_interval_usec=100) + + collector.set_stats( + sample_interval_usec=100, + duration_sec=1.0, + sample_rate=100.0, + custom_key='custom_value', + another_key=42 + ) + + self.assertEqual(collector.stats['custom_key'], 'custom_value') + self.assertEqual(collector.stats['another_key'], 42) + + def test_set_stats_with_none_error_rate(self): + """Test set_stats with error_rate=None.""" + collector = HeatmapCollector(sample_interval_usec=100) + collector.set_stats(sample_interval_usec=100, duration_sec=1.0, sample_rate=100.0) + + self.assertIn('error_rate', collector.stats) + self.assertIsNone(collector.stats['error_rate']) + + +class TestHeatmapCollectorProcessFrames(unittest.TestCase): + """Test HeatmapCollector.process_frames() method.""" + + def test_process_frames_increments_total_samples(self): + """Test that process_frames increments total samples count.""" + collector = HeatmapCollector(sample_interval_usec=100) + + initial_count = collector._total_samples + frames = [('file.py', (10, 10, -1, -1), 'func', None)] + collector.process_frames(frames, thread_id=1) + + self.assertEqual(collector._total_samples, initial_count + 1) + + def test_process_frames_records_line_samples(self): + """Test that process_frames records line samples.""" + collector = HeatmapCollector(sample_interval_usec=100) + + frames = [('test.py', (5, 5, -1, -1), 'test_func', None)] + collector.process_frames(frames, thread_id=1) + + # Check that line was recorded + self.assertIn(('test.py', 5), collector.line_samples) + self.assertEqual(collector.line_samples[('test.py', 5)], 1) + + def test_process_frames_records_multiple_lines_in_stack(self): + """Test that process_frames records all lines in a stack.""" + collector = HeatmapCollector(sample_interval_usec=100) + + frames = [ + ('file1.py', (10, 10, -1, -1), 'func1', None), + ('file2.py', (20, 20, -1, -1), 'func2', None), + ('file3.py', (30, 30, -1, -1), 'func3', None) + ] + collector.process_frames(frames, thread_id=1) + + # All frames should be recorded + self.assertIn(('file1.py', 10), collector.line_samples) + self.assertIn(('file2.py', 20), collector.line_samples) + self.assertIn(('file3.py', 30), collector.line_samples) + + def test_process_frames_distinguishes_self_samples(self): + """Test that process_frames distinguishes self (leaf) samples.""" + collector = HeatmapCollector(sample_interval_usec=100) + + frames = [ + ('leaf.py', (5, 5, -1, -1), 'leaf_func', None), # This is the leaf (top of stack) + ('caller.py', (10, 10, -1, -1), 'caller_func', None) + ] + collector.process_frames(frames, thread_id=1) + + # Leaf should have self sample + self.assertIn(('leaf.py', 5), collector.line_self_samples) + self.assertEqual(collector.line_self_samples[('leaf.py', 5)], 1) + + # Caller should NOT have self sample + self.assertNotIn(('caller.py', 10), collector.line_self_samples) + + def test_process_frames_accumulates_samples(self): + """Test that multiple calls accumulate samples.""" + collector = HeatmapCollector(sample_interval_usec=100) + + frames = [('file.py', (10, 10, -1, -1), 'func', None)] + + collector.process_frames(frames, thread_id=1) + collector.process_frames(frames, thread_id=1) + collector.process_frames(frames, thread_id=1) + + self.assertEqual(collector.line_samples[('file.py', 10)], 3) + self.assertEqual(collector._total_samples, 3) + + def test_process_frames_ignores_invalid_frames(self): + """Test that process_frames ignores invalid frames.""" + collector = HeatmapCollector(sample_interval_usec=100) + + # These should be ignored + invalid_frames = [ + ('', (1, 1, -1, -1), 'test', None), + ('[eval]', (1, 1, -1, -1), 'test', None), + ('', (1, 1, -1, -1), 'test', None), + (None, (1, 1, -1, -1), 'test', None), + ('__init__', (0, 0, -1, -1), 'test', None), # Special invalid frame + ] + + for frame in invalid_frames: + collector.process_frames([frame], thread_id=1) + + # Should not record these invalid frames + for frame in invalid_frames: + if frame[0]: + self.assertNotIn((frame[0], frame[1][0]), collector.line_samples) + + def test_process_frames_builds_call_graph(self): + """Test that process_frames builds call graph relationships.""" + collector = HeatmapCollector(sample_interval_usec=100) + + frames = [ + ('callee.py', (5, 5, -1, -1), 'callee_func', None), + ('caller.py', (10, 10, -1, -1), 'caller_func', None) + ] + collector.process_frames(frames, thread_id=1) + + # Check that call relationship was recorded + caller_key = ('caller.py', 10) + self.assertIn(caller_key, collector.call_graph) + + # Check callers graph + callee_key = ('callee.py', 5) + self.assertIn(callee_key, collector.callers_graph) + + def test_process_frames_records_function_definitions(self): + """Test that process_frames records function definition locations.""" + collector = HeatmapCollector(sample_interval_usec=100) + + frames = [('module.py', (42, 42, -1, -1), 'my_function', None)] + collector.process_frames(frames, thread_id=1) + + self.assertIn(('module.py', 'my_function'), collector.function_definitions) + self.assertEqual(collector.function_definitions[('module.py', 'my_function')], 42) + + def test_process_frames_tracks_edge_samples(self): + """Test that process_frames tracks edge sample counts.""" + collector = HeatmapCollector(sample_interval_usec=100) + + frames = [ + ('callee.py', (5, 5, -1, -1), 'callee', None), + ('caller.py', (10, 10, -1, -1), 'caller', None) + ] + + # Process same call stack multiple times + collector.process_frames(frames, thread_id=1) + collector.process_frames(frames, thread_id=1) + + # Check that edge count is tracked + self.assertGreater(len(collector.edge_samples), 0) + + def test_process_frames_weight_applies_to_identical_samples(self): + collector = HeatmapCollector(sample_interval_usec=100) + + frames = [ + ('callee.py', (5, 5, -1, -1), 'callee', None), + ('caller.py', (10, 10, -1, -1), 'caller', None), + ] + + collector.process_frames(frames, thread_id=1, weight=5) + + edge_key = (('caller.py', 10), ('callee.py', 5)) + self.assertEqual(collector.edge_samples[edge_key], 5) + self.assertEqual(collector.line_samples[('callee.py', 5)], 5) + self.assertEqual(collector.line_samples[('caller.py', 10)], 5) + + def test_process_frames_handles_empty_frames(self): + """Test that process_frames handles empty frame list.""" + collector = HeatmapCollector(sample_interval_usec=100) + + initial_count = collector._total_samples + collector.process_frames([], thread_id=1) + + # Should still increment total samples + self.assertEqual(collector._total_samples, initial_count + 1) + + def test_process_frames_with_file_samples_dict(self): + """Test that file_samples dict is properly populated.""" + collector = HeatmapCollector(sample_interval_usec=100) + + frames = [('test.py', (10, 10, -1, -1), 'func', None)] + collector.process_frames(frames, thread_id=1) + + self.assertIn('test.py', collector.file_samples) + self.assertIn(10, collector.file_samples['test.py']) + self.assertEqual(collector.file_samples['test.py'][10], 1) + + +def frame(filename, line, func): + """Create a frame tuple: (filename, location, funcname, opcode).""" + return (filename, (line, line, -1, -1), func, None) + + +class TestHeatmapCollectorNavigationButtons(unittest.TestCase): + """Test navigation button behavior for caller/callee relationships. + + For every call stack: + - Root frames (entry points): only DOWN arrow (callees) + - Middle frames: both UP and DOWN arrows + - Leaf frames: only UP arrow (callers) + """ + + def collect(self, *stacks): + """Create collector and process frame stacks.""" + collector = HeatmapCollector(sample_interval_usec=100) + for stack in stacks: + collector.process_frames(stack, thread_id=1) + return collector + + def test_deep_call_stack_relationships(self): + """Test root/middle/leaf navigation in a 5-level call stack.""" + # Stack: root -> A -> B -> C -> leaf + stack = [ + frame('leaf.py', 5, 'leaf'), + frame('c.py', 10, 'func_c'), + frame('b.py', 15, 'func_b'), + frame('a.py', 20, 'func_a'), + frame('root.py', 25, 'root'), + ] + c = self.collect(stack) + + # Root: only callees (no one calls it) + self.assertIn(('root.py', 25), c.call_graph) + self.assertNotIn(('root.py', 25), c.callers_graph) + + # Middle frames: both callers and callees + for key in [('a.py', 20), ('b.py', 15), ('c.py', 10)]: + self.assertIn(key, c.call_graph) + self.assertIn(key, c.callers_graph) + + # Leaf: only callers (doesn't call anyone) + self.assertNotIn(('leaf.py', 5), c.call_graph) + self.assertIn(('leaf.py', 5), c.callers_graph) + + def test_all_lines_in_function_see_callers(self): + """Test that interior lines map to their function for caller lookup.""" + # Same function sampled at different lines (12, 15, 10) + c = self.collect( + [frame('mod.py', 12, 'my_func'), frame('caller.py', 100, 'caller')], + [frame('mod.py', 15, 'my_func'), frame('caller.py', 100, 'caller')], + [frame('mod.py', 10, 'my_func'), frame('caller.py', 100, 'caller')], + ) + + # All lines should map to same function + for line in [10, 12, 15]: + self.assertEqual(c.line_to_function[('mod.py', line)], 'my_func') + + # Function definition line should have callers + func_def = c.function_definitions[('mod.py', 'my_func')] + self.assertIn(('mod.py', func_def), c.callers_graph) + + def test_multiple_callers_and_callees(self): + """Test multiple callers/callees are recorded correctly.""" + # Two callers -> target, and caller -> two callees + c = self.collect( + [frame('target.py', 10, 'target'), frame('caller1.py', 20, 'c1')], + [frame('target.py', 10, 'target'), frame('caller2.py', 30, 'c2')], + [frame('callee1.py', 5, 'f1'), frame('dispatcher.py', 40, 'dispatch')], + [frame('callee2.py', 6, 'f2'), frame('dispatcher.py', 40, 'dispatch')], + ) + + # Target has 2 callers + callers = c.callers_graph[('target.py', 10)] + self.assertEqual({x[0] for x in callers}, {'caller1.py', 'caller2.py'}) + + # Dispatcher has 2 callees + callees = c.call_graph[('dispatcher.py', 40)] + self.assertEqual({x[0] for x in callees}, {'callee1.py', 'callee2.py'}) + + def test_edge_samples_counted(self): + """Test that repeated calls accumulate edge counts.""" + stack = [frame('callee.py', 10, 'callee'), frame('caller.py', 20, 'caller')] + c = self.collect(stack, stack, stack) + + edge_key = (('caller.py', 20), ('callee.py', 10)) + self.assertEqual(c.edge_samples[edge_key], 3) + + +class TestHeatmapCollectorExport(unittest.TestCase): + """Test HeatmapCollector.export() method.""" + + def setUp(self): + """Set up test directory.""" + self.test_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.test_dir) + + def test_export_creates_output_directory(self): + """Test that export creates the output directory.""" + collector = HeatmapCollector(sample_interval_usec=100) + + # Add some data + frames = [('test.py', (10, 10, -1, -1), 'func', None)] + collector.process_frames(frames, thread_id=1) + + output_path = os.path.join(self.test_dir, 'heatmap_output') + + with captured_stdout(), captured_stderr(): + collector.export(output_path) + + self.assertTrue(os.path.exists(output_path)) + self.assertTrue(os.path.isdir(output_path)) + + def test_export_creates_index_html(self): + """Test that export creates index.html.""" + collector = HeatmapCollector(sample_interval_usec=100) + + frames = [('test.py', (10, 10, -1, -1), 'func', None)] + collector.process_frames(frames, thread_id=1) + + output_path = os.path.join(self.test_dir, 'heatmap_output') + + with captured_stdout(), captured_stderr(): + collector.export(output_path) + + index_path = os.path.join(output_path, 'index.html') + self.assertTrue(os.path.exists(index_path)) + + def test_export_creates_file_htmls(self): + """Test that export creates individual file HTMLs.""" + collector = HeatmapCollector(sample_interval_usec=100) + + frames = [('test.py', (10, 10, -1, -1), 'func', None)] + collector.process_frames(frames, thread_id=1) + + output_path = os.path.join(self.test_dir, 'heatmap_output') + + with captured_stdout(), captured_stderr(): + collector.export(output_path) + + # Check for file_XXXX.html files + html_files = [f for f in os.listdir(output_path) + if f.startswith('file_') and f.endswith('.html')] + self.assertGreater(len(html_files), 0) + + def test_export_with_empty_data(self): + """Test export with no data collected.""" + collector = HeatmapCollector(sample_interval_usec=100) + + output_path = os.path.join(self.test_dir, 'empty_output') + + # Should handle empty data gracefully + with captured_stdout(), captured_stderr(): + collector.export(output_path) + + def test_export_handles_html_suffix(self): + """Test that export handles .html suffix in output path.""" + collector = HeatmapCollector(sample_interval_usec=100) + + frames = [('test.py', (10, 10, -1, -1), 'func', None)] + collector.process_frames(frames, thread_id=1) + + # Path with .html suffix should be stripped + output_path = os.path.join(self.test_dir, 'output.html') + + with captured_stdout(), captured_stderr(): + collector.export(output_path) + + # Should create directory without .html + expected_dir = os.path.join(self.test_dir, 'output') + self.assertTrue(os.path.exists(expected_dir)) + + def test_export_with_multiple_files(self): + """Test export with multiple files.""" + collector = HeatmapCollector(sample_interval_usec=100) + + # Add samples for multiple files + collector.process_frames([('file1.py', (10, 10, -1, -1), 'func1', None)], thread_id=1) + collector.process_frames([('file2.py', (20, 20, -1, -1), 'func2', None)], thread_id=1) + collector.process_frames([('file3.py', (30, 30, -1, -1), 'func3', None)], thread_id=1) + + output_path = os.path.join(self.test_dir, 'multi_file') + + with captured_stdout(), captured_stderr(): + collector.export(output_path) + + # Should create HTML for each file + html_files = [f for f in os.listdir(output_path) + if f.startswith('file_') and f.endswith('.html')] + self.assertGreaterEqual(len(html_files), 3) + + def test_export_index_contains_file_references(self): + """Test that index.html contains references to profiled files.""" + collector = HeatmapCollector(sample_interval_usec=100) + collector.set_stats(sample_interval_usec=100, duration_sec=1.0, sample_rate=100.0) + + frames = [('mytest.py', (10, 10, -1, -1), 'my_func', None)] + collector.process_frames(frames, thread_id=1) + + output_path = os.path.join(self.test_dir, 'test_output') + + with captured_stdout(), captured_stderr(): + collector.export(output_path) + + index_path = os.path.join(output_path, 'index.html') + with open(index_path, 'r', encoding='utf-8') as f: + content = f.read() + + # Should contain reference to the file + self.assertIn('mytest', content) + + def test_export_file_html_has_line_numbers(self): + """Test that exported file HTML contains line numbers.""" + collector = HeatmapCollector(sample_interval_usec=100) + + # Create a temporary Python file + temp_file = os.path.join(self.test_dir, 'temp_source.py') + with open(temp_file, 'w') as f: + f.write('def test():\n pass\n') + + frames = [(temp_file, (1, 1, -1, -1), 'test', None)] + collector.process_frames(frames, thread_id=1) + + output_path = os.path.join(self.test_dir, 'line_test') + + with captured_stdout(), captured_stderr(): + collector.export(output_path) + + # Find the generated file HTML + html_files = [f for f in os.listdir(output_path) + if f.startswith('file_') and f.endswith('.html')] + + if html_files: + with open(os.path.join(output_path, html_files[0]), 'r', encoding='utf-8') as f: + content = f.read() + + # Should have line-related content + self.assertIn('line-', content) + + +class MockFrameInfo: + """Mock FrameInfo for testing. + + Frame format: (filename, location, funcname, opcode) where: + - location is a tuple (lineno, end_lineno, col_offset, end_col_offset) + - opcode is an int or None + """ + + def __init__(self, filename, lineno, funcname, opcode=None): + self.filename = filename + self.funcname = funcname + self.opcode = opcode + self.location = (lineno, lineno, -1, -1) + + def __iter__(self): + return iter((self.filename, self.location, self.funcname, self.opcode)) + + def __getitem__(self, index): + return (self.filename, self.location, self.funcname, self.opcode)[index] + + def __len__(self): + return 4 + + def __repr__(self): + return f"MockFrameInfo('{self.filename}', {self.location}, '{self.funcname}', {self.opcode})" + + +class MockThreadInfo: + """Mock ThreadInfo for testing since the real one isn't accessible.""" + + def __init__(self, thread_id, frame_info, status=0): + self.thread_id = thread_id + self.frame_info = frame_info + self.status = status # Thread status flags + + def __repr__(self): + return f"MockThreadInfo(thread_id={self.thread_id}, frame_info={self.frame_info})" + + +class MockInterpreterInfo: + """Mock InterpreterInfo for testing since the real one isn't accessible.""" + + def __init__(self, interpreter_id, threads): + self.interpreter_id = interpreter_id + self.threads = threads + + def __repr__(self): + return f"MockInterpreterInfo(interpreter_id={self.interpreter_id}, threads={self.threads})" + + +class TestHeatmapCollector(unittest.TestCase): + """Tests for HeatmapCollector functionality.""" + + def test_heatmap_collector_basic(self): + """Test basic HeatmapCollector functionality.""" + collector = HeatmapCollector(sample_interval_usec=100) + + # Test empty state + self.assertEqual(len(collector.file_samples), 0) + self.assertEqual(len(collector.line_samples), 0) + + # Test collecting sample data - frames are 4-tuples: (filename, location, funcname, opcode) + test_frames = [ + MockInterpreterInfo( + 0, + [MockThreadInfo( + 1, + [MockFrameInfo("file.py", 10, "func1"), MockFrameInfo("file.py", 20, "func2")], + )] + ) + ] + collector.collect(test_frames) + + # Should have recorded samples for the file + self.assertGreater(len(collector.line_samples), 0) + self.assertIn("file.py", collector.file_samples) + + # Check that line samples were recorded + file_data = collector.file_samples["file.py"] + self.assertGreater(len(file_data), 0) + + def test_heatmap_collector_export(self): + """Test heatmap HTML export functionality.""" + heatmap_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, heatmap_dir) + + collector = HeatmapCollector(sample_interval_usec=100) + + # Create test data with multiple files using MockFrameInfo + test_frames1 = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [MockFrameInfo("file.py", 10, "func1"), MockFrameInfo("file.py", 20, "func2")])], + ) + ] + test_frames2 = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [MockFrameInfo("file.py", 10, "func1"), MockFrameInfo("file.py", 20, "func2")])], + ) + ] # Same stack + test_frames3 = [ + MockInterpreterInfo(0, [MockThreadInfo(1, [MockFrameInfo("other.py", 5, "other_func")])]) + ] + + collector.collect(test_frames1) + collector.collect(test_frames2) + collector.collect(test_frames3) + + # Export heatmap + with (captured_stdout(), captured_stderr()): + collector.export(heatmap_dir) + + # Verify index.html was created + index_path = os.path.join(heatmap_dir, "index.html") + self.assertTrue(os.path.exists(index_path)) + self.assertGreater(os.path.getsize(index_path), 0) + + # Check index contains HTML content + with open(index_path, "r", encoding="utf-8") as f: + content = f.read() + + # Should be valid HTML + self.assertIn("", content.lower()) + self.assertIn(" Counter of linenos + self.assertIn("test.py", collector.file_samples) + # Line 10 should have samples + self.assertIn(10, collector.file_samples["test.py"]) + + def test_heatmap_with_none_location(self): + """Test HeatmapCollector handles None location gracefully.""" + collector = HeatmapCollector(sample_interval_usec=1000) + + # Synthetic frame with None location + frame = MockFrameInfo("~", 0, "") + frame.location = None + frames = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame])] + ) + ] + # Should not raise + collector.collect(frames) + + def test_heatmap_export_with_location_data(self): + """Test HeatmapCollector export includes location info.""" + tmp_dir = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, tmp_dir) + + collector = HeatmapCollector(sample_interval_usec=1000) + + frame = MockFrameInfo("test.py", 10, "process") + frame.location = LocationInfo(10, 12, 0, 30) + frames = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame])] + ) + ] + collector.collect(frames) + + # Export should work + with (captured_stdout(), captured_stderr()): + collector.export(tmp_dir) + self.assertTrue(os.path.exists(os.path.join(tmp_dir, "index.html"))) + + def test_heatmap_collector_frame_format(self): + """Test HeatmapCollector with 4-element frame format.""" + collector = HeatmapCollector(sample_interval_usec=1000) + + frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("app.py", 100, "main", opcode=90), + MockFrameInfo("utils.py", 50, "helper", opcode=100), + MockFrameInfo("lib.py", 25, "process", opcode=None), + ], + ) + ], + ) + ] + collector.collect(frames) + + # Should have recorded data for the files + self.assertIn("app.py", collector.file_samples) + self.assertIn("utils.py", collector.file_samples) + self.assertIn("lib.py", collector.file_samples) + + +if __name__ == "__main__": + unittest.main() diff --git a/Lib/test/test_profiling/test_sampling_profiler/__init__.py b/Lib/test/test_profiling/test_sampling_profiler/__init__.py new file mode 100644 index 000000000..616ae5b49 --- /dev/null +++ b/Lib/test/test_profiling/test_sampling_profiler/__init__.py @@ -0,0 +1,9 @@ +"""Tests for the sampling profiler (profiling.sampling).""" + +import os +from test.support import load_package_tests + + +def load_tests(*args): + """Load all tests from this subpackage.""" + return load_package_tests(os.path.dirname(__file__), *args) diff --git a/Lib/test/test_profiling/test_sampling_profiler/helpers.py b/Lib/test/test_profiling/test_sampling_profiler/helpers.py new file mode 100644 index 000000000..b07776d41 --- /dev/null +++ b/Lib/test/test_profiling/test_sampling_profiler/helpers.py @@ -0,0 +1,202 @@ +"""Helper utilities for sampling profiler tests.""" + +import contextlib +import socket +import subprocess +import sys +import unittest +from collections import namedtuple + +from test.support import SHORT_TIMEOUT +from test.support.socket_helper import find_unused_port +from test.support.os_helper import unlink + + +PROCESS_VM_READV_SUPPORTED = False + +try: + from _remote_debugging import PROCESS_VM_READV_SUPPORTED # noqa: F401 + import _remote_debugging # noqa: F401 +except ImportError: + raise unittest.SkipTest( + "Test only runs when _remote_debugging is available" + ) +else: + import profiling.sampling # noqa: F401 + from profiling.sampling.sample import SampleProfiler # noqa: F401 + + +skip_if_not_supported = unittest.skipIf( + ( + sys.platform != "darwin" + and sys.platform != "linux" + and sys.platform != "win32" + ), + "Test only runs on Linux, Windows and MacOS", +) + +SubprocessInfo = namedtuple("SubprocessInfo", ["process", "socket"]) + + +def _wait_for_signal(sock, expected_signals, timeout=SHORT_TIMEOUT): + """ + Wait for expected signal(s) from a socket with proper timeout and EOF handling. + + Args: + sock: Connected socket to read from + expected_signals: Single bytes object or list of bytes objects to wait for + timeout: Socket timeout in seconds + + Returns: + bytes: Complete accumulated response buffer + + Raises: + RuntimeError: If connection closed before signal received or timeout + """ + if isinstance(expected_signals, bytes): + expected_signals = [expected_signals] + + sock.settimeout(timeout) + buffer = b"" + + while True: + # Check if all expected signals are in buffer + if all(sig in buffer for sig in expected_signals): + return buffer + + try: + chunk = sock.recv(4096) + if not chunk: + raise RuntimeError( + f"Connection closed before receiving expected signals. " + f"Expected: {expected_signals}, Got: {buffer[-200:]!r}" + ) + buffer += chunk + except socket.timeout: + raise RuntimeError( + f"Timeout waiting for signals. " + f"Expected: {expected_signals}, Got: {buffer[-200:]!r}" + ) from None + except OSError as e: + raise RuntimeError( + f"Socket error while waiting for signals: {e}. " + f"Expected: {expected_signals}, Got: {buffer[-200:]!r}" + ) from None + + +def _cleanup_sockets(*sockets): + """Safely close multiple sockets, ignoring errors.""" + for sock in sockets: + if sock is not None: + try: + sock.close() + except OSError: + pass + + +def _cleanup_process(proc, timeout=SHORT_TIMEOUT): + """Terminate a process gracefully, escalating to kill if needed.""" + if proc.poll() is not None: + return + proc.terminate() + try: + proc.wait(timeout=timeout) + return + except subprocess.TimeoutExpired: + pass + proc.kill() + try: + proc.wait(timeout=timeout) + except subprocess.TimeoutExpired: + pass # Process refuses to die, nothing more we can do + + +@contextlib.contextmanager +def test_subprocess(script, wait_for_working=False): + """Context manager to create a test subprocess with socket synchronization. + + Args: + script: Python code to execute in the subprocess. If wait_for_working + is True, script should send b"working" after starting work. + wait_for_working: If True, wait for both "ready" and "working" signals. + Default False for backward compatibility. + + Yields: + SubprocessInfo: Named tuple with process and socket objects + """ + # Find an unused port for socket communication + port = find_unused_port() + + # Inject socket connection code at the beginning of the script + socket_code = f""" +import socket +_test_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +_test_sock.connect(('localhost', {port})) +_test_sock.sendall(b"ready") +""" + + # Combine socket code with user script + full_script = socket_code + script + + # Create server socket to wait for process to be ready + server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + server_socket.bind(("localhost", port)) + server_socket.settimeout(SHORT_TIMEOUT) + server_socket.listen(1) + + proc = subprocess.Popen( + [sys.executable, "-c", full_script], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + + client_socket = None + try: + # Wait for process to connect and send ready signal + client_socket, _ = server_socket.accept() + server_socket.close() + server_socket = None + + # Wait for ready signal, and optionally working signal + if wait_for_working: + _wait_for_signal(client_socket, [b"ready", b"working"]) + else: + _wait_for_signal(client_socket, b"ready") + + yield SubprocessInfo(proc, client_socket) + finally: + _cleanup_sockets(client_socket, server_socket) + _cleanup_process(proc) + + +def close_and_unlink(file): + """Close a file and unlink it from the filesystem.""" + file.close() + unlink(file.name) + + +def jsonl_tables(records): + """Extract the canonical sections of a parsed JSONL profile. + + Returns ``(meta, str_defs, frame_defs, agg, end)`` where ``str_defs`` is a + ``{str_id: value}`` dict, ``frame_defs`` is a flat list of all frame + definitions across chunks, and ``agg`` is the first agg record (sufficient + for tests that only emit one chunk). + """ + meta = next(record for record in records if record["type"] == "meta") + end = next(record for record in records if record["type"] == "end") + agg = next(record for record in records if record["type"] == "agg") + str_defs = { + item["str_id"]: item["value"] + for record in records + if record["type"] == "string_table" + for item in record["strings"] + } + frame_defs = [ + item + for record in records + if record["type"] == "frame_table" + for item in record["frames"] + ] + return meta, str_defs, frame_defs, agg, end diff --git a/Lib/test/test_profiling/test_sampling_profiler/mocks.py b/Lib/test/test_profiling/test_sampling_profiler/mocks.py new file mode 100644 index 000000000..6ac2d08e8 --- /dev/null +++ b/Lib/test/test_profiling/test_sampling_profiler/mocks.py @@ -0,0 +1,112 @@ +"""Mock classes for sampling profiler tests.""" + +from collections import namedtuple + +# Matches the C structseq LocationInfo from _remote_debugging +LocationInfo = namedtuple('LocationInfo', ['lineno', 'end_lineno', 'col_offset', 'end_col_offset']) + + +class MockFrameInfo: + """Mock FrameInfo for testing. + + Frame format: (filename, location, funcname, opcode) where: + - location is a tuple (lineno, end_lineno, col_offset, end_col_offset) + - opcode is an int or None + """ + + def __init__(self, filename, lineno, funcname, opcode=None): + self.filename = filename + self.funcname = funcname + self.opcode = opcode + self.location = LocationInfo(lineno, lineno, -1, -1) + + def __iter__(self): + return iter((self.filename, self.location, self.funcname, self.opcode)) + + def __getitem__(self, index): + return (self.filename, self.location, self.funcname, self.opcode)[index] + + def __len__(self): + return 4 + + def __repr__(self): + return f"MockFrameInfo('{self.filename}', {self.location}, '{self.funcname}', {self.opcode})" + + +class MockThreadInfo: + """Mock ThreadInfo for testing since the real one isn't accessible.""" + + def __init__( + self, thread_id, frame_info, status=0 + ): # Default to THREAD_STATE_RUNNING (0) + self.thread_id = thread_id + self.frame_info = frame_info + self.status = status + + def __repr__(self): + return f"MockThreadInfo(thread_id={self.thread_id}, frame_info={self.frame_info}, status={self.status})" + + +class MockInterpreterInfo: + """Mock InterpreterInfo for testing since the real one isn't accessible.""" + + def __init__(self, interpreter_id, threads): + self.interpreter_id = interpreter_id + self.threads = threads + + def __repr__(self): + return f"MockInterpreterInfo(interpreter_id={self.interpreter_id}, threads={self.threads})" + + +class MockCoroInfo: + """Mock CoroInfo for testing async tasks.""" + + def __init__(self, task_name, call_stack): + self.task_name = task_name # In reality, this is the parent task ID + self.call_stack = call_stack + + def __repr__(self): + return f"MockCoroInfo(task_name={self.task_name}, call_stack={self.call_stack})" + + +class MockTaskInfo: + """Mock TaskInfo for testing async tasks.""" + + def __init__(self, task_id, task_name, coroutine_stack, awaited_by=None): + self.task_id = task_id + self.task_name = task_name + self.coroutine_stack = coroutine_stack # List of CoroInfo objects + self.awaited_by = awaited_by or [] # List of CoroInfo objects (parents) + + def __repr__(self): + return f"MockTaskInfo(task_id={self.task_id}, task_name={self.task_name})" + + +class MockAwaitedInfo: + """Mock AwaitedInfo for testing async tasks.""" + + def __init__(self, thread_id, awaited_by): + self.thread_id = thread_id + self.awaited_by = awaited_by # List of TaskInfo objects + + def __repr__(self): + return f"MockAwaitedInfo(thread_id={self.thread_id}, awaited_by={len(self.awaited_by)} tasks)" + + +def make_diff_collector_with_mock_baseline(baseline_samples): + """Create a DiffFlamegraphCollector with baseline injected directly, + skipping the binary round-trip that _load_baseline normally does.""" + from profiling.sampling.stack_collector import ( + DiffFlamegraphCollector, + FlamegraphCollector, + ) + + baseline = FlamegraphCollector(1000) + for sample in baseline_samples: + baseline.collect(sample) + + # Path is unused since we inject _baseline_collector directly; + # use __file__ as a dummy path that passes the existence check. + diff = DiffFlamegraphCollector(1000, baseline_binary_path=__file__) + diff._baseline_collector = baseline + return diff diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py b/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py new file mode 100644 index 000000000..ff944b316 --- /dev/null +++ b/Lib/test/test_profiling/test_sampling_profiler/test_binary_format.py @@ -0,0 +1,1788 @@ +"""Tests for binary format round-trip functionality.""" + +import json +import os +import pathlib +import random +import struct +import tempfile +import unittest +from collections import defaultdict + +try: + import _remote_debugging + from _remote_debugging import ( + InterpreterInfo, + ThreadInfo, + FrameInfo, + LocationInfo, + THREAD_STATUS_HAS_GIL, + THREAD_STATUS_ON_CPU, + THREAD_STATUS_UNKNOWN, + THREAD_STATUS_GIL_REQUESTED, + THREAD_STATUS_HAS_EXCEPTION, + THREAD_STATUS_MAIN_THREAD, + ) + from profiling.sampling.binary_collector import BinaryCollector + from profiling.sampling.binary_reader import BinaryReader, convert_binary_to_format + from profiling.sampling.constants import PROFILING_MODE_CPU + from profiling.sampling.gecko_collector import GeckoCollector + + ZSTD_AVAILABLE = _remote_debugging.zstd_available() +except ImportError: + raise unittest.SkipTest( + "Test only runs when _remote_debugging is available" + ) + +from .helpers import jsonl_tables + + +def make_frame(filename, lineno, funcname, end_lineno=None, column=None, + end_column=None, opcode=None): + """Create a FrameInfo struct sequence with full location info and opcode.""" + if end_lineno is None: + end_lineno = lineno + if column is None: + column = 0 + if end_column is None: + end_column = 0 + location = LocationInfo((lineno, end_lineno, column, end_column)) + return FrameInfo((filename, location, funcname, opcode)) + + +def make_thread(thread_id, frames, status=0): + """Create a ThreadInfo struct sequence.""" + return ThreadInfo((thread_id, status, frames)) + + +def make_interpreter(interp_id, threads): + """Create an InterpreterInfo struct sequence.""" + return InterpreterInfo((interp_id, threads)) + + +def extract_lineno(location): + """Extract line number from location (tuple or int or None).""" + if location is None: + return 0 # Treat None as 0 + if isinstance(location, tuple): + return location[0] if location[0] is not None else 0 + return location + + +def extract_location(location): + """Extract full location info as dict from location tuple or None.""" + if location is None: + return {"lineno": 0, "end_lineno": 0, "column": 0, "end_column": 0} + if isinstance(location, tuple) and len(location) >= 4: + return { + "lineno": location[0] if location[0] is not None else 0, + "end_lineno": location[1] if location[1] is not None else 0, + "column": location[2] if location[2] is not None else 0, + "end_column": location[3] if location[3] is not None else 0, + } + # Fallback for old-style location + lineno = location[0] if isinstance(location, tuple) else location + return {"lineno": lineno or 0, "end_lineno": lineno or 0, "column": 0, "end_column": 0} + + +def frame_to_dict(frame): + """Convert a FrameInfo to a dict.""" + loc = extract_location(frame.location) + return { + "filename": frame.filename, + "funcname": frame.funcname, + "lineno": loc["lineno"], + "end_lineno": loc["end_lineno"], + "column": loc["column"], + "end_column": loc["end_column"], + "opcode": frame.opcode, + } + + +class RawCollector: + """Collector that captures all raw data grouped by thread.""" + + def __init__(self): + # Key: (interpreter_id, thread_id) -> list of samples for that thread + self.by_thread = defaultdict(list) + self.total_count = 0 + + def collect(self, stack_frames, timestamps_us): + """Capture the raw sample data.""" + # timestamps_us is a list; add one sample per timestamp + count = len(timestamps_us) + for interp in stack_frames: + for thread in interp.threads: + frames = [frame_to_dict(f) for f in thread.frame_info] + key = (interp.interpreter_id, thread.thread_id) + sample = {"status": thread.status, "frames": frames} + for _ in range(count): + self.by_thread[key].append(sample) + self.total_count += count + + def export(self, filename): + pass + + +def samples_to_by_thread(samples): + """Convert input samples to by-thread format for comparison.""" + by_thread = defaultdict(list) + for sample in samples: + for interp in sample: + for thread in interp.threads: + frames = [frame_to_dict(f) for f in thread.frame_info] + key = (interp.interpreter_id, thread.thread_id) + by_thread[key].append( + { + "status": thread.status, + "frames": frames, + } + ) + return by_thread + + +class BinaryFormatTestBase(unittest.TestCase): + """Base class with common setup/teardown for binary format tests.""" + + def setUp(self): + self.temp_files = [] + + def tearDown(self): + for f in self.temp_files: + if os.path.exists(f): + os.unlink(f) + + def create_binary_file(self, samples, interval=1000, compression="none", + mode=None, capture_config=None): + """Create a test binary file and track it for cleanup.""" + filename, _ = self.write_binary_file( + samples, interval, compression, mode, capture_config + ) + return filename + + def write_binary_file(self, samples, interval=1000, compression="none", + mode=None, capture_config=None): + """Like create_binary_file but also returns the writer collector.""" + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + + collector = BinaryCollector( + filename, interval, compression=compression, mode=mode, + capture_config=capture_config, + ) + for sample in samples: + collector.collect(sample) + collector.export(None) + return filename, collector + + def roundtrip(self, samples, interval=1000, compression="none"): + """Write samples to binary and read back.""" + filename = self.create_binary_file(samples, interval, compression) + collector = RawCollector() + with BinaryReader(filename) as reader: + count = reader.replay_samples(collector) + return collector, count + + def assert_samples_equal(self, expected_samples, collector): + """Assert that roundtripped samples match input exactly, per-thread.""" + expected = samples_to_by_thread(expected_samples) + + # Same threads present + self.assertEqual( + set(expected.keys()), + set(collector.by_thread.keys()), + "Thread set mismatch", + ) + + # For each thread, samples match in order + for key in expected: + exp_samples = expected[key] + act_samples = collector.by_thread[key] + interp_id, thread_id = key + + self.assertEqual( + len(exp_samples), + len(act_samples), + f"Thread ({interp_id}, {thread_id}): sample count mismatch " + f"(expected {len(exp_samples)}, got {len(act_samples)})", + ) + + for i, (exp, act) in enumerate(zip(exp_samples, act_samples)): + self.assertEqual( + exp["status"], + act["status"], + f"Thread ({interp_id}, {thread_id}), sample {i}: " + f"status mismatch (expected {exp['status']}, got {act['status']})", + ) + + self.assertEqual( + len(exp["frames"]), + len(act["frames"]), + f"Thread ({interp_id}, {thread_id}), sample {i}: " + f"frame count mismatch", + ) + + for j, (exp_frame, act_frame) in enumerate( + zip(exp["frames"], act["frames"]) + ): + for field in ("filename", "funcname", "lineno", "end_lineno", + "column", "end_column", "opcode"): + self.assertEqual( + exp_frame[field], + act_frame[field], + f"Thread ({interp_id}, {thread_id}), sample {i}, " + f"frame {j}: {field} mismatch " + f"(expected {exp_frame[field]!r}, got {act_frame[field]!r})", + ) + + +class TestBinaryRoundTrip(BinaryFormatTestBase): + """Tests for exact binary format round-trip.""" + + def test_single_sample_single_frame(self): + """Single sample with one frame roundtrips exactly.""" + samples = [ + [ + make_interpreter( + 0, + [ + make_thread( + 12345, [make_frame("test.py", 42, "myfunc")] + ) + ], + ) + ] + ] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + def test_single_sample_multi_frame(self): + """Single sample with call stack roundtrips exactly.""" + frames = [ + make_frame("inner.py", 10, "inner"), + make_frame("middle.py", 20, "middle"), + make_frame("outer.py", 30, "outer"), + ] + samples = [[make_interpreter(0, [make_thread(100, frames)])]] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + def test_multiple_samples_same_stack(self): + """Multiple identical samples roundtrip exactly (tests RLE).""" + frame = make_frame("hot.py", 99, "hot_func") + samples = [ + [make_interpreter(0, [make_thread(1, [frame])])] + for _ in range(100) + ] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 100) + self.assert_samples_equal(samples, collector) + + def test_multiple_samples_varying_stacks(self): + """Multiple samples with varying stacks roundtrip exactly.""" + samples = [] + for i in range(20): + depth = i % 5 + 1 + frames = [ + make_frame(f"f{j}.py", j * 10 + i, f"func{j}") + for j in range(depth) + ] + samples.append([make_interpreter(0, [make_thread(1, frames)])]) + collector, count = self.roundtrip(samples) + self.assertEqual(count, 20) + self.assert_samples_equal(samples, collector) + + def test_thread_ids_preserved(self): + """Thread IDs are preserved exactly.""" + thread_ids = [1, 12345, 0x7FFF12345678, 999999] + samples = [] + for tid in thread_ids: + samples.append( + [ + make_interpreter( + 0, [make_thread(tid, [make_frame("t.py", 10, "f")])] + ) + ] + ) + collector, count = self.roundtrip(samples) + self.assertEqual(count, len(thread_ids)) + self.assert_samples_equal(samples, collector) + + def test_interpreter_ids_preserved(self): + """Interpreter IDs are preserved exactly.""" + interp_ids = [0, 1, 5, 100] + samples = [] + for iid in interp_ids: + samples.append( + [ + make_interpreter( + iid, [make_thread(1, [make_frame("i.py", 10, "f")])] + ) + ] + ) + collector, count = self.roundtrip(samples) + self.assertEqual(count, len(interp_ids)) + self.assert_samples_equal(samples, collector) + + def test_status_flags_preserved(self): + """All thread status flags are preserved exactly.""" + statuses = [ + 0, + THREAD_STATUS_HAS_GIL, + THREAD_STATUS_ON_CPU, + THREAD_STATUS_UNKNOWN, + THREAD_STATUS_GIL_REQUESTED, + THREAD_STATUS_HAS_EXCEPTION, + THREAD_STATUS_MAIN_THREAD, + THREAD_STATUS_HAS_GIL | THREAD_STATUS_ON_CPU, + THREAD_STATUS_HAS_GIL | THREAD_STATUS_HAS_EXCEPTION, + THREAD_STATUS_HAS_GIL + | THREAD_STATUS_ON_CPU + | THREAD_STATUS_GIL_REQUESTED, + ] + samples = [] + for i, status in enumerate(statuses): + samples.append( + [ + make_interpreter( + 0, + [ + make_thread( + 1, [make_frame("s.py", 10 + i, "f")], status + ) + ], + ) + ] + ) + collector, count = self.roundtrip(samples) + self.assertEqual(count, len(statuses)) + self.assert_samples_equal(samples, collector) + + def test_binary_replay_preserves_main_thread_for_gecko(self): + """Binary replay preserves main thread identity for GeckoCollector.""" + samples = [ + [ + make_interpreter( + 0, + [ + make_thread( + 1, + [make_frame("main.py", 10, "main")], + THREAD_STATUS_MAIN_THREAD, + ), + make_thread(2, [make_frame("worker.py", 20, "worker")]), + ], + ) + ] + ] + filename = self.create_binary_file(samples) + collector = GeckoCollector(1000) + + with BinaryReader(filename) as reader: + count = reader.replay_samples(collector) + + self.assertEqual(count, 2) + profile = collector._build_profile() + threads = {thread["tid"]: thread for thread in profile["threads"]} + self.assertTrue(threads[1]["isMainThread"]) + self.assertFalse(threads[2]["isMainThread"]) + + def test_multiple_threads_per_sample(self): + """Multiple threads in one sample roundtrip exactly.""" + threads = [ + make_thread( + 1, [make_frame("t1.py", 10, "t1")], THREAD_STATUS_HAS_GIL + ), + make_thread( + 2, [make_frame("t2.py", 20, "t2")], THREAD_STATUS_ON_CPU + ), + make_thread(3, [make_frame("t3.py", 30, "t3")], 0), + ] + samples = [[make_interpreter(0, threads)] for _ in range(10)] + collector, count = self.roundtrip(samples) + # 10 samples × 3 threads = 30 thread-samples + self.assertEqual(count, 30) + self.assert_samples_equal(samples, collector) + + def test_multiple_interpreters_per_sample(self): + """Multiple interpreters in one sample roundtrip exactly.""" + samples = [ + [ + make_interpreter( + 0, [make_thread(1, [make_frame("i0.py", 10, "i0")])] + ), + make_interpreter( + 1, [make_thread(2, [make_frame("i1.py", 20, "i1")])] + ), + ] + for _ in range(5) + ] + collector, count = self.roundtrip(samples) + # 5 samples × 2 interpreters × 1 thread = 10 thread-samples + self.assertEqual(count, 10) + self.assert_samples_equal(samples, collector) + + def test_same_thread_id_different_interpreters(self): + """Same thread_id in different interpreters must be tracked separately.""" + # This test catches bugs where thread state is keyed only by thread_id + # without considering interpreter_id + samples = [] + # Interleave samples from interpreter 0 and 1, both using thread_id=1 + for i in range(20): + interp_id = i % 2 # Alternate between interpreter 0 and 1 + frame = make_frame( + f"interp{interp_id}.py", 10 + i, f"func{interp_id}" + ) + samples.append( + [make_interpreter(interp_id, [make_thread(1, [frame])])] + ) + + collector, count = self.roundtrip(samples) + self.assertEqual(count, 20) + self.assert_samples_equal(samples, collector) + + # Verify both interpreters are present + keys = set(collector.by_thread.keys()) + self.assertIn((0, 1), keys) # interpreter 0, thread 1 + self.assertIn((1, 1), keys) # interpreter 1, thread 1 + + # Verify each interpreter got 10 samples + self.assertEqual(len(collector.by_thread[(0, 1)]), 10) + self.assertEqual(len(collector.by_thread[(1, 1)]), 10) + + # Verify the samples are in the right order for each interpreter + for i, sample in enumerate(collector.by_thread[(0, 1)]): + expected_lineno = 10 + i * 2 # 10, 12, 14, ... + self.assertEqual(sample["frames"][0]["lineno"], expected_lineno) + self.assertEqual(sample["frames"][0]["filename"], "interp0.py") + + for i, sample in enumerate(collector.by_thread[(1, 1)]): + expected_lineno = 11 + i * 2 # 11, 13, 15, ... + self.assertEqual(sample["frames"][0]["lineno"], expected_lineno) + self.assertEqual(sample["frames"][0]["filename"], "interp1.py") + + def test_deep_call_stack(self): + """Deep call stack roundtrips exactly.""" + depth = 100 + frames = [ + make_frame(f"f{i}.py", i + 1, f"func{i}") for i in range(depth) + ] + samples = [[make_interpreter(0, [make_thread(1, frames)])]] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + def test_line_numbers_preserved(self): + """Various line numbers are preserved exactly.""" + linenos = [1, 100, 1000, 65535, 100000] + samples = [] + for lineno in linenos: + samples.append( + [ + make_interpreter( + 0, [make_thread(1, [make_frame("l.py", lineno, "f")])] + ) + ] + ) + collector, count = self.roundtrip(samples) + self.assertEqual(count, len(linenos)) + self.assert_samples_equal(samples, collector) + + @unittest.skipUnless(ZSTD_AVAILABLE, "zstd compression not available") + def test_zstd_compression_roundtrip(self): + """Zstd compressed data roundtrips exactly.""" + samples = [] + for i in range(200): + frames = [ + make_frame(f"z{j}.py", j * 10 + i + 1, f"zfunc{j}") + for j in range(3) + ] + samples.append([make_interpreter(0, [make_thread(1, frames)])]) + collector, count = self.roundtrip(samples, compression="zstd") + self.assertEqual(count, 200) + self.assert_samples_equal(samples, collector) + + def test_sample_interval_preserved(self): + """Sample interval is preserved in file metadata.""" + intervals = [100, 500, 1000, 5000, 10000] + for interval in intervals: + with self.subTest(interval=interval): + samples = [ + [ + make_interpreter( + 0, [make_thread(1, [make_frame("i.py", 1, "f")])] + ) + ] + ] + filename = self.create_binary_file(samples, interval=interval) + with BinaryReader(filename) as reader: + info = reader.get_info() + self.assertEqual(info["sample_interval_us"], interval) + + def test_profiling_mode_preserved(self): + filename = self.create_binary_file([], mode=PROFILING_MODE_CPU) + with BinaryReader(filename) as reader: + self.assertEqual(reader.get_info()["mode"], PROFILING_MODE_CPU) + + def test_missing_profiling_mode_is_unknown(self): + filename = self.create_binary_file([]) + with BinaryReader(filename) as reader: + self.assertIsNone(reader.get_info()["mode"]) + + def test_capture_config_preserved(self): + capture_config = { + "all_threads": True, + "native": True, + "gc": False, + "opcodes": True, + "blocking": False, + } + filename = self.create_binary_file( + [], capture_config=capture_config + ) + with BinaryReader(filename) as reader: + self.assertEqual( + reader.get_info()["capture_config"], capture_config + ) + + def test_missing_capture_config_is_unknown(self): + filename = self.create_binary_file([]) + with BinaryReader(filename) as reader: + self.assertIsNone(reader.get_info()["capture_config"]) + + def test_threads_interleaved_samples(self): + """Multiple threads with interleaved varying samples.""" + samples = [] + for i in range(30): + threads = [ + make_thread( + 1, + [make_frame("t1.py", 10 + i, "t1")], + THREAD_STATUS_HAS_GIL if i % 2 == 0 else 0, + ), + make_thread( + 2, + [make_frame("t2.py", 20 + i, "t2")], + THREAD_STATUS_ON_CPU if i % 3 == 0 else 0, + ), + ] + samples.append([make_interpreter(0, threads)]) + collector, count = self.roundtrip(samples) + self.assertEqual(count, 60) + self.assert_samples_equal(samples, collector) + + def test_full_location_roundtrip(self): + """Full source location (end_lineno, column, end_column) roundtrips.""" + frames = [ + make_frame("test.py", 10, "func1", end_lineno=12, column=4, end_column=20), + make_frame("test.py", 20, "func2", end_lineno=20, column=8, end_column=45), + make_frame("test.py", 30, "func3", end_lineno=35, column=0, end_column=100), + ] + samples = [[make_interpreter(0, [make_thread(1, frames)])]] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + def test_opcode_roundtrip(self): + """Opcode values roundtrip exactly.""" + opcodes = [0, 1, 50, 100, 150, 200, 254] # Valid Python opcodes + samples = [] + for opcode in opcodes: + frame = make_frame("test.py", 10, "func", opcode=opcode) + samples.append([make_interpreter(0, [make_thread(1, [frame])])]) + collector, count = self.roundtrip(samples) + self.assertEqual(count, len(opcodes)) + self.assert_samples_equal(samples, collector) + + def test_opcode_none_roundtrip(self): + """Opcode=None (sentinel 255) roundtrips as None.""" + frame = make_frame("test.py", 10, "func", opcode=None) + samples = [[make_interpreter(0, [make_thread(1, [frame])])]] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + def test_mixed_location_and_opcode(self): + """Mixed full location and opcode data roundtrips.""" + frames = [ + make_frame("a.py", 10, "a", end_lineno=15, column=4, end_column=30, opcode=100), + make_frame("b.py", 20, "b", end_lineno=20, column=0, end_column=50, opcode=None), + make_frame("c.py", 30, "c", end_lineno=32, column=8, end_column=25, opcode=50), + ] + samples = [[make_interpreter(0, [make_thread(1, frames)])]] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + def test_delta_encoding_multiline(self): + """Multi-line spans (large end_lineno delta) roundtrip correctly.""" + # This tests the delta encoding: end_lineno = lineno + delta + frames = [ + make_frame("test.py", 1, "small", end_lineno=1, column=0, end_column=10), + make_frame("test.py", 100, "medium", end_lineno=110, column=0, end_column=50), + make_frame("test.py", 1000, "large", end_lineno=1500, column=0, end_column=200), + ] + samples = [[make_interpreter(0, [make_thread(1, frames)])]] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + def test_column_positions_preserved(self): + """Various column positions are preserved exactly.""" + columns = [(0, 10), (4, 50), (8, 100), (100, 200)] + samples = [] + for col, end_col in columns: + frame = make_frame("test.py", 10, "func", column=col, end_column=end_col) + samples.append([make_interpreter(0, [make_thread(1, [frame])])]) + collector, count = self.roundtrip(samples) + self.assertEqual(count, len(columns)) + self.assert_samples_equal(samples, collector) + + def test_same_line_different_opcodes(self): + """Same line with different opcodes creates distinct frames.""" + # This tests that opcode is part of the frame key + frames = [ + make_frame("test.py", 10, "func", opcode=100), + make_frame("test.py", 10, "func", opcode=101), + make_frame("test.py", 10, "func", opcode=102), + ] + samples = [[make_interpreter(0, [make_thread(1, [f])]) for f in frames]] + collector, count = self.roundtrip(samples) + # Verify all three opcodes are preserved distinctly + self.assertEqual(count, 3) + + def test_same_line_different_columns(self): + """Same line with different columns creates distinct frames.""" + frames = [ + make_frame("test.py", 10, "func", column=0, end_column=10), + make_frame("test.py", 10, "func", column=15, end_column=25), + make_frame("test.py", 10, "func", column=30, end_column=40), + ] + samples = [[make_interpreter(0, [make_thread(1, [f])]) for f in frames]] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 3) + + +class TestBinaryEdgeCases(BinaryFormatTestBase): + """Tests for edge cases in binary format.""" + + def test_unicode_filenames(self): + """Unicode filenames roundtrip exactly.""" + filenames = [ + "/путь/файл.py", + "/路径/文件.py", + "/パス/ファイル.py", + "/chemin/café.py", + ] + for fname in filenames: + with self.subTest(filename=fname): + samples = [ + [ + make_interpreter( + 0, [make_thread(1, [make_frame(fname, 1, "func")])] + ) + ] + ] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + def test_unicode_funcnames(self): + """Unicode function names roundtrip exactly.""" + funcnames = [ + "функция", + "函数", + "関数", + "función", + ] + for funcname in funcnames: + with self.subTest(funcname=funcname): + samples = [ + [ + make_interpreter( + 0, + [ + make_thread( + 1, [make_frame("test.py", 1, funcname)] + ) + ], + ) + ] + ] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + def test_special_char_filenames(self): + """Filenames with special characters roundtrip exactly.""" + filenames = [ + "/path/with spaces/file.py", + "/path/with\ttab/file.py", + "/path/with'quote/file.py", + '/path/with"double/file.py', + "/path/with\\backslash/file.py", + ] + for fname in filenames: + with self.subTest(filename=fname): + samples = [ + [ + make_interpreter( + 0, [make_thread(1, [make_frame(fname, 1, "func")])] + ) + ] + ] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + def test_special_funcnames(self): + """Function names with special characters roundtrip exactly.""" + funcnames = [ + "", + "", + "", + "", + "__init__", + "func.inner", + ] + for funcname in funcnames: + with self.subTest(funcname=funcname): + samples = [ + [ + make_interpreter( + 0, + [ + make_thread( + 1, [make_frame("test.py", 1, funcname)] + ) + ], + ) + ] + ] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + def test_long_filename(self): + """Long filename roundtrips exactly.""" + long_file = "/very/long/path/" + "sub/" * 50 + "file.py" + samples = [ + [ + make_interpreter( + 0, [make_thread(1, [make_frame(long_file, 1, "func")])] + ) + ] + ] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + def test_long_funcname(self): + """Long function name roundtrips exactly.""" + long_func = "very_long_function_name_" + "x" * 200 + samples = [ + [ + make_interpreter( + 0, [make_thread(1, [make_frame("test.py", 1, long_func)])] + ) + ] + ] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + def test_empty_funcname(self): + """Empty function name roundtrips exactly.""" + samples = [ + [ + make_interpreter( + 0, [make_thread(1, [make_frame("test.py", 1, "")])] + ) + ] + ] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + @unittest.skipUnless(ZSTD_AVAILABLE, "zstd compression not available") + def test_large_sample_count(self): + """Large number of samples roundtrips exactly.""" + num = 5000 + samples = [ + [ + make_interpreter( + 0, + [ + make_thread( + 1, [make_frame("test.py", (i % 100) + 1, "func")] + ) + ], + ) + ] + for i in range(num) + ] + collector, count = self.roundtrip(samples, compression="zstd") + self.assertEqual(count, num) + self.assert_samples_equal(samples, collector) + + def test_context_manager_cleanup(self): + """Reader cleans up on context exit.""" + samples = [ + [ + make_interpreter( + 0, [make_thread(1, [make_frame("t.py", 1, "f")])] + ) + ] + ] + filename = self.create_binary_file(samples) + reader = BinaryReader(filename) + with reader: + collector = RawCollector() + count = reader.replay_samples(collector) + self.assertEqual(count, 1) + with self.assertRaises(RuntimeError): + reader.replay_samples(collector) + + def test_invalid_file_path(self): + """Invalid file path raises appropriate error.""" + with self.assertRaises((FileNotFoundError, OSError, ValueError)): + with BinaryReader("/nonexistent/path/file.bin") as reader: + reader.replay_samples(RawCollector()) + + def test_path_arguments_round_trip(self): + """Reader and writer accept str, bytes or os.PathLike.""" + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + + for path_arg in (filename, os.fsencode(filename), pathlib.Path(filename)): + with self.subTest(path_type=type(path_arg).__name__): + writer = _remote_debugging.BinaryWriter(path_arg, 1000, 0) + writer.finalize() + reader = _remote_debugging.BinaryReader(path_arg) + info = reader.get_info() + reader.close() + self.assertEqual(info["sample_count"], 0) + + def test_rejects_non_pathlike(self): + """Reader and writer raise TypeError on non-path-like filenames.""" + with self.assertRaises(TypeError): + _remote_debugging.BinaryWriter(123, 1000, 0) + with self.assertRaises(TypeError): + _remote_debugging.BinaryReader(123) + + def test_invalid_path_error_preserves_pathlib(self): + """Missing path: OSError carries the original path object, not a string.""" + missing = pathlib.Path("/i/do/not/exist") + with self.assertRaises(FileNotFoundError) as cm: + _remote_debugging.BinaryReader(missing) + self.assertEqual(os.fspath(cm.exception.filename), os.fspath(missing)) + + def test_writer_handles_empty_stack_first_sample(self): + """BinaryWriter.write_sample tolerates an empty stack on a fresh thread. + + Regression test for the C-level RLE bug in process_thread_sample: a + freshly-created ThreadEntry has prev_stack_depth == 0, so an empty + curr_stack compares as STACK_REPEAT against the zero-initialized + previous stack. Before the fix, this fell through the + `&& !is_new_thread` guard into write_sample_with_encoding, which had + no handler for STACK_REPEAT and raised + RuntimeError("Invalid stack encoding type"). Goes through + BinaryWriter.write_sample directly so the test cannot be masked by + any Python-level filtering. + """ + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + + writer = _remote_debugging.BinaryWriter(filename, 1000, 0, compression=0) + empty_sample = [ + make_interpreter( + 0, [make_thread(99, [], status=THREAD_STATUS_UNKNOWN)] + ) + ] + # First sample for a fresh thread has empty frame_info — the exact + # scenario that exposes the bug. + writer.write_sample(empty_sample, 1000) + writer.write_sample(empty_sample, 2000) + # Mix in a real sample to exercise the transition out of the + # empty-stack RLE buffer. + real_sample = [ + make_interpreter(0, [make_thread(1, [make_frame("a.py", 1, "f")])]) + ] + writer.write_sample(real_sample, 3000) + writer.finalize() + + reader_collector = RawCollector() + with BinaryReader(filename) as reader: + count = reader.replay_samples(reader_collector) + # Empty-stack samples are recorded as STACK_REPEAT records with + # depth-0 stacks; the file must replay all three samples. + self.assertEqual(count, 3) + + def test_writer_handles_mixed_empty_and_real_first_sample(self): + """First sample with one empty + one real thread roundtrips through C.""" + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + + writer = _remote_debugging.BinaryWriter(filename, 1000, 0, compression=0) + sample = [ + make_interpreter( + 0, + [ + make_thread(1, [make_frame("a.py", 1, "f")]), + make_thread(99, [], status=THREAD_STATUS_UNKNOWN), + ], + ) + ] + # Two samples so RLE state is exercised. + writer.write_sample(sample, 1000) + writer.write_sample(sample, 2000) + writer.finalize() + + # Replay must succeed without raising RuntimeError, and the real + # thread's frames must round-trip. + reader_collector = RawCollector() + with BinaryReader(filename) as reader: + reader.replay_samples(reader_collector) + self.assertIn((0, 1), reader_collector.by_thread) + self.assertEqual(len(reader_collector.by_thread[(0, 1)]), 2) + + def test_writer_total_samples_after_finalize_matches_reader(self): + """BinaryWriter.total_samples after finalize() matches the reader's count.""" + # Five IDENTICAL samples force every sample beyond the first into the + # per-thread RLE buffer. Regression for the cached_total_samples + # ordering bug: capturing the cache BEFORE binary_writer_finalize() + # missed the buffered samples that flush_pending_rle() counts. Keep + # the samples identical to preserve coverage. See gh-149342. + samples = [ + [make_interpreter(0, [make_thread(1, [make_frame("a.py", 1, "f")])])] + ] * 5 + filename, writer_collector = self.write_binary_file(samples) + reader_collector = RawCollector() + with BinaryReader(filename) as reader: + replayed = reader.replay_samples(reader_collector) + self.assertEqual(writer_collector.total_samples, len(samples)) + self.assertEqual(writer_collector.total_samples, replayed) + + def test_rle_buffer_flushes_before_finalize(self): + """RLE buffer flushes before the writer is destroyed during finalize().""" + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + + frame = make_frame("rle_boundary.py", 1, "hot") + sample = [make_interpreter(0, [make_thread(1, [frame])])] + + writer = _remote_debugging.BinaryWriter(filename, 1000, 0, compression=0) + for i in range(10_000): + writer.write_sample(sample, i * 1000) + self.assertGreater(writer.get_stats()["repeat_records"], 0) + + writer.finalize() + + reader_collector = RawCollector() + with BinaryReader(filename) as reader: + replayed = reader.replay_samples(reader_collector) + + self.assertEqual(replayed, 10_000) + self.assertEqual(writer.total_samples, 10_000) + + def test_writer_total_samples_after_context_manager_matches_reader(self): + """total_samples after `with BinaryWriter(...)` matches the reader's count. + + Regression for the asymmetry between finalize() and __exit__ in + module.c: __exit__ also calls binary_writer_finalize and must + preserve cached_total_samples like finalize() does, otherwise the + getter returns 0 once self->writer is NULL. + """ + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + + sample = [ + make_interpreter(0, [make_thread(1, [make_frame("a.py", 1, "f")])]) + ] + with _remote_debugging.BinaryWriter(filename, 1000, 0, compression=0) as w: + for i in range(5): + w.write_sample(sample, i * 1000) + self.assertEqual(w.total_samples, 5) + + reader_collector = RawCollector() + with BinaryReader(filename) as reader: + self.assertEqual(reader.replay_samples(reader_collector), 5) + + def test_writer_total_samples_after_close_returns_zero(self): + """close() discards data; total_samples reflects no cached count.""" + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + + w = _remote_debugging.BinaryWriter(filename, 1000, 0, compression=0) + sample = [ + make_interpreter(0, [make_thread(1, [make_frame("a.py", 1, "f")])]) + ] + for i in range(5): + w.write_sample(sample, i * 1000) + w.close() + self.assertEqual(w.total_samples, 0) + + +class TestBinaryFormatValidation(BinaryFormatTestBase): + """Tests for malformed binary files.""" + + HDR_OFF_SAMPLES = 28 + HDR_OFF_THREADS = 36 + HDR_OFF_STR_TABLE = 40 + HDR_OFF_FRAME_TABLE = 48 + FILE_HEADER_PLACEHOLDER_SIZE = 64 + FILE_FOOTER_SIZE = 32 + FTR_OFF_STRINGS = 0 + FTR_OFF_FRAMES = 4 + + def test_replay_rejects_more_threads_than_declared(self): + """Replay rejects files with more unique threads than the header declares.""" + threads = [ + make_thread(1, [make_frame("t1.py", 10, "t1")]), + make_thread(2, [make_frame("t2.py", 20, "t2")]), + ] + samples = [[make_interpreter(0, threads)]] + filename = self.create_binary_file(samples, compression="none") + + with open(filename, "r+b") as raw: + raw.seek(self.HDR_OFF_THREADS) + raw.write(struct.pack("=I", 1)) + + with BinaryReader(filename) as reader: + self.assertEqual(reader.get_info()["thread_count"], 1) + with self.assertRaises(ValueError) as cm: + reader.replay_samples(RawCollector()) + self.assertEqual( + str(cm.exception), + "Invalid thread count: sample data contains more unique " + "threads than declared in header (declared 1, found at least 2)", + ) + + def test_replay_rejects_sample_count_mismatch(self): + """Replay rejects files whose decoded samples disagree with the header.""" + samples = [[make_interpreter(0, [ + make_thread(1, [make_frame("sample.py", 10, "sample")]) + ])]] + filename = self.create_binary_file(samples, compression="none") + + with open(filename, "r+b") as raw: + raw.seek(self.HDR_OFF_SAMPLES) + raw.write(struct.pack("=Q", 2)) + + with BinaryReader(filename) as reader: + self.assertEqual(reader.get_info()["sample_count"], 2) + with self.assertRaises(ValueError) as cm: + reader.replay_samples(RawCollector()) + self.assertEqual( + str(cm.exception), + "Sample count mismatch: header declares 2 samples " + "but replay decoded 1", + ) + + def test_replay_rejects_trailing_partial_sample_header(self): + """Replay rejects partial sample bytes instead of silently stopping.""" + filename = self.create_binary_file([], compression="none") + sample_data_end = self.FILE_HEADER_PLACEHOLDER_SIZE + 1 + + with open(filename, "r+b") as raw: + raw.seek(self.HDR_OFF_STR_TABLE) + raw.write(struct.pack("=Q", sample_data_end)) + raw.seek(self.HDR_OFF_FRAME_TABLE) + raw.write(struct.pack("=Q", sample_data_end)) + + with BinaryReader(filename) as reader: + with self.assertRaises(ValueError) as cm: + reader.replay_samples(RawCollector()) + self.assertEqual(str(cm.exception), "Truncated sample data: 1 trailing bytes") + + # Minimum on-disk size of one table entry (see binary_io.h). + MIN_STRING_ENTRY_SIZE = 1 + MIN_FRAME_ENTRY_SIZE = 7 + + def _read_offset(self, filename, hdr_off): + with open(filename, "rb") as raw: + raw.seek(hdr_off) + return struct.unpack("=Q", raw.read(8))[0] + + def _patch_footer_count(self, filename, ftr_off, value): + size = os.path.getsize(filename) + with open(filename, "r+b") as raw: + raw.seek(size - self.FILE_FOOTER_SIZE + ftr_off) + raw.write(struct.pack("=I", value)) + + def test_open_rejects_string_count_larger_than_file(self): + """Open rejects a footer string count larger than the file.""" + samples = [[make_interpreter(0, [ + make_thread(1, [make_frame("s.py", 10, "s")]) + ])]] + filename = self.create_binary_file(samples, compression="none") + size = os.path.getsize(filename) + str_off = self._read_offset(filename, self.HDR_OFF_STR_TABLE) + max_strings = (size - str_off) // self.MIN_STRING_ENTRY_SIZE + self._patch_footer_count(filename, self.FTR_OFF_STRINGS, 0xFFFFFFFF) + + with self.assertRaises(ValueError) as cm: + with BinaryReader(filename): + pass + self.assertEqual( + str(cm.exception), + f"Invalid string count 4294967295 exceeds maximum " + f"possible {max_strings}", + ) + + def test_open_rejects_frame_count_larger_than_file(self): + """Open rejects a footer frame count larger than the file.""" + samples = [[make_interpreter(0, [ + make_thread(1, [make_frame("f.py", 10, "f")]) + ])]] + filename = self.create_binary_file(samples, compression="none") + size = os.path.getsize(filename) + frame_off = self._read_offset(filename, self.HDR_OFF_FRAME_TABLE) + max_frames = (size - frame_off) // self.MIN_FRAME_ENTRY_SIZE + self._patch_footer_count(filename, self.FTR_OFF_FRAMES, 0xFFFFFFFF) + + # On a 32-bit build this count overflows the allocation size, so the + # size_t overflow guard (OverflowError) fires before the count check. + with self.assertRaises((ValueError, OverflowError)) as cm: + with BinaryReader(filename): + pass + if isinstance(cm.exception, ValueError): + self.assertEqual( + str(cm.exception), + f"Invalid frame count 4294967295 exceeds maximum " + f"possible {max_frames}", + ) + + def test_open_accepts_frame_count_at_capacity_boundary(self): + """A frame count at the file-size cap opens; one more is rejected.""" + samples = [[make_interpreter(0, [ + make_thread(1, [make_frame("f.py", 10, "f")]) + ])]] + filename = self.create_binary_file(samples, compression="none") + size = os.path.getsize(filename) + frame_off = self._read_offset(filename, self.HDR_OFF_FRAME_TABLE) + max_frames = (size - frame_off) // self.MIN_FRAME_ENTRY_SIZE + + self._patch_footer_count(filename, self.FTR_OFF_FRAMES, max_frames) + with BinaryReader(filename): + pass + + self._patch_footer_count(filename, self.FTR_OFF_FRAMES, max_frames + 1) + with self.assertRaises(ValueError) as cm: + with BinaryReader(filename): + pass + self.assertEqual( + str(cm.exception), + f"Invalid frame count {max_frames + 1} exceeds maximum " + f"possible {max_frames}", + ) + + def test_sample_count_reads_full_64_bits(self): + """sample_count values requiring the upper 32 bits decode correctly.""" + filename = self.create_binary_file([], compression="none") + big_count = 0x1_0002_0003 + + with open(filename, "r+b") as raw: + raw.seek(self.HDR_OFF_SAMPLES) + raw.write(struct.pack("=Q", big_count)) + + with BinaryReader(filename) as reader: + self.assertEqual(reader.get_info()["sample_count"], big_count) + + def test_sample_count_boundary_values(self): + """Values above the old u32 ceiling decode fine.""" + filename = self.create_binary_file([], compression="none") + + for value in (0xFFFFFFFF - 1, 0xFFFFFFFF, 0xFFFFFFFF + 1): + with self.subTest(value=value): + with open(filename, "r+b") as raw: + raw.seek(self.HDR_OFF_SAMPLES) + raw.write(struct.pack("=Q", value)) + + with BinaryReader(filename) as reader: + self.assertEqual(reader.get_info()["sample_count"], value) + + +class TestBinaryEncodings(BinaryFormatTestBase): + """Tests specifically targeting different stack encodings.""" + + def test_stack_full_encoding(self): + """First sample uses STACK_FULL encoding and roundtrips.""" + frames = [make_frame(f"f{i}.py", i + 1, f"func{i}") for i in range(5)] + samples = [[make_interpreter(0, [make_thread(1, frames)])]] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1) + self.assert_samples_equal(samples, collector) + + def test_stack_repeat_encoding(self): + """Identical consecutive samples use RLE and roundtrip.""" + frame = make_frame("repeat.py", 42, "repeat_func") + samples = [ + [make_interpreter(0, [make_thread(1, [frame])])] + for _ in range(1000) + ] + collector, count = self.roundtrip(samples) + self.assertEqual(count, 1000) + self.assert_samples_equal(samples, collector) + + def test_stack_suffix_encoding(self): + """Samples sharing suffix use STACK_SUFFIX and roundtrip.""" + samples = [] + for i in range(10): + frames = [make_frame(f"new{i}.py", i + 1, f"new{i}")] + frames.extend( + [ + make_frame(f"shared{j}.py", j + 1, f"shared{j}") + for j in range(5) + ] + ) + samples.append([make_interpreter(0, [make_thread(1, frames)])]) + collector, count = self.roundtrip(samples) + self.assertEqual(count, 10) + self.assert_samples_equal(samples, collector) + + def test_stack_pop_push_encoding(self): + """Samples with pop+push pattern roundtrip.""" + samples = [] + base_frames = [make_frame("base.py", 10, "base")] + + # Call deeper + samples.append([make_interpreter(0, [make_thread(1, base_frames)])]) + samples.append( + [ + make_interpreter( + 0, + [ + make_thread( + 1, + [make_frame("call1.py", 20, "call1")] + + base_frames, + ) + ], + ) + ] + ) + samples.append( + [ + make_interpreter( + 0, + [ + make_thread( + 1, + [ + make_frame("call2.py", 30, "call2"), + make_frame("call1.py", 20, "call1"), + ] + + base_frames, + ) + ], + ) + ] + ) + # Return + samples.append( + [ + make_interpreter( + 0, + [ + make_thread( + 1, + [make_frame("call1.py", 25, "call1")] + + base_frames, + ) + ], + ) + ] + ) + samples.append([make_interpreter(0, [make_thread(1, base_frames)])]) + + collector, count = self.roundtrip(samples) + self.assertEqual(count, 5) + self.assert_samples_equal(samples, collector) + + def test_mixed_encodings(self): + """Mix of different encoding patterns roundtrips.""" + samples = [] + # Some repeated samples (RLE) + frame1 = make_frame("hot.py", 1, "hot") + for _ in range(20): + samples.append([make_interpreter(0, [make_thread(1, [frame1])])]) + # Some varying samples + for i in range(20): + frames = [make_frame(f"vary{i}.py", i + 1, f"vary{i}")] + samples.append([make_interpreter(0, [make_thread(1, frames)])]) + # More repeated + for _ in range(20): + samples.append([make_interpreter(0, [make_thread(1, [frame1])])]) + + collector, count = self.roundtrip(samples) + self.assertEqual(count, 60) + self.assert_samples_equal(samples, collector) + + def test_alternating_threads_status_changes(self): + """Alternating thread status changes roundtrip correctly.""" + samples = [] + for i in range(50): + status1 = THREAD_STATUS_HAS_GIL if i % 2 == 0 else 0 + status2 = ( + THREAD_STATUS_ON_CPU if i % 3 == 0 else THREAD_STATUS_HAS_GIL + ) + threads = [ + make_thread(1, [make_frame("t1.py", 10, "t1")], status1), + make_thread(2, [make_frame("t2.py", 20, "t2")], status2), + ] + samples.append([make_interpreter(0, threads)]) + collector, count = self.roundtrip(samples) + self.assertEqual(count, 100) + self.assert_samples_equal(samples, collector) + + +class TestBinaryStress(BinaryFormatTestBase): + """Randomized stress tests for binary format.""" + + @unittest.skipUnless(ZSTD_AVAILABLE, "zstd compression not available") + def test_random_samples_stress(self): + """Stress test with random samples - exercises hash table resizing.""" + random.seed(42) # Reproducible + + # Large pools to force hash table resizing (exceeds initial 8192/4096 sizes) + filenames = [f"file{i}.py" for i in range(200)] + funcnames = [f"func{i}" for i in range(300)] + thread_ids = list(range(1, 50)) + interp_ids = list(range(10)) + statuses = [ + 0, + THREAD_STATUS_HAS_GIL, + THREAD_STATUS_ON_CPU, + THREAD_STATUS_HAS_GIL | THREAD_STATUS_ON_CPU, + THREAD_STATUS_HAS_EXCEPTION, + ] + + samples = [] + for _ in range(1000): + num_interps = random.randint(1, 3) + interps = [] + for _ in range(num_interps): + iid = random.choice(interp_ids) + num_threads = random.randint(1, 5) + threads = [] + for _ in range(num_threads): + tid = random.choice(thread_ids) + status = random.choice(statuses) + depth = random.randint(1, 15) + frames = [] + for _ in range(depth): + fname = random.choice(filenames) + func = random.choice(funcnames) + # Wide line number range to create many unique frames + lineno = random.randint(1, 5000) + frames.append(make_frame(fname, lineno, func)) + threads.append(make_thread(tid, frames, status)) + interps.append(make_interpreter(iid, threads)) + samples.append(interps) + + collector, count = self.roundtrip(samples, compression="zstd") + self.assertGreater(count, 0) + self.assert_samples_equal(samples, collector) + + def test_rle_stress(self): + """Stress test RLE encoding with identical samples.""" + random.seed(123) + + # Create a few distinct stacks + stacks = [] + for i in range(5): + depth = random.randint(1, 8) + frames = [ + make_frame(f"rle{j}.py", j * 10, f"rle{j}") + for j in range(depth) + ] + stacks.append(frames) + + # Generate samples with repeated stacks (should trigger RLE) + samples = [] + for _ in range(100): + stack = random.choice(stacks) + repeat = random.randint(1, 50) + for _ in range(repeat): + samples.append([make_interpreter(0, [make_thread(1, stack)])]) + + collector, count = self.roundtrip(samples) + self.assertEqual(count, len(samples)) + self.assert_samples_equal(samples, collector) + + @unittest.skipUnless(ZSTD_AVAILABLE, "zstd compression not available") + def test_multi_thread_stress(self): + """Stress test with many threads and interleaved samples.""" + random.seed(456) + + thread_ids = list(range(1, 20)) + samples = [] + + for i in range(300): + # Randomly select 1-5 threads for this sample + num_threads = random.randint(1, 5) + selected = random.sample(thread_ids, num_threads) + threads = [] + for tid in selected: + status = random.choice( + [0, THREAD_STATUS_HAS_GIL, THREAD_STATUS_ON_CPU] + ) + depth = random.randint(1, 5) + frames = [ + make_frame(f"mt{tid}_{j}.py", i + j, f"f{j}") + for j in range(depth) + ] + threads.append(make_thread(tid, frames, status)) + samples.append([make_interpreter(0, threads)]) + + collector, count = self.roundtrip(samples, compression="zstd") + self.assertGreater(count, 0) + self.assert_samples_equal(samples, collector) + + def test_encoding_transitions_stress(self): + """Stress test stack encoding transitions.""" + random.seed(789) + + base_frames = [ + make_frame(f"base{i}.py", i, f"base{i}") for i in range(5) + ] + samples = [] + + for i in range(200): + choice = random.randint(0, 4) + if choice == 0: + # Full new stack + depth = random.randint(1, 8) + frames = [ + make_frame(f"new{i}_{j}.py", j, f"new{j}") + for j in range(depth) + ] + elif choice == 1: + # Repeat previous (will use RLE if identical) + frames = base_frames[: random.randint(1, 5)] + elif choice == 2: + # Add frames on top (suffix encoding) + extra = random.randint(1, 3) + frames = [ + make_frame(f"top{i}_{j}.py", j, f"top{j}") + for j in range(extra) + ] + frames.extend(base_frames[: random.randint(2, 4)]) + else: + # Pop and push (pop-push encoding) + keep = random.randint(1, 3) + push = random.randint(0, 2) + frames = [ + make_frame(f"push{i}_{j}.py", j, f"push{j}") + for j in range(push) + ] + frames.extend(base_frames[:keep]) + + samples.append([make_interpreter(0, [make_thread(1, frames)])]) + + collector, count = self.roundtrip(samples) + self.assertEqual(count, len(samples)) + self.assert_samples_equal(samples, collector) + + @unittest.skipUnless(ZSTD_AVAILABLE, "zstd compression not available") + def test_same_thread_id_multiple_interpreters_stress(self): + """Stress test: same thread_id across multiple interpreters with interleaved samples. + + This test catches bugs where thread state is keyed only by thread_id + without considering interpreter_id (both in writer and reader). + """ + random.seed(999) + + # Multiple interpreters, each with overlapping thread_ids + interp_ids = [0, 1, 2, 3] + # Same thread_ids used across all interpreters + shared_thread_ids = [1, 2, 3] + + filenames = [f"file{i}.py" for i in range(10)] + funcnames = [f"func{i}" for i in range(15)] + statuses = [0, THREAD_STATUS_HAS_GIL, THREAD_STATUS_ON_CPU] + + samples = [] + for i in range(1000): + # Randomly pick an interpreter + iid = random.choice(interp_ids) + # Randomly pick 1-3 threads (from shared pool) + num_threads = random.randint(1, 3) + selected_tids = random.sample(shared_thread_ids, num_threads) + + threads = [] + for tid in selected_tids: + status = random.choice(statuses) + depth = random.randint(1, 6) + frames = [] + for d in range(depth): + # Include interpreter and thread info in frame data for verification + fname = f"i{iid}_t{tid}_{random.choice(filenames)}" + func = random.choice(funcnames) + lineno = i * 10 + d + 1 # Unique per sample + frames.append(make_frame(fname, lineno, func)) + threads.append(make_thread(tid, frames, status)) + + samples.append([make_interpreter(iid, threads)]) + + collector, count = self.roundtrip(samples, compression="zstd") + self.assertGreater(count, 0) + self.assert_samples_equal(samples, collector) + + # Verify that we have samples from multiple (interpreter, thread) combinations + # with the same thread_id + keys = set(collector.by_thread.keys()) + # Should have samples for same thread_id in different interpreters + for tid in shared_thread_ids: + interps_with_tid = [iid for (iid, t) in keys if t == tid] + self.assertGreater( + len(interps_with_tid), + 1, + f"Thread {tid} should appear in multiple interpreters", + ) + + +class TimestampCollector: + """Collector that captures timestamps for verification.""" + + def __init__(self): + self.all_timestamps = [] + + def collect(self, stack_frames, timestamps_us=None): + if timestamps_us is not None: + self.all_timestamps.extend(timestamps_us) + + def export(self, filename): + pass + + +class TestTimestampPreservation(BinaryFormatTestBase): + """Tests for timestamp preservation during binary round-trip.""" + + def test_timestamp_preservation(self): + """Timestamps are preserved during round-trip.""" + frame = make_frame("test.py", 10, "func") + timestamps = [1000000, 2000000, 3000000] + + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + + collector = BinaryCollector(filename, 1000, compression="none") + for ts in timestamps: + sample = [make_interpreter(0, [make_thread(1, [frame])])] + collector.collect(sample, timestamp_us=ts) + collector.export(None) + + ts_collector = TimestampCollector() + with BinaryReader(filename) as reader: + count = reader.replay_samples(ts_collector) + + self.assertEqual(count, 3) + self.assertEqual(ts_collector.all_timestamps, timestamps) + + def test_timestamp_preservation_with_rle(self): + """RLE-batched samples preserve individual timestamps.""" + frame = make_frame("rle.py", 42, "rle_func") + + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as f: + filename = f.name + self.temp_files.append(filename) + + # Identical samples (triggers RLE) with different timestamps + collector = BinaryCollector(filename, 1000, compression="none") + expected_timestamps = [] + for i in range(50): + ts = 1000000 + i * 100 + expected_timestamps.append(ts) + sample = [make_interpreter(0, [make_thread(1, [frame])])] + collector.collect(sample, timestamp_us=ts) + collector.export(None) + + ts_collector = TimestampCollector() + with BinaryReader(filename) as reader: + count = reader.replay_samples(ts_collector) + + self.assertEqual(count, 50) + self.assertEqual(ts_collector.all_timestamps, expected_timestamps) + + +class TestBinaryReplayToFlamegraph(BinaryFormatTestBase): + def test_replay_includes_persisted_stats(self): + frames = [ + make_frame("hot.py", 99, "hot_func"), + make_frame("main.py", 1, "main"), + ] + samples = [ + [ + make_interpreter( + 0, + [make_thread(1, frames, THREAD_STATUS_HAS_GIL)], + ) + ] + for _ in range(5) + ] + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as file: + bin_path = file.name + self.temp_files.append(bin_path) + collector = BinaryCollector(bin_path, 2000, compression="none") + for sample in samples: + collector.collect(sample) + collector.set_stats( + 2000, 1.25, 4.0, error_rate=2.5, missed_samples=1.5 + ) + collector.export(None) + + with BinaryReader(bin_path) as reader: + info = reader.get_info() + self.assertEqual(info["duration_sec"], 1.25) + self.assertEqual(info["sample_rate"], 4.0) + self.assertEqual(info["error_rate"], 2.5) + self.assertEqual(info["missed_samples"], 1.5) + + with tempfile.NamedTemporaryFile(suffix=".html", delete=False) as file: + html_path = file.name + self.temp_files.append(html_path) + + convert_binary_to_format(bin_path, html_path, "flamegraph") + + with open(html_path, encoding="utf-8") as file: + content = file.read() + self.assertIn('"duration_sec": 1.25', content) + self.assertIn('"sample_rate": 4.0', content) + self.assertIn('"error_rate": 2.5', content) + self.assertIn('"missed_samples": 1.5', content) + + def test_legacy_binary_has_no_measured_stats(self): + frame = make_frame("hot.py", 99, "hot_func") + bin_path = self.create_binary_file([ + [make_interpreter(0, [make_thread(1, [frame])])] + ]) + + with BinaryReader(bin_path) as reader: + info = reader.get_info() + + self.assertIsNone(info["duration_sec"]) + self.assertIsNone(info["sample_rate"]) + self.assertIsNone(info["error_rate"]) + self.assertIsNone(info["missed_samples"]) + + def test_original_stats_extension_remains_readable(self): + frame = make_frame("hot.py", 99, "hot_func") + with tempfile.NamedTemporaryFile(suffix=".bin", delete=False) as file: + bin_path = file.name + self.temp_files.append(bin_path) + collector = BinaryCollector(bin_path, 2000, compression="none") + collector.collect([ + make_interpreter(0, [make_thread(1, [frame])]) + ]) + collector.set_stats(2000, 1.25, 4.0) + collector.export(None) + + with open(bin_path, "rb") as file: + data = file.read() + stats = data[-88:-32] + footer = bytearray(data[-32:]) + old_stats = stats[:16] + b"TACHSTAT" + struct.pack("=II", 1, 32) + old_data = bytearray(data[:-88] + old_stats + footer) + struct.pack_into("=Q", old_data, -24, len(old_data)) + with open(bin_path, "wb") as file: + file.write(old_data) + + with BinaryReader(bin_path) as reader: + info = reader.get_info() + self.assertEqual(info["duration_sec"], 1.25) + self.assertEqual(info["sample_rate"], 4.0) + self.assertIsNone(info["error_rate"]) + self.assertIsNone(info["missed_samples"]) + + +class TestBinaryReplayToJsonl(BinaryFormatTestBase): + """Tests for binary -> JSONL replay via convert_binary_to_format.""" + + def _replay_to_jsonl(self, samples, interval=1000, mode=None): + bin_path = self.create_binary_file( + samples, interval=interval, mode=mode + ) + with tempfile.NamedTemporaryFile(suffix=".jsonl", delete=False) as f: + jsonl_path = f.name + self.temp_files.append(jsonl_path) + + convert_binary_to_format(bin_path, jsonl_path, "jsonl") + + with open(jsonl_path, "r", encoding="utf-8") as f: + return [json.loads(line) for line in f] + + def test_binary_replay_to_jsonl_basic(self): + """Replay a small .bin to JSONL: meta/end shape, samples_total, run_id.""" + frame = make_frame("hot.py", 99, "hot_func") + samples = [ + [make_interpreter(0, [make_thread(1, [frame])])] + for _ in range(5) + ] + records = self._replay_to_jsonl(samples, interval=2000) + meta, _, frame_defs, _, end = jsonl_tables(records) + + self.assertEqual(meta["sample_interval_usec"], 2000) + self.assertEqual(end["samples_total"], 5) + + run_ids = {r["run_id"] for r in records} + self.assertEqual(len(run_ids), 1) + self.assertRegex(next(iter(run_ids)), r"^[0-9a-f]{32}$") + + self.assertEqual(len(frame_defs), 1) + self.assertEqual(frame_defs[0]["line"], 99) + + def test_binary_replay_to_jsonl_preserves_mode(self): + frame = make_frame("hot.py", 99, "hot_func") + records = self._replay_to_jsonl( + [[make_interpreter(0, [make_thread(1, [frame])])]], + mode=PROFILING_MODE_CPU, + ) + + meta = next(record for record in records if record["type"] == "meta") + self.assertEqual(meta["mode"], "cpu") + + def test_binary_replay_to_jsonl_rle_weight_propagation(self): + """RLE-batched identical samples land as a single agg entry with the right total.""" + frame = make_frame("rle.py", 42, "rle_func") + samples = [ + [make_interpreter(0, [make_thread(1, [frame])])] + for _ in range(50) + ] + records = self._replay_to_jsonl(samples) + _, _, _, agg, end = jsonl_tables(records) + + self.assertEqual(end["samples_total"], 50) + self.assertEqual(agg["entries"], [ + {"frame_id": 0, "self": 50, "cumulative": 50}, + ]) + + def test_binary_replay_to_jsonl_omits_unavailable_columns(self): + """Columns the binary recorder did not capture are omitted, not 0.""" + # make_frame defaults column/end_column to 0; pass column=-1 / end_column=-1 + # so the binary side records LOCATION_NOT_AVAILABLE. + frame = make_frame("nocol.py", 7, "no_col", column=-1, end_column=-1) + samples = [[make_interpreter(0, [make_thread(1, [frame])])]] + records = self._replay_to_jsonl(samples) + _, _, frame_defs, _, _ = jsonl_tables(records) + + self.assertEqual(len(frame_defs), 1) + fd = frame_defs[0] + self.assertEqual(fd["line"], 7) + self.assertNotIn("col", fd) + self.assertNotIn("end_col", fd) + + +if __name__ == "__main__": + unittest.main() diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py new file mode 100644 index 000000000..533ca3637 --- /dev/null +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -0,0 +1,3543 @@ +"""Tests for sampling profiler collector components.""" + +import json +import marshal +import opcode +import os +import tempfile +import unittest + +from test.support import is_emscripten + +try: + import _remote_debugging # noqa: F401 + from profiling.sampling import gecko_collector + from profiling.sampling.pstats_collector import PstatsCollector + from profiling.sampling.stack_collector import ( + CollapsedStackCollector, + FlamegraphCollector, + ) + from profiling.sampling.jsonl_collector import JsonlCollector + from profiling.sampling.gecko_collector import GeckoCollector + from profiling.sampling.heatmap_collector import _TemplateLoader + from profiling.sampling.collector import extract_lineno, normalize_location + from profiling.sampling.opcode_utils import get_opcode_info, format_opcode + from profiling.sampling.constants import ( + PROFILING_MODE_WALL, + PROFILING_MODE_CPU, + DEFAULT_LOCATION, + ) + from _remote_debugging import ( + THREAD_STATUS_HAS_GIL, + THREAD_STATUS_ON_CPU, + THREAD_STATUS_GIL_REQUESTED, + THREAD_STATUS_MAIN_THREAD, + ) +except ImportError: + raise unittest.SkipTest( + "Test only runs when _remote_debugging is available" + ) + +from test.support import captured_stdout, captured_stderr + +from .mocks import ( + MockAwaitedInfo, + MockCoroInfo, + MockFrameInfo, + MockInterpreterInfo, + MockTaskInfo, + MockThreadInfo, + LocationInfo, + make_diff_collector_with_mock_baseline, +) +from .helpers import close_and_unlink, jsonl_tables + + +def resolve_name(node, strings): + """Resolve a flamegraph node's name from the string table.""" + idx = node.get("name", 0) + if isinstance(idx, int) and 0 <= idx < len(strings): + return strings[idx] + return str(idx) + + +def find_child_by_name(children, strings, substr): + """Find a child node whose resolved name contains substr.""" + for child in children: + if substr in resolve_name(child, strings): + return child + return None + + +def export_gecko_profile(testcase, collector): + gecko_out = tempfile.NamedTemporaryFile(suffix=".json", delete=False) + testcase.addCleanup(close_and_unlink, gecko_out) + # We cannot overwrite an open file on Windows. + gecko_out.close() + + with captured_stdout(), captured_stderr(): + collector.export(gecko_out.name) + + testcase.assertGreater(os.path.getsize(gecko_out.name), 0) + with open(gecko_out.name, encoding="utf-8") as file: + return json.load(file) + + +def assert_gecko_column_lengths(testcase, table, columns): + expected = table["length"] + for column in columns: + testcase.assertEqual( + len(table[column]), expected, + f"{column!r} has wrong length", + ) + + +def gecko_marker_names(profile, markers): + string_array = profile["shared"]["stringArray"] + return [string_array[idx] for idx in markers["name"]] + + +def gecko_opcode_marker_data(profile): + markers = profile["threads"][0]["markers"] + return [ + data for data in markers["data"] + if data.get("type") == "Opcode" + ] + + +class TestSampleProfilerComponents(unittest.TestCase): + """Unit tests for individual profiler components.""" + + def test_mock_frame_info_with_empty_and_unicode_values(self): + """Test MockFrameInfo handles empty strings, unicode characters, and very long names correctly.""" + # Test with empty strings + frame = MockFrameInfo("", 0, "") + self.assertEqual(frame.filename, "") + self.assertEqual(frame.location.lineno, 0) + self.assertEqual(frame.funcname, "") + + # Test with unicode characters + frame = MockFrameInfo("文件.py", 42, "函数名") + self.assertEqual(frame.filename, "文件.py") + self.assertEqual(frame.funcname, "函数名") + + # Test with very long names + long_filename = "x" * 1000 + ".py" + long_funcname = "func_" + "x" * 1000 + frame = MockFrameInfo(long_filename, 999999, long_funcname) + self.assertEqual(frame.filename, long_filename) + self.assertEqual(frame.location.lineno, 999999) + self.assertEqual(frame.funcname, long_funcname) + + def test_heatmap_navigation_restarts_line_highlight(self): + """Test heatmap navigation can replay target line highlights.""" + loader = _TemplateLoader() + + self.assertIn(".code-line:target", loader.file_css) + self.assertIn("function restartLineHighlight(target)", loader.file_js) + self.assertIn("target.style.animation = 'none'", loader.file_js) + self.assertIn("void target.offsetWidth", loader.file_js) + self.assertIn("url.href === window.location.href", loader.file_js) + self.assertIn("navigateToLine(JSON.parse(navData).link)", loader.file_js) + self.assertIn("navigateToLine(linkData.link)", loader.file_js) + + def test_pstats_collector_with_extreme_intervals_and_empty_data(self): + """Test PstatsCollector handles zero/large intervals, empty frames, None thread IDs, and duplicate frames.""" + # Test with zero interval + collector = PstatsCollector(sample_interval_usec=0) + self.assertEqual(collector.sample_interval_usec, 0) + + # Test with very large interval + collector = PstatsCollector(sample_interval_usec=1000000000) + self.assertEqual(collector.sample_interval_usec, 1000000000) + + # Test collecting empty frames list + collector = PstatsCollector(sample_interval_usec=1000) + collector.collect([]) + self.assertEqual(len(collector.result), 0) + + # Test collecting frames with None thread id + test_frames = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(None, [MockFrameInfo("file.py", 10, "func", None)])], + ) + ] + collector.collect(test_frames) + # Should still process the frames + self.assertEqual(len(collector.result), 1) + + # Test collecting duplicate frames in same sample (recursive function) + test_frames = [ + MockInterpreterInfo( + 0, # interpreter_id + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("file.py", 10, "func1"), + MockFrameInfo("file.py", 10, "func1"), # Duplicate (recursion) + ], + ) + ], + ) + ] + collector = PstatsCollector(sample_interval_usec=1000) + collector.collect(test_frames) + # Should count only once per sample to avoid over-counting recursive functions + self.assertEqual( + collector.result[("file.py", 10, "func1")]["cumulative_calls"], 1 + ) + + def test_pstats_collector_single_frame_stacks(self): + """Test PstatsCollector with single-frame call stacks to trigger len(frames) <= 1 branch.""" + collector = PstatsCollector(sample_interval_usec=1000) + + # Test with exactly one frame (should trigger the <= 1 condition) + single_frame = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, [MockFrameInfo("single.py", 10, "single_func")] + ) + ], + ) + ] + collector.collect(single_frame) + + # Should record the single frame with inline call + self.assertEqual(len(collector.result), 1) + single_key = ("single.py", 10, "single_func") + self.assertIn(single_key, collector.result) + self.assertEqual(collector.result[single_key]["direct_calls"], 1) + self.assertEqual(collector.result[single_key]["cumulative_calls"], 1) + + # Test with empty frames (should also trigger <= 1 condition) + empty_frames = [MockInterpreterInfo(0, [MockThreadInfo(1, [])])] + collector.collect(empty_frames) + + # Should not add any new entries + self.assertEqual( + len(collector.result), 1 + ) # Still just the single frame + + # Test mixed single and multi-frame stacks + mixed_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [MockFrameInfo("single2.py", 20, "single_func2")], + ), # Single frame + MockThreadInfo( + 2, + [ # Multi-frame stack + MockFrameInfo("multi.py", 30, "multi_func1"), + MockFrameInfo("multi.py", 40, "multi_func2"), + ], + ), + ], + ), + ] + collector.collect(mixed_frames) + + # Should have recorded all functions + self.assertEqual( + len(collector.result), 4 + ) # single + single2 + multi1 + multi2 + + # Verify single frame handling + single2_key = ("single2.py", 20, "single_func2") + self.assertIn(single2_key, collector.result) + self.assertEqual(collector.result[single2_key]["direct_calls"], 1) + self.assertEqual(collector.result[single2_key]["cumulative_calls"], 1) + + # Verify multi-frame handling still works + multi1_key = ("multi.py", 30, "multi_func1") + multi2_key = ("multi.py", 40, "multi_func2") + self.assertIn(multi1_key, collector.result) + self.assertIn(multi2_key, collector.result) + self.assertEqual(collector.result[multi1_key]["direct_calls"], 1) + self.assertEqual( + collector.result[multi2_key]["cumulative_calls"], 1 + ) # Called from multi1 + + def test_collapsed_stack_collector_with_empty_and_deep_stacks(self): + """Test CollapsedStackCollector handles empty frames, single-frame stacks, and very deep call stacks.""" + collector = CollapsedStackCollector(1000) + + # Test with empty frames + collector.collect([]) + self.assertEqual(len(collector.stack_counter), 0) + + # Test with single frame stack + test_frames = [ + MockInterpreterInfo( + 0, [MockThreadInfo(1, [MockFrameInfo("file.py", 10, "func")])] + ) + ] + collector.collect(test_frames) + self.assertEqual(len(collector.stack_counter), 1) + (((path, thread_id), count),) = collector.stack_counter.items() + self.assertEqual(path, (("file.py", 10, "func"),)) + self.assertEqual(thread_id, 1) + self.assertEqual(count, 1) + + # Test with very deep stack + deep_stack = [MockFrameInfo(f"file{i}.py", i, f"func{i}") for i in range(100)] + test_frames = [MockInterpreterInfo(0, [MockThreadInfo(1, deep_stack)])] + collector = CollapsedStackCollector(1000) + collector.collect(test_frames) + # One aggregated path with 100 frames (reversed) + (((path_tuple, thread_id),),) = (collector.stack_counter.keys(),) + self.assertEqual(len(path_tuple), 100) + self.assertEqual(path_tuple[0], ("file99.py", 99, "func99")) + self.assertEqual(path_tuple[-1], ("file0.py", 0, "func0")) + self.assertEqual(thread_id, 1) + + def test_pstats_collector_basic(self): + """Test basic PstatsCollector functionality.""" + collector = PstatsCollector(sample_interval_usec=1000) + + # Test empty state + self.assertEqual(len(collector.result), 0) + self.assertEqual(len(collector.stats), 0) + + # Test collecting sample data + test_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("file.py", 10, "func1"), + MockFrameInfo("file.py", 20, "func2"), + ], + ) + ], + ) + ] + collector.collect(test_frames) + + # Should have recorded calls for both functions + self.assertEqual(len(collector.result), 2) + self.assertIn(("file.py", 10, "func1"), collector.result) + self.assertIn(("file.py", 20, "func2"), collector.result) + + # Top-level function should have direct call + self.assertEqual( + collector.result[("file.py", 10, "func1")]["direct_calls"], 1 + ) + self.assertEqual( + collector.result[("file.py", 10, "func1")]["cumulative_calls"], 1 + ) + + # Calling function should have cumulative call but no direct calls + self.assertEqual( + collector.result[("file.py", 20, "func2")]["cumulative_calls"], 1 + ) + self.assertEqual( + collector.result[("file.py", 20, "func2")]["direct_calls"], 0 + ) + + def test_pstats_collector_create_stats(self): + """Test PstatsCollector stats creation.""" + collector = PstatsCollector( + sample_interval_usec=1000000 + ) # 1 second intervals + + test_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("file.py", 10, "func1"), + MockFrameInfo("file.py", 20, "func2"), + ], + ) + ], + ) + ] + collector.collect(test_frames) + collector.collect(test_frames) # Collect twice + + collector.create_stats() + + # Check stats format: (direct_calls, cumulative_calls, tt, ct, callers) + func1_stats = collector.stats[("file.py", 10, "func1")] + self.assertEqual(func1_stats[0], 2) # direct_calls (top of stack) + self.assertEqual(func1_stats[1], 2) # cumulative_calls + self.assertEqual( + func1_stats[2], 2.0 + ) # tt (total time - 2 samples * 1 sec) + self.assertEqual(func1_stats[3], 2.0) # ct (cumulative time) + + func2_stats = collector.stats[("file.py", 20, "func2")] + self.assertEqual( + func2_stats[0], 0 + ) # direct_calls (never top of stack) + self.assertEqual( + func2_stats[1], 2 + ) # cumulative_calls (appears in stack) + self.assertEqual(func2_stats[2], 0.0) # tt (no direct calls) + self.assertEqual(func2_stats[3], 2.0) # ct (cumulative time) + + def test_collapsed_stack_collector_basic(self): + collector = CollapsedStackCollector(1000) + + # Test empty state + self.assertEqual(len(collector.stack_counter), 0) + + # Test collecting sample data + test_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, [MockFrameInfo("file.py", 10, "func1"), MockFrameInfo("file.py", 20, "func2")] + ) + ], + ) + ] + collector.collect(test_frames) + + # Should store one reversed path + self.assertEqual(len(collector.stack_counter), 1) + (((path, thread_id), count),) = collector.stack_counter.items() + expected_tree = (("file.py", 20, "func2"), ("file.py", 10, "func1")) + self.assertEqual(path, expected_tree) + self.assertEqual(thread_id, 1) + self.assertEqual(count, 1) + + def test_collapsed_stack_collector_export(self): + collapsed_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, collapsed_out) + + collector = CollapsedStackCollector(1000) + + test_frames1 = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, [MockFrameInfo("file.py", 10, "func1"), MockFrameInfo("file.py", 20, "func2")] + ) + ], + ) + ] + test_frames2 = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, [MockFrameInfo("file.py", 10, "func1"), MockFrameInfo("file.py", 20, "func2")] + ) + ], + ) + ] # Same stack + test_frames3 = [ + MockInterpreterInfo( + 0, [MockThreadInfo(1, [MockFrameInfo("other.py", 5, "other_func")])] + ) + ] + + collector.collect(test_frames1) + collector.collect(test_frames2) + collector.collect(test_frames3) + + with captured_stdout(), captured_stderr(): + collector.export(collapsed_out.name) + # Check file contents + with open(collapsed_out.name, "r") as f: + content = f.read() + + lines = content.strip().split("\n") + self.assertEqual(len(lines), 2) # Two unique stacks + + # Check collapsed format: tid:X;file:func:line;file:func:line count + stack1_expected = "tid:1;file.py:func2:20;file.py:func1:10 2" + stack2_expected = "tid:1;other.py:other_func:5 1" + + self.assertIn(stack1_expected, lines) + self.assertIn(stack2_expected, lines) + + def test_flamegraph_collector_basic(self): + """Test basic FlamegraphCollector functionality.""" + collector = FlamegraphCollector(1000) + + # Empty collector should produce 'No Data' + data = collector._convert_to_flamegraph_format() + # With string table, name is now an index - resolve it using the strings array + strings = data.get("strings", []) + self.assertIn(resolve_name(data, strings), ("No Data", "No significant data")) + + # Test collecting sample data + test_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, [MockFrameInfo("file.py", 10, "func1"), MockFrameInfo("file.py", 20, "func2")] + ) + ], + ) + ] + collector.collect(test_frames) + + # Convert and verify structure: func2 -> func1 with counts = 1 + data = collector._convert_to_flamegraph_format() + # Expect promotion: root is the single child (func2), with func1 as its only child + strings = data.get("strings", []) + name = resolve_name(data, strings) + self.assertTrue(name.startswith("Program Root: ")) + self.assertIn("func2 (file.py:20)", name) + label = strings[data["label"]] + self.assertTrue(label.startswith("Program Root: ")) + self.assertEqual(data["self"], 0) # non-leaf: no self time + children = data.get("children", []) + self.assertEqual(len(children), 1) + child = children[0] + self.assertIn("func1 (file.py:10)", resolve_name(child, strings)) + self.assertEqual(child["value"], 1) + self.assertEqual(child["self"], 1) # leaf: all time is self + self.assertEqual(data["stats"]["sample_interval_usec"], 1000) + + def test_flamegraph_collector_export(self): + """Test flamegraph HTML export functionality.""" + flamegraph_out = tempfile.NamedTemporaryFile( + suffix=".html", delete=False + ) + self.addCleanup(close_and_unlink, flamegraph_out) + + collector = FlamegraphCollector(10000) + + # Create some test data (use Interpreter/Thread objects like runtime) + test_frames1 = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, [MockFrameInfo("file.py", 10, "func1"), MockFrameInfo("file.py", 20, "func2")] + ) + ], + ) + ] + test_frames2 = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, [MockFrameInfo("file.py", 10, "func1"), MockFrameInfo("file.py", 20, "func2")] + ) + ], + ) + ] # Same stack + test_frames3 = [ + MockInterpreterInfo( + 0, [MockThreadInfo(1, [MockFrameInfo("other.py", 5, "other_func")])] + ) + ] + + collector.collect(test_frames1) + collector.collect(test_frames2) + collector.collect(test_frames3) + + # Export flamegraph + with captured_stdout(), captured_stderr(): + export_ok = collector.export(flamegraph_out.name) + + # Verify file was created and contains valid data + self.assertTrue(export_ok) + self.assertTrue(os.path.exists(flamegraph_out.name)) + self.assertGreater(os.path.getsize(flamegraph_out.name), 0) + + # Check file contains HTML content + with open(flamegraph_out.name, "r", encoding="utf-8") as f: + content = f.read() + + # Should be valid HTML + self.assertIn("", content.lower()) + self.assertIn("")], status=THREAD_STATUS_HAS_GIL), + ], + ) + ] + collector.collect(stack_frames_gc) + self.assertEqual(collector.samples_with_gc_frames, 1) + + # Another sample without GC + collector.collect(stack_frames_1) + self.assertEqual(collector.samples_with_gc_frames, 1) # Still 1 + + # Another GC sample + collector.collect(stack_frames_gc) + self.assertEqual(collector.samples_with_gc_frames, 2) + + def test_flamegraph_collector_restores_replay_stats(self): + """Replay restores measured values from binary metadata.""" + collector = FlamegraphCollector(1000) + collector.set_replay_stats({ + "duration_sec": 1.25, + "sample_rate": 4.0, + "error_rate": 2.5, + "missed_samples": 1.5, + }) + + self.assertEqual(collector.stats["duration_sec"], 1.25) + self.assertEqual(collector.stats["sample_rate"], 4.0) + self.assertEqual(collector.stats["error_rate"], 2.5) + self.assertEqual(collector.stats["missed_samples"], 1.5) + + def test_flamegraph_collector_leaves_legacy_replay_stats_unavailable(self): + collector = FlamegraphCollector(1000) + original_stats = collector.stats.copy() + collector.set_replay_stats({ + "duration_sec": None, + "sample_rate": None, + }) + self.assertEqual(collector.stats, original_stats) + + def test_flamegraph_collector_per_thread_stats(self): + """Test per-thread statistics tracking in FlamegraphCollector.""" + collector = FlamegraphCollector(sample_interval_usec=1000) + + # Multiple threads with different states + stack_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo(1, [MockFrameInfo("a.py", 1, "func_a")], status=THREAD_STATUS_HAS_GIL), + MockThreadInfo(2, [MockFrameInfo("b.py", 2, "func_b")], status=THREAD_STATUS_ON_CPU), + MockThreadInfo(3, [MockFrameInfo("c.py", 3, "func_c")], status=THREAD_STATUS_GIL_REQUESTED), + ], + ) + ] + collector.collect(stack_frames) + + # Check per-thread stats + self.assertIn(1, collector.per_thread_stats) + self.assertIn(2, collector.per_thread_stats) + self.assertIn(3, collector.per_thread_stats) + + # Thread 1: has GIL + self.assertEqual(collector.per_thread_stats[1]["has_gil"], 1) + self.assertEqual(collector.per_thread_stats[1]["on_cpu"], 0) + self.assertEqual(collector.per_thread_stats[1]["total"], 1) + + # Thread 2: on CPU + self.assertEqual(collector.per_thread_stats[2]["has_gil"], 0) + self.assertEqual(collector.per_thread_stats[2]["on_cpu"], 1) + self.assertEqual(collector.per_thread_stats[2]["total"], 1) + + # Thread 3: waiting + self.assertEqual(collector.per_thread_stats[3]["gil_requested"], 1) + self.assertEqual(collector.per_thread_stats[3]["total"], 1) + + # Test accumulation across samples + stack_frames_2 = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo(1, [MockFrameInfo("a.py", 2, "func_b")], status=THREAD_STATUS_ON_CPU), + ], + ) + ] + collector.collect(stack_frames_2) + + self.assertEqual(collector.per_thread_stats[1]["has_gil"], 1) + self.assertEqual(collector.per_thread_stats[1]["on_cpu"], 1) + self.assertEqual(collector.per_thread_stats[1]["total"], 2) + + def test_flamegraph_collector_percentage_calculations(self): + """Test that percentage calculations are correct in exported data.""" + collector = FlamegraphCollector(sample_interval_usec=1000) + + # Create scenario: 60% GIL held, 40% not held + for i in range(6): + stack_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo(1, [MockFrameInfo("a.py", 1, "func")], status=THREAD_STATUS_HAS_GIL), + ], + ) + ] + collector.collect(stack_frames) + + for i in range(4): + stack_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo(1, [MockFrameInfo("a.py", 1, "func")], status=THREAD_STATUS_ON_CPU), + ], + ) + ] + collector.collect(stack_frames) + + # Export to get calculated percentages + data = collector._convert_to_flamegraph_format() + thread_stats = data["stats"]["thread_stats"] + + self.assertAlmostEqual(thread_stats["has_gil_pct"], 60.0, places=1) + self.assertAlmostEqual(thread_stats["on_cpu_pct"], 40.0, places=1) + self.assertEqual(thread_stats["total"], 10) + + def test_flamegraph_collector_mode_handling(self): + """Test that profiling mode is correctly passed through to exported data.""" + collector = FlamegraphCollector(sample_interval_usec=1000) + + # Collect some data + stack_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo(1, [MockFrameInfo("a.py", 1, "func")], status=THREAD_STATUS_HAS_GIL), + ], + ) + ] + collector.collect(stack_frames) + + # Set stats with mode + collector.set_stats( + sample_interval_usec=1000, + duration_sec=1.0, + sample_rate=1000.0, + mode=PROFILING_MODE_CPU + ) + + data = collector._convert_to_flamegraph_format() + self.assertEqual(data["stats"]["mode"], PROFILING_MODE_CPU) + + def test_flamegraph_collector_zero_samples_edge_case(self): + """Test that collector handles zero samples gracefully.""" + collector = FlamegraphCollector(sample_interval_usec=1000) + + # Export without collecting any samples + data = collector._convert_to_flamegraph_format() + + # Should return a valid structure with no data + self.assertIn("name", data) + self.assertEqual(data["value"], 0) + self.assertIn("children", data) + self.assertEqual(len(data["children"]), 0) + + def test_flamegraph_collector_json_structure_includes_stats(self): + """Test that exported JSON includes thread_stats and per_thread_stats.""" + collector = FlamegraphCollector(sample_interval_usec=1000) + + # Collect some data with multiple threads + stack_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo(1, [MockFrameInfo("a.py", 1, "func_a")], status=THREAD_STATUS_HAS_GIL), + MockThreadInfo(2, [MockFrameInfo("b.py", 2, "func_b")], status=THREAD_STATUS_ON_CPU), + ], + ) + ] + collector.collect(stack_frames) + + # Set stats + collector.set_stats( + sample_interval_usec=1000, + duration_sec=1.0, + sample_rate=1000.0, + mode=PROFILING_MODE_WALL + ) + + # Export and verify structure + data = collector._convert_to_flamegraph_format() + + # Check that stats object exists and contains expected fields + self.assertIn("stats", data) + stats = data["stats"] + + # Verify thread_stats exists and has expected structure + self.assertIn("thread_stats", stats) + thread_stats = stats["thread_stats"] + self.assertIn("has_gil_pct", thread_stats) + self.assertIn("on_cpu_pct", thread_stats) + self.assertIn("gil_requested_pct", thread_stats) + self.assertIn("gc_pct", thread_stats) + self.assertIn("total", thread_stats) + + # Verify per_thread_stats exists and has data for both threads + self.assertIn("per_thread_stats", stats) + per_thread_stats = stats["per_thread_stats"] + self.assertIn(1, per_thread_stats) + self.assertIn(2, per_thread_stats) + + # Check per-thread structure + for thread_id in [1, 2]: + thread_data = per_thread_stats[thread_id] + self.assertIn("has_gil_pct", thread_data) + self.assertIn("on_cpu_pct", thread_data) + self.assertIn("gil_requested_pct", thread_data) + self.assertIn("gc_pct", thread_data) + self.assertIn("total", thread_data) + + def test_flamegraph_nodes_include_per_thread_values(self): + collector = FlamegraphCollector(sample_interval_usec=1000) + root = MockFrameInfo("app.py", 1, "main") + collector.process_frames( + [MockFrameInfo("app.py", 10, "worker_a"), root], + thread_id=1, + weight=2, + ) + collector.process_frames( + [MockFrameInfo("app.py", 20, "worker_b"), root], + thread_id=2, + weight=3, + ) + + data = collector._convert_to_flamegraph_format() + + self.assertEqual(data["thread_values"], {1: [2, 0], 2: [3, 0]}) + children_by_line = {child["lineno"]: child for child in data["children"]} + self.assertEqual(children_by_line[10]["thread_values"], {1: [2, 2]}) + self.assertEqual(children_by_line[20]["thread_values"], {2: [3, 3]}) + + def test_flamegraph_pruning_preserves_low_volume_thread(self): + collector = FlamegraphCollector(sample_interval_usec=1000) + collector.process_frames( + [MockFrameInfo("app.py", 10, "busy")], + thread_id=1, + weight=1999, + ) + collector.process_frames( + [MockFrameInfo("app.py", 20, "rare")], + thread_id=2, + ) + + data = collector._convert_to_flamegraph_format() + + self.assertEqual(data["thread_values"], {1: [1999, 0], 2: [1, 0]}) + children_by_line = {child["lineno"]: child for child in data["children"]} + self.assertEqual(children_by_line[10]["thread_values"], {1: [1999, 1999]}) + self.assertEqual(children_by_line[20]["thread_values"], {2: [1, 1]}) + + def test_flamegraph_does_not_promote_incomplete_root(self): + collector = FlamegraphCollector(sample_interval_usec=1000) + collector.process_frames( + [MockFrameInfo("app.py", 1, "busy")], + thread_id=1, + weight=2000, + ) + for line in range(2, 2002): + collector.process_frames( + [MockFrameInfo("app.py", line, f"fragment_{line}")], + thread_id=2, + ) + + data = collector._convert_to_flamegraph_format() + + self.assertNotIn("filename", data) + self.assertEqual(data["thread_values"], {1: [2000, 0], 2: [2000, 0]}) + self.assertEqual(len(data["children"]), 1) + self.assertEqual(data["children"][0]["thread_values"], {1: [2000, 2000]}) + + def test_flamegraph_nodes_include_per_thread_opcodes(self): + collector = FlamegraphCollector(sample_interval_usec=1000) + collector.process_frames( + [MockFrameInfo("app.py", 10, "worker", opcode=100)], + thread_id=1, + weight=2, + ) + collector.process_frames( + [MockFrameInfo("app.py", 10, "worker", opcode=101)], + thread_id=2, + weight=3, + ) + + data = collector._convert_to_flamegraph_format() + + self.assertEqual(data["opcodes"], {100: 2, 101: 3}) + self.assertEqual( + data["thread_opcodes"], + {1: {100: 2}, 2: {101: 3}}, + ) + + def test_flamegraph_collector_per_thread_gc_percentage(self): + """Test that per-thread GC percentage uses total samples as denominator.""" + collector = FlamegraphCollector(sample_interval_usec=1000) + + # Create 10 samples total: + # - Thread 1 appears in all 10 samples, has GC in 2 of them + # - Thread 2 appears in only 5 samples, has GC in 1 of them + + # First 5 samples: both threads, thread 1 has GC in 2 + for i in range(5): + has_gc = i < 2 # First 2 samples have GC for thread 1 + frames_1 = [MockFrameInfo("~", 0, "")] if has_gc else [MockFrameInfo("a.py", 1, "func_a")] + stack_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo(1, frames_1, status=THREAD_STATUS_HAS_GIL), + MockThreadInfo(2, [MockFrameInfo("b.py", 2, "func_b")], status=THREAD_STATUS_ON_CPU), + ], + ) + ] + collector.collect(stack_frames) + + # Next 5 samples: only thread 1, thread 2 appears in first of these with GC + for i in range(5): + if i == 0: + # Thread 2 appears in this sample with GC + stack_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo(1, [MockFrameInfo("a.py", 1, "func_a")], status=THREAD_STATUS_HAS_GIL), + MockThreadInfo(2, [MockFrameInfo("~", 0, "")], status=THREAD_STATUS_ON_CPU), + ], + ) + ] + else: + # Only thread 1 + stack_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo(1, [MockFrameInfo("a.py", 1, "func_a")], status=THREAD_STATUS_HAS_GIL), + ], + ) + ] + collector.collect(stack_frames) + + # Set stats and export + collector.set_stats( + sample_interval_usec=1000, + duration_sec=1.0, + sample_rate=1000.0, + mode=PROFILING_MODE_WALL + ) + + data = collector._convert_to_flamegraph_format() + per_thread_stats = data["stats"]["per_thread_stats"] + + # Thread 1: appeared in 10 samples, had GC in 2 + # GC percentage should be 2/10 = 20% (using total samples, not thread appearances) + self.assertEqual(collector.per_thread_stats[1]["gc_samples"], 2) + self.assertEqual(collector.per_thread_stats[1]["total"], 10) + self.assertAlmostEqual(per_thread_stats[1]["gc_pct"], 20.0, places=1) + + # Thread 2: appeared in 6 samples, had GC in 1 + # GC percentage should be 1/10 = 10% (using total samples, not thread appearances) + self.assertEqual(collector.per_thread_stats[2]["gc_samples"], 1) + self.assertEqual(collector.per_thread_stats[2]["total"], 6) + self.assertAlmostEqual(per_thread_stats[2]["gc_pct"], 10.0, places=1) + + def test_diff_flamegraph_identical_profiles(self): + """When baseline and current are identical, diff should be ~0.""" + test_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 10, "func1"), + MockFrameInfo("file.py", 20, "func2"), + ]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline([test_frames] * 3) + for _ in range(3): + diff.collect(test_frames) + + data = diff._convert_to_flamegraph_format() + strings = data.get("strings", []) + + self.assertTrue(data["stats"]["is_differential"]) + self.assertEqual(data["stats"]["baseline_samples"], 3) + self.assertEqual(data["stats"]["current_samples"], 3) + self.assertAlmostEqual(data["stats"]["baseline_scale"], 1.0) + + children = data.get("children", []) + self.assertEqual(len(children), 1) + child = children[0] + self.assertIn("func1", resolve_name(child, strings)) + self.assertEqual(child["self_time"], 3) + self.assertAlmostEqual(child["baseline"], 3.0) + self.assertAlmostEqual(child["diff"], 0.0, places=1) + self.assertAlmostEqual(child["diff_pct"], 0.0, places=1) + + self.assertEqual(data["stats"]["elided_count"], 0) + self.assertNotIn("elided_flamegraph", data["stats"]) + + def test_diff_flamegraph_new_function(self): + """A function only in current should have diff_pct=100 and baseline=0.""" + baseline_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 10, "func1"), + MockFrameInfo("file.py", 20, "func2"), + ]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline([baseline_frames]) + diff.collect([ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 30, "new_func"), + MockFrameInfo("file.py", 10, "func1"), + MockFrameInfo("file.py", 20, "func2"), + ]) + ]) + ]) + + data = diff._convert_to_flamegraph_format() + strings = data.get("strings", []) + + children = data.get("children", []) + self.assertEqual(len(children), 1) + func1_node = children[0] + self.assertIn("func1", resolve_name(func1_node, strings)) + + func1_children = func1_node.get("children", []) + self.assertEqual(len(func1_children), 1) + new_func_node = func1_children[0] + self.assertIn("new_func", resolve_name(new_func_node, strings)) + self.assertEqual(new_func_node["baseline"], 0) + self.assertGreater(new_func_node["self_time"], 0) + self.assertEqual(new_func_node["diff"], new_func_node["self_time"]) + self.assertAlmostEqual(new_func_node["diff_pct"], 100.0) + + def test_diff_flamegraph_changed_functions(self): + """Functions with different sample counts should have correct diff and diff_pct.""" + hot_leaf_sample = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 10, "hot_leaf"), + MockFrameInfo("file.py", 20, "caller"), + ]) + ]) + ] + cold_leaf_sample = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 30, "cold_leaf"), + MockFrameInfo("file.py", 20, "caller"), + ]) + ]) + ] + + # Baseline: 2 samples, current: 4, scale = 2.0 + diff = make_diff_collector_with_mock_baseline( + [hot_leaf_sample, cold_leaf_sample] + ) + for _ in range(3): + diff.collect(hot_leaf_sample) + diff.collect(cold_leaf_sample) + + data = diff._convert_to_flamegraph_format() + strings = data.get("strings", []) + self.assertAlmostEqual(data["stats"]["baseline_scale"], 2.0) + + children = data.get("children", []) + hot_node = find_child_by_name(children, strings, "hot_leaf") + cold_node = find_child_by_name(children, strings, "cold_leaf") + self.assertIsNotNone(hot_node) + self.assertIsNotNone(cold_node) + + # hot_leaf regressed (+50%) + self.assertAlmostEqual(hot_node["baseline"], 2.0) + self.assertEqual(hot_node["self_time"], 3) + self.assertAlmostEqual(hot_node["diff"], 1.0) + self.assertAlmostEqual(hot_node["diff_pct"], 50.0) + + # cold_leaf improved (-50%) + self.assertAlmostEqual(cold_node["baseline"], 2.0) + self.assertEqual(cold_node["self_time"], 1) + self.assertAlmostEqual(cold_node["diff"], -1.0) + self.assertAlmostEqual(cold_node["diff_pct"], -50.0) + + def test_diff_flamegraph_rejects_mismatched_profiling_modes(self): + from profiling.sampling.binary_collector import BinaryCollector + from profiling.sampling.stack_collector import DiffFlamegraphCollector + + bin_file = tempfile.NamedTemporaryFile(suffix=".bin", delete=False) + self.addCleanup(close_and_unlink, bin_file) + writer = BinaryCollector( + bin_file.name, + sample_interval_usec=1000, + compression="none", + mode=PROFILING_MODE_CPU, + ) + writer.export(None) + + diff = DiffFlamegraphCollector( + 1000, + baseline_binary_path=bin_file.name, + mode=PROFILING_MODE_WALL, + ) + with self.assertRaisesRegex(ValueError, "profiling mode"): + diff._convert_to_flamegraph_format() + + def test_diff_flamegraph_rejects_mismatched_capture_config(self): + from profiling.sampling.binary_collector import BinaryCollector + from profiling.sampling.stack_collector import DiffFlamegraphCollector + + bin_file = tempfile.NamedTemporaryFile(suffix=".bin", delete=False) + self.addCleanup(close_and_unlink, bin_file) + writer = BinaryCollector( + bin_file.name, + sample_interval_usec=1000, + compression="none", + capture_config={"all_threads": True}, + ) + writer.export(None) + + diff = DiffFlamegraphCollector( + 1000, + baseline_binary_path=bin_file.name, + capture_config={"all_threads": False}, + ) + with self.assertRaisesRegex(ValueError, "all_threads"): + diff._convert_to_flamegraph_format() + + def test_diff_flamegraph_scale_factor(self): + """Scale factor adjusts when sample counts differ.""" + baseline_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 10, "func1"), + MockFrameInfo("file.py", 20, "func2"), + ]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline([baseline_frames]) + for _ in range(4): + diff.collect(baseline_frames) + + data = diff._convert_to_flamegraph_format() + self.assertAlmostEqual(data["stats"]["baseline_scale"], 4.0) + + children = data.get("children", []) + self.assertEqual(len(children), 1) + func1_node = children[0] + self.assertEqual(func1_node["self_time"], 4) + self.assertAlmostEqual(func1_node["baseline"], 4.0) + self.assertAlmostEqual(func1_node["diff"], 0.0) + self.assertAlmostEqual(func1_node["diff_pct"], 0.0) + + def test_diff_flamegraph_elided_stacks(self): + """Paths in baseline but not current produce elided stacks.""" + baseline_frames_1 = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 10, "func1"), + MockFrameInfo("file.py", 20, "func2"), + ]) + ]) + ] + baseline_frames_2 = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 30, "old_func"), + MockFrameInfo("file.py", 20, "func2"), + ]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline([baseline_frames_1, baseline_frames_2]) + for _ in range(2): + diff.collect(baseline_frames_1) + + data = diff._convert_to_flamegraph_format() + + self.assertEqual(data["stats"]["elided_count"], 1) + self.assertIn("elided_flamegraph", data["stats"]) + elided = data["stats"]["elided_flamegraph"] + self.assertTrue(elided["stats"]["is_differential"]) + self.assertIn("strings", elided) + + elided_strings = elided.get("strings", []) + children = elided.get("children", []) + self.assertEqual(len(children), 1) + child = children[0] + self.assertIn("old_func", resolve_name(child, elided_strings)) + self.assertEqual(child["self_time"], 0) + self.assertAlmostEqual(child["diff_pct"], -100.0) + self.assertGreater(child["baseline"], 0) + self.assertAlmostEqual(child["diff"], -child["baseline"]) + + def test_diff_flamegraph_counts_elided_stacks_not_paths(self): + """Internal and leaf stack endings are counted separately.""" + internal_stack = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 20, "old_mid"), + MockFrameInfo("file.py", 10, "root"), + ]) + ]) + ] + leaf_stack = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 30, "old_leaf"), + MockFrameInfo("file.py", 20, "old_mid"), + MockFrameInfo("file.py", 10, "root"), + ]) + ]) + ] + current_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [MockFrameInfo("file.py", 10, "root")]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline( + [internal_stack, leaf_stack] + ) + diff.collect(current_frames) + + data = diff._convert_to_flamegraph_format() + self.assertEqual(data["stats"]["elided_count"], 2) + + def test_diff_flamegraph_renders_small_elided_stack(self): + """Elided stacks are not removed by the significance filter.""" + common_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 20, "common"), + MockFrameInfo("file.py", 10, "root"), + ]) + ]) + ] + old_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 30, "old_tiny"), + MockFrameInfo("file.py", 10, "root"), + ]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline( + [common_frames] * 1999 + [old_frames] + ) + for _ in range(1999): + diff.collect(common_frames) + + data = diff._convert_to_flamegraph_format() + self.assertEqual(data["stats"]["elided_count"], 1) + self.assertIn("elided_flamegraph", data["stats"]) + + elided = data["stats"]["elided_flamegraph"] + strings = elided["strings"] + self.assertIsNotNone( + find_child_by_name(elided.get("children", []), strings, "old_tiny") + ) + + def test_diff_flamegraph_elided_top_level_root(self): + """Elided top-level roots do not crash metadata generation.""" + baseline_frames_1 = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 10, "kept_leaf"), + MockFrameInfo("file.py", 20, "kept_root"), + ]) + ]) + ] + baseline_frames_2 = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 30, "old_leaf"), + MockFrameInfo("file.py", 40, "old_root"), + ]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline([ + baseline_frames_1, + baseline_frames_2, + ]) + diff.collect(baseline_frames_1) + + data = diff._convert_to_flamegraph_format() + elided = data["stats"]["elided_flamegraph"] + elided_strings = elided.get("strings", []) + children = elided.get("children", []) + + self.assertEqual(len(children), 1) + self.assertIn("old_root", resolve_name(children[0], elided_strings)) + + def test_diff_flamegraph_function_matched_despite_line_change(self): + """Functions match by (filename, funcname), ignoring lineno.""" + baseline_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 10, "func1"), + MockFrameInfo("file.py", 20, "func2"), + ]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline([baseline_frames]) + # Same functions but different line numbers + diff.collect([ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 99, "func1"), + MockFrameInfo("file.py", 55, "func2"), + ]) + ]) + ]) + + data = diff._convert_to_flamegraph_format() + strings = data.get("strings", []) + + children = data.get("children", []) + self.assertEqual(len(children), 1) + child = children[0] + self.assertIn("func1", resolve_name(child, strings)) + self.assertGreater(child["baseline"], 0) + self.assertGreater(child["self_time"], 0) + self.assertAlmostEqual(child["diff"], 0.0, places=1) + self.assertAlmostEqual(child["diff_pct"], 0.0, places=1) + + def test_diff_flamegraph_does_not_duplicate_line_values(self): + """Function aggregates are apportioned across line nodes.""" + def sample(line): + return [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", line, "func"), + MockFrameInfo("file.py", 1, "caller"), + ]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline( + [sample(10), sample(20)] + ) + diff.collect(sample(10)) + diff.collect(sample(20)) + + data = diff._convert_to_flamegraph_format() + children = data["children"] + self.assertEqual(sum(node["self"] for node in children), 2) + self.assertEqual(sum(node["self_time"] for node in children), 2) + self.assertEqual(sum(node["baseline"] for node in children), 2) + for node in children: + self.assertEqual(node["self"], 1) + self.assertEqual(node["self_time"], 1) + self.assertAlmostEqual(node["baseline"], 1.0) + self.assertAlmostEqual(node["diff"], 0.0) + + def test_diff_flamegraph_line_totals_include_allocated_self(self): + """A line's baseline self time cannot exceed its inclusive time.""" + def sample(*frames): + return [ + MockInterpreterInfo(0, [MockThreadInfo(1, list(frames))]) + ] + + target_10 = MockFrameInfo("file.py", 10, "target") + target_20 = MockFrameInfo("file.py", 20, "target") + child = MockFrameInfo("file.py", 30, "child") + + diff = make_diff_collector_with_mock_baseline( + [sample(target_10)] * 100 + ) + for _ in range(10): + diff.collect(sample(target_10)) + for _ in range(90): + diff.collect(sample(child, target_20)) + + data = diff._convert_to_flamegraph_format() + nodes = data["children"] + self.assertEqual(sum(node["baseline"] for node in nodes), 100) + self.assertEqual(sum(node["baseline_total"] for node in nodes), 100) + for node in nodes: + self.assertGreaterEqual(node["baseline"], 0) + self.assertLessEqual(node["baseline"], node["baseline_total"]) + + def test_diff_flamegraph_does_not_duplicate_elided_line_values(self): + """Elided metadata uses each rendered line node's samples.""" + def sample(line, funcname="old_func"): + return [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", line, funcname), + MockFrameInfo("file.py", 1, "caller"), + ]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline( + [sample(10), sample(20)] + ) + diff.collect(sample(30, "new_func")) + + data = diff._convert_to_flamegraph_format() + elided = data["stats"]["elided_flamegraph"] + children = elided["children"] + scale = data["stats"]["baseline_scale"] + self.assertEqual(sum(node["self"] for node in children), 2) + self.assertEqual(sum(node["baseline"] for node in children), 2 * scale) + for node in children: + self.assertEqual(node["self"], 1) + self.assertAlmostEqual(node["baseline"], scale) + self.assertAlmostEqual(node["diff"], -scale) + + def test_diff_flamegraph_elided_ancestors_have_no_lost_self_time(self): + """Matched ancestors only carry inclusive elided geometry.""" + root = MockFrameInfo("file.py", 10, "root") + common = MockFrameInfo("file.py", 20, "common", opcode=100) + old = MockFrameInfo("file.py", 30, "old") + + common_sample = [ + MockInterpreterInfo(0, [MockThreadInfo(1, [common, root])]) + ] + old_sample = [ + MockInterpreterInfo(0, [MockThreadInfo(1, [old, common, root])]) + ] + + diff = make_diff_collector_with_mock_baseline( + [common_sample] * 3 + [old_sample] + ) + diff.collect(common_sample) + + data = diff._convert_to_flamegraph_format() + elided_root = data["stats"]["elided_flamegraph"] + common_node = elided_root["children"][0] + old_node = common_node["children"][0] + + for ancestor in (elided_root, common_node): + self.assertEqual(ancestor["self"], 0) + self.assertEqual(ancestor["baseline"], 0) + self.assertNotIn("opcodes", ancestor) + self.assertLessEqual( + ancestor["baseline"], ancestor["baseline_total"] + ) + self.assertEqual(old_node["self"], 1) + self.assertEqual(old_node["baseline"], old_node["baseline_total"]) + + def test_diff_flamegraph_empty_current(self): + """Empty current profile still produces differential metadata and elided paths.""" + baseline_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [MockFrameInfo("file.py", 10, "func1")]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline([baseline_frames]) + # Don't collect anything in current + + data = diff._convert_to_flamegraph_format() + self.assertIn("name", data) + self.assertEqual(data["value"], 0) + # Differential metadata should still be populated + self.assertTrue(data["stats"]["is_differential"]) + # All baseline paths should be elided since current is empty + self.assertGreater(data["stats"]["elided_count"], 0) + + def test_diff_flamegraph_empty_baseline(self): + """Empty baseline with non-empty current uses scale=1.0 fallback.""" + diff = make_diff_collector_with_mock_baseline([]) + diff.collect([ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 10, "func1"), + MockFrameInfo("file.py", 20, "func2"), + ]) + ]) + ]) + + data = diff._convert_to_flamegraph_format() + strings = data.get("strings", []) + + self.assertTrue(data["stats"]["is_differential"]) + self.assertEqual(data["stats"]["baseline_samples"], 0) + self.assertEqual(data["stats"]["current_samples"], 1) + self.assertAlmostEqual(data["stats"]["baseline_scale"], 1.0) + self.assertEqual(data["stats"]["elided_count"], 0) + + children = data.get("children", []) + self.assertEqual(len(children), 1) + child = children[0] + self.assertIn("func1", resolve_name(child, strings)) + self.assertEqual(child["self_time"], 1) + self.assertAlmostEqual(child["baseline"], 0.0) + self.assertAlmostEqual(child["diff"], 1.0) + self.assertAlmostEqual(child["diff_pct"], 100.0) + + def test_diff_flamegraph_export(self): + """DiffFlamegraphCollector export produces differential HTML.""" + test_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 10, "func1"), + MockFrameInfo("file.py", 20, "func2"), + ]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline([test_frames]) + diff.collect(test_frames) + + flamegraph_out = tempfile.NamedTemporaryFile( + suffix=".html", delete=False + ) + self.addCleanup(close_and_unlink, flamegraph_out) + + with captured_stdout(), captured_stderr(): + export_ok = diff.export(flamegraph_out.name) + + self.assertTrue(export_ok) + self.assertTrue(os.path.exists(flamegraph_out.name)) + self.assertGreater(os.path.getsize(flamegraph_out.name), 0) + + with open(flamegraph_out.name, "r", encoding="utf-8") as f: + content = f.read() + + self.assertIn("", content.lower()) + self.assertIn("Differential Flamegraph", content) + self.assertIn('"is_differential": true', content) + self.assertIn("d3-flame-graph", content) + self.assertIn('id="diff-legend-section"', content) + self.assertIn("Differential Colors", content) + + def test_diff_flamegraph_preserves_metadata(self): + """Differential mode preserves threads and opcodes metadata.""" + test_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [MockFrameInfo("a.py", 10, "func_a", opcode=100)]), + MockThreadInfo(2, [MockFrameInfo("b.py", 20, "func_b", opcode=200)]), + ]) + ] + + diff = make_diff_collector_with_mock_baseline([test_frames]) + diff.collect(test_frames) + + data = diff._convert_to_flamegraph_format() + strings = data.get("strings", []) + + self.assertTrue(data["stats"]["is_differential"]) + + self.assertIn("threads", data) + self.assertEqual(len(data["threads"]), 2) + + children = data.get("children", []) + self.assertEqual(len(children), 2) + + opcodes_found = set() + for child in children: + self.assertIn("diff", child) + self.assertIn("diff_pct", child) + self.assertIn("baseline", child) + self.assertIn("self_time", child) + self.assertIn("threads", child) + + if "opcodes" in child: + opcodes_found.update(child["opcodes"].keys()) + + self.assertIn(100, opcodes_found) + self.assertIn(200, opcodes_found) + + self.assertIn("per_thread_stats", data["stats"]) + per_thread_stats = data["stats"]["per_thread_stats"] + self.assertIn(1, per_thread_stats) + self.assertIn(2, per_thread_stats) + + def test_diff_flamegraph_elided_preserves_metadata(self): + """Elided flamegraph preserves thread_stats, per_thread_stats, and opcodes.""" + baseline_frames_1 = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 10, "func1", opcode=100), + MockFrameInfo("file.py", 20, "func2", opcode=101), + ], status=THREAD_STATUS_HAS_GIL) + ]) + ] + baseline_frames_2 = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [ + MockFrameInfo("file.py", 30, "old_func", opcode=200), + MockFrameInfo("file.py", 20, "func2", opcode=101), + ], status=THREAD_STATUS_HAS_GIL) + ]) + ] + + diff = make_diff_collector_with_mock_baseline([baseline_frames_1, baseline_frames_2]) + for _ in range(2): + diff.collect(baseline_frames_1) + + data = diff._convert_to_flamegraph_format() + elided = data["stats"]["elided_flamegraph"] + + self.assertTrue(elided["stats"]["is_differential"]) + self.assertIn("thread_stats", elided["stats"]) + self.assertIn("per_thread_stats", elided["stats"]) + self.assertIn("baseline_samples", elided["stats"]) + self.assertIn("current_samples", elided["stats"]) + self.assertIn("strings", elided) + + elided_strings = elided.get("strings", []) + children = elided.get("children", []) + self.assertEqual(len(children), 1) + old_func_node = children[0] + if "opcodes" in old_func_node: + self.assertIn(200, old_func_node["opcodes"]) + self.assertEqual(old_func_node["self_time"], 0) + self.assertAlmostEqual(old_func_node["diff_pct"], -100.0) + + def test_diff_flamegraph_load_baseline(self): + """Diff annotations work when baseline is loaded from a binary file.""" + from profiling.sampling.binary_collector import BinaryCollector + from profiling.sampling.stack_collector import DiffFlamegraphCollector + from .test_binary_format import make_frame, make_thread, make_interpreter + + hot_sample = [make_interpreter(0, [make_thread(1, [ + make_frame("file.py", 10, "hot_leaf"), + make_frame("file.py", 20, "caller"), + ])])] + cold_sample = [make_interpreter(0, [make_thread(1, [ + make_frame("file.py", 30, "cold_leaf"), + make_frame("file.py", 20, "caller"), + ])])] + + # Baseline: 2 samples, current: 4, scale = 2.0 + bin_file = tempfile.NamedTemporaryFile(suffix=".bin", delete=False) + self.addCleanup(close_and_unlink, bin_file) + + writer = BinaryCollector( + bin_file.name, sample_interval_usec=1000, compression='none' + ) + writer.collect(hot_sample) + writer.collect(cold_sample) + writer.export(None) + + diff = DiffFlamegraphCollector( + 1000, baseline_binary_path=bin_file.name + ) + hot_mock = [MockInterpreterInfo(0, [MockThreadInfo(1, [ + MockFrameInfo("file.py", 10, "hot_leaf"), + MockFrameInfo("file.py", 20, "caller"), + ])])] + cold_mock = [MockInterpreterInfo(0, [MockThreadInfo(1, [ + MockFrameInfo("file.py", 30, "cold_leaf"), + MockFrameInfo("file.py", 20, "caller"), + ])])] + for _ in range(3): + diff.collect(hot_mock) + diff.collect(cold_mock) + + data = diff._convert_to_flamegraph_format() + strings = data.get("strings", []) + + self.assertTrue(data["stats"]["is_differential"]) + self.assertAlmostEqual(data["stats"]["baseline_scale"], 2.0) + + children = data.get("children", []) + hot_node = find_child_by_name(children, strings, "hot_leaf") + cold_node = find_child_by_name(children, strings, "cold_leaf") + self.assertIsNotNone(hot_node) + self.assertIsNotNone(cold_node) + + # hot_leaf regressed (+50%) + self.assertAlmostEqual(hot_node["baseline"], 2.0) + self.assertEqual(hot_node["self_time"], 3) + self.assertAlmostEqual(hot_node["diff"], 1.0) + self.assertAlmostEqual(hot_node["diff_pct"], 50.0) + + # cold_leaf improved (-50%) + self.assertAlmostEqual(cold_node["baseline"], 2.0) + self.assertEqual(cold_node["self_time"], 1) + self.assertAlmostEqual(cold_node["diff"], -1.0) + self.assertAlmostEqual(cold_node["diff_pct"], -50.0) + + def test_jsonl_collector_export_exact_output(self): + jsonl_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, jsonl_out) + + collector = JsonlCollector(1000) + collector.run_id = "run-123" + + test_frames1 = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("file.py", 10, "func1"), + MockFrameInfo("file.py", 20, "func2"), + ], + ) + ], + ) + ] + test_frames2 = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("file.py", 10, "func1"), + MockFrameInfo("file.py", 20, "func2"), + ], + ) + ], + ) + ] # Same stack + test_frames3 = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, [MockFrameInfo("other.py", 5, "other_func")] + ) + ], + ) + ] + + collector.collect(test_frames1) + collector.collect(test_frames2) + collector.collect(test_frames3) + + collector.export(jsonl_out.name) + + with open(jsonl_out.name, "r", encoding="utf-8") as f: + content = f.read() + + self.assertEqual( + content, + ( + '{"type":"meta","v":0,"run_id":"run-123","sample_interval_usec":1000}\n' + '{"type":"string_table","v":0,"run_id":"run-123","strings":[{"str_id":0,"value":"func1"},{"str_id":1,"value":"file.py"},{"str_id":2,"value":"func2"},{"str_id":3,"value":"other_func"},{"str_id":4,"value":"other.py"}]}\n' + '{"type":"frame_table","v":0,"run_id":"run-123","frames":[{"frame_id":0,"path_str_id":1,"func_str_id":0,"line":10,"end_line":10},{"frame_id":1,"path_str_id":1,"func_str_id":2,"line":20,"end_line":20},{"frame_id":2,"path_str_id":4,"func_str_id":3,"line":5,"end_line":5}]}\n' + '{"type":"agg","v":0,"run_id":"run-123","kind":"frame","scope":"final","samples_total":3,"entries":[{"frame_id":0,"self":2,"cumulative":2},{"frame_id":1,"self":0,"cumulative":2},{"frame_id":2,"self":1,"cumulative":1}]}\n' + '{"type":"end","v":0,"run_id":"run-123","samples_total":3}\n' + ), + ) + + def test_jsonl_collector_export_includes_mode_in_meta(self): + jsonl_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, jsonl_out) + + collector = JsonlCollector(1000, mode=PROFILING_MODE_CPU) + collector.collect( + [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, [MockFrameInfo("file.py", 10, "func")] + ) + ], + ) + ] + ) + collector.export(jsonl_out.name) + + with open(jsonl_out.name, "r", encoding="utf-8") as f: + records = [json.loads(line) for line in f] + + meta_record = next( + record for record in records if record["type"] == "meta" + ) + self.assertEqual(meta_record["mode"], "cpu") + + def test_jsonl_collector_export_empty_profile(self): + jsonl_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, jsonl_out) + + collector = JsonlCollector(1000) + collector.run_id = "run-123" + collector.export(jsonl_out.name) + + with open(jsonl_out.name, "r", encoding="utf-8") as f: + records = [json.loads(line) for line in f] + + self.assertEqual( + [record["type"] for record in records], ["meta", "end"] + ) + self.assertEqual(records[0]["sample_interval_usec"], 1000) + self.assertEqual(records[0]["run_id"], "run-123") + self.assertEqual(records[1]["samples_total"], 0) + self.assertEqual(records[1]["run_id"], "run-123") + + def test_jsonl_collector_recursive_frames_counted_once_per_sample(self): + jsonl_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, jsonl_out) + + collector = JsonlCollector(1000) + collector.collect( + [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo( + "recursive.py", 10, "recursive_func" + ), + MockFrameInfo( + "recursive.py", 10, "recursive_func" + ), + MockFrameInfo( + "recursive.py", 10, "recursive_func" + ), + ], + ) + ], + ) + ] + ) + collector.export(jsonl_out.name) + + with open(jsonl_out.name, "r", encoding="utf-8") as f: + records = [json.loads(line) for line in f] + + _, _, frame_defs, agg_record, end_record = jsonl_tables(records) + self.assertEqual(len(frame_defs), 1) + self.assertEqual( + agg_record["entries"], + [ + { + "frame_id": frame_defs[0]["frame_id"], + "self": 1, + "cumulative": 1, + } + ], + ) + self.assertEqual(agg_record["samples_total"], 1) + self.assertEqual(end_record["samples_total"], 1) + + def test_jsonl_collector_skip_idle_filters_threads(self): + jsonl_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, jsonl_out) + + active_status = THREAD_STATUS_HAS_GIL | THREAD_STATUS_ON_CPU + frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [MockFrameInfo("active1.py", 10, "active_func1")], + status=active_status, + ), + MockThreadInfo( + 2, + [MockFrameInfo("idle.py", 20, "idle_func")], + status=0, + ), + MockThreadInfo( + 3, + [MockFrameInfo("active2.py", 30, "active_func2")], + status=active_status, + ), + ], + ) + ] + + def export_summary(skip_idle): + collector = JsonlCollector(1000, skip_idle=skip_idle) + collector.collect(frames) + collector.export(jsonl_out.name) + + with open(jsonl_out.name, "r", encoding="utf-8") as f: + records = [json.loads(line) for line in f] + + _, str_defs, frame_defs, agg_record, _ = jsonl_tables(records) + paths = {str_defs[item["path_str_id"]] for item in frame_defs} + funcs = {str_defs[item["func_str_id"]] for item in frame_defs} + return paths, funcs, agg_record["samples_total"] + + paths, funcs, samples_total = export_summary(skip_idle=True) + self.assertEqual(paths, {"active1.py", "active2.py"}) + self.assertEqual(funcs, {"active_func1", "active_func2"}) + self.assertEqual(samples_total, 2) + + paths, funcs, samples_total = export_summary(skip_idle=False) + self.assertEqual(paths, {"active1.py", "idle.py", "active2.py"}) + self.assertEqual(funcs, {"active_func1", "idle_func", "active_func2"}) + self.assertEqual(samples_total, 3) + + def test_jsonl_collector_splits_large_exports_into_chunks(self): + jsonl_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, jsonl_out) + + collector = JsonlCollector(1000) + + for i in range(257): + collector.collect( + [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo( + f"file{i}.py", i + 1, f"func{i}" + ) + ], + ) + ], + ) + ] + ) + + collector.export(jsonl_out.name) + + with open(jsonl_out.name, "r", encoding="utf-8") as f: + records = [json.loads(line) for line in f] + + run_ids = {record["run_id"] for record in records} + self.assertEqual(len(run_ids), 1) + self.assertRegex(next(iter(run_ids)), r"^[0-9a-f]{32}$") + + _, str_defs, frame_defs, agg_record, end_record = jsonl_tables( + records + ) + str_chunks = [ + record for record in records if record["type"] == "string_table" + ] + frame_chunks = [ + record for record in records if record["type"] == "frame_table" + ] + agg_chunks = [record for record in records if record["type"] == "agg"] + + self.assertEqual( + [len(record["strings"]) for record in str_chunks], + [256, 256, 2], + ) + self.assertEqual( + [len(record["frames"]) for record in frame_chunks], [256, 1] + ) + self.assertEqual( + [len(record["entries"]) for record in agg_chunks], [256, 1] + ) + self.assertEqual(len(str_defs), 514) + self.assertEqual(len(frame_defs), 257) + self.assertEqual(agg_record["samples_total"], 257) + self.assertEqual(end_record["samples_total"], 257) + + def test_jsonl_collector_respects_weight_for_rle_batched_samples(self): + """weight>1 (from binary replay RLE) is honored in self/cumulative.""" + jsonl_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, jsonl_out) + + collector = JsonlCollector(1000) + leaf = MockFrameInfo("file.py", 10, "leaf") + non_leaf = MockFrameInfo("file.py", 20, "non_leaf") + + collector.process_frames([leaf, non_leaf], _thread_id=1, weight=5) + collector.export(jsonl_out.name) + + with open(jsonl_out.name, "r", encoding="utf-8") as f: + records = [json.loads(line) for line in f] + + _, str_defs, frame_defs, agg, end = jsonl_tables(records) + self.assertEqual(end["samples_total"], 5) + self.assertEqual(agg["samples_total"], 5) + self.assertEqual( + {str_defs[fd["func_str_id"]]: fd["frame_id"] for fd in frame_defs}, + {"leaf": 0, "non_leaf": 1}, + ) + self.assertEqual(agg["entries"], [ + {"frame_id": 0, "self": 5, "cumulative": 5}, + {"frame_id": 1, "self": 0, "cumulative": 5}, + ]) + + def test_jsonl_collector_recursion_with_weight(self): + """Recursion dedup respects weight, not occurrence count.""" + jsonl_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, jsonl_out) + + collector = JsonlCollector(1000) + recursive = MockFrameInfo("rec.py", 10, "f") + + collector.process_frames([recursive] * 3, _thread_id=1, weight=3) + collector.export(jsonl_out.name) + + with open(jsonl_out.name, "r", encoding="utf-8") as f: + records = [json.loads(line) for line in f] + + _, _, frame_defs, agg, _ = jsonl_tables(records) + self.assertEqual(len(frame_defs), 1) + self.assertEqual(agg["entries"], [ + {"frame_id": 0, "self": 3, "cumulative": 3}, + ]) + + def test_jsonl_collector_emits_col_and_end_col_when_present(self): + """All four location fields are emitted when col/end_col are >= 0.""" + jsonl_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, jsonl_out) + + collector = JsonlCollector(1000) + frame = MockFrameInfo("test.py", 0, "f") + frame.location = LocationInfo(42, 45, 4, 12) + frames = [ + MockInterpreterInfo( + 0, [MockThreadInfo(1, [frame], status=THREAD_STATUS_HAS_GIL)] + ) + ] + collector.collect(frames) + collector.export(jsonl_out.name) + + with open(jsonl_out.name, "r", encoding="utf-8") as f: + records = [json.loads(line) for line in f] + + _, str_defs, frame_defs, _, _ = jsonl_tables(records) + self.assertEqual(frame_defs, [ + { + "frame_id": 0, + "path_str_id": 1, + "func_str_id": 0, + "line": 42, + "end_line": 45, + "col": 4, + "end_col": 12, + }, + ]) + self.assertEqual(str_defs, {0: "f", 1: "test.py"}) + + def test_jsonl_collector_partial_location_elision(self): + """Negative col/end_col/end_line fields are individually elided.""" + # _get_or_create_frame_id interns funcname before filename, so + # func_str_id=0 ("f") and path_str_id=1 ("test.py"). + common = {"frame_id": 0, "path_str_id": 1, "func_str_id": 0} + cases = [ + (LocationInfo(42, 45, -1, 12), + {**common, "line": 42, "end_line": 45, "end_col": 12}), + (LocationInfo(42, 45, 4, -1), + {**common, "line": 42, "end_line": 45, "col": 4}), + (LocationInfo(42, 0, 4, 8), + {**common, "line": 42, "col": 4, "end_col": 8}), + ] + for loc, expected_frame_def in cases: + with self.subTest(location=loc): + jsonl_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, jsonl_out) + + collector = JsonlCollector(1000) + frame = MockFrameInfo("test.py", 0, "f") + frame.location = loc + frames = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame], status=THREAD_STATUS_HAS_GIL)], + ) + ] + collector.collect(frames) + collector.export(jsonl_out.name) + + with open(jsonl_out.name, "r", encoding="utf-8") as f: + records = [json.loads(line) for line in f] + + _, _, frame_defs, _, _ = jsonl_tables(records) + self.assertEqual(frame_defs, [expected_frame_def]) + + +class TestRecursiveFunctionHandling(unittest.TestCase): + """Tests for correct handling of recursive functions in cumulative stats.""" + + def test_pstats_collector_recursive_function_single_sample(self): + """Test that recursive functions are counted once per sample, not per occurrence.""" + collector = PstatsCollector(sample_interval_usec=1000) + + # Simulate a recursive function appearing 5 times in one sample + recursive_frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("test.py", 10, "recursive_func"), + MockFrameInfo("test.py", 10, "recursive_func"), + MockFrameInfo("test.py", 10, "recursive_func"), + MockFrameInfo("test.py", 10, "recursive_func"), + MockFrameInfo("test.py", 10, "recursive_func"), + ], + ) + ], + ) + ] + collector.collect(recursive_frames) + + location = ("test.py", 10, "recursive_func") + # Should count as 1 cumulative call (present in 1 sample), not 5 + self.assertEqual(collector.result[location]["cumulative_calls"], 1) + # Direct calls should be 1 (top of stack) + self.assertEqual(collector.result[location]["direct_calls"], 1) + + def test_pstats_collector_recursive_function_multiple_samples(self): + """Test cumulative counting across multiple samples with recursion.""" + collector = PstatsCollector(sample_interval_usec=1000) + + # Sample 1: recursive function at depth 3 + sample1 = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("test.py", 10, "recursive_func"), + MockFrameInfo("test.py", 10, "recursive_func"), + MockFrameInfo("test.py", 10, "recursive_func"), + ], + ) + ], + ) + ] + # Sample 2: recursive function at depth 2 + sample2 = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("test.py", 10, "recursive_func"), + MockFrameInfo("test.py", 10, "recursive_func"), + ], + ) + ], + ) + ] + # Sample 3: recursive function at depth 4 + sample3 = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("test.py", 10, "recursive_func"), + MockFrameInfo("test.py", 10, "recursive_func"), + MockFrameInfo("test.py", 10, "recursive_func"), + MockFrameInfo("test.py", 10, "recursive_func"), + ], + ) + ], + ) + ] + + collector.collect(sample1) + collector.collect(sample2) + collector.collect(sample3) + + location = ("test.py", 10, "recursive_func") + # Should count as 3 cumulative calls (present in 3 samples) + # Not 3+2+4=9 which would be the buggy behavior + self.assertEqual(collector.result[location]["cumulative_calls"], 3) + self.assertEqual(collector.result[location]["direct_calls"], 3) + + def test_pstats_collector_mixed_recursive_and_nonrecursive(self): + """Test a call stack with both recursive and non-recursive functions.""" + collector = PstatsCollector(sample_interval_usec=1000) + + # Stack: main -> foo (recursive x3) -> bar + frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("test.py", 50, "bar"), # top of stack + MockFrameInfo("test.py", 20, "foo"), # recursive + MockFrameInfo("test.py", 20, "foo"), # recursive + MockFrameInfo("test.py", 20, "foo"), # recursive + MockFrameInfo("test.py", 10, "main"), # bottom + ], + ) + ], + ) + ] + collector.collect(frames) + + # bar: 1 cumulative (in stack), 1 direct (top) + self.assertEqual(collector.result[("test.py", 50, "bar")]["cumulative_calls"], 1) + self.assertEqual(collector.result[("test.py", 50, "bar")]["direct_calls"], 1) + + # foo: 1 cumulative (counted once despite 3 occurrences), 0 direct + self.assertEqual(collector.result[("test.py", 20, "foo")]["cumulative_calls"], 1) + self.assertEqual(collector.result[("test.py", 20, "foo")]["direct_calls"], 0) + + # main: 1 cumulative, 0 direct + self.assertEqual(collector.result[("test.py", 10, "main")]["cumulative_calls"], 1) + self.assertEqual(collector.result[("test.py", 10, "main")]["direct_calls"], 0) + + def test_pstats_collector_cumulative_percentage_cannot_exceed_100(self): + """Test that cumulative percentage stays <= 100% even with deep recursion.""" + collector = PstatsCollector(sample_interval_usec=1000000) # 1 second for easy math + + # Collect 10 samples, each with recursive function at depth 100 + for _ in range(10): + frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [MockFrameInfo("test.py", 10, "deep_recursive")] * 100, + ) + ], + ) + ] + collector.collect(frames) + + location = ("test.py", 10, "deep_recursive") + # Cumulative calls should be 10 (number of samples), not 1000 + self.assertEqual(collector.result[location]["cumulative_calls"], 10) + + # Verify stats calculation gives correct percentage + collector.create_stats() + stats = collector.stats[location] + # stats format: (direct_calls, cumulative_calls, total_time, cumulative_time, callers) + cumulative_calls = stats[1] + self.assertEqual(cumulative_calls, 10) + + def test_pstats_collector_different_lines_same_function_counted_separately(self): + """Test that different line numbers in same function are tracked separately.""" + collector = PstatsCollector(sample_interval_usec=1000) + + # Function with multiple line numbers (e.g., different call sites within recursion) + frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("test.py", 15, "func"), # line 15 + MockFrameInfo("test.py", 12, "func"), # line 12 + MockFrameInfo("test.py", 15, "func"), # line 15 again + MockFrameInfo("test.py", 10, "func"), # line 10 + ], + ) + ], + ) + ] + collector.collect(frames) + + # Each unique (file, line, func) should be counted once + self.assertEqual(collector.result[("test.py", 15, "func")]["cumulative_calls"], 1) + self.assertEqual(collector.result[("test.py", 12, "func")]["cumulative_calls"], 1) + self.assertEqual(collector.result[("test.py", 10, "func")]["cumulative_calls"], 1) + + +class TestLocationHelpers(unittest.TestCase): + """Tests for location handling helper functions.""" + + def test_extract_lineno_from_location_info(self): + """Test extracting lineno from LocationInfo namedtuple.""" + loc = LocationInfo(42, 45, 0, 10) + self.assertEqual(extract_lineno(loc), 42) + + def test_extract_lineno_from_tuple(self): + """Test extracting lineno from plain tuple.""" + loc = (100, 105, 5, 20) + self.assertEqual(extract_lineno(loc), 100) + + def test_extract_lineno_from_none(self): + """Test extracting lineno from None (synthetic frames).""" + self.assertEqual(extract_lineno(None), 0) + + def test_extract_lineno_from_int(self): + """Test extracting lineno from a bare integer line number. + + Mirrors normalize_location's int contract so callers like the + collapsed/flamegraph collectors do not crash on a bare-int location. + """ + self.assertEqual(extract_lineno(42), 42) + self.assertEqual(extract_lineno(0), 0) + + def test_normalize_location_with_int(self): + """Test normalize_location expands a legacy integer line number.""" + result = normalize_location(42) + self.assertEqual(result, (42, 42, -1, -1)) + + def test_normalize_location_with_location_info(self): + """Test normalize_location passes through LocationInfo.""" + loc = LocationInfo(10, 15, 0, 5) + result = normalize_location(loc) + self.assertEqual(result, loc) + + def test_normalize_location_with_tuple(self): + """Test normalize_location passes through tuple.""" + loc = (10, 15, 0, 5) + result = normalize_location(loc) + self.assertEqual(result, loc) + + def test_normalize_location_with_none(self): + """Test normalize_location returns DEFAULT_LOCATION for None.""" + result = normalize_location(None) + self.assertEqual(result, DEFAULT_LOCATION) + self.assertEqual(result, (0, 0, -1, -1)) + + +class TestOpcodeFormatting(unittest.TestCase): + """Tests for opcode formatting utilities.""" + + def test_get_opcode_info_standard_opcode(self): + """Test get_opcode_info for a standard opcode.""" + # LOAD_CONST is a standard opcode + load_const = opcode.opmap.get('LOAD_CONST') + if load_const is not None: + info = get_opcode_info(load_const) + self.assertEqual(info['opname'], 'LOAD_CONST') + self.assertEqual(info['base_opname'], 'LOAD_CONST') + self.assertFalse(info['is_specialized']) + + def test_get_opcode_info_unknown_opcode(self): + """Test get_opcode_info for an unknown opcode.""" + info = get_opcode_info(999) + self.assertEqual(info['opname'], '<999>') + self.assertEqual(info['base_opname'], '<999>') + self.assertFalse(info['is_specialized']) + + def test_format_opcode_standard(self): + """Test format_opcode for a standard opcode.""" + load_const = opcode.opmap.get('LOAD_CONST') + if load_const is not None: + formatted = format_opcode(load_const) + self.assertEqual(formatted, 'LOAD_CONST') + + def test_format_opcode_specialized(self): + """Test format_opcode for a specialized opcode shows base in parens.""" + if not hasattr(opcode, '_specialized_opmap'): + self.skipTest("No specialized opcodes in this Python version") + if not hasattr(opcode, '_specializations'): + self.skipTest("No specialization info in this Python version") + + # Find any specialized opcode to test + for base_name, variants in opcode._specializations.items(): + if not variants: + continue + variant_name = variants[0] + variant_opcode = opcode._specialized_opmap.get(variant_name) + if variant_opcode is None: + continue + formatted = format_opcode(variant_opcode) + # Should show: VARIANT_NAME (BASE_NAME) + self.assertIn(variant_name, formatted) + self.assertIn(f'({base_name})', formatted) + return + + self.skipTest("No specialized opcodes found") + + def test_format_opcode_unknown(self): + """Test format_opcode for an unknown opcode.""" + formatted = format_opcode(999) + self.assertEqual(formatted, '<999>') + + +class TestLocationInCollectors(unittest.TestCase): + """Tests for location tuple handling in each collector.""" + + def _make_frames_with_location(self, location, opcode=None): + """Create test frames with a specific location.""" + frame = MockFrameInfo("test.py", 0, "test_func", opcode) + # Override the location + frame.location = location + return [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame], status=THREAD_STATUS_HAS_GIL)] + ) + ] + + def test_pstats_collector_with_location_info(self): + """Test PstatsCollector handles LocationInfo properly.""" + collector = PstatsCollector(sample_interval_usec=1000) + + # Frame with LocationInfo + frame = MockFrameInfo("test.py", 42, "my_function") + frames = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame], status=THREAD_STATUS_HAS_GIL)] + ) + ] + collector.collect(frames) + + # Should extract lineno from location + key = ("test.py", 42, "my_function") + self.assertIn(key, collector.result) + self.assertEqual(collector.result[key]["direct_calls"], 1) + + def test_pstats_collector_with_none_location(self): + """Test PstatsCollector handles None location (synthetic frames).""" + collector = PstatsCollector(sample_interval_usec=1000) + + # Create frame with None location (like GC frame) + frame = MockFrameInfo("~", 0, "") + frame.location = None # Synthetic frame has no location + frames = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame], status=THREAD_STATUS_HAS_GIL)] + ) + ] + collector.collect(frames) + + # Should use lineno=0 for None location + key = ("~", 0, "") + self.assertIn(key, collector.result) + + def test_collapsed_stack_with_location_info(self): + """Test CollapsedStackCollector handles LocationInfo properly.""" + collector = CollapsedStackCollector(1000) + + frame1 = MockFrameInfo("main.py", 10, "main") + frame2 = MockFrameInfo("utils.py", 25, "helper") + frames = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame1, frame2], status=THREAD_STATUS_HAS_GIL)] + ) + ] + collector.collect(frames) + + # Check that linenos were extracted correctly + self.assertEqual(len(collector.stack_counter), 1) + (path, _), count = list(collector.stack_counter.items())[0] + # Reversed order: helper at top, main at bottom + self.assertEqual(path[0], ("utils.py", 25, "helper")) + self.assertEqual(path[1], ("main.py", 10, "main")) + + def test_flamegraph_collector_with_location_info(self): + """Test FlamegraphCollector handles LocationInfo properly.""" + collector = FlamegraphCollector(sample_interval_usec=1000) + + frame = MockFrameInfo("app.py", 100, "process_data") + frames = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame], status=THREAD_STATUS_HAS_GIL)] + ) + ] + collector.collect(frames) + + data = collector._convert_to_flamegraph_format() + # Verify the function name includes lineno from location + strings = data.get("strings", []) + name_found = any("process_data" in s and "100" in s for s in strings if isinstance(s, str)) + self.assertTrue(name_found, f"Expected to find 'process_data' with line 100 in {strings}") + + def test_gecko_collector_with_location_info(self): + """Test GeckoCollector handles LocationInfo properly.""" + collector = GeckoCollector(sample_interval_usec=1000) + + frame = MockFrameInfo("server.py", 50, "handle_request") + frames = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame], status=THREAD_STATUS_HAS_GIL)] + ) + ] + collector.collect(frames) + + profile = collector._build_profile() + # Check that the function was recorded + self.assertEqual(len(profile["threads"]), 1) + thread_data = profile["threads"][0] + string_array = profile["shared"]["stringArray"] + + # Verify function name is in string table + self.assertIn("handle_request", string_array) + + def test_jsonl_collector_with_location_info(self): + """Test JsonlCollector handles LocationInfo properly.""" + jsonl_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, jsonl_out) + + collector = JsonlCollector(sample_interval_usec=1000) + + # Frame with LocationInfo + frame = MockFrameInfo("test.py", 42, "my_function") + frames = [ + MockInterpreterInfo( + 0, [MockThreadInfo(1, [frame], status=THREAD_STATUS_HAS_GIL)] + ) + ] + collector.collect(frames) + + collector.export(jsonl_out.name) + + with open(jsonl_out.name, "r", encoding="utf-8") as f: + records = [json.loads(line) for line in f] + + meta, str_defs, frame_defs, agg, end = jsonl_tables(records) + self.assertEqual(meta["sample_interval_usec"], 1000) + self.assertEqual(agg["samples_total"], 1) + self.assertEqual(end["samples_total"], 1) + self.assertEqual(len(frame_defs), 1) + self.assertEqual(str_defs[frame_defs[0]["path_str_id"]], "test.py") + self.assertEqual(str_defs[frame_defs[0]["func_str_id"]], "my_function") + self.assertEqual( + frame_defs[0], + { + "frame_id": 0, + "path_str_id": frame_defs[0]["path_str_id"], + "func_str_id": frame_defs[0]["func_str_id"], + "line": 42, + "end_line": 42, + }, + ) + + def test_jsonl_collector_with_none_location(self): + """Test JsonlCollector handles None location (synthetic frames).""" + jsonl_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, jsonl_out) + + collector = JsonlCollector(sample_interval_usec=1000) + + # Create frame with None location (like GC frame) + frame = MockFrameInfo("~", 0, "") + frame.location = None # Synthetic frame has no location + frames = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame], status=THREAD_STATUS_HAS_GIL)] + ) + ] + collector.collect(frames) + + collector.export(jsonl_out.name) + + with open(jsonl_out.name, "r", encoding="utf-8") as f: + records = [json.loads(line) for line in f] + + meta, str_defs, frame_defs, agg, end = jsonl_tables(records) + self.assertEqual(meta["sample_interval_usec"], 1000) + self.assertEqual(agg["samples_total"], 1) + self.assertEqual(end["samples_total"], 1) + self.assertEqual(len(frame_defs), 1) + self.assertEqual(str_defs[frame_defs[0]["path_str_id"]], "~") + self.assertEqual(str_defs[frame_defs[0]["func_str_id"]], "") + self.assertEqual( + frame_defs[0], + { + "frame_id": 0, + "path_str_id": frame_defs[0]["path_str_id"], + "func_str_id": frame_defs[0]["func_str_id"], + "line": 0, + }, + ) + + +class TestOpcodeHandling(unittest.TestCase): + """Tests for opcode field handling in collectors.""" + + def test_frame_with_opcode(self): + """Test MockFrameInfo properly stores opcode.""" + frame = MockFrameInfo("test.py", 10, "my_func", opcode=90) + self.assertEqual(frame.opcode, 90) + # Verify tuple representation includes opcode + self.assertEqual(frame[3], 90) + self.assertEqual(len(frame), 4) + + def test_frame_without_opcode(self): + """Test MockFrameInfo with no opcode defaults to None.""" + frame = MockFrameInfo("test.py", 10, "my_func") + self.assertIsNone(frame.opcode) + self.assertIsNone(frame[3]) + + def test_collectors_ignore_opcode_for_key_generation(self): + """Test that collectors use (filename, lineno, funcname) as key, not opcode.""" + collector = PstatsCollector(sample_interval_usec=1000) + + # Same function, different opcodes + frame1 = MockFrameInfo("test.py", 10, "func", opcode=90) + frame2 = MockFrameInfo("test.py", 10, "func", opcode=100) + + frames1 = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame1], status=THREAD_STATUS_HAS_GIL)] + ) + ] + frames2 = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame2], status=THREAD_STATUS_HAS_GIL)] + ) + ] + + collector.collect(frames1) + collector.collect(frames2) + + # Should be counted as same function (opcode not in key) + key = ("test.py", 10, "func") + self.assertIn(key, collector.result) + self.assertEqual(collector.result[key]["direct_calls"], 2) + + +class TestGeckoOpcodeMarkers(unittest.TestCase): + """Tests for GeckoCollector opcode interval markers.""" + + def test_gecko_collector_opcodes_disabled_by_default(self): + """Test that opcode tracking is disabled by default.""" + collector = GeckoCollector(sample_interval_usec=1000) + self.assertFalse(collector.opcodes_enabled) + + def test_gecko_collector_opcodes_enabled(self): + """Test that opcode tracking can be enabled.""" + collector = GeckoCollector(sample_interval_usec=1000, opcodes=True) + self.assertTrue(collector.opcodes_enabled) + + def test_gecko_opcode_state_tracking(self): + """Test that GeckoCollector tracks opcode state changes.""" + collector = GeckoCollector(sample_interval_usec=1000, opcodes=True) + self.addCleanup(collector._cleanup_spills) + + # First sample with opcode 90 (RAISE_VARARGS) + frame1 = MockFrameInfo("test.py", 10, "func", opcode=90) + frames1 = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame1], status=THREAD_STATUS_HAS_GIL)] + ) + ] + collector.collect(frames1) + + # Should start tracking this opcode state + self.assertIn(1, collector.opcode_state) + state = collector.opcode_state[1] + self.assertEqual(state[0], 90) # opcode + self.assertEqual(state[1], 10) # lineno + self.assertEqual(state[3], "func") # funcname + + def test_gecko_opcode_state_change_emits_marker(self): + """Test that opcode state change emits an interval marker.""" + collector = GeckoCollector(sample_interval_usec=1000, opcodes=True) + + # First sample: opcode 90 + frame1 = MockFrameInfo("test.py", 10, "func", opcode=90) + frames1 = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame1], status=THREAD_STATUS_HAS_GIL)] + ) + ] + collector.collect(frames1) + + # Second sample: different opcode 100 + frame2 = MockFrameInfo("test.py", 10, "func", opcode=100) + frames2 = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame2], status=THREAD_STATUS_HAS_GIL)] + ) + ] + collector.collect(frames2) + + # Should have emitted a marker for the first opcode + profile = collector._build_profile() + markers = profile["threads"][0]["markers"] + assert_gecko_column_lengths( + self, markers, + ("data", "name", "startTime", "endTime", "phase", "category"), + ) + opcode_markers = gecko_opcode_marker_data(profile) + self.assertIn( + { + "opcode": 90, + "line": 10, + "function": "func", + }, + [ + { + "opcode": marker["opcode"], + "line": marker["line"], + "function": marker["function"], + } + for marker in opcode_markers + ], + ) + + def test_gecko_opcode_markers_not_emitted_when_disabled(self): + """Test that no opcode markers when opcodes=False.""" + collector = GeckoCollector(sample_interval_usec=1000, opcodes=False) + + frame1 = MockFrameInfo("test.py", 10, "func", opcode=90) + frames1 = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame1], status=THREAD_STATUS_HAS_GIL)] + ) + ] + collector.collect(frames1) + + frame2 = MockFrameInfo("test.py", 10, "func", opcode=100) + frames2 = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame2], status=THREAD_STATUS_HAS_GIL)] + ) + ] + collector.collect(frames2) + + profile = collector._build_profile() + self.assertEqual(gecko_opcode_marker_data(profile), []) + self.assertEqual(profile["meta"]["markerSchema"], []) + + def test_gecko_opcode_with_none_opcode(self): + """Test that None opcode doesn't cause issues.""" + collector = GeckoCollector(sample_interval_usec=1000, opcodes=True) + + # Frame with no opcode (None) + frame = MockFrameInfo("test.py", 10, "func", opcode=None) + frames = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [frame], status=THREAD_STATUS_HAS_GIL)] + ) + ] + collector.collect(frames) + + profile = collector._build_profile() + self.assertEqual(gecko_opcode_marker_data(profile), []) + + +class TestCollectorFrameFormat(unittest.TestCase): + """Tests verifying all collectors handle the 4-element frame format.""" + + def _make_sample_frames(self): + """Create sample frames with full format: (filename, location, funcname, opcode).""" + return [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("app.py", 100, "main", opcode=90), + MockFrameInfo("utils.py", 50, "helper", opcode=100), + MockFrameInfo("lib.py", 25, "process", opcode=None), + ], + status=THREAD_STATUS_HAS_GIL, + ) + ], + ) + ] + + def test_pstats_collector_frame_format(self): + """Test PstatsCollector with 4-element frame format.""" + collector = PstatsCollector(sample_interval_usec=1000) + collector.collect(self._make_sample_frames()) + + # All three functions should be recorded + self.assertEqual(len(collector.result), 3) + self.assertIn(("app.py", 100, "main"), collector.result) + self.assertIn(("utils.py", 50, "helper"), collector.result) + self.assertIn(("lib.py", 25, "process"), collector.result) + + def test_collapsed_stack_frame_format(self): + """Test CollapsedStackCollector with 4-element frame format.""" + collector = CollapsedStackCollector(sample_interval_usec=1000) + collector.collect(self._make_sample_frames()) + + self.assertEqual(len(collector.stack_counter), 1) + (path, _), _ = list(collector.stack_counter.items())[0] + # 3 frames in the path (reversed order) + self.assertEqual(len(path), 3) + + def test_flamegraph_collector_frame_format(self): + """Test FlamegraphCollector with 4-element frame format.""" + collector = FlamegraphCollector(sample_interval_usec=1000) + collector.collect(self._make_sample_frames()) + + data = collector._convert_to_flamegraph_format() + # Should have processed the frames + self.assertIn("children", data) + + def test_gecko_collector_frame_format(self): + """Test GeckoCollector with 4-element frame format.""" + collector = GeckoCollector(sample_interval_usec=1000) + collector.collect(self._make_sample_frames()) + + profile = collector._build_profile() + # Should have one thread with the frames + self.assertEqual(len(profile["threads"]), 1) + thread = profile["threads"][0] + # Should have recorded 3 functions + self.assertEqual(thread["funcTable"]["length"], 3) + + def test_jsonl_collector_frame_format(self): + """Test JsonlCollector with 4-element frame format.""" + collector = JsonlCollector(sample_interval_usec=1000) + collector.collect(self._make_sample_frames()) + + with tempfile.NamedTemporaryFile(delete=False) as f: + self.addClassCleanup(close_and_unlink, f) + collector.export(f.name) + + with open(f.name, "r", encoding="utf-8") as fp: + records = [json.loads(line) for line in fp] + + _, str_defs, frame_defs, _, _ = jsonl_tables(records) + + self.assertEqual(len(frame_defs), 3) + + paths = {str_defs[item["path_str_id"]] for item in frame_defs} + funcs = {str_defs[item["func_str_id"]] for item in frame_defs} + + self.assertEqual(paths, {"app.py", "utils.py", "lib.py"}) + self.assertEqual(funcs, {"main", "helper", "process"}) + + +class TestInternalFrameFiltering(unittest.TestCase): + """Tests for filtering internal profiler frames from output.""" + + def test_filter_internal_frames(self): + """Test that _sync_coordinator frames are filtered from anywhere in stack.""" + from profiling.sampling.collector import filter_internal_frames + + # Stack with _sync_coordinator in the middle (realistic scenario) + frames = [ + MockFrameInfo("user_script.py", 10, "user_func"), + MockFrameInfo("/path/to/_sync_coordinator.py", 100, "main"), + MockFrameInfo("", 87, "_run_code"), + ] + + filtered = filter_internal_frames(frames) + self.assertEqual(len(filtered), 2) + self.assertEqual(filtered[0].filename, "user_script.py") + self.assertEqual(filtered[1].filename, "") + + def test_pstats_collector_filters_internal_frames(self): + """Test that PstatsCollector filters out internal frames.""" + collector = PstatsCollector(sample_interval_usec=1000) + + frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("user_script.py", 10, "user_func"), + MockFrameInfo("/path/to/_sync_coordinator.py", 100, "main"), + MockFrameInfo("", 87, "_run_code"), + ], + status=THREAD_STATUS_HAS_GIL, + ) + ], + ) + ] + collector.collect(frames) + + self.assertEqual(len(collector.result), 2) + self.assertIn(("user_script.py", 10, "user_func"), collector.result) + self.assertIn(("", 87, "_run_code"), collector.result) + + def test_gecko_collector_filters_internal_frames(self): + """Test that GeckoCollector filters out internal frames.""" + collector = GeckoCollector(sample_interval_usec=1000) + + frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("app.py", 50, "run"), + MockFrameInfo("/lib/_sync_coordinator.py", 100, "main"), + ], + status=THREAD_STATUS_HAS_GIL, + ) + ], + ) + ] + collector.collect(frames) + + profile = collector._build_profile() + string_array = profile["shared"]["stringArray"] + + # Should not contain _sync_coordinator functions + for s in string_array: + self.assertNotIn("_sync_coordinator", s) + + def test_flamegraph_collector_filters_internal_frames(self): + """Test that FlamegraphCollector filters out internal frames.""" + collector = FlamegraphCollector(sample_interval_usec=1000) + + frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("app.py", 50, "run"), + MockFrameInfo("/lib/_sync_coordinator.py", 100, "main"), + MockFrameInfo("", 87, "_run_code"), + ], + status=THREAD_STATUS_HAS_GIL, + ) + ], + ) + ] + collector.collect(frames) + + data = collector._convert_to_flamegraph_format() + strings = data.get("strings", []) + + for s in strings: + self.assertNotIn("_sync_coordinator", s) + + def test_collapsed_stack_collector_filters_internal_frames(self): + """Test that CollapsedStackCollector filters out internal frames.""" + collector = CollapsedStackCollector(sample_interval_usec=1000) + + frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("app.py", 50, "run"), + MockFrameInfo("/lib/_sync_coordinator.py", 100, "main"), + ], + status=THREAD_STATUS_HAS_GIL, + ) + ], + ) + ] + collector.collect(frames) + + # Check that no stack contains _sync_coordinator + for (call_tree, _), _ in collector.stack_counter.items(): + for filename, _, _ in call_tree: + self.assertNotIn("_sync_coordinator", filename) + + def test_jsonl_collector_filters_internal_frames(self): + """Test that JsonlCollector filters out internal frames.""" + jsonl_out = tempfile.NamedTemporaryFile(delete=False) + self.addCleanup(close_and_unlink, jsonl_out) + + frames = [ + MockInterpreterInfo( + 0, + [ + MockThreadInfo( + 1, + [ + MockFrameInfo("app.py", 50, "run"), + MockFrameInfo("/lib/_sync_coordinator.py", 100, "main"), + MockFrameInfo("", 87, "_run_code"), + ], + status=THREAD_STATUS_HAS_GIL, + ) + ], + ) + ] + + collector = JsonlCollector(sample_interval_usec=1000) + collector.collect(frames) + collector.export(jsonl_out.name) + + with open(jsonl_out.name, "r", encoding="utf-8") as f: + records = [json.loads(line) for line in f] + + _, str_defs, frame_defs, _, _ = jsonl_tables(records) + + paths = {str_defs[item["path_str_id"]] for item in frame_defs} + + self.assertIn("app.py", paths) + self.assertIn("", paths) + + for path in paths: + self.assertNotIn("_sync_coordinator", path) diff --git a/Lib/test/test_profiling/test_tracing_profiler.py b/Lib/test/test_profiling/test_tracing_profiler.py new file mode 100644 index 000000000..50de9af86 --- /dev/null +++ b/Lib/test/test_profiling/test_tracing_profiler.py @@ -0,0 +1,308 @@ +"""Test suite for the cProfile module.""" +import multiprocessing +import sys +import unittest + +# rip off all interesting stuff from test_profile +import profiling.tracing as cProfile +import tempfile +import textwrap +from test.test_profile import ProfileTest, regenerate_expected_output +from test.support.script_helper import assert_python_failure, assert_python_ok +from test import support + + +class CProfileTest(ProfileTest): + profilerclass = cProfile.Profile + profilermodule = cProfile + expected_max_output = "{built-in method builtins.max}" + + def get_expected_output(self): + return _ProfileOutput + + def test_bad_counter_during_dealloc(self): + # bpo-3895 + import _lsprof + + with support.catch_unraisable_exception() as cm: + obj = _lsprof.Profiler(lambda: int) + obj.enable() + obj.disable() + obj.clear() + + self.assertEqual(cm.unraisable.exc_type, TypeError) + + def test_crash_with_not_enough_args(self): + # gh-126220 + import _lsprof + + for profile in [_lsprof.Profiler(), cProfile.Profile()]: + for method in [ + "_pystart_callback", + "_pyreturn_callback", + "_ccall_callback", + "_creturn_callback", + ]: + with self.subTest(profile=profile, method=method): + method_obj = getattr(profile, method) + with self.assertRaises(TypeError): + method_obj() # should not crash + + def test_evil_external_timer(self): + # gh-120289 + # Disabling profiler in external timer should not crash + import _lsprof + class EvilTimer(): + def __init__(self, disable_count): + self.count = 0 + self.disable_count = disable_count + + def __call__(self): + self.count += 1 + if self.count == self.disable_count: + profiler_with_evil_timer.disable() + return self.count + + # this will trigger external timer to disable profiler at + # call event - in initContext in _lsprof.c + with support.catch_unraisable_exception() as cm: + profiler_with_evil_timer = _lsprof.Profiler(EvilTimer(1)) + profiler_with_evil_timer.enable() + # Make a call to trigger timer + (lambda: None)() + profiler_with_evil_timer.disable() + profiler_with_evil_timer.clear() + self.assertEqual(cm.unraisable.exc_type, RuntimeError) + + # this will trigger external timer to disable profiler at + # return event - in Stop in _lsprof.c + with support.catch_unraisable_exception() as cm: + profiler_with_evil_timer = _lsprof.Profiler(EvilTimer(2)) + profiler_with_evil_timer.enable() + # Make a call to trigger timer + (lambda: None)() + profiler_with_evil_timer.disable() + profiler_with_evil_timer.clear() + self.assertEqual(cm.unraisable.exc_type, RuntimeError) + + def test_enable_in_external_timer(self): + # gh-157639: Enabling the profiler from an external timer should not crash + import _lsprof + + # the timer re-arms monitoring from inside disable(), so the tool + # id stays claimed once the profiler is torn down + self.addCleanup(sys.monitoring.free_tool_id, sys.monitoring.PROFILER_ID) + + def timer(): + try: + profiler.enable() + except Exception: + pass + return 0 + + profiler = _lsprof.Profiler(timer=timer) + profiler.enable() + (lambda: None)() + profiler.disable() + profiler.clear() + + def test_profile_enable_disable(self): + prof = self.profilerclass() + # Make sure we clean ourselves up if the test fails for some reason. + self.addCleanup(prof.disable) + + prof.enable() + self.assertEqual( + sys.monitoring.get_tool(sys.monitoring.PROFILER_ID), "cProfile") + + prof.disable() + self.assertIs(sys.monitoring.get_tool(sys.monitoring.PROFILER_ID), None) + + def test_profile_as_context_manager(self): + prof = self.profilerclass() + # Make sure we clean ourselves up if the test fails for some reason. + self.addCleanup(prof.disable) + + with prof as __enter__return_value: + # profile.__enter__ should return itself. + self.assertIs(prof, __enter__return_value) + + # profile should be set as the global profiler inside the + # with-block + self.assertEqual( + sys.monitoring.get_tool(sys.monitoring.PROFILER_ID), "cProfile") + + # profile shouldn't be set once we leave the with-block. + self.assertIs(sys.monitoring.get_tool(sys.monitoring.PROFILER_ID), None) + + def test_second_profiler(self): + pr = self.profilerclass() + pr2 = self.profilerclass() + pr.enable() + self.addCleanup(pr.disable) + msg = f"tool {sys.monitoring.PROFILER_ID} is already in use" + self.assertRaisesRegex(ValueError, msg, pr2.enable) + + def test_throw(self): + """ + gh-106152 + generator.throw() should trigger a call in cProfile + """ + + def gen(): + yield + + pr = self.profilerclass() + pr.enable() + g = gen() + try: + g.throw(SyntaxError) + except SyntaxError: + pass + pr.disable() + pr.create_stats() + + self.assertTrue(any("throw" in func[2] for func in pr.stats.keys())), + + def test_clear_with_nested_calls(self): + # Calling clear() during nested profiled calls should not leak + # ProfilerContexts. clearEntries() must walk the entire linked list, + # not just free the top context. + import _lsprof + + def level3(profiler): + profiler.clear() + + def level2(profiler): + level3(profiler) + + def level1(profiler): + level2(profiler) + + profiler = _lsprof.Profiler() + profiler.enable() + for _ in range(100): + level1(profiler) + profiler.disable() + profiler.clear() + + def test_bad_descriptor(self): + # gh-132250 + # cProfile should not crash when the profiler callback fails to locate + # the actual function of a method. + with self.profilerclass() as prof: + with self.assertRaises(TypeError): + bytes.find(str()) + + +class TestCommandLine(unittest.TestCase): + def test_sort(self): + rc, out, err = assert_python_failure('-m', 'cProfile', '-s', 'demo') + self.assertGreater(rc, 0) + self.assertIn(b"option -s: invalid choice: 'demo'", err) + + def test_profile_script_importing_main(self): + """Check that scripts that reference __main__ see their own namespace + when being profiled.""" + with tempfile.NamedTemporaryFile("w+", delete_on_close=False) as f: + f.write(textwrap.dedent("""\ + class Foo: + pass + import __main__ + assert Foo == __main__.Foo + """)) + f.close() + assert_python_ok('-m', "cProfile", f.name) + + def _test_process_run_pickle(self, start_method): + val = 10 + with tempfile.NamedTemporaryFile("w+", delete_on_close=False) as f: + f.write(textwrap.dedent( + f'''\ + import multiprocessing + + def worker(x): + print(__name__) + exit(x ** 2) + + if __name__ == "__main__": + multiprocessing.set_start_method('{start_method}') + p = multiprocessing.Process(target=worker, args=({val},)) + p.start() + p.join() + print("p.exitcode =", p.exitcode) + ''')) + f.close() + _, out, err = assert_python_ok('-m', "cProfile", f.name) + self.assertIn(b"__mp_main__", out) + self.assertIn(bytes(f"exitcode = {val**2}", encoding='utf8'), out) + self.assertNotIn(b"Can't pickle", err) + + def test_process_spawn_pickle(self): + # gh-140729: test use Process in cProfile. + self._test_process_run_pickle('spawn') + + @unittest.skipUnless("forkserver" in multiprocessing.get_all_start_methods(), + "forkserver start method is not available") + def test_process_forkserver_pickle(self): + # gh-140729: test use Process in cProfile. + self._test_process_run_pickle('forkserver') + + +def main(): + if '-r' not in sys.argv: + unittest.main() + else: + regenerate_expected_output(__file__, CProfileTest) + + +# Don't remove this comment. Everything below it is auto-generated. +#--cut-------------------------------------------------------------------------- +_ProfileOutput = {} +_ProfileOutput['print_stats'] = """\ + 28 0.028 0.001 0.028 0.001 profilee.py:110(__getattr__) + 1 0.270 0.270 1.000 1.000 profilee.py:25(testfunc) + 23/3 0.150 0.007 0.170 0.057 profilee.py:35(factorial) + 20 0.020 0.001 0.020 0.001 profilee.py:48(mul) + 2 0.040 0.020 0.600 0.300 profilee.py:55(helper) + 4 0.116 0.029 0.120 0.030 profilee.py:73(helper1) + 2 0.000 0.000 0.140 0.070 profilee.py:84(helper2_indirect) + 8 0.312 0.039 0.400 0.050 profilee.py:88(helper2) + 8 0.064 0.008 0.080 0.010 profilee.py:98(subhelper)""" +_ProfileOutput['print_callers'] = """\ +profilee.py:110(__getattr__) <- 16 0.016 0.016 profilee.py:98(subhelper) +profilee.py:25(testfunc) <- 1 0.270 1.000 :1() +profilee.py:35(factorial) <- 1 0.014 0.130 profilee.py:25(testfunc) + 20/3 0.130 0.147 profilee.py:35(factorial) + 2 0.006 0.040 profilee.py:84(helper2_indirect) +profilee.py:48(mul) <- 20 0.020 0.020 profilee.py:35(factorial) +profilee.py:55(helper) <- 2 0.040 0.600 profilee.py:25(testfunc) +profilee.py:73(helper1) <- 4 0.116 0.120 profilee.py:55(helper) +profilee.py:84(helper2_indirect) <- 2 0.000 0.140 profilee.py:55(helper) +profilee.py:88(helper2) <- 6 0.234 0.300 profilee.py:55(helper) + 2 0.078 0.100 profilee.py:84(helper2_indirect) +profilee.py:98(subhelper) <- 8 0.064 0.080 profilee.py:88(helper2) +{built-in method builtins.hasattr} <- 4 0.000 0.004 profilee.py:73(helper1) + 8 0.000 0.008 profilee.py:88(helper2) +{built-in method sys.exception} <- 4 0.000 0.000 profilee.py:73(helper1) +{method 'append' of 'list' objects} <- 4 0.000 0.000 profilee.py:73(helper1)""" +_ProfileOutput['print_callees'] = """\ +:1() -> 1 0.270 1.000 profilee.py:25(testfunc) +profilee.py:110(__getattr__) -> +profilee.py:25(testfunc) -> 1 0.014 0.130 profilee.py:35(factorial) + 2 0.040 0.600 profilee.py:55(helper) +profilee.py:35(factorial) -> 20/3 0.130 0.147 profilee.py:35(factorial) + 20 0.020 0.020 profilee.py:48(mul) +profilee.py:48(mul) -> +profilee.py:55(helper) -> 4 0.116 0.120 profilee.py:73(helper1) + 2 0.000 0.140 profilee.py:84(helper2_indirect) + 6 0.234 0.300 profilee.py:88(helper2) +profilee.py:73(helper1) -> 4 0.000 0.004 {built-in method builtins.hasattr} +profilee.py:84(helper2_indirect) -> 2 0.006 0.040 profilee.py:35(factorial) + 2 0.078 0.100 profilee.py:88(helper2) +profilee.py:88(helper2) -> 8 0.064 0.080 profilee.py:98(subhelper) +profilee.py:98(subhelper) -> 16 0.016 0.016 profilee.py:110(__getattr__) +{built-in method builtins.hasattr} -> 12 0.012 0.012 profilee.py:110(__getattr__)""" + +if __name__ == "__main__": + main() diff --git a/crates/host_env/src/fs.rs b/crates/host_env/src/fs.rs index d6516c763..498ffbf68 100644 --- a/crates/host_env/src/fs.rs +++ b/crates/host_env/src/fs.rs @@ -11,6 +11,10 @@ pub fn open(path: impl AsRef) -> io::Result { File::open(path) } +pub fn create(path: impl AsRef) -> io::Result { + File::create(path) +} + pub fn read(path: impl AsRef) -> io::Result> { fs::read(path) } diff --git a/crates/pylib/Cargo.toml b/crates/pylib/Cargo.toml index 0a9326640..b1962409a 100644 --- a/crates/pylib/Cargo.toml +++ b/crates/pylib/Cargo.toml @@ -2,7 +2,7 @@ name = "rustpython-pylib" description = "A subset of the Python standard library for use with RustPython" license-file = "Lib/PSF-LICENSE" -include = ["Cargo.toml", "build.rs", "src/**/*.rs", "Lib/", "!Lib/**/test/", "!Lib/**/*.pyc"] +include = ["Cargo.toml", "build.rs", "src/**/*.rs", "LICENSE-profiling", "Lib/", "!Lib/**/test/", "!Lib/**/*.pyc"] authors = ["CPython Developers"] version.workspace = true edition.workspace = true @@ -10,7 +10,7 @@ rust-version.workspace = true repository.workspace = true [features] -freeze-stdlib = ["dep:rustpython-compiler-core", "dep:rustpython-derive"] +freeze-stdlib = ["dep:rustpython-compiler-core", "dep:rustpython-derive", "dep:sha2"] [dependencies] rustpython-compiler-core = { workspace = true, optional = true } @@ -18,6 +18,7 @@ rustpython-derive = { workspace = true, optional = true } [build-dependencies] glob = { workspace = true } +sha2 = { workspace = true, optional = true } [lints] workspace = true \ No newline at end of file diff --git a/crates/pylib/LICENSE-profiling b/crates/pylib/LICENSE-profiling new file mode 100644 index 000000000..5dd1460ed --- /dev/null +++ b/crates/pylib/LICENSE-profiling @@ -0,0 +1,225 @@ +Vendored profiling output assets +=============================== + +License notices and terms copied from CPython commit +d220409a13b77de7a6c30abe16ea90db01528edc, with the section reference adapted +for this standalone notice. +Source notices: Doc/license.rst, "Profiling module" and the Apache License +text referenced there (reproduced below from the "OpenSSL" section). +https://github.com/python/cpython/blob/d220409a13b77de7a6c30abe16ea90db01528edc/Doc/license.rst + +Asset mapping under Lib/profiling/sampling/_vendor/: +- d3/7.8.5/d3.min.js: d3.js notice below. +- d3-flame-graph/4.1.3/d3-flamegraph.css: Apache License 2.0. +- d3-flame-graph/4.1.3/d3-flamegraph.min.js: Apache License 2.0. +- d3-flame-graph/4.1.3/d3-flamegraph-tooltip.min.js: Apache License 2.0. + +Canonical profiling notices +--------------------------- + +The :mod:`!profiling` module includes vendored third-party libraries in +:file:`Lib/profiling/sampling/_vendor/` with the following licenses: + +**d3-flamegraph** + +The d3-flamegraph library is distributed under the Apache License, Version 2.0. +See "Referenced Apache License text" below for the full text of the Apache License Version 2.0. + +**d3.js** + +The d3.js library contains the following notice:: + + Copyright 2010-2021 Mike Bostock + + Permission to use, copy, modify, and/or distribute this software for any purpose + with or without fee is hereby granted, provided that the above copyright notice + and this permission notice appear in all copies. + + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH + REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND + FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, + INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS + OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER + TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF + THIS SOFTWARE. + + +Referenced Apache License text +------------------------------ + + Apache License + Version 2.0, January 2004 + https://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/crates/pylib/build.rs b/crates/pylib/build.rs index f96ef9b47..55f82d255 100644 --- a/crates/pylib/build.rs +++ b/crates/pylib/build.rs @@ -1,6 +1,8 @@ const CRATE_ROOT: &str = "../.."; fn main() { + #[cfg(feature = "freeze-stdlib")] + embed_profiling_resources(); process_python_libs(format!("{CRATE_ROOT}/vm/Lib/python_builtins/*").as_str()); process_python_libs(format!("{CRATE_ROOT}/vm/Lib/core_modules/*").as_str()); @@ -50,3 +52,132 @@ fn process_python_libs(pattern: &str) { println!("cargo:rerun-if-changed={display}"); } } + +// spell-checker: ignore pyfile +// Only these canonical CPython 3.15.0 assets are bundled. This is deliberately +// not a recursive embedding of Lib data or tests. Keep the notices in +// crates/pylib/LICENSE-profiling with every distribution of these bytes. +#[cfg(feature = "freeze-stdlib")] +const PROFILING_RESOURCES: &[(&str, &str)] = &[ + ( + "profiling/sampling/_assets/tachyon-logo.png", + "b72b0e28079ee7c033458f279e1c721d4d689db43f6e616848504d09ebe9c85d", + ), + ( + "profiling/sampling/_flamegraph_assets/flamegraph.css", + "a57f0512fe7071ca7915ac063e45df2173d7cea3b1fdded36fbb481cc14dafbb", + ), + ( + "profiling/sampling/_flamegraph_assets/flamegraph.js", + "9b39e61bfeadb6e4f9132c6dedb3b6f62f421821dc98261079ca6942f7649995", + ), + ( + "profiling/sampling/_flamegraph_assets/flamegraph_template.html", + "6469b5f46e27eaccf9b212e2d30d30807bca15873ea106a66726cbafc0babf39", + ), + ( + "profiling/sampling/_heatmap_assets/heatmap.css", + "17dd5a24ea930b2c8e3e51cd16dda20b68434273b9400ef3f965159e31213608", + ), + ( + "profiling/sampling/_heatmap_assets/heatmap.js", + "335f02d7b8baa94f0294afc0a71a3a4cbc5799187524524ce2a62ee18d16023f", + ), + ( + "profiling/sampling/_heatmap_assets/heatmap_index.js", + "d75ff0fe6781bba58b3a2ce3af63a329da39a7002f3eabf1adcc3cdc14c4d3fd", + ), + ( + "profiling/sampling/_heatmap_assets/heatmap_index_template.html", + "152b8b80c6cd318403c8e4a7a6d3b9256ae2dc3459698c606398bb58e199cffe", + ), + ( + "profiling/sampling/_heatmap_assets/heatmap_pyfile_template.html", + "ed68e3905f19b4535d561628359464ef7d575332a126a827fdc7b70ca4c17099", + ), + ( + "profiling/sampling/_heatmap_assets/heatmap_shared.js", + "15e4ba8b7830fb5e33f9aec95c42acf62b950df11210884aee62eddd580a68ab", + ), + ( + "profiling/sampling/_shared_assets/base.css", + "f09f2a549ce69a31e6a3fc29979a32204781320b377bd6488f00f71c3811af23", + ), + ( + "profiling/sampling/_shared_assets/base.js", + "b6f0d0a0720a7f3b20b14be710b7db257a89098178f3ab785d77da76eb019b18", + ), + ( + "profiling/sampling/_vendor/d3/7.8.5/d3.min.js", + "d6b03aefc9f6c44c7bc78713679c78c295028fa914319119e5cc4b4954855b1c", + ), + ( + "profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph-tooltip.min.js", + "105738fc5fe7ca67327cf9a91a63fbd54e5fd3d2becd0e07650ca919b5678ac9", + ), + ( + "profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph.css", + "7d75d68cb662819a37401248863100dd9db351f4bebf3310845d9196fbab7e2a", + ), + ( + "profiling/sampling/_vendor/d3-flame-graph/4.1.3/d3-flamegraph.min.js", + "1846f1e636ed1bd2c32fc87d1e24d63aff7e4f18629b4bbd819351f09ea22353", + ), +]; + +#[cfg(feature = "freeze-stdlib")] +fn embed_profiling_resources() { + use core::fmt::Write; + use sha2::{Digest, Sha256}; + use std::path::PathBuf; + + // Match LIB_PATH's Windows checkout-link handling without relying on Git's + // ability to create a symlink. Canonicalize the root before containment checks. + let lib = if cfg!(windows) { + std::fs::read_to_string("Lib") + .map_or_else(|_| PathBuf::from("Lib"), |link| PathBuf::from(link.trim())) + } else { + PathBuf::from("Lib") + }; + let lib = lib + .canonicalize() + .expect("resolve canonical Lib resource root"); + let out = PathBuf::from(std::env::var_os("OUT_DIR").expect("Cargo OUT_DIR")); + let mut source = String::from( + "pub const FROZEN_STDLIB_RESOURCES: &[FrozenResourceBundle] = &[(\"profiling\", &[\n", + ); + println!("cargo:rerun-if-changed=Lib"); + for (index, &(relative, expected)) in PROFILING_RESOURCES.iter().enumerate() { + let path = lib.join(relative); + println!("cargo:rerun-if-changed={}", path.display()); + let canonical = path.canonicalize().expect("resolve profiling resource"); + assert!( + canonical.starts_with(&lib), + "profiling resource escapes Lib: {relative}" + ); + assert!( + canonical.is_file(), + "profiling resource is not a file: {relative}" + ); + let bytes = std::fs::read(&canonical).expect("read profiling resource"); + let mut actual = String::with_capacity(64); + for byte in Sha256::digest(&bytes) { + write!(actual, "{byte:02x}").expect("write digest hex"); + } + assert_eq!( + actual, expected, + "profiling resource differs from pinned CPython: {relative}" + ); + // Snapshot exactly the bytes we hashed, rather than asking include_bytes! + // to reread a mutable source file after this build script has returned. + let snapshot = out.join(format!("profiling-resource-{index}.bin")); + std::fs::write(&snapshot, bytes).expect("write owned resource snapshot"); + let logical = relative + .strip_prefix("profiling/") + .expect("profiling resource path"); + writeln!(source, "({logical:?}, include_bytes!({snapshot:?})),") + .expect("write resource source"); + } + source.push_str("])];\n"); + std::fs::write(out.join("frozen_resources.rs"), source).expect("write resource sidecar"); +} diff --git a/crates/pylib/src/lib.rs b/crates/pylib/src/lib.rs index db957f633..a116242e3 100644 --- a/crates/pylib/src/lib.rs +++ b/crates/pylib/src/lib.rs @@ -14,3 +14,12 @@ pub const LIB_PATH: &str = match option_env!("win_lib_path") { #[cfg(feature = "freeze-stdlib")] pub const FROZEN_STDLIB: &rustpython_compiler_core::frozen::FrozenLib = rustpython_derive::py_freeze!(dir = "../Lib", crate_name = "rustpython_compiler_core"); + +#[cfg(feature = "freeze-stdlib")] +type FrozenResource = (&'static str, &'static [u8]); +#[cfg(feature = "freeze-stdlib")] +type FrozenResourceBundle = (&'static str, &'static [FrozenResource]); + +// Resource bytes are registered alongside the exact frozen module batch. +#[cfg(feature = "freeze-stdlib")] +include!(concat!(env!("OUT_DIR"), "/frozen_resources.rs")); diff --git a/crates/stdlib/src/_remote_debugging.rs b/crates/stdlib/src/_remote_debugging.rs index 618ea9fe0..898bb9efc 100644 --- a/crates/stdlib/src/_remote_debugging.rs +++ b/crates/stdlib/src/_remote_debugging.rs @@ -1,19 +1,46 @@ +mod binary; + pub(crate) use _remote_debugging::module_def; #[pymodule] mod _remote_debugging { + use super::binary; use crate::vm::{ - Py, PyObjectRef, PyResult, VirtualMachine, - builtins::PyType, - function::FuncArgs, - types::{Constructor, PyStructSequence}, + AsObject, Py, PyObject, PyObjectRef, PyRef, PyResult, VirtualMachine, + builtins::{PyBaseExceptionRef, PyDictRef, PyList, PyTuple, PyType}, + common::{lock::PyMutex, rc::PyRc}, + convert::{IntoPyException, ToPyObject}, + function::{FsPath, FuncArgs}, + types::{Constructor, DefaultConstructor, Initializer, PyStructSequence}, }; + use alloc::sync::Arc; + use core::sync::atomic::{AtomicBool, Ordering}; + + #[pystruct_sequence_data] + struct LocationInfoData { + lineno: PyObjectRef, + end_lineno: PyObjectRef, + col_offset: PyObjectRef, + end_col_offset: PyObjectRef, + } + + #[pyattr] + #[pystruct_sequence( + name = "LocationInfo", + module = "_remote_debugging", + data = "LocationInfoData" + )] + struct LocationInfo; + + #[pyclass(with(PyStructSequence))] + impl LocationInfo {} #[pystruct_sequence_data] struct FrameInfoData { - filename: String, - lineno: i64, - funcname: String, + filename: PyObjectRef, + location: PyObjectRef, + funcname: PyObjectRef, + opcode: PyObjectRef, } #[pyattr] @@ -58,6 +85,7 @@ mod _remote_debugging { #[pystruct_sequence_data] struct ThreadInfoData { thread_id: PyObjectRef, + status: PyObjectRef, frame_info: PyObjectRef, } @@ -72,6 +100,41 @@ mod _remote_debugging { #[pyclass(with(PyStructSequence))] impl ThreadInfo {} + #[pystruct_sequence_data] + struct InterpreterInfoData { + interpreter_id: PyObjectRef, + threads: PyObjectRef, + } + + #[pyattr] + #[pystruct_sequence( + name = "InterpreterInfo", + module = "_remote_debugging", + data = "InterpreterInfoData" + )] + struct InterpreterInfo; + + #[pyclass(with(PyStructSequence))] + impl InterpreterInfo {} + + #[pyattr] + const THREAD_STATUS_HAS_GIL: u8 = 1; + #[pyattr] + const THREAD_STATUS_ON_CPU: u8 = 2; + #[pyattr] + const THREAD_STATUS_UNKNOWN: u8 = 4; + #[pyattr] + const THREAD_STATUS_GIL_REQUESTED: u8 = 8; + #[pyattr] + const THREAD_STATUS_HAS_EXCEPTION: u8 = 16; + #[pyattr] + const THREAD_STATUS_MAIN_THREAD: u8 = 32; + + #[pyfunction] + fn zstd_available() -> bool { + false + } + #[pystruct_sequence_data] struct AwaitedInfoData { thread_id: PyObjectRef, @@ -89,6 +152,640 @@ mod _remote_debugging { #[pyclass(with(PyStructSequence))] impl AwaitedInfo {} + fn codec_error(error: binary::Error, vm: &VirtualMachine) -> PyBaseExceptionRef { + match error { + binary::Error::Io(error) => error.into_pyexception(vm), + binary::Error::Value(message) => vm.new_value_error(message), + binary::Error::Overflow(message) => vm.new_overflow_error(message), + binary::Error::Memory => vm.new_memory_error("unable to allocate profiling data"), + binary::Error::Runtime(message) => vm.new_runtime_error(message), + } + } + + fn codec_path_error( + error: binary::Error, + path: PyObjectRef, + vm: &VirtualMachine, + ) -> PyBaseExceptionRef { + let is_io = matches!(error, binary::Error::Io(_)); + let exception = codec_error(error, vm); + if is_io { + let _ = exception.as_object().set_attr("filename", path, vm); + } + exception + } + + fn list_items(value: &PyObject, name: &str, vm: &VirtualMachine) -> PyResult> { + value + .downcast_ref::() + .map(|list| list.borrow_vec().to_vec()) + .ok_or_else(|| vm.new_type_error(format!("{name} must be a list"))) + } + + fn record_fields<'a>( + value: &'a PyObject, + count: usize, + name: &str, + vm: &VirtualMachine, + ) -> PyResult<&'a [PyObjectRef]> { + value + .downcast_ref::() + .map(|tuple| tuple.as_slice()) + .filter(|fields| fields.len() >= count) + .ok_or_else(|| { + vm.new_type_error(format!( + "{name} must be a tuple with at least {count} fields" + )) + }) + } + + fn normalize_frame(value: &PyObject, vm: &VirtualMachine) -> PyResult { + let fields = record_fields(value, 4, "frame", vm)?; + let mut location = [-1; 4]; + if !vm.is_none(&fields[1]) { + let values = record_fields(&fields[1], 4, "location", vm)?; + for (target, value) in location.iter_mut().zip(values) { + // Match CPython: C long is 32-bit on Windows, 64-bit on LP64. + #[allow(clippy::unnecessary_cast)] + let normalized = value.try_to_value::(vm).unwrap_or(-1) as i32; + *target = normalized; + } + } + let opcode = fields[3] + .try_to_value::(vm) + .ok() + .filter(|value| (0..=254).contains(value)) + .map_or(255, |value| value as u8); + Ok(binary::Frame { + filename: fields[0].try_to_value::(vm)?, + funcname: fields[2].try_to_value::(vm)?, + location, + opcode, + }) + } + + #[derive(FromArgs)] + struct BinaryWriterArgs { + #[pyarg(any)] + filename: PyObjectRef, + #[pyarg(any)] + sample_interval_us: u64, + #[pyarg(any)] + start_time_us: u64, + #[pyarg(named, default = 0)] + compression: i32, + #[pyarg(named, default = -1)] + mode: i32, + #[pyarg(named, default = -1)] + capture_features: i32, + } + + #[derive(Debug, Default)] + struct WriterState { + writer: Option, + cached_total_samples: u64, + } + + #[pyattr] + #[pyclass(name = "BinaryWriter", module = "_remote_debugging")] + #[derive(Debug, PyPayload, Default)] + struct BinaryWriter { + state: PyMutex, + } + + impl DefaultConstructor for BinaryWriter {} + + impl Initializer for BinaryWriter { + type Args = BinaryWriterArgs; + fn init(zelf: &Py, args: Self::Args, vm: &VirtualMachine) -> PyResult<()> { + if !(-1..=4).contains(&args.mode) { + return Err(vm.new_value_error("invalid profiling mode")); + } + if !(-1..=31).contains(&args.capture_features) { + return Err(vm.new_value_error("invalid capture features")); + } + let path = + FsPath::try_from_path_like(args.filename.clone(), true, vm)?.to_path_buf(vm)?; + let old = { + let mut state = zelf.state.lock(); + state.cached_total_samples = 0; + state.writer.take() + }; + drop(old); + let writer = binary::Writer::create( + &path, + args.sample_interval_us, + args.start_time_us, + args.compression, + args.mode, + args.capture_features, + [ + crate::vm::version::MAJOR as u8, + crate::vm::version::MINOR as u8, + crate::vm::version::MICRO as u8, + ], + ) + .map_err(|error| codec_path_error(error, args.filename, vm))?; + zelf.state.lock().writer = Some(writer); + Ok(()) + } + } + + #[derive(FromArgs)] + struct BinaryExitArgs { + #[pyarg(positional, optional)] + exc_type: Option, + #[pyarg(positional, optional)] + _exc_value: Option, + #[pyarg(positional, optional)] + _exc_tb: Option, + } + + #[derive(FromArgs)] + struct SetStatsArgs { + #[pyarg(any)] + duration_sec: f64, + #[pyarg(any)] + sample_rate: f64, + #[pyarg(any, optional)] + error_rate: Option, + #[pyarg(any, optional)] + missed_samples: Option, + } + + fn optional_float(value: Option, vm: &VirtualMachine) -> PyResult> { + value + .filter(|value| !vm.is_none(value)) + .map(|value| value.try_float(vm).map(|value| value.to_f64())) + .transpose() + } + + #[pyclass(with(Constructor, Initializer))] + impl BinaryWriter { + #[pymethod] + fn write_sample( + zelf: &Py, + stack_frames: PyObjectRef, + timestamp_us: u64, + vm: &VirtualMachine, + ) -> PyResult<()> { + if zelf.state.lock().writer.is_none() { + return Err(vm.new_value_error("Writer is closed")); + } + for interpreter in list_items(&stack_frames, "stack_frames", vm)? { + let fields = record_fields(&interpreter, 2, "interpreter", vm)?; + let interpreter_id = fields[0].try_to_value::(vm)?; + for thread in list_items(&fields[1], "threads", vm)? { + let fields = record_fields(&thread, 3, "thread", vm)?; + let thread_id = fields[0].try_to_value::(vm)?; + let status = fields[1].try_to_value::(vm)? as u8; + let frame_objects = { + let list = fields[2] + .downcast_ref::() + .ok_or_else(|| vm.new_type_error("frame_info must be a list"))?; + list.borrow_vec() + .iter() + .take(256) + .cloned() + .collect::>() + }; + let frames = frame_objects + .into_iter() + .map(|frame| normalize_frame(&frame, vm)) + .collect::>>()?; + let sample = binary::Sample { + interpreter_id, + thread_id, + status, + frames, + timestamp_us, + }; + // All Python conversions above have finished before borrowing file state. + let result = { + let mut state = zelf.state.lock(); + state + .writer + .as_mut() + .map(|writer| writer.write_sample(&sample)) + }; + result + .ok_or_else(|| vm.new_value_error("Writer is closed"))? + .map_err(|error| codec_error(error, vm))?; + } + } + Ok(()) + } + + #[pymethod] + fn set_stats(zelf: &Py, args: SetStatsArgs, vm: &VirtualMachine) -> PyResult<()> { + let is_open = zelf.state.lock().writer.is_some(); + if !is_open { + return Err(vm.new_value_error("Writer is closed")); + } + let profile = binary::ProfileStats { + duration_sec: args.duration_sec, + sample_rate: args.sample_rate, + error_rate: optional_float(args.error_rate, vm)?, + missed_samples: optional_float(args.missed_samples, vm)?, + }; + let result = { + let mut state = zelf.state.lock(); + state + .writer + .as_mut() + .map(|writer| writer.set_stats(profile)) + }; + result + .ok_or_else(|| vm.new_value_error("Writer is closed"))? + .map_err(|error| codec_error(error, vm)) + } + + #[pymethod] + fn finalize(zelf: &Py, vm: &VirtualMachine) -> PyResult<()> { + let result = { + let mut state = zelf.state.lock(); + if let Some(writer) = state.writer.as_mut() { + match writer.finalize() { + Ok(()) => { + state.cached_total_samples = writer.total_samples(); + Ok(state.writer.take()) + } + Err(error) => Err(Some(error)), + } + } else { + Err(None) + } + }; + match result { + Ok(writer) => { + drop(writer); + Ok(()) + } + Err(Some(error)) => Err(codec_error(error, vm)), + Err(None) => Err(vm.new_value_error("Writer is already closed")), + } + } + + #[pymethod] + fn close(zelf: &Py) { + let writer = zelf.state.lock().writer.take(); + drop(writer); + } + + #[pygetset] + fn total_samples(zelf: &Py) -> u64 { + let state = zelf.state.lock(); + state + .writer + .as_ref() + .map_or(state.cached_total_samples, binary::Writer::total_samples) + } + + #[pymethod] + fn get_stats(zelf: &Py, vm: &VirtualMachine) -> PyResult { + let stats = zelf + .state + .lock() + .writer + .as_ref() + .map(|writer| *writer.stats()); + let stats = stats.ok_or_else(|| vm.new_value_error("Writer is closed"))?; + let dict = vm.ctx.new_dict(); + for (key, value) in [ + ("repeat_records", stats.repeat_records), + ("repeat_samples", stats.repeat_samples), + ("full_records", stats.full_records), + ("suffix_records", stats.suffix_records), + ("pop_push_records", stats.pop_push_records), + ("total_records", stats.total_records()), + ("total_samples", stats.total_samples), + ("total_frames_written", stats.total_frames_written), + ("frames_saved", stats.frames_saved), + ("bytes_written", stats.bytes_written), + ] { + dict.set_item(key, value.to_pyobject(vm), vm)?; + } + dict.set_item( + "frame_compression_pct", + stats.frame_compression_pct().to_pyobject(vm), + vm, + )?; + Ok(dict) + } + + #[pymethod] + fn __enter__(zelf: PyRef) -> PyRef { + zelf + } + + #[pymethod] + fn __exit__(zelf: &Py, args: BinaryExitArgs, vm: &VirtualMachine) -> PyResult { + if args.exc_type.as_ref().is_none_or(|value| vm.is_none(value)) { + let is_open = zelf.state.lock().writer.is_some(); + if is_open && let Err(error) = Self::finalize(zelf, vm) { + Self::close(zelf); + return Err(error); + } + } else { + Self::close(zelf); + } + Ok(false) + } + } + + #[derive(Debug)] + struct ReaderOpen { + reader: Arc, + stats: PyMutex, + replaying: AtomicBool, + } + + struct ReplayLease(PyRc); + impl Drop for ReplayLease { + fn drop(&mut self) { + self.0.replaying.store(false, Ordering::Release); + } + } + + #[pyattr] + #[pyclass(name = "BinaryReader", module = "_remote_debugging")] + #[derive(Debug, PyPayload, Default)] + struct BinaryReader { + state: PyMutex>>, + } + + impl DefaultConstructor for BinaryReader {} + + #[derive(FromArgs)] + struct BinaryReaderArgs { + #[pyarg(any)] + filename: PyObjectRef, + } + + impl Initializer for BinaryReader { + type Args = BinaryReaderArgs; + fn init(zelf: &Py, args: Self::Args, vm: &VirtualMachine) -> PyResult<()> { + let path = + FsPath::try_from_path_like(args.filename.clone(), true, vm)?.to_path_buf(vm)?; + let old = zelf.state.lock().take(); + drop(old); + let reader = binary::Reader::open(&path) + .map_err(|error| codec_path_error(error, args.filename, vm))?; + *zelf.state.lock() = Some(PyRc::new(ReaderOpen { + reader: Arc::new(reader), + stats: PyMutex::new(binary::ReaderStats::default()), + replaying: AtomicBool::new(false), + })); + Ok(()) + } + } + + fn replay_records(sample: &binary::Sample, vm: &VirtualMachine) -> PyObjectRef { + let frames = sample + .frames + .iter() + .map(|frame| { + let location = if frame.location[0] == -1 { + vm.ctx.none() + } else { + vm.ctx + .new_tuple( + frame + .location + .iter() + .map(|value| value.to_pyobject(vm)) + .collect(), + ) + .into() + }; + FrameInfo::from_data( + FrameInfoData { + filename: frame.filename.clone().to_pyobject(vm), + location, + funcname: frame.funcname.clone().to_pyobject(vm), + opcode: if frame.opcode == 255 { + vm.ctx.none() + } else { + frame.opcode.to_pyobject(vm) + }, + }, + vm, + ) + .into() + }) + .collect(); + let thread = ThreadInfo::from_data( + ThreadInfoData { + thread_id: sample.thread_id.to_pyobject(vm), + status: sample.status.to_pyobject(vm), + frame_info: vm.ctx.new_list(frames).into(), + }, + vm, + ); + let interpreter = InterpreterInfo::from_data( + InterpreterInfoData { + interpreter_id: sample.interpreter_id.to_pyobject(vm), + threads: vm.ctx.new_list(vec![thread.into()]).into(), + }, + vm, + ); + vm.ctx.new_list(vec![interpreter.into()]).into() + } + + #[derive(FromArgs)] + struct ReplayArgs { + #[pyarg(any)] + collector: PyObjectRef, + #[pyarg(any, optional)] + progress_callback: Option, + } + + #[pyclass(with(Constructor, Initializer))] + impl BinaryReader { + fn opened(zelf: &Py, vm: &VirtualMachine) -> PyResult> { + let open = zelf.state.lock().clone(); + open.ok_or_else(|| vm.new_value_error("Reader is closed")) + } + + #[pymethod] + fn close(zelf: &Py) { + let old = zelf.state.lock().take(); + drop(old); + } + + #[pygetset] + fn sample_count(zelf: &Py) -> u64 { + zelf.state + .lock() + .as_ref() + .map_or(0, |open| open.reader.metadata().sample_count) + } + + #[pygetset] + fn sample_interval_us(zelf: &Py) -> u64 { + zelf.state + .lock() + .as_ref() + .map_or(0, |open| open.reader.metadata().sample_interval_us) + } + + #[pymethod] + fn get_info(zelf: &Py, vm: &VirtualMachine) -> PyResult { + let open = Self::opened(zelf, vm)?; + let info = open.reader.metadata(); + let dict = vm.ctx.new_dict(); + dict.set_item("version", info.version.to_pyobject(vm), vm)?; + dict.set_item( + "python_version", + vm.ctx + .new_tuple( + info.python_version + .iter() + .map(|value| value.to_pyobject(vm)) + .collect(), + ) + .into(), + vm, + )?; + for (key, value) in [ + ("start_time_us", info.start_time_us), + ("sample_interval_us", info.sample_interval_us), + ("sample_count", info.sample_count), + ("thread_count", u64::from(info.thread_count)), + ("string_count", u64::from(info.string_count)), + ("frame_count", u64::from(info.frame_count)), + ] { + dict.set_item(key, value.to_pyobject(vm), vm)?; + } + dict.set_item( + "compression_type", + (info.compression_type as i32).to_pyobject(vm), + vm, + )?; + dict.set_item("mode", info.mode.to_pyobject(vm), vm)?; + dict.set_item( + "capture_features", + info.capture_features.to_pyobject(vm), + vm, + )?; + let profile = info.profile_stats.as_ref(); + for (key, value) in [ + ("duration_sec", profile.map(|value| value.duration_sec)), + ("sample_rate", profile.map(|value| value.sample_rate)), + ("error_rate", profile.and_then(|value| value.error_rate)), + ( + "missed_samples", + profile.and_then(|value| value.missed_samples), + ), + ] { + dict.set_item(key, value.to_pyobject(vm), vm)?; + } + Ok(dict) + } + + #[pymethod] + fn get_stats(zelf: &Py, vm: &VirtualMachine) -> PyResult { + let open = Self::opened(zelf, vm)?; + let stats = *open.stats.lock(); + let dict = vm.ctx.new_dict(); + for (key, value) in [ + ("repeat_records", stats.repeat_records), + ("repeat_samples", stats.repeat_samples), + ("full_records", stats.full_records), + ("suffix_records", stats.suffix_records), + ("pop_push_records", stats.pop_push_records), + ("total_records", stats.total_records()), + ("total_samples", stats.total_samples), + ("stack_reconstructions", stats.stack_reconstructions), + ] { + dict.set_item(key, value.to_pyobject(vm), vm)?; + } + Ok(dict) + } + + #[pymethod] + fn replay(zelf: &Py, args: ReplayArgs, vm: &VirtualMachine) -> PyResult { + let open = Self::opened(zelf, vm)?; + if open + .replaying + .compare_exchange(false, true, Ordering::Acquire, Ordering::Relaxed) + .is_err() + { + return Err(vm.new_runtime_error("A replay is already active on this reader")); + } + let _lease = ReplayLease(open.clone()); + // The per-open snapshot survives close/reinitialization, but cannot + // publish counters into a replacement reader. No lock crosses Python. + let initial_collect = args.collector.get_attr("collect", vm).map_err(|error| { + if error.fast_isinstance(vm.ctx.exceptions.attribute_error) { + vm.new_type_error("Collector must have a collect() method") + } else { + error + } + })?; + drop(initial_collect); + let progress = args.progress_callback.filter(|value| !vm.is_none(value)); + let total = open.reader.metadata().sample_count; + if let Some(callback) = &progress { + callback.call((0_u64, total), vm)?; + } + let mut replay = open.reader.replay(); + let mut committed_samples = 0; + loop { + let batch = replay.next_batch(); + let mut stats = *replay.stats(); + stats.total_samples = committed_samples; + *open.stats.lock() = stats; + let Some(batch) = batch.map_err(|error| codec_error(error, vm))? else { + break; + }; + let records = replay_records(&batch.sample, vm); + let timestamps: PyObjectRef = vm + .ctx + .new_list( + batch + .timestamps + .iter() + .map(|value| value.to_pyobject(vm)) + .collect(), + ) + .into(); + args.collector + .get_attr("collect", vm)? + .call((records, timestamps), vm)?; + if batch.record_complete { + committed_samples = replay.replayed(); + *open.stats.lock() = *replay.stats(); + if let Some(callback) = &progress { + if batch.is_repeat + && committed_samples % 1000 < u64::from(batch.record_samples) + { + callback.call((committed_samples, total), vm)?; + } + if committed_samples % 1000 == 0 { + callback.call((committed_samples, total), vm)?; + } + } + } + } + if let Some(callback) = &progress { + callback.call((replay.replayed(), total), vm)?; + } + Ok(replay.replayed()) + } + + #[pymethod] + fn __enter__(zelf: PyRef) -> PyRef { + zelf + } + + #[pymethod] + fn __exit__(zelf: &Py, _args: BinaryExitArgs) -> bool { + Self::close(zelf); + false + } + } + #[pyattr] #[pyclass(name = "RemoteUnwinder", module = "_remote_debugging")] #[derive(Debug, PyPayload)] diff --git a/crates/stdlib/src/_remote_debugging/binary.rs b/crates/stdlib/src/_remote_debugging/binary.rs new file mode 100644 index 000000000..d77b66226 --- /dev/null +++ b/crates/stdlib/src/_remote_debugging/binary.rs @@ -0,0 +1,1313 @@ +//! Offline sampling files, compatible with CPython's version-one TACH format. +//! +//! Only the sample stream is compressible. This implementation writes raw +//! samples and explicitly rejects zstd instead of misidentifying raw data. + +use alloc::sync::Arc; +use std::{ + collections::HashMap, + fs::File, + io::{self, Read, Seek, SeekFrom, Write}, + path::Path, +}; + +pub(crate) const FORMAT_VERSION: u32 = 1; +const MAGIC: u32 = 0x5441_4348; +const HEADER_SIZE: usize = 64; +const FOOTER_SIZE: usize = 32; +const STATS_SIZE: usize = 56; +const STACK_LIMIT: usize = 256; +const WRITE_BUFFER_SIZE: usize = 512 * 1024; +const REPEAT_BUFFER_SIZE: usize = 16 * 1024; +const FULL: u8 = 1; +const SUFFIX: u8 = 2; +const POP_PUSH: u8 = 3; +const REPEAT: u8 = 0; + +#[derive(Debug)] +pub(crate) enum Error { + Io(io::Error), + Value(String), + Overflow(&'static str), + Memory, + Runtime(&'static str), +} + +pub(crate) type Result = core::result::Result; + +impl From for Error { + fn from(error: io::Error) -> Self { + Self::Io(error) + } +} + +fn invalid(message: impl Into) -> Error { + Error::Value(message.into()) +} + +fn reserve(values: &mut Vec, additional: usize) -> Result<()> { + let capacity = values + .len() + .checked_add(additional) + .ok_or(Error::Overflow("Array capacity overflow"))?; + let allocation = capacity + .checked_mul(core::mem::size_of::()) + .ok_or(Error::Overflow("Array allocation size overflow"))?; + if allocation > isize::MAX as usize { + return Err(Error::Overflow("Array allocation size overflow")); + } + values.try_reserve(additional).map_err(|_| Error::Memory) +} + +fn copy_string(value: &str) -> Result { + let mut result = String::new(); + result.try_reserve(value.len()).map_err(|_| Error::Memory)?; + result.push_str(value); + Ok(result) +} + +fn push_varint(bytes: &mut Vec, mut value: u64) -> Result<()> { + reserve(bytes, 10)?; + while value >= 0x80 { + bytes.push((value as u8 & 0x7f) | 0x80); + value >>= 7; + } + bytes.push(value as u8); + Ok(()) +} + +fn push_signed(bytes: &mut Vec, value: i32) -> Result<()> { + push_varint( + bytes, + (((value as u32) << 1) ^ ((value >> 31) as u32)).into(), + ) +} + +fn add(counter: &mut u64, value: u64) -> Result<()> { + *counter = counter + .checked_add(value) + .ok_or(Error::Overflow("Sample statistics overflow"))?; + Ok(()) +} + +#[derive(Debug)] +pub(crate) struct Frame { + pub(crate) filename: String, + pub(crate) funcname: String, + pub(crate) location: [i32; 4], + pub(crate) opcode: u8, +} + +#[derive(Debug)] +pub(crate) struct Sample { + pub(crate) interpreter_id: u32, + pub(crate) thread_id: u64, + pub(crate) status: u8, + pub(crate) frames: Vec, + pub(crate) timestamp_us: u64, +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct ProfileStats { + pub(crate) duration_sec: f64, + pub(crate) sample_rate: f64, + pub(crate) error_rate: Option, + pub(crate) missed_samples: Option, +} + +impl ProfileStats { + fn validate(&self) -> Result<()> { + if !self.duration_sec.is_finite() || self.duration_sec < 0.0 { + return Err(invalid("duration must be a finite non-negative value")); + } + if !self.sample_rate.is_finite() || self.sample_rate < 0.0 { + return Err(invalid("sample rate must be a finite non-negative value")); + } + if self + .error_rate + .is_some_and(|value| !value.is_finite() || value < 0.0) + { + return Err(invalid("error rate must be a finite non-negative value")); + } + if self.missed_samples.is_some_and(|value| !value.is_finite()) { + return Err(invalid("missed samples must be a finite value")); + } + Ok(()) + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct WriterStats { + pub(crate) repeat_records: u64, + pub(crate) repeat_samples: u64, + pub(crate) full_records: u64, + pub(crate) suffix_records: u64, + pub(crate) pop_push_records: u64, + pub(crate) total_samples: u64, + pub(crate) total_frames_written: u64, + pub(crate) frames_saved: u64, + pub(crate) bytes_written: u64, +} + +impl WriterStats { + pub(crate) fn total_records(&self) -> u64 { + self.repeat_records + self.full_records + self.suffix_records + self.pop_push_records + } + + pub(crate) fn frame_compression_pct(&self) -> f64 { + let potential = self.total_frames_written as f64 + self.frames_saved as f64; + if potential == 0.0 { + 0.0 + } else { + self.frames_saved as f64 / potential * 100.0 + } + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub(crate) struct ReaderStats { + pub(crate) repeat_records: u64, + pub(crate) repeat_samples: u64, + pub(crate) full_records: u64, + pub(crate) suffix_records: u64, + pub(crate) pop_push_records: u64, + pub(crate) total_samples: u64, + pub(crate) stack_reconstructions: u64, +} + +impl ReaderStats { + pub(crate) fn total_records(&self) -> u64 { + self.repeat_records + self.full_records + self.suffix_records + self.pop_push_records + } +} + +#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] +struct FrameEntry { + filename: u32, + funcname: u32, + location: [i32; 4], + opcode: u8, +} + +#[derive(Debug)] +struct WriterThread { + thread_id: u64, + interpreter_id: u32, + timestamp: u64, + stack: [u32; STACK_LIMIT], + depth: usize, + pending: Vec, + pending_count: u32, +} + +#[derive(Debug)] +pub(crate) struct Writer { + file: Option, + buffer: Vec, + strings: Vec, + string_indices: HashMap, + frames: Vec, + frame_indices: HashMap, + threads: Vec, + thread_indices: HashMap<(u64, u32), usize>, + interval: u64, + start: u64, + compression: i32, + mode: i32, + features: i32, + python_version: [u8; 3], + profile: Option, + stats: WriterStats, +} + +impl Writer { + #[allow(clippy::too_many_arguments)] + pub(crate) fn create( + path: &Path, + interval: u64, + start: u64, + compression: i32, + mode: i32, + features: i32, + python_version: [u8; 3], + ) -> Result { + if !(-1..=4).contains(&mode) { + return Err(invalid("invalid profiling mode")); + } + if !(-1..=31).contains(&features) { + return Err(invalid("invalid capture features")); + } + if compression == 1 { + return Err(Error::Runtime( + "zstd compression requested but not available (HAVE_ZSTD not defined)", + )); + } + let mut buffer = Vec::new(); + reserve(&mut buffer, WRITE_BUFFER_SIZE)?; + let mut file = rustpython_host_env::fs::create(path)?; + file.write_all(&[0; HEADER_SIZE])?; + Ok(Self { + file: Some(file), + buffer, + strings: Vec::new(), + string_indices: HashMap::new(), + frames: Vec::new(), + frame_indices: HashMap::new(), + threads: Vec::new(), + thread_indices: HashMap::new(), + interval, + start, + compression, + mode, + features, + python_version, + profile: None, + stats: WriterStats::default(), + }) + } + + fn file(&mut self) -> Result<&mut File> { + self.file + .as_mut() + .ok_or_else(|| invalid("Writer is closed")) + } + + fn flush_buffer(&mut self) -> Result<()> { + if !self.buffer.is_empty() { + self.file + .as_mut() + .ok_or_else(|| invalid("Writer is closed"))? + .write_all(&self.buffer)?; + self.buffer.clear(); + } + Ok(()) + } + + fn write_bytes(&mut self, mut bytes: &[u8]) -> Result<()> { + let length = bytes.len() as u64; + while !bytes.is_empty() { + let count = bytes.len().min(WRITE_BUFFER_SIZE - self.buffer.len()); + self.buffer.extend_from_slice(&bytes[..count]); + bytes = &bytes[count..]; + if self.buffer.len() == WRITE_BUFFER_SIZE { + self.flush_buffer()?; + } + } + add(&mut self.stats.bytes_written, length) + } + + fn intern_string(&mut self, value: &str) -> Result { + if let Some(index) = self.string_indices.get(value) { + return Ok(*index); + } + if self.strings.len() >= u32::MAX as usize { + return Err(Error::Overflow("too many strings for binary format")); + } + if value.len() > u32::MAX as usize { + return Err(Error::Overflow( + "string length exceeds binary format maximum", + )); + } + reserve(&mut self.strings, 1)?; + self.string_indices + .try_reserve(1) + .map_err(|_| Error::Memory)?; + let stored = copy_string(value)?; + let key = copy_string(value)?; + let index = self.strings.len() as u32; + self.strings.push(stored); + self.string_indices.insert(key, index); + Ok(index) + } + + fn intern_frame(&mut self, frame: &Frame) -> Result { + let entry = FrameEntry { + filename: self.intern_string(&frame.filename)?, + funcname: self.intern_string(&frame.funcname)?, + location: frame.location, + opcode: frame.opcode, + }; + if let Some(index) = self.frame_indices.get(&entry) { + return Ok(*index); + } + if self.frames.len() >= u32::MAX as usize { + return Err(Error::Overflow("too many frames for binary format")); + } + // Reject unrepresentable deltas before adding a frame to the table. + location_delta(entry.location[0], entry.location[1])?; + location_delta(entry.location[2], entry.location[3])?; + reserve(&mut self.frames, 1)?; + self.frame_indices + .try_reserve(1) + .map_err(|_| Error::Memory)?; + let index = self.frames.len() as u32; + self.frames.push(entry); + self.frame_indices.insert(entry, index); + Ok(index) + } + + fn thread(&mut self, thread_id: u64, interpreter_id: u32) -> Result { + let key = (thread_id, interpreter_id); + if let Some(index) = self.thread_indices.get(&key) { + return Ok(*index); + } + if self.threads.len() >= u32::MAX as usize { + return Err(Error::Overflow("too many threads for binary format")); + } + reserve(&mut self.threads, 1)?; + self.thread_indices + .try_reserve(1) + .map_err(|_| Error::Memory)?; + let index = self.threads.len(); + self.threads.push(WriterThread { + thread_id, + interpreter_id, + timestamp: self.start, + stack: [0; STACK_LIMIT], + depth: 0, + pending: Vec::new(), + pending_count: 0, + }); + self.thread_indices.insert(key, index); + Ok(index) + } + + fn flush_repeat(&mut self, index: usize) -> Result<()> { + let thread = &self.threads[index]; + if thread.pending_count == 0 { + return Ok(()); + } + let count = thread.pending_count; + let saved = u64::from(count) * thread.depth as u64; + let mut header = record_header(thread.thread_id, thread.interpreter_id, REPEAT)?; + push_varint(&mut header, count.into())?; + self.write_bytes(&header)?; + let mut pending = core::mem::take(&mut self.threads[index].pending); + let result = self.write_bytes(&pending); + if result.is_ok() { + pending.clear(); + self.threads[index].pending_count = 0; + } + self.threads[index].pending = pending; + result?; + add(&mut self.stats.repeat_records, 1)?; + add(&mut self.stats.repeat_samples, count.into())?; + add(&mut self.stats.frames_saved, saved) + } + + pub(crate) fn write_sample(&mut self, sample: &Sample) -> Result<()> { + self.file()?; + let mut stack = [0; STACK_LIMIT]; + let depth = sample.frames.len().min(STACK_LIMIT); + for (slot, frame) in stack.iter_mut().zip(sample.frames.iter()).take(depth) { + *slot = self.intern_frame(frame)?; + } + let index = self.thread(sample.thread_id, sample.interpreter_id)?; + let thread = &self.threads[index]; + let delta = sample.timestamp_us.wrapping_sub(thread.timestamp); + let previous = &thread.stack[..thread.depth]; + let current = &stack[..depth]; + if previous == current { + if thread.pending.len() + 11 > REPEAT_BUFFER_SIZE { + self.flush_repeat(index)?; + } + let thread = &mut self.threads[index]; + reserve(&mut thread.pending, 11)?; + push_varint(&mut thread.pending, delta)?; + thread.pending.push(sample.status); + thread.pending_count += 1; + } else { + let shared = previous + .iter() + .rev() + .zip(current.iter().rev()) + .take_while(|(left, right)| left == right) + .count(); + let pop = previous.len() - shared; + let push = depth - shared; + let encoding = if shared == 0 { + FULL + } else if pop == 0 && push > 0 { + SUFFIX + } else if shared < depth / 2 { + FULL + } else { + POP_PUSH + }; + self.flush_repeat(index)?; + let mut record = record_header(sample.thread_id, sample.interpreter_id, encoding)?; + push_varint(&mut record, delta)?; + reserve(&mut record, 1)?; + record.push(sample.status); + let written = match encoding { + FULL => { + push_varint(&mut record, depth as u64)?; + depth + } + SUFFIX => { + push_varint(&mut record, shared as u64)?; + push_varint(&mut record, push as u64)?; + push + } + _ => { + push_varint(&mut record, pop as u64)?; + push_varint(&mut record, push as u64)?; + push + } + }; + for &frame in &stack[..written] { + push_varint(&mut record, frame.into())?; + } + self.write_bytes(&record)?; + match encoding { + FULL => add(&mut self.stats.full_records, 1)?, + SUFFIX => add(&mut self.stats.suffix_records, 1)?, + _ => add(&mut self.stats.pop_push_records, 1)?, + } + add(&mut self.stats.total_frames_written, written as u64)?; + if encoding != FULL { + add(&mut self.stats.frames_saved, shared as u64)?; + } + self.threads[index].stack = stack; + self.threads[index].depth = depth; + } + self.threads[index].timestamp = sample.timestamp_us; + add(&mut self.stats.total_samples, 1) + } + + pub(crate) fn set_stats(&mut self, stats: ProfileStats) -> Result<()> { + self.file()?; + stats.validate()?; + self.profile = Some(stats); + Ok(()) + } + + pub(crate) fn stats(&self) -> &WriterStats { + &self.stats + } + + pub(crate) fn total_samples(&self) -> u64 { + self.stats.total_samples + } + + pub(crate) fn finalize(&mut self) -> Result<()> { + if self.file.is_none() { + return Err(invalid("Writer is already closed")); + } + for index in 0..self.threads.len() { + self.flush_repeat(index)?; + } + self.flush_buffer()?; + let string_offset = self.file()?.stream_position()?; + let mut scratch = Vec::new(); + reserve(&mut scratch, 64)?; + let file = self + .file + .as_mut() + .ok_or_else(|| invalid("Writer is closed"))?; + for string in &self.strings { + scratch.clear(); + push_varint(&mut scratch, string.len() as u64)?; + file.write_all(&scratch)?; + file.write_all(string.as_bytes())?; + } + let frame_offset = file.stream_position()?; + for frame in &self.frames { + scratch.clear(); + push_varint(&mut scratch, frame.filename.into())?; + push_varint(&mut scratch, frame.funcname.into())?; + push_signed(&mut scratch, frame.location[0])?; + push_signed( + &mut scratch, + location_delta(frame.location[0], frame.location[1])?, + )?; + push_signed(&mut scratch, frame.location[2])?; + push_signed( + &mut scratch, + location_delta(frame.location[2], frame.location[3])?, + )?; + reserve(&mut scratch, 1)?; + scratch.push(frame.opcode); + file.write_all(&scratch)?; + } + if let Some(stats) = self.profile { + let mut bytes = [0; STATS_SIZE]; + bytes[0..8].copy_from_slice(&stats.duration_sec.to_ne_bytes()); + bytes[8..16].copy_from_slice(&stats.sample_rate.to_ne_bytes()); + bytes[16..24].copy_from_slice(&stats.error_rate.unwrap_or(0.0).to_ne_bytes()); + bytes[24..32].copy_from_slice(&stats.missed_samples.unwrap_or(0.0).to_ne_bytes()); + let present = u32::from(stats.error_rate.is_some()) + | (u32::from(stats.missed_samples.is_some()) << 1); + bytes[32..36].copy_from_slice(&present.to_ne_bytes()); + bytes[40..48].copy_from_slice(b"TACHSTAT"); + bytes[48..52].copy_from_slice(&1_u32.to_ne_bytes()); + bytes[52..56].copy_from_slice(&(STATS_SIZE as u32).to_ne_bytes()); + file.write_all(&bytes)?; + } + let file_size = file + .stream_position()? + .checked_add(FOOTER_SIZE as u64) + .ok_or(Error::Overflow("File size exceeds binary format maximum"))?; + let mut footer = [0; FOOTER_SIZE]; + footer[0..4].copy_from_slice(&(self.strings.len() as u32).to_ne_bytes()); + footer[4..8].copy_from_slice(&(self.frames.len() as u32).to_ne_bytes()); + footer[8..16].copy_from_slice(&file_size.to_ne_bytes()); + file.write_all(&footer)?; + let mut header = [0; HEADER_SIZE]; + header[0..4].copy_from_slice(&MAGIC.to_ne_bytes()); + header[4..8].copy_from_slice(&FORMAT_VERSION.to_ne_bytes()); + header[8..11].copy_from_slice(&self.python_version); + header[12..20].copy_from_slice(&self.start.to_ne_bytes()); + header[20..28].copy_from_slice(&self.interval.to_ne_bytes()); + header[28..36].copy_from_slice(&self.stats.total_samples.to_ne_bytes()); + header[36..40].copy_from_slice(&(self.threads.len() as u32).to_ne_bytes()); + header[40..48].copy_from_slice(&string_offset.to_ne_bytes()); + header[48..56].copy_from_slice(&frame_offset.to_ne_bytes()); + header[56..60].copy_from_slice(&(self.compression as u32).to_ne_bytes()); + let mut config = (self.mode + 1) as u32; + if self.features >= 0 { + config |= 8 | ((self.features as u32) << 4); + } + header[60..64].copy_from_slice(&config.to_ne_bytes()); + file.seek(SeekFrom::Start(0))?; + file.write_all(&header)?; + file.flush()?; + self.file.take(); + Ok(()) + } +} + +fn location_delta(start: i32, end: i32) -> Result { + if start == -1 || end == -1 { + Ok(0) + } else { + end.checked_sub(start) + .ok_or(Error::Overflow("Source location delta exceeds i32 range")) + } +} + +fn record_header(thread: u64, interpreter: u32, encoding: u8) -> Result> { + let mut bytes = Vec::new(); + reserve(&mut bytes, 32)?; + bytes.extend_from_slice(&thread.to_ne_bytes()); + bytes.extend_from_slice(&interpreter.to_ne_bytes()); + bytes.push(encoding); + Ok(bytes) +} + +#[derive(Clone, Copy, Debug)] +enum Endian { + Little, + Big, +} + +impl Endian { + fn u32(self, bytes: [u8; 4]) -> u32 { + match self { + Self::Little => u32::from_le_bytes(bytes), + Self::Big => u32::from_be_bytes(bytes), + } + } + + fn u64(self, bytes: [u8; 8]) -> u64 { + match self { + Self::Little => u64::from_le_bytes(bytes), + Self::Big => u64::from_be_bytes(bytes), + } + } +} + +struct Cursor<'a> { + data: &'a [u8], + offset: usize, +} + +impl<'a> Cursor<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, offset: 0 } + } + + fn remaining(&self) -> usize { + self.data.len() - self.offset + } + + fn bytes(&mut self, count: usize) -> Result<&'a [u8]> { + let end = self + .offset + .checked_add(count) + .ok_or_else(|| invalid("Unexpected end of binary data"))?; + let result = self + .data + .get(self.offset..end) + .ok_or_else(|| invalid("Unexpected end of binary data"))?; + self.offset = end; + Ok(result) + } + + fn fixed(&mut self) -> Result<[u8; N]> { + self.bytes(N)? + .try_into() + .map_err(|_| invalid("Unexpected end of binary data")) + } + + fn byte(&mut self) -> Result { + Ok(self.fixed::<1>()?[0]) + } + + fn varint(&mut self) -> Result { + let mut pos = self.offset; + let mut value = 0_u64; + for shift in (0..=63).step_by(7) { + let byte = *self + .data + .get(pos) + .ok_or_else(|| invalid("Invalid or incomplete varint in binary data"))?; + pos += 1; + if shift == 63 && byte & 0xfe != 0 { + return Err(invalid("Invalid or incomplete varint in binary data")); + } + value |= u64::from(byte & 0x7f) << shift; + if byte & 0x80 == 0 { + self.offset = pos; + return Ok(value); + } + } + Err(invalid("Invalid or incomplete varint in binary data")) + } + + fn var_u32(&mut self) -> Result { + let previous = self.offset; + let value = self.varint()?; + match u32::try_from(value) { + Ok(value) => Ok(value), + Err(_) => { + self.offset = previous; + Err(invalid("Invalid or incomplete varint in binary data")) + } + } + } + + fn signed(&mut self) -> Result { + let value = self.var_u32()?; + Ok(((value >> 1) as i32) ^ -((value & 1) as i32)) + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) struct Metadata { + pub(crate) version: u32, + pub(crate) python_version: [u8; 3], + pub(crate) start_time_us: u64, + pub(crate) sample_interval_us: u64, + pub(crate) sample_count: u64, + pub(crate) thread_count: u32, + pub(crate) string_count: u32, + pub(crate) frame_count: u32, + pub(crate) compression_type: u32, + pub(crate) mode: Option, + pub(crate) capture_features: Option, + pub(crate) profile_stats: Option, +} + +/// The snapshot and parsed tables use memory proportional to actual file bytes, +/// not header sample counts. Repeat timestamps are expanded only one batch at a +/// time, and every batch is bounded by encoded bytes remaining in the snapshot. +#[derive(Debug)] +pub(crate) struct Reader { + data: Vec, + sample_end: usize, + endian: Endian, + strings: Vec, + frames: Vec, + metadata: Metadata, +} + +impl Reader { + pub(crate) fn open(path: &Path) -> Result { + let mut file = rustpython_host_env::fs::open(path)?; + let mut data = Vec::new(); + let mut chunk = [0; 64 * 1024]; + loop { + let count = match file.read(&mut chunk) { + Err(error) if error.kind() == io::ErrorKind::Interrupted => continue, + result => result?, + }; + if count == 0 { + break; + } + reserve(&mut data, count)?; + data.extend_from_slice(&chunk[..count]); + } + if data.len() < HEADER_SIZE { + return Err(invalid("File too small for header")); + } + if data.len() < HEADER_SIZE + FOOTER_SIZE { + return Err(invalid("File too small for footer")); + } + let mut header = Cursor::new(&data[..HEADER_SIZE]); + let magic = header.fixed::<4>()?; + let endian = if u32::from_le_bytes(magic) == MAGIC { + Endian::Little + } else if u32::from_be_bytes(magic) == MAGIC { + Endian::Big + } else { + return Err(invalid(format!( + "Invalid magic number: 0x{:08x}", + u32::from_ne_bytes(magic) + ))); + }; + let version = endian.u32(header.fixed()?); + let python_version = header.fixed::<3>()?; + header.byte()?; + if version != FORMAT_VERSION { + return Err(invalid(if version > FORMAT_VERSION { + format!( + "Binary file was created with Python {}.{}.{} (format version {}), \ + but this reader supports format version {}", + python_version[0], + python_version[1], + python_version[2], + version, + FORMAT_VERSION + ) + } else { + format!( + "Unsupported format version {version} (this reader supports version {FORMAT_VERSION})" + ) + })); + } + let start_time_us = endian.u64(header.fixed()?); + let sample_interval_us = endian.u64(header.fixed()?); + let sample_count = endian.u64(header.fixed()?); + let thread_count = endian.u32(header.fixed()?); + let string_offset = endian.u64(header.fixed()?); + let frame_offset = endian.u64(header.fixed()?); + let compression_type = endian.u32(header.fixed()?); + let config = endian.u32(header.fixed()?); + let mode = config & 7; + if mode > 5 { + return Err(invalid(format!("Invalid profiling mode in header: {mode}"))); + } + let footer_start = data.len() - FOOTER_SIZE; + let mut footer = Cursor::new(&data[footer_start..]); + let string_count = endian.u32(footer.fixed()?); + let frame_count = endian.u32(footer.fixed()?); + let (profile_stats, tables_end) = parse_profile_stats(&data, endian, footer_start)?; + for (name, offset) in [("string", string_offset), ("frame", frame_offset)] { + if offset > data.len() as u64 { + return Err(invalid(format!( + "Invalid {name} table offset: {offset} exceeds file size {}", + data.len() + ))); + } + if offset < HEADER_SIZE as u64 { + return Err(invalid(format!( + "Invalid {name} table offset: {offset} is before data section" + ))); + } + } + if string_offset > frame_offset { + return Err(invalid(format!( + "Invalid table offsets: string table ({string_offset}) is after frame table ({frame_offset})" + ))); + } + let string_offset = usize::try_from(string_offset) + .map_err(|_| Error::Overflow("File is too large to read"))?; + let frame_offset = usize::try_from(frame_offset) + .map_err(|_| Error::Overflow("File is too large to read"))?; + if frame_offset > tables_end { + return Err(invalid("Frame table overlaps file trailer")); + } + if compression_type == 1 { + return Err(Error::Runtime( + "File uses zstd compression but zstd support not compiled in", + )); + } + let mut string_data = Cursor::new(&data[string_offset..frame_offset]); + validate_count("string", string_count, string_data.remaining(), 1)?; + let mut strings = Vec::new(); + reserve(&mut strings, string_count as usize)?; + for _ in 0..string_count { + let length = string_data + .var_u32() + .map_err(|_| invalid("Malformed varint in string table"))?; + let bytes = string_data + .bytes(length as usize) + .map_err(|_| invalid("String table overflow"))?; + strings.push(decode_utf8_replacing(bytes)?); + } + let mut frame_data = Cursor::new(&data[frame_offset..tables_end]); + validate_count("frame", frame_count, frame_data.remaining(), 7)?; + let mut frames = Vec::new(); + reserve(&mut frames, frame_count as usize)?; + for _ in 0..frame_count { + let filename = frame_data + .var_u32() + .map_err(|_| invalid("Malformed varint in frame table (filename)"))?; + let funcname = frame_data + .var_u32() + .map_err(|_| invalid("Malformed varint in frame table (funcname)"))?; + let line = frame_data + .signed() + .map_err(|_| invalid("Malformed varint in frame table (lineno)"))?; + let line_delta = frame_data + .signed() + .map_err(|_| invalid("Malformed varint in frame table (end_lineno_delta)"))?; + let column = frame_data + .signed() + .map_err(|_| invalid("Malformed varint in frame table (column)"))?; + let column_delta = frame_data + .signed() + .map_err(|_| invalid("Malformed varint in frame table (end_column_delta)"))?; + let opcode = frame_data + .byte() + .map_err(|_| invalid("Unexpected end of frame table (opcode)"))?; + frames.push(FrameEntry { + filename, + funcname, + location: [ + line, + reconstruct_location(line, line_delta)?, + column, + reconstruct_location(column, column_delta)?, + ], + opcode, + }); + } + Ok(Self { + data, + sample_end: string_offset, + endian, + strings, + frames, + metadata: Metadata { + version, + python_version, + start_time_us, + sample_interval_us, + sample_count, + thread_count, + string_count, + frame_count, + compression_type, + mode: (mode != 0).then_some(mode as i32 - 1), + capture_features: (config & 8 != 0).then_some(((config >> 4) & 31) as i32), + profile_stats, + }, + }) + } + + pub(crate) fn metadata(&self) -> &Metadata { + &self.metadata + } + + pub(crate) fn replay(self: &Arc) -> Replay { + Replay { + reader: Arc::clone(self), + offset: HEADER_SIZE, + threads: Vec::new(), + thread_indices: HashMap::new(), + repeat: None, + stats: ReaderStats::default(), + finished: false, + } + } + + fn build_sample(&self, thread: &ReaderThread, status: u8, timestamp_us: u64) -> Result { + let mut frames = Vec::new(); + reserve(&mut frames, thread.depth)?; + for &index in &thread.stack[..thread.depth] { + let frame = self + .frames + .get(index as usize) + .ok_or_else(|| invalid(format!("Invalid frame index: {index}")))?; + let filename = self + .strings + .get(frame.filename as usize) + .ok_or_else(|| invalid("Invalid string index in frame"))?; + let funcname = self + .strings + .get(frame.funcname as usize) + .ok_or_else(|| invalid("Invalid string index in frame"))?; + frames.push(Frame { + filename: copy_string(filename)?, + funcname: copy_string(funcname)?, + location: frame.location, + opcode: frame.opcode, + }); + } + Ok(Sample { + interpreter_id: thread.interpreter_id, + thread_id: thread.thread_id, + status, + frames, + timestamp_us, + }) + } +} + +fn reconstruct_location(start: i32, delta: i32) -> Result { + if start == -1 { + Ok(-1) + } else { + start + .checked_add(delta) + .ok_or_else(|| invalid("Source location exceeds i32 range")) + } +} + +fn validate_count(name: &str, count: u32, available: usize, minimum: usize) -> Result<()> { + let maximum = available / minimum; + if count as u64 > maximum as u64 { + return Err(invalid(format!( + "Invalid {name} count {count} exceeds maximum possible {maximum}" + ))); + } + Ok(()) +} + +fn decode_utf8_replacing(mut bytes: &[u8]) -> Result { + let mut result = String::new(); + while !bytes.is_empty() { + match core::str::from_utf8(bytes) { + Ok(valid) => { + result.try_reserve(valid.len()).map_err(|_| Error::Memory)?; + result.push_str(valid); + break; + } + Err(error) => { + let count = error.valid_up_to(); + let valid = core::str::from_utf8(&bytes[..count]) + .map_err(|_| invalid("Invalid UTF-8 string boundary"))?; + let additional = count.checked_add(3).ok_or(Error::Memory)?; + result.try_reserve(additional).map_err(|_| Error::Memory)?; + result.push_str(valid); + result.push('\u{fffd}'); + let skip = error.error_len().unwrap_or(bytes.len() - count); + bytes = &bytes[count + skip..]; + } + } + } + Ok(result) +} + +fn parse_profile_stats( + data: &[u8], + endian: Endian, + footer_start: usize, +) -> Result<(Option, usize)> { + if footer_start < 32 || &data[footer_start - 16..footer_start - 8] != b"TACHSTAT" { + return Ok((None, footer_start)); + } + let mut tail = Cursor::new(&data[footer_start - 8..footer_start]); + let version = endian.u32(tail.fixed()?); + let size = endian.u32(tail.fixed()?) as usize; + if size < 32 || size > footer_start { + return Err(invalid("Invalid profiling statistics size")); + } + let start = footer_start - size; + if start < HEADER_SIZE { + return Err(invalid("Invalid profiling statistics size")); + } + if version != 1 { + return Ok((None, start)); + } + let mut cursor = Cursor::new(&data[start..footer_start]); + let duration_sec = f64::from_bits(endian.u64(cursor.fixed()?)); + let sample_rate = f64::from_bits(endian.u64(cursor.fixed()?)); + let (error_rate, missed_samples) = if size >= STATS_SIZE { + let error_rate = f64::from_bits(endian.u64(cursor.fixed()?)); + let missed_samples = f64::from_bits(endian.u64(cursor.fixed()?)); + let present = endian.u32(cursor.fixed()?); + ( + (present & 1 != 0).then_some(error_rate), + (present & 2 != 0).then_some(missed_samples), + ) + } else { + (None, None) + }; + let stats = ProfileStats { + duration_sec, + sample_rate, + error_rate, + missed_samples, + }; + stats + .validate() + .map_err(|_| invalid("Invalid profiling statistics values"))?; + Ok((Some(stats), start)) +} + +#[derive(Debug)] +struct ReaderThread { + thread_id: u64, + interpreter_id: u32, + timestamp: u64, + stack: [u32; STACK_LIMIT], + depth: usize, +} + +#[derive(Clone, Copy, Debug)] +struct RepeatState { + thread: usize, + remaining: u32, + total: u32, +} + +#[derive(Debug)] +pub(crate) struct Batch { + pub(crate) sample: Sample, + pub(crate) timestamps: Vec, + pub(crate) is_repeat: bool, + pub(crate) record_complete: bool, + pub(crate) record_samples: u32, +} + +/// Independent replay state keeps close and Python callbacks from invalidating +/// any borrowed file data. No sample-count-sized allocation is performed. +#[derive(Debug)] +pub(crate) struct Replay { + reader: Arc, + offset: usize, + threads: Vec, + thread_indices: HashMap<(u64, u32), usize>, + repeat: Option, + stats: ReaderStats, + finished: bool, +} + +impl Replay { + pub(crate) fn stats(&self) -> &ReaderStats { + &self.stats + } + + pub(crate) fn replayed(&self) -> u64 { + self.stats.total_samples + } + + fn thread(&mut self, thread_id: u64, interpreter_id: u32) -> Result { + let key = (thread_id, interpreter_id); + if let Some(index) = self.thread_indices.get(&key) { + return Ok(*index); + } + if self.threads.len() as u64 >= u64::from(self.reader.metadata.thread_count) { + return Err(invalid(format!( + "Invalid thread count: sample data contains more unique threads than declared in header \ + (declared {}, found at least {})", + self.reader.metadata.thread_count, + self.threads.len() + 1 + ))); + } + reserve(&mut self.threads, 1)?; + self.thread_indices + .try_reserve(1) + .map_err(|_| Error::Memory)?; + let index = self.threads.len(); + self.threads.push(ReaderThread { + thread_id, + interpreter_id, + timestamp: self.reader.metadata.start_time_us, + stack: [0; STACK_LIMIT], + depth: 0, + }); + self.thread_indices.insert(key, index); + Ok(index) + } + + pub(crate) fn next_batch(&mut self) -> Result> { + if self.finished { + return Ok(None); + } + let result = self.next_batch_inner(); + if result.is_err() { + self.finished = true; + } + result + } + + fn next_batch_inner(&mut self) -> Result> { + loop { + if let Some(repeat) = self.repeat { + return self.repeat_batch(repeat).map(Some); + } + if self.offset == self.reader.sample_end { + if self.stats.total_samples != self.reader.metadata.sample_count { + return Err(invalid(format!( + "Sample count mismatch: header declares {} samples but replay decoded {}", + self.reader.metadata.sample_count, self.stats.total_samples + ))); + } + self.finished = true; + return Ok(None); + } + let remaining = self.reader.sample_end - self.offset; + if remaining < 13 { + return Err(invalid(format!( + "Truncated sample data: {remaining} trailing bytes" + ))); + } + let mut cursor = Cursor::new(&self.reader.data[self.offset..self.reader.sample_end]); + let thread_id = self.reader.endian.u64(cursor.fixed()?); + let interpreter_id = self.reader.endian.u32(cursor.fixed()?); + let encoding = cursor.byte()?; + self.offset += cursor.offset; + let index = self.thread(thread_id, interpreter_id)?; + let mut cursor = Cursor::new(&self.reader.data[self.offset..self.reader.sample_end]); + if encoding == REPEAT { + let count = cursor + .var_u32() + .map_err(|_| invalid("Malformed varint for RLE count"))?; + validate_count("RLE", count, cursor.remaining(), 2)?; + validate_sample_count(self.stats.total_samples, u64::from(count))?; + self.offset += cursor.offset; + add(&mut self.stats.repeat_records, 1)?; + add(&mut self.stats.repeat_samples, count.into())?; + if count != 0 { + self.repeat = Some(RepeatState { + thread: index, + remaining: count, + total: count, + }); + } + continue; + } + if ![FULL, SUFFIX, POP_PUSH].contains(&encoding) { + return Err(invalid(format!("Unknown stack encoding: {encoding}"))); + } + let delta = cursor + .varint() + .map_err(|_| invalid("Malformed varint in sample data"))?; + let status = cursor + .byte() + .map_err(|_| invalid("Unexpected end of sample data"))?; + let thread = &mut self.threads[index]; + decode_stack(thread, encoding, &mut cursor)?; + thread.timestamp = thread.timestamp.wrapping_add(delta); + self.offset += cursor.offset; + match encoding { + FULL => add(&mut self.stats.full_records, 1)?, + SUFFIX => add(&mut self.stats.suffix_records, 1)?, + _ => add(&mut self.stats.pop_push_records, 1)?, + } + add(&mut self.stats.stack_reconstructions, 1)?; + validate_sample_count(self.stats.total_samples, 1)?; + let sample = self.reader.build_sample(thread, status, thread.timestamp)?; + let mut timestamps = Vec::new(); + reserve(&mut timestamps, 1)?; + timestamps.push(thread.timestamp); + add(&mut self.stats.total_samples, 1)?; + return Ok(Some(Batch { + sample, + timestamps, + is_repeat: false, + record_complete: true, + record_samples: 1, + })); + } + } + + fn repeat_batch(&mut self, mut repeat: RepeatState) -> Result { + let mut cursor = Cursor::new(&self.reader.data[self.offset..self.reader.sample_end]); + let thread = &mut self.threads[repeat.thread]; + let mut timestamps = Vec::new(); + let mut batch_status = None; + while repeat.remaining > 0 { + let previous = cursor.offset; + let delta = cursor + .varint() + .map_err(|_| invalid("Malformed varint in RLE sample data"))?; + let status = cursor + .byte() + .map_err(|_| invalid("Unexpected end of sample data in RLE"))?; + if batch_status.is_some_and(|previous| previous != status) { + cursor.offset = previous; + break; + } + reserve(&mut timestamps, 1)?; + batch_status = Some(status); + thread.timestamp = thread.timestamp.wrapping_add(delta); + timestamps.push(thread.timestamp); + repeat.remaining -= 1; + } + let status = batch_status.ok_or_else(|| invalid("Empty RLE batch"))?; + let timestamp = *timestamps + .first() + .ok_or_else(|| invalid("Empty RLE batch"))?; + let sample = self.reader.build_sample(thread, status, timestamp)?; + self.offset += cursor.offset; + add(&mut self.stats.total_samples, timestamps.len() as u64)?; + let record_complete = repeat.remaining == 0; + self.repeat = (!record_complete).then_some(repeat); + Ok(Batch { + sample, + timestamps, + is_repeat: true, + record_complete, + record_samples: repeat.total, + }) + } +} + +fn validate_sample_count(previous: u64, additional: u64) -> Result<()> { + if additional > (isize::MAX as u64).saturating_sub(previous) { + Err(Error::Overflow("Sample count exceeds Py_ssize_t maximum")) + } else { + Ok(()) + } +} + +fn decode_stack(thread: &mut ReaderThread, encoding: u8, cursor: &mut Cursor<'_>) -> Result<()> { + let (keep, added, source) = match encoding { + FULL => { + let depth = cursor.var_u32()?; + if depth > STACK_LIMIT as u32 { + return Err(invalid(format!( + "Stack depth {depth} exceeds capacity {STACK_LIMIT}" + ))); + } + (0, depth as usize, 0) + } + SUFFIX => { + let shared = cursor.var_u32()?; + let new = cursor.var_u32()?; + if shared as u64 > thread.depth as u64 { + return Err(invalid(format!( + "Shared count {shared} exceeds current stack depth {}", + thread.depth + ))); + } + let depth = u64::from(shared) + u64::from(new); + validate_depth(depth)?; + ( + shared as usize, + new as usize, + thread.depth - shared as usize, + ) + } + _ => { + let pop = cursor.var_u32()?; + let push = cursor.var_u32()?; + let keep = (thread.depth as u64).saturating_sub(u64::from(pop)); + validate_depth(keep + u64::from(push))?; + (keep as usize, push as usize, pop as usize) + } + }; + if keep != 0 { + thread.stack.copy_within(source..source + keep, added); + } + for slot in &mut thread.stack[..added] { + *slot = cursor.var_u32()?; + } + thread.depth = keep + added; + Ok(()) +} + +fn validate_depth(depth: u64) -> Result<()> { + if depth > STACK_LIMIT as u64 { + Err(invalid(format!( + "Final stack depth {depth} exceeds capacity {STACK_LIMIT}" + ))) + } else { + Ok(()) + } +} diff --git a/crates/vm/src/builtins/code.rs b/crates/vm/src/builtins/code.rs index be6807efe..199c42e19 100644 --- a/crates/vm/src/builtins/code.rs +++ b/crates/vm/src/builtins/code.rs @@ -15,6 +15,7 @@ use crate::{ convert::ToPyObject, frozen, function::OptionalArg, + object::{Traverse, TraverseFn}, types::{Comparable, Constructor, Hashable, Representable}, }; use alloc::fmt; @@ -440,7 +441,7 @@ struct CoCached { freevars: OnceCell, } -#[pyclass(module = false, name = "code")] +#[pyclass(module = false, name = "code", traverse = "manual", clear = false)] pub struct PyCode { #[pymember(name = "co_argcount", path = "arg_count")] #[pymember(name = "co_posonlyargcount", path = "posonlyarg_count")] @@ -482,6 +483,34 @@ pub struct PyCode { pub has_exc_handling: bool, } +// SAFETY: Visit each owned reference once, including initialized cache cells. +// Constant elements belong to the constants tuple and are not direct edges. +// Interned names and source paths are borrowed static references. +unsafe impl Traverse for PyCode { + fn traverse(&self, tracer: &mut TraverseFn<'_>) { + self.co_consts().traverse(tracer); + self.co_name.traverse(tracer); + self.qualified_name.traverse(tracer); + self.co_names.traverse(tracer); + self.co_localsplusnames.traverse(tracer); + self.co_localspluskinds.traverse(tracer); + self.co_linetable.traverse(tracer); + self.co_exceptiontable.traverse(tracer); + if let Some(value) = self.co_cached.code.get() { + value.traverse(tracer); + } + if let Some(value) = self.co_cached.varnames.get() { + value.traverse(tracer); + } + if let Some(value) = self.co_cached.cellvars.get() { + value.traverse(tracer); + } + if let Some(value) = self.co_cached.freevars.get() { + value.traverse(tracer); + } + } +} + impl Deref for PyCode { type Target = CodeObject; fn deref(&self) -> &Self::Target { diff --git a/crates/vm/src/builtins/float.rs b/crates/vm/src/builtins/float.rs index de38a89fa..0a753446a 100644 --- a/crates/vm/src/builtins/float.rs +++ b/crates/vm/src/builtins/float.rs @@ -305,8 +305,12 @@ impl Py { }) } - #[pystaticmethod] - fn __getformat__(typestr: PyUtf8StrRef, vm: &VirtualMachine) -> PyResult { + #[pyclassmethod] + fn __getformat__( + _cls: PyTypeRef, + typestr: PyUtf8StrRef, + vm: &VirtualMachine, + ) -> PyResult { if !matches!(typestr.as_str(), "double" | "float") { return Err( vm.new_value_error("__getformat__() argument 1 must be 'double' or 'float'") diff --git a/crates/vm/src/frozen_resources.rs b/crates/vm/src/frozen_resources.rs new file mode 100644 index 000000000..c0868433f --- /dev/null +++ b/crates/vm/src/frozen_resources.rs @@ -0,0 +1,719 @@ +//! Resource indexes bound to a particular registration of frozen modules. + +use crate::frozen::FrozenModule; +use alloc::{ + collections::{BTreeMap, BTreeSet}, + sync::Arc, +}; +use core::fmt; +use std::collections::HashMap; + +/// A package-relative logical POSIX path and its embedded contents. +pub type FrozenResource = (&'static str, &'static [u8]); +/// A frozen package name and the resources registered with its code. +pub type FrozenResourceBundle = (&'static str, &'static [FrozenResource]); + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum ResourceError { + InvalidPath(String), + DuplicatePath(String), + FileDirectoryCollision(String), + DuplicateModule(String), + DuplicateOwner(String), + MissingOwner(String), + OwnerNotPackage(String), + NotFound(String), + IsDirectory(String), + NotDirectory(String), +} + +impl fmt::Display for ResourceError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let (description, path) = match self { + Self::InvalidPath(path) => ("invalid relative resource path", path), + Self::DuplicatePath(path) => ("duplicate resource path", path), + Self::FileDirectoryCollision(path) => ("resource is both a file and directory", path), + Self::DuplicateModule(path) => { + ("duplicate frozen module in resource registration", path) + } + Self::DuplicateOwner(path) => ("duplicate frozen resource owner", path), + Self::MissingOwner(path) => ("resource owner is absent from this registration", path), + Self::OwnerNotPackage(path) => ("resource owner is not a package", path), + Self::NotFound(path) => ("resource does not exist", path), + Self::IsDirectory(path) => ("resource is a directory", path), + Self::NotDirectory(path) => ("resource is not a directory", path), + }; + write!(f, "{description}: {path:?}") + } +} + +impl core::error::Error for ResourceError {} + +fn normalize_relative(path: &str) -> Result { + if path.starts_with('/') || path.contains(['\\', '\0', ':']) { + return Err(ResourceError::InvalidPath(path.to_owned())); + } + let mut parts = Vec::new(); + for part in path.split('/') { + match part { + "" | "." => {} + ".." => return Err(ResourceError::InvalidPath(path.to_owned())), + _ => parts.push(part), + } + } + Ok(parts.join("/")) +} + +/// An immutable tree of embedded files, with explicit inferred directories. +#[derive(Debug)] +pub struct ResourceIndex { + root_name: &'static str, + files: BTreeMap, + directories: BTreeSet, +} + +impl ResourceIndex { + fn add_directory(&mut self, path: &str) -> Result<(), ResourceError> { + let normalized = normalize_relative(path)?; + if normalized != path { + return Err(ResourceError::InvalidPath(path.to_owned())); + } + let mut directory = path; + loop { + if self.files.contains_key(directory) { + return Err(ResourceError::FileDirectoryCollision(directory.to_owned())); + } + self.directories.insert(directory.to_owned()); + let Some((parent, _)) = directory.rsplit_once('/') else { + break; + }; + directory = parent; + } + Ok(()) + } + + fn new(owner: &'static str, resources: &[FrozenResource]) -> Result { + let mut index = Self { + root_name: owner.rsplit('.').next().unwrap_or(owner), + files: BTreeMap::new(), + directories: BTreeSet::from([String::new()]), + }; + for &(path, bytes) in resources { + let normalized = normalize_relative(path)?; + // Manifest paths are canonical; query paths may contain harmless dots or slashes. + if normalized.is_empty() || normalized != path { + return Err(ResourceError::InvalidPath(path.to_owned())); + } + if index.files.contains_key(path) { + return Err(ResourceError::DuplicatePath(path.to_owned())); + } + if index.directories.contains(path) { + return Err(ResourceError::FileDirectoryCollision(path.to_owned())); + } + let mut parent = path; + while let Some((prefix, _)) = parent.rsplit_once('/') { + if index.files.contains_key(prefix) { + return Err(ResourceError::FileDirectoryCollision(prefix.to_owned())); + } + index.directories.insert(prefix.to_owned()); + parent = prefix; + } + index.files.insert(normalized, bytes); + } + Ok(index) + } +} + +/// An immutable snapshot of one directory or file in an embedded resource tree. +#[derive(Clone, Debug)] +pub struct ResourceView { + index: Arc, + prefix: String, +} + +impl ResourceView { + #[must_use] + pub fn name(&self) -> &str { + if self.prefix.is_empty() { + self.index.root_name + } else { + self.prefix.rsplit('/').next().unwrap_or(&self.prefix) + } + } + + #[must_use] + pub fn is_file(&self) -> bool { + self.index.files.contains_key(&self.prefix) + } + + #[must_use] + pub fn is_dir(&self) -> bool { + self.index.directories.contains(&self.prefix) + } + + pub fn join(&self, descendant: &str) -> Result { + let descendant = normalize_relative(descendant)?; + let prefix = match (self.prefix.is_empty(), descendant.is_empty()) { + (_, true) => self.prefix.clone(), + (true, false) => descendant, + (false, false) => format!("{}/{descendant}", self.prefix), + }; + Ok(Self { + index: self.index.clone(), + prefix, + }) + } + + pub fn children(&self) -> Result, ResourceError> { + if !self.is_dir() { + return Err(if self.is_file() { + ResourceError::NotDirectory(self.prefix.clone()) + } else { + ResourceError::NotFound(self.prefix.clone()) + }); + } + let prefix = if self.prefix.is_empty() { + String::new() + } else { + format!("{}/", self.prefix) + }; + let children: BTreeSet<_> = self + .index + .files + .keys() + .chain(self.index.directories.iter()) + .filter(|path| { + path.strip_prefix(prefix.as_str()) + .is_some_and(|rest| !rest.is_empty() && !rest.contains('/')) + }) + .cloned() + .collect(); + Ok(children + .into_iter() + .map(|prefix| Self { + index: self.index.clone(), + prefix, + }) + .collect()) + } + + pub fn bytes(&self) -> Result<&'static [u8], ResourceError> { + self.index.files.get(&self.prefix).copied().ok_or_else(|| { + if self.is_dir() { + ResourceError::IsDirectory(self.prefix.clone()) + } else { + ResourceError::NotFound(self.prefix.clone()) + } + }) + } +} + +pub(crate) struct FrozenRegistration { + modules: Vec<(&'static str, FrozenModule)>, + resources: BTreeMap<&'static str, Arc>, +} + +impl FrozenRegistration { + pub(crate) fn code_only( + modules: impl IntoIterator, + ) -> Self { + Self { + modules: modules.into_iter().collect(), + resources: BTreeMap::new(), + } + } + + pub(crate) fn with_resources( + modules: impl IntoIterator, + resources: &[FrozenResourceBundle], + ) -> Result { + let mut registration = Self::code_only(modules); + let mut names = BTreeMap::new(); + for &(name, module) in ®istration.modules { + if names.insert(name, module.package).is_some() { + return Err(ResourceError::DuplicateModule(name.to_owned())); + } + } + for &(owner, files) in resources { + if owner.is_empty() + || owner.split('.').any(str::is_empty) + || owner.contains(['/', '\\', '\0', ':']) + { + return Err(ResourceError::InvalidPath(owner.to_owned())); + } + match names.get(owner) { + None => return Err(ResourceError::MissingOwner(owner.to_owned())), + Some(false) => return Err(ResourceError::OwnerNotPackage(owner.to_owned())), + Some(true) => {} + } + if registration.resources.contains_key(owner) { + return Err(ResourceError::DuplicateOwner(owner.to_owned())); + } + let mut index = ResourceIndex::new(owner, files)?; + for (&name, &package) in &names { + if package + && let Some(suffix) = name.strip_prefix(owner) + && let Some(relative) = suffix.strip_prefix('.') + { + index.add_directory(&relative.replace('.', "/"))?; + } + } + registration.resources.insert(owner, Arc::new(index)); + } + Ok(registration) + } +} + +#[derive(Clone, Copy)] +struct FrozenEntry { + module: FrozenModule, + registration: u64, +} + +#[derive(Clone)] +struct ResourceBinding { + owner: &'static str, + owner_registration: u64, + anchor_registration: u64, + view: ResourceView, +} + +/// Frozen modules with resource ownership invalidated on every replacement. +/// +/// Mutable map access is deliberately not exposed: even identical-byte replacements +/// are distinct registrations and must not inherit another registration's resources. +#[derive(Clone, Default)] +pub struct FrozenModuleRegistry { + modules: HashMap<&'static str, FrozenEntry, rapidhash::quality::RandomState>, + bindings: BTreeMap<&'static str, ResourceBinding>, + next_registration: u64, +} + +impl FrozenModuleRegistry { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn get(&self, name: &str) -> Option<&FrozenModule> { + self.modules.get(name).map(|entry| &entry.module) + } + + pub fn keys(&self) -> impl Iterator { + self.modules.keys() + } + + #[must_use] + pub fn len(&self) -> usize { + self.modules.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.modules.is_empty() + } + + #[must_use] + pub fn contains_key(&self, name: &str) -> bool { + self.modules.contains_key(name) + } + + fn next_token(&mut self) -> u64 { + self.next_registration = self + .next_registration + .checked_add(1) + .expect("frozen module registration counter exhausted"); + self.next_registration + } + + fn invalidate(&mut self, name: &str) { + self.bindings.retain(|anchor, binding| { + *anchor != name + && binding.owner != name + && !anchor + .strip_prefix(name) + .is_some_and(|suffix| suffix.starts_with('.')) + }); + } + + pub fn insert(&mut self, name: &'static str, module: FrozenModule) -> Option { + let registration = self.next_token(); + self.invalidate(name); + self.modules + .insert( + name, + FrozenEntry { + module, + registration, + }, + ) + .map(|entry| entry.module) + } + + pub fn remove(&mut self, name: &str) -> Option { + self.invalidate(name); + self.modules.remove(name).map(|entry| entry.module) + } + + pub fn clear(&mut self) { + self.bindings.clear(); + self.modules.clear(); + } + + pub fn register_with_resources( + &mut self, + modules: impl IntoIterator, + resources: &[FrozenResourceBundle], + ) -> Result<(), ResourceError> { + let registration = FrozenRegistration::with_resources(modules, resources)?; + self.apply_registration(registration); + Ok(()) + } + + pub(crate) fn apply_registration(&mut self, batch: FrozenRegistration) { + let registration = self.next_token(); + for &(name, module) in &batch.modules { + self.invalidate(name); + self.modules.insert( + name, + FrozenEntry { + module, + registration, + }, + ); + } + for &(anchor, module) in &batch.modules { + let package = if module.package { + anchor + } else if let Some((package, _)) = anchor.rsplit_once('.') { + package + } else { + continue; + }; + let owner = batch + .resources + .keys() + .copied() + .filter(|owner| { + package == *owner + || package + .strip_prefix(*owner) + .is_some_and(|suffix| suffix.starts_with('.')) + }) + .max_by_key(|owner| owner.len()); + let Some(owner) = owner else { + continue; + }; + // Every containing package down to the owner must belong to this batch. + let mut current = package; + let eligible = + loop { + if !self.modules.get(current).is_some_and(|entry| { + entry.registration == registration && entry.module.package + }) { + break false; + } + if current == owner { + break true; + } + let Some((parent, _)) = current.rsplit_once('.') else { + break false; + }; + current = parent; + }; + if !eligible { + continue; + } + let prefix = package + .strip_prefix(owner) + .unwrap_or_default() + .trim_start_matches('.') + .replace('.', "/"); + let view = ResourceView { + index: batch.resources[owner].clone(), + prefix, + }; + self.bindings.insert( + anchor, + ResourceBinding { + owner, + owner_registration: registration, + anchor_registration: registration, + view, + }, + ); + } + } + + /// Acquire a snapshot only if both the exact anchor and owner are still current. + #[must_use] + pub fn resource_view(&self, fullname: &str) -> Option { + let binding = self.bindings.get(fullname)?; + let anchor = self.modules.get(fullname)?; + let owner = self.modules.get(binding.owner)?; + (anchor.registration == binding.anchor_registration + && owner.registration == binding.owner_registration + && owner.module.package) + .then(|| binding.view.clone()) + } +} + +impl Extend<(&'static str, FrozenModule)> for FrozenModuleRegistry { + fn extend>(&mut self, iter: T) { + self.apply_registration(FrozenRegistration::code_only(iter)); + } +} + +impl FromIterator<(&'static str, FrozenModule)> for FrozenModuleRegistry { + fn from_iter>(iter: T) -> Self { + let mut registry = Self::new(); + registry.extend(iter); + registry + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::frozen::FrozenCodeObject; + + const ASSETS: &[FrozenResource] = + &[("data.txt", b"hello"), ("nested/4.1.3/data.bin", b"\0\xff")]; + const BUNDLES: &[FrozenResourceBundle] = &[("sample", ASSETS)]; + + fn module(package: bool) -> FrozenModule { + FrozenModule { + code: FrozenCodeObject { + bytes: b"same-code", + }, + package, + } + } + + fn registered() -> FrozenModuleRegistry { + let mut registry = FrozenModuleRegistry::new(); + registry + .register_with_resources( + [("sample", module(true)), ("sample.worker", module(false))], + BUNDLES, + ) + .unwrap(); + registry + } + + #[test] + fn replacement_never_inherits_identical_code_resources() { + let mut registry = registered(); + let snapshot = registry.resource_view("sample").unwrap(); + registry.insert("sample", module(true)); + assert!(registry.resource_view("sample").is_none()); + assert!(registry.resource_view("sample.worker").is_none()); + assert_eq!( + snapshot.join("data.txt").unwrap().bytes().unwrap(), + b"hello" + ); + registry + .register_with_resources([("sample", module(true))], BUNDLES) + .unwrap(); + assert!(registry.resource_view("sample").is_some()); + assert!(registry.resource_view("sample.worker").is_none()); + } + + #[test] + fn anchor_replacement_and_unrelated_registration() { + let mut registry = registered(); + registry.extend([("other", module(true)), ("sample.worker", module(false))]); + assert!(registry.resource_view("sample").is_some()); + assert!(registry.resource_view("sample.worker").is_none()); + assert!(registry.resource_view("sample.unknown").is_none()); + assert!(registry.resource_view("samplex").is_none()); + } + + #[test] + fn clones_have_independent_ownership() { + let parent = registered(); + let mut child = parent.clone(); + child.remove("sample"); + assert!(parent.resource_view("sample").is_some()); + assert!(child.resource_view("sample").is_none()); + child.insert("sample", module(true)); + assert!(child.resource_view("sample").is_none()); + } + + #[test] + fn resource_replacement_and_clear_do_not_merge_old_assets() { + const REPLACEMENT: &[FrozenResourceBundle] = &[("sample", &[("new.txt", b"new")])]; + let mut registry = registered(); + registry + .register_with_resources([("sample", module(true))], REPLACEMENT) + .unwrap(); + let view = registry.resource_view("sample").unwrap(); + assert!(!view.join("data.txt").unwrap().is_file()); + assert_eq!(view.join("new.txt").unwrap().bytes().unwrap(), b"new"); + registry.clear(); + registry.extend([("sample", module(true))]); + assert!(registry.resource_view("sample").is_none()); + } + + #[test] + fn invalid_batch_is_atomic() { + let mut registry = registered(); + assert_eq!( + registry.register_with_resources([("other", module(true))], BUNDLES), + Err(ResourceError::MissingOwner("sample".to_owned())) + ); + assert!(!registry.contains_key("other")); + assert!(registry.resource_view("sample").is_some()); + assert_eq!( + registry.register_with_resources([("sample", module(false))], BUNDLES), + Err(ResourceError::OwnerNotPackage("sample".to_owned())) + ); + assert_eq!( + registry.register_with_resources( + [("sample", module(true)), ("sample", module(true))], + BUNDLES + ), + Err(ResourceError::DuplicateModule("sample".to_owned())) + ); + } + + #[test] + fn tree_queries_are_component_based() { + let registry = registered(); + let root = registry.resource_view("sample").unwrap(); + assert_eq!(root.name(), "sample"); + assert!(root.is_dir()); + assert_eq!( + root.children() + .unwrap() + .iter() + .map(ResourceView::name) + .collect::>(), + ["data.txt", "nested"] + ); + assert_eq!( + root.join("./nested//4.1.3/data.bin") + .unwrap() + .bytes() + .unwrap(), + b"\0\xff" + ); + assert!(!root.join("nest").unwrap().is_dir()); + assert!(matches!(root.bytes(), Err(ResourceError::IsDirectory(_)))); + assert!(matches!( + root.join("data.txt").unwrap().children(), + Err(ResourceError::NotDirectory(_)) + )); + let missing = root.join("missing").unwrap(); + assert!(!missing.is_file() && !missing.is_dir()); + assert!(matches!(missing.bytes(), Err(ResourceError::NotFound(_)))); + assert!(matches!( + missing.children(), + Err(ResourceError::NotFound(_)) + )); + } + + #[test] + fn unsafe_paths_are_rejected() { + let root = registered().resource_view("sample").unwrap(); + for path in [ + "../data.txt", + "nested/../data.txt", + "/data.txt", + "C:data.txt", + "C:/data.txt", + "\\\\host\\file", + "nested\\data.txt", + "nul\0name", + ] { + assert!(matches!( + root.join(path), + Err(ResourceError::InvalidPath(_)) + )); + } + assert_eq!(root.join("").unwrap().name(), "sample"); + } + + #[test] + fn manifests_reject_collisions_in_either_order() { + for entries in [ + vec![("a", b"x".as_slice()), ("a/b", b"x".as_slice())], + vec![("a/b", b"x".as_slice()), ("a", b"x".as_slice())], + ] { + assert!(matches!( + ResourceIndex::new("sample", &entries), + Err(ResourceError::FileDirectoryCollision(_)) + )); + } + assert!(matches!( + ResourceIndex::new("sample", &[("a", b"x"), ("a", b"y")]), + Err(ResourceError::DuplicatePath(_)) + )); + assert!(matches!( + ResourceIndex::new("sample", &[("./a", b"x")]), + Err(ResourceError::InvalidPath(_)) + )); + } + + #[test] + fn nested_package_anchors_require_same_registration() { + let mut registry = FrozenModuleRegistry::new(); + registry.insert("sample.nested", module(true)); + registry + .register_with_resources( + [ + ("sample", module(true)), + ("sample.nested.worker", module(false)), + ], + BUNDLES, + ) + .unwrap(); + assert!(registry.resource_view("sample.nested.worker").is_none()); + registry + .register_with_resources( + [ + ("sample", module(true)), + ("sample.nested", module(true)), + ("sample.nested.worker", module(false)), + ], + BUNDLES, + ) + .unwrap(); + let view = registry.resource_view("sample.nested.worker").unwrap(); + assert_eq!(view.name(), "nested"); + assert!(view.is_dir()); + registry.insert("sample.nested", module(true)); + assert!(registry.resource_view("sample.nested.worker").is_none()); + } + + #[test] + fn package_without_files_has_an_empty_directory_view() { + let mut registry = FrozenModuleRegistry::new(); + registry + .register_with_resources( + [("sample", module(true)), ("sample.empty", module(true))], + BUNDLES, + ) + .unwrap(); + let root = registry.resource_view("sample.empty").unwrap(); + assert!(root.is_dir()); + assert!(!root.is_file()); + assert!(root.children().unwrap().is_empty()); + assert!(matches!(root.bytes(), Err(ResourceError::IsDirectory(_)))); + } + + #[test] + fn package_directories_cannot_collide_with_embedded_files() { + const FILE: &[FrozenResourceBundle] = &[("sample", &[("nested", b"file")])]; + let mut registry = FrozenModuleRegistry::new(); + assert_eq!( + registry.register_with_resources( + [("sample", module(true)), ("sample.nested", module(true))], + FILE, + ), + Err(ResourceError::FileDirectoryCollision("nested".to_owned())) + ); + assert!(registry.is_empty()); + } +} diff --git a/crates/vm/src/function/method.rs b/crates/vm/src/function/method.rs index d586c099b..8a186ebd6 100644 --- a/crates/vm/src/function/method.rs +++ b/crates/vm/src/function/method.rs @@ -7,6 +7,7 @@ use crate::{ }, class::PyClassDef, function::{FuncArgs, IntoPyNativeFn, PyNativeFn, SigArg}, + types::PyTypeFlags, }; bitflags::bitflags! { @@ -294,8 +295,21 @@ impl PyMethodDef { class: &'static Py, ) -> PyRef { debug_assert!(self.flags.contains(PyMethodFlags::STATIC)); + // Match PyCFunction_NewEx(meth, type, type_module(type)): snapshot + // module metadata here so later mutation/deletion remains observable. + let module = if class.slots.flags.has_feature(PyTypeFlags::HEAPTYPE) { + class.attributes.get(ctx.intern_str("__module__")) + } else { + let name = class.slot_name(); + let module = name + .rsplit_once('.') + .map_or("builtins", |(module, _)| module); + Some(ctx.intern_str(module).to_owned().into()) + }; // m_self is the type; STATIC skips prepending it on call. - self.build_bound_function(ctx, class.to_owned().into()) + let function = self.build_bound_function(ctx, class.to_owned().into()); + drop(function.module.store(module)); + function } /// Concatenate method groups. A pending body is copied from `docs`, then cleared. diff --git a/crates/vm/src/import.rs b/crates/vm/src/import.rs index fe9b449dd..bc0c715e1 100644 --- a/crates/vm/src/import.rs +++ b/crates/vm/src/import.rs @@ -133,7 +133,12 @@ pub(crate) fn init_importlib_base(vm: &mut VirtualMachine) -> PyResult Result PyBaseExceptionRef { + let exception = match &error { + ResourceError::InvalidPath(_) => return vm.new_value_error(error.to_string()), + ResourceError::NotFound(_) => vm.ctx.exceptions.file_not_found_error, + ResourceError::IsDirectory(_) => vm.ctx.exceptions.is_a_directory_error, + ResourceError::NotDirectory(_) => vm.ctx.exceptions.not_a_directory_error, + _ => return vm.new_runtime_error(error.to_string()), + }; + vm.new_os_subtype_error(exception.to_owned(), None, error.to_string()) + .upcast() + } + + fn join_resource( + view: &ResourceView, + path: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { + let path = FsPath::try_from_path_like(path, false, vm)?; + let FsPath::Str(path) = path else { + return Err(vm.new_type_error("resource paths must be str or os.PathLike[str]")); + }; + let path = path + .to_str() + .ok_or_else(|| vm.new_value_error("resource paths must not contain surrogates"))?; + view.join(path).map_err(|error| resource_error(error, vm)) + } + + fn open_binary(view: &ResourceView, vm: &VirtualMachine) -> PyResult { + let bytes = view.bytes().map_err(|error| resource_error(error, vm))?; + // Import lazily: installing the importer hook must not bootstrap _io. + let io = vm.import("_io", 0)?; + let buffer = io + .get_attr("BytesIO", vm)? + .call((vm.ctx.new_bytes(bytes.to_vec()),), vm)?; + io.get_attr("BufferedReader", vm)?.call((buffer,), vm) + } + + #[derive(FromArgs)] + struct OpenArgs { + #[pyarg(any, optional, py_default = "'r'")] + mode: OptionalArg, + #[pyarg(any, optional)] + encoding: Option, + #[pyarg(any, optional)] + errors: Option, + #[pyarg(any, optional)] + newline: Option, + } + + #[derive(FromArgs)] + struct ReadTextArgs { + #[pyarg(any, optional)] + encoding: Option, + #[pyarg(any, optional)] + errors: Option, + #[pyarg(named, optional)] + newline: Option, + } + + #[pyattr] + #[pyclass(name = "_FrozenResourceReader", module = "_imp")] + #[derive(Debug, PyPayload)] + struct FrozenResourceReader { + view: ResourceView, + } + + #[pyclass(flags(DISALLOW_INSTANTIATION))] + impl FrozenResourceReader { + #[pymethod] + fn open_resource(zelf: &Py, resource: PyObjectRef, vm: &VirtualMachine) -> PyResult { + open_binary(&join_resource(&zelf.view, resource, vm)?, vm) + } + + #[pymethod] + fn resource_path(_zelf: &Py, resource: PyObjectRef, vm: &VirtualMachine) -> PyResult { + // Embedded assets have no filesystem path. importlib.resources.as_file + // performs any requested temporary extraction through Traversable. + Err(vm.new_exception( + vm.ctx.exceptions.file_not_found_error.to_owned(), + vec![resource], + )) + } + + #[pymethod] + fn is_resource( + zelf: &Py, + resource: PyObjectRef, + vm: &VirtualMachine, + ) -> PyResult { + Ok(join_resource(&zelf.view, resource, vm)?.is_file()) + } + + #[pymethod] + fn contents(zelf: &Py, vm: &VirtualMachine) -> PyResult { + let children = zelf + .view + .children() + .map_err(|error| resource_error(error, vm))?; + let names = children + .iter() + .map(|child| vm.ctx.new_str(child.name()).into()) + .collect(); + Ok(vm.ctx.new_tuple(names).as_object().get_iter(vm)?.into()) + } + + #[pymethod] + fn files(zelf: &Py, vm: &VirtualMachine) -> PyRef { + FrozenTraversable { + view: zelf.view.clone(), + } + .into_ref(&vm.ctx) + } + } + + #[pyattr] + #[pyclass(name = "_FrozenTraversable", module = "_imp")] + #[derive(Debug, PyPayload)] + struct FrozenTraversable { + view: ResourceView, + } + + #[pyclass(with(AsNumber), flags(DISALLOW_INSTANTIATION))] + impl FrozenTraversable { + #[pygetset] + fn name(zelf: &Py) -> String { + zelf.view.name().to_owned() + } + + #[pymethod] + fn iterdir(zelf: &Py, vm: &VirtualMachine) -> PyResult { + let children = zelf + .view + .children() + .map_err(|error| resource_error(error, vm))?; + let children = children + .into_iter() + .map(|view| Self { view }.into_ref(&vm.ctx).into()) + .collect(); + Ok(vm.ctx.new_tuple(children).as_object().get_iter(vm)?.into()) + } + + #[pymethod] + fn is_dir(zelf: &Py) -> bool { + zelf.view.is_dir() + } + + #[pymethod] + fn is_file(zelf: &Py) -> bool { + zelf.view.is_file() + } + + #[pymethod] + fn joinpath( + zelf: &Py, + descendants: PosArgs, + vm: &VirtualMachine, + ) -> PyResult> { + let mut view = zelf.view.clone(); + for descendant in descendants { + view = join_resource(&view, descendant, vm)?; + } + Ok(Self { view }.into_ref(&vm.ctx)) + } + + #[pymethod] + fn open(zelf: &Py, args: OpenArgs, vm: &VirtualMachine) -> PyResult { + let mode = match &args.mode { + OptionalArg::Present(mode) => mode.as_bytes(), + OptionalArg::Missing => b"r", + }; + match mode { + b"rb" => { + if args.encoding.is_some() || args.errors.is_some() || args.newline.is_some() { + return Err(vm.new_value_error( + "binary mode does not accept encoding, errors or newline", + )); + } + open_binary(&zelf.view, vm) + } + b"r" => { + let buffer = open_binary(&zelf.view, vm)?; + let mut wrapper_args = FuncArgs::from(vec![buffer]); + for (name, value) in [ + ("encoding", args.encoding), + ("errors", args.errors), + ("newline", args.newline), + ] { + if let Some(value) = value { + wrapper_args.kwargs.insert(name.into(), value); + } + } + vm.import("_io", 0)? + .get_attr("TextIOWrapper", vm)? + .call(wrapper_args, vm) + } + _ => Err(vm.new_value_error("frozen resources support only 'r' and 'rb' modes")), + } + } + + #[pymethod] + fn read_bytes(zelf: &Py, vm: &VirtualMachine) -> PyResult { + let bytes = zelf + .view + .bytes() + .map_err(|error| resource_error(error, vm))?; + Ok(vm.ctx.new_bytes(bytes.to_vec()).into()) + } + + #[pymethod] + fn read_text(zelf: &Py, args: ReadTextArgs, vm: &VirtualMachine) -> PyResult { + let stream = Self::open( + zelf, + OpenArgs { + mode: OptionalArg::Missing, + encoding: args.encoding, + errors: args.errors, + newline: args.newline, + }, + vm, + )?; + let result = vm.call_method(&stream, "read", ()); + let close = vm.call_method(&stream, "close", ()); + match (result, close) { + (Err(read_error), Err(close_error)) => { + close_error.set_context(Some(read_error)); + Err(close_error) + } + (Err(error), Ok(_)) | (Ok(_), Err(error)) => Err(error), + (Ok(contents), Ok(_)) => Ok(contents), + } + } + } + + impl AsNumber for FrozenTraversable { + fn as_number() -> &'static PyNumberMethods { + static AS_NUMBER: PyNumberMethods = PyNumberMethods { + true_divide: Some(|left, right, vm| { + let Some(path) = left.downcast_ref::() else { + return Ok(vm.ctx.not_implemented()); + }; + let view = join_resource(&path.view, right.to_owned(), vm)?; + Ok(FrozenTraversable { view }.into_ref(&vm.ctx).into()) + }), + ..PyNumberMethods::NOT_IMPLEMENTED + }; + &AS_NUMBER + } + } + + #[pyfunction] + fn _get_frozen_resource_reader( + fullname: PyStrRef, + vm: &VirtualMachine, + ) -> Option> { + let name = fullname.to_str()?; + super::super::find_frozen(name, vm).ok()?; + // Resources belong to an explicit exact-fullname registration. Resolving + // an unbound alias to another name would bypass registration ownership. + let view = vm.state.frozen.resource_view(name)?; + Some(FrozenResourceReader { view }.into_ref(&vm.ctx)) + } +} diff --git a/crates/vm/src/stdlib/_lsprof.rs b/crates/vm/src/stdlib/_lsprof.rs new file mode 100644 index 000000000..8d1b5173f --- /dev/null +++ b/crates/vm/src/stdlib/_lsprof.rs @@ -0,0 +1,800 @@ +//! Deterministic profiling driven by the VM's execution monitoring events. +// cspell:ignore pystart pythrow creturn + +pub(crate) use _lsprof::module_def; + +#[pymodule] +mod _lsprof { + use crate::{ + AsObject, Py, PyObject, PyObjectRef, PyPayload, PyResult, VirtualMachine, + builtins::{ + PyFloat, PyModule, PyStr, PyType, + builtin_func::{PyNativeFunction, PyNativeMethod}, + descriptor::PyMethodDescriptor, + }, + common::{lock::PyMutex, wtf8::Wtf8Buf}, + convert::ToPyObject, + function::{FuncArgs, OptionalArg, PyMethodDef}, + object::{Traverse, TraverseFn}, + stdlib::sys::monitoring::MonitoringEvent, + types::{Constructor, Initializer, PyStructSequence}, + vm::thread::{self, critical_section, lock::PyMutex as OperationMutex}, + }; + use std::collections::HashMap; + + const TOOL_ID: i32 = 2; + const CALLBACKS: &[(MonitoringEvent, &str)] = &[ + (MonitoringEvent::PyStart, "_pystart_callback"), + (MonitoringEvent::PyResume, "_pystart_callback"), + (MonitoringEvent::PyThrow, "_pythrow_callback"), + (MonitoringEvent::PyReturn, "_pyreturn_callback"), + (MonitoringEvent::PyYield, "_pyreturn_callback"), + (MonitoringEvent::PyUnwind, "_pyreturn_callback"), + (MonitoringEvent::Call, "_ccall_callback"), + (MonitoringEvent::CReturn, "_creturn_callback"), + (MonitoringEvent::CRaise, "_creturn_callback"), + ]; + + #[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)] + enum Key { + Python(usize), + Native(usize), + } + + #[derive(Clone, Debug, Default)] + struct Counts { + total: i128, + inline: i128, + calls: usize, + recursive: usize, + depth: isize, + } + + impl Counts { + fn stop(&mut self, total: i128, inline: i128) { + self.depth -= 1; + if self.depth == 0 { + self.total += total; + } else { + self.recursive += 1; + } + self.inline += inline; + self.calls += 1; + } + } + + #[derive(Clone, Debug)] + struct Entry { + code: PyObjectRef, + // Dynamically allocated native method definitions must outlive their keys. + owner: Option, + counts: Counts, + callees: HashMap, + } + + #[derive(Debug)] + struct Context { + id: u64, + key: Key, + start: i128, + subtime: i128, + } + + #[derive(Debug, Default)] + struct State { + entries: HashMap, + stack: Vec, + epoch: u64, + next_context: u64, + missing: Option, + timer: Option, + unit: f64, + enabled: bool, + subcalls: bool, + builtins: bool, + in_timer: bool, + } + + #[pyattr] + #[pyclass(name = "Profiler", module = "_lsprof", traverse = "manual")] + #[derive(Debug, PyPayload)] + struct Profiler { + operation: OperationMutex, + interpreter_id: i64, + state: PyMutex, + } + + impl Profiler { + // Operation exclusion is separate from payload borrowing. Keep this + // profiler interpreter-bound even though PyMutex itself is not. + fn with_operation(&self, vm: &VirtualMachine, f: impl FnOnce() -> R) -> R { + assert_eq!(self.interpreter_id, vm.state.interpreter_id); + assert_eq!( + thread::try_with_current_vm(|current| current.state.interpreter_id), + Some(self.interpreter_id), + "profiler operation requires the current interpreter" + ); + #[cfg(feature = "threading")] + { + let slot = thread::current_thread_slot() + .expect("profiler operation requires a thread slot"); + assert_eq!( + slot.state.load(core::sync::atomic::Ordering::Acquire), + thread::ThreadState::Attached as i32, + "profiler operation requires an attached interpreter" + ); + } + critical_section::with(vm, &self.operation, f) + } + } + + // SAFETY: Each owned Python reference is visited exactly once. Contexts + // contain keys only; they do not own additional references to the entries. + unsafe impl Traverse for Profiler { + fn traverse(&self, tracer: &mut TraverseFn<'_>) { + { + let state = self.state.lock(); + state.timer.traverse(tracer); + state.missing.traverse(tracer); + #[expect( + clippy::iter_over_hash_type, + reason = "GC tracing visits every owned reference regardless of entry order" + )] + for entry in state.entries.values() { + entry.code.traverse(tracer); + entry.owner.traverse(tracer); + } + } + } + + fn clear(&mut self, out: &mut Vec) { + let state = self.state.get_mut(); + out.extend(state.timer.take()); + #[expect( + clippy::iter_over_hash_type, + reason = "GC clearing transfers every owned reference; entry order is irrelevant" + )] + for (_, entry) in state.entries.drain() { + out.push(entry.code); + out.extend(entry.owner); + } + out.extend(state.missing.take()); + state.stack.clear(); + state.enabled = false; + state.in_timer = false; + } + } + + #[derive(FromArgs)] + struct InitArgs { + #[pyarg(any, optional)] + timer: OptionalArg, + #[pyarg(any, default = 0.0)] + timeunit: f64, + #[pyarg(any, default = true)] + subcalls: bool, + #[pyarg(any, default = true)] + builtins: bool, + } + + #[derive(FromArgs)] + struct EnableArgs { + #[pyarg(any, default = true)] + subcalls: bool, + #[pyarg(any, default = true)] + builtins: bool, + } + + impl Constructor for Profiler { + type Args = FuncArgs; + + fn py_new(_cls: &Py, _args: Self::Args, vm: &VirtualMachine) -> PyResult { + Ok(Self { + operation: OperationMutex::new(), + interpreter_id: vm.state.interpreter_id, + state: PyMutex::new(State::default()), + }) + } + } + + impl Initializer for Profiler { + type Args = InitArgs; + + fn init(zelf: &Py, args: InitArgs, vm: &VirtualMachine) -> PyResult<()> { + let old_timer = { + let mut state = zelf.state.lock(); + state.unit = args.timeunit; + state.subcalls = args.subcalls; + state.builtins = args.builtins; + core::mem::replace(&mut state.timer, args.timer.into_option()) + }; + drop(old_timer); + let monitoring = vm.sys_module.get_attr("monitoring", vm)?; + let missing = monitoring.get_attr("MISSING", vm); + let old_missing = { + let mut state = zelf.state.lock(); + core::mem::replace(&mut state.missing, missing.as_ref().ok().cloned()) + }; + drop(old_missing); + drop(monitoring); + missing.map(|_| ()) + } + } + + // Declared before the callable snapshot so its finalizer still observes + // in_timer during unwinding, as it does on the ordinary call-return path. + struct TimerCallGuard<'a> { + profiler: &'a Profiler, + active: bool, + } + + impl Drop for TimerCallGuard<'_> { + fn drop(&mut self) { + if self.active { + self.profiler.state.lock().in_timer = false; + } + } + } + + impl Profiler { + fn timer(&self, vm: &VirtualMachine) -> i128 { + let mut reset = TimerCallGuard { + profiler: self, + active: false, + }; + let timer = { + let mut state = self.state.lock(); + let timer = state.timer.clone(); + if timer.is_some() { + state.in_timer = true; + reset.active = true; + } + timer + }; + let result = if let Some(timer) = timer { + let value = timer.call((), vm); + // Release our callable root at call return, before clearing the + // flag. A replaced timer's finalizer is still inside the call. + drop(timer); + drop(reset); + // Conversion and result destruction occur with the flag clear. + let unit = self.state.lock().unit; + value.and_then(|value| { + if unit > 0.0 { + value.try_into_value::(vm).map(i128::from) + } else if let Some(value) = value.downcast_ref::() { + let nanos = (value.to_f64() * 1_000_000_000.0).floor(); + if nanos.is_nan() { + Err(vm.new_value_error("Invalid value NaN (not a number)")) + } else if !(i64::MIN as f64..-(i64::MIN as f64)).contains(&nanos) { + Err(vm + .new_overflow_error("timestamp too large to convert to C PyTime_t")) + } else { + Ok(nanos as i64 as i128) + } + } else { + let seconds = value.try_into_value::(vm)?; + seconds + .checked_mul(1_000_000_000) + .map(i128::from) + .ok_or_else(|| { + vm.new_overflow_error( + "timestamp too large to convert to C PyTime_t", + ) + }) + } + }) + } else { + super::super::time::profiler_time(vm).map(|nanos| nanos as i128) + }; + match result { + Ok(time) => time, + Err(error) => { + let timer = self.state.lock().timer.clone(); + let message = + timer + .as_ref() + .and_then(|timer| timer.repr(vm).ok()) + .map(|timer| { + format!("Exception ignored while calling _lsprof timer {timer}") + }); + drop(timer); + vm.run_unraisable(error, message, vm.ctx.none()); + 0 + } + } + } + + fn enter( + &self, + key: Key, + code: PyObjectRef, + owner: Option, + vm: &VirtualMachine, + ) { + // Unused input owners are dropped after the data guard, never in entry(). + let mut incoming = Some(Entry { + code, + owner, + counts: Counts::default(), + callees: HashMap::new(), + }); + let token = { + let mut state = self.state.lock(); + if state.in_timer { + return; + } + state + .entries + .entry(key) + .or_insert_with(|| incoming.take().unwrap()); + state.entries.get_mut(&key).unwrap().counts.depth += 1; + let caller = state + .subcalls + .then(|| state.stack.last().map(|ctx| ctx.key)) + .flatten(); + if let Some(caller) = caller { + state + .entries + .get_mut(&caller) + .unwrap() + .callees + .entry(key) + .or_default() + .depth += 1; + } + let id = state.next_context; + state.next_context = id + .checked_add(1) + .expect("profiler context identity exhausted"); + state.stack.push(Context { + id, + key, + start: 0, + subtime: 0, + }); + (state.epoch, id) + }; + drop(incoming); + let start = self.timer(vm); + let mut state = self.state.lock(); + if state.epoch == token.0 + && let Some(context) = state.stack.iter_mut().find(|context| context.id == token.1) + { + context.start = start; + } + } + + fn leave(&self, key: Key, vm: &VirtualMachine) { + let token = { + let mut state = self.state.lock(); + if state.in_timer { + return; + } + let Some(context) = state.stack.last() else { + return; + }; + let token = (state.epoch, context.id); + if !state.entries.contains_key(&key) { + state.stack.pop(); + return; + } + token + }; + let now = self.timer(vm); + let mut state = self.state.lock(); + // Clear or a reentrant return can retire the exact saved context. + if state.epoch != token.0 { + return; + } + let Some(index) = state.stack.iter().position(|context| context.id == token.1) else { + return; + }; + let context = &state.stack[index]; + let total = now - context.start; + let inline = total - context.subtime; + let caller = index.checked_sub(1).map(|index| state.stack[index].key); + if let Some(parent) = index.checked_sub(1).map(|index| &mut state.stack[index]) { + parent.subtime += total; + } + // Stop restores its saved previous context, even after reentrant entry. + // Retire any discarded suffix without fabricating completed calls. + state.stack.truncate(index); + state + .entries + .get_mut(&key) + .unwrap() + .counts + .stop(total, inline); + if state.subcalls + && let Some(caller) = caller + && let Some(counts) = state + .entries + .get_mut(&caller) + .and_then(|entry| entry.callees.get_mut(&key)) + { + counts.stop(total, inline); + } + } + } + + // Builtin keys use the method definition, never the transient bound method + // object or its receiver. This both aggregates calls and avoids retaining + // every receiver that the application has used while profiling. + enum NativeCall<'a> { + Function(&'a PyNativeFunction), + Descriptor(&'a Py, &'a PyObject), + } + + impl NativeCall<'_> { + fn key(&self) -> Key { + let method = match self { + Self::Function(function) => function.value, + Self::Descriptor(descriptor, _) => descriptor.method, + }; + Key::Native(method as *const PyMethodDef as usize) + } + + fn owner(&self) -> Option { + match self { + Self::Function(function) => function._method_def_owner.clone(), + Self::Descriptor(descriptor, _) => descriptor._method_def_owner.clone(), + } + } + + fn label(&self, vm: &VirtualMachine) -> PyObjectRef { + let function = match self { + Self::Descriptor(descriptor, receiver) => { + let name = descriptor.method.name; + if let Some(attribute) = receiver.class().get_attr(vm.ctx.intern_str(name)) + && let Ok(label) = attribute.repr(vm) + { + return label.into(); + } + return vm.ctx.new_str(format!("")).into(); + } + Self::Function(function) => function, + }; + let name = function.value.name; + // CPython normalizes the raw m_self, including STATIC's hidden + // type receiver, rather than its call-argument accessor. + let receiver = function + .zelf + .as_deref() + .or(function.module_object.as_deref()); + let module = function.module.load_owned(); + let label = if let Some(receiver) = receiver { + if let Some(descriptor) = receiver.class().get_attr(vm.ctx.intern_str(name)) + && let Ok(label) = descriptor.repr(vm) + { + return label.into(); + } + if let Some(module) = module + .as_ref() + .and_then(|module| module.downcast_ref::()) + { + return qualified_label(module, name, true, vm); + } + format!("") + } else { + let module_name = if let Some(module) = module + .as_ref() + .and_then(|module| module.downcast_ref::()) + { + module + .dict() + .get_item_opt(identifier!(vm, __name__), vm) + .ok() + .flatten() + } else { + module + }; + if let Some(module) = module_name + .as_ref() + .and_then(|module| module.downcast_ref::()) + && module.as_wtf8() != "builtins" + { + return qualified_label(module, name, false, vm); + } + format!("<{name}>") + }; + vm.ctx.new_str(label).into() + } + } + + fn qualified_label( + module: &PyStr, + name: &str, + builtin: bool, + vm: &VirtualMachine, + ) -> PyObjectRef { + let mut label = Wtf8Buf::from(if builtin { ""); + vm.ctx.new_str(label).into() + } + + fn native_call<'a>( + callable: &'a PyObject, + arg: &'a PyObject, + missing: Option<&PyObjectRef>, + ) -> Option> { + if let Some(descriptor) = callable.downcast_ref::() { + if missing.is_some_and(|missing| arg.is(missing)) + || !arg.fast_isinstance(descriptor.common.typ) + { + return None; + } + return Some(NativeCall::Descriptor(descriptor, arg)); + } + let function = if let Some(function) = callable.downcast_ref::() { + &**function + } else { + let method = callable.downcast_ref::()?; + &method.func + }; + Some(NativeCall::Function(function)) + } + + #[pyclass(with(Constructor, Initializer), flags(BASETYPE))] + impl Profiler { + #[pymethod] + fn enable(zelf: &Py, args: EnableArgs, vm: &VirtualMachine) -> PyResult<()> { + zelf.with_operation(vm, || { + { + let mut state = zelf.state.lock(); + state.subcalls = args.subcalls; + state.builtins = args.builtins; + } + let monitoring = vm.sys_module.get_attr("monitoring", vm)?; + vm.call_method(&monitoring, "use_tool_id", (TOOL_ID, "cProfile"))?; + let mut events = 0; + for &(event, method) in CALLBACKS { + let callback = zelf.as_object().get_attr(method, vm)?; + vm.call_method( + &monitoring, + "register_callback", + (TOOL_ID, event.mask(), callback), + )?; + events |= event.mask(); + } + vm.call_method(&monitoring, "set_events", (TOOL_ID, events))?; + drop(monitoring); + zelf.state.lock().enabled = true; + Ok(()) + }) + } + + #[pymethod] + fn disable(zelf: &Py, vm: &VirtualMachine) -> PyResult<()> { + zelf.with_operation(vm, || { + let (in_timer, enabled) = { + let state = zelf.state.lock(); + (state.in_timer, state.enabled) + }; + if in_timer { + return Err(vm.new_runtime_error("cannot disable profiler in external timer")); + } + if !enabled { + return Ok(()); + } + let monitoring = vm.sys_module.get_attr("monitoring", vm)?; + for &(event, _) in CALLBACKS { + vm.call_method( + &monitoring, + "register_callback", + (TOOL_ID, event.mask(), vm.ctx.none()), + )?; + } + vm.call_method(&monitoring, "set_events", (TOOL_ID, 0))?; + vm.call_method(&monitoring, "free_tool_id", (TOOL_ID,))?; + drop(monitoring); + zelf.state.lock().enabled = false; + loop { + let key = zelf.state.lock().stack.last().map(|ctx| ctx.key); + let Some(key) = key else { + break; + }; + zelf.leave(key, vm); + } + Ok(()) + }) + } + + #[pymethod] + fn clear(zelf: &Py, vm: &VirtualMachine) -> PyResult<()> { + zelf.with_operation(vm, || { + if zelf.state.lock().in_timer { + return Err(vm.new_runtime_error("cannot clear profiler in external timer")); + } + let entries = { + let mut state = zelf.state.lock(); + state.stack.clear(); + state.epoch = state + .epoch + .checked_add(1) + .expect("profiler epoch exhausted"); + core::mem::take(&mut state.entries) + }; + drop(entries); + Ok(()) + }) + } + + #[pymethod] + fn getstats(zelf: &Py, vm: &VirtualMachine) -> Vec { + zelf.with_operation(vm, || { + let (entries, factor) = { + let state = zelf.state.lock(); + let factor = if state.timer.is_some() && state.unit != 0.0 { + state.unit + } else { + 1e-9 + }; + (state.entries.clone(), factor) + }; + entries + .values() + .filter(|entry| entry.counts.calls != 0) + .map(|entry| { + let calls = if entry.callees.is_empty() { + vm.ctx.none() + } else { + vm.ctx + .new_list( + entry + .callees + .iter() + .map(|(key, counts)| { + SubentryData { + code: entries[key].code.clone(), + callcount: counts.calls, + reccallcount: counts.recursive, + totaltime: counts.total as f64 * factor, + inlinetime: counts.inline as f64 * factor, + } + .to_pyobject(vm) + }) + .collect(), + ) + .into() + }; + EntryData { + code: entry.code.clone(), + callcount: entry.counts.calls, + reccallcount: entry.counts.recursive, + totaltime: entry.counts.total as f64 * factor, + inlinetime: entry.counts.inline as f64 * factor, + calls, + } + .to_pyobject(vm) + }) + .collect() + }) + } + + #[pymethod] + fn _pystart_callback( + zelf: &Py, + code: PyObjectRef, + _offset: PyObjectRef, + vm: &VirtualMachine, + ) { + zelf.with_operation(vm, || { + zelf.enter(Key::Python(code.get_id()), code, None, vm); + }) + } + + #[pymethod] + fn _pythrow_callback( + zelf: &Py, + code: PyObjectRef, + _offset: PyObjectRef, + _exception: PyObjectRef, + vm: &VirtualMachine, + ) { + zelf.with_operation(vm, || { + zelf.enter(Key::Python(code.get_id()), code, None, vm); + }) + } + + #[pymethod] + fn _pyreturn_callback( + zelf: &Py, + code: PyObjectRef, + _offset: PyObjectRef, + _retval: PyObjectRef, + vm: &VirtualMachine, + ) { + zelf.with_operation(vm, || { + zelf.leave(Key::Python(code.get_id()), vm); + }) + } + + #[pymethod] + fn _ccall_callback( + zelf: &Py, + _code: PyObjectRef, + _offset: PyObjectRef, + callable: PyObjectRef, + arg: PyObjectRef, + vm: &VirtualMachine, + ) { + zelf.with_operation(vm, || { + let (builtins, missing) = { + let state = zelf.state.lock(); + (state.builtins, state.missing.clone()) + }; + if builtins && let Some(call) = native_call(&callable, &arg, missing.as_ref()) { + if zelf.state.lock().in_timer { + return; + } + let key = call.key(); + let existing = zelf + .state + .lock() + .entries + .get(&key) + .map(|entry| entry.code.clone()); + let code = existing.unwrap_or_else(|| call.label(vm)); + zelf.enter(key, code, call.owner(), vm); + } + }) + } + + #[pymethod] + fn _creturn_callback( + zelf: &Py, + _code: PyObjectRef, + _offset: PyObjectRef, + callable: PyObjectRef, + arg: PyObjectRef, + vm: &VirtualMachine, + ) { + zelf.with_operation(vm, || { + let (builtins, missing) = { + let state = zelf.state.lock(); + (state.builtins, state.missing.clone()) + }; + if builtins && let Some(call) = native_call(&callable, &arg, missing.as_ref()) { + zelf.leave(call.key(), vm); + } + }) + } + } + + #[pystruct_sequence_data] + struct EntryData { + code: PyObjectRef, + callcount: usize, + reccallcount: usize, + totaltime: f64, + inlinetime: f64, + calls: PyObjectRef, + } + + #[pyattr] + #[pystruct_sequence(name = "profiler_entry", module = "_lsprof", data = "EntryData")] + struct ProfilerEntry; + + #[pyclass(with(PyStructSequence))] + impl ProfilerEntry {} + + #[pystruct_sequence_data] + struct SubentryData { + code: PyObjectRef, + callcount: usize, + reccallcount: usize, + totaltime: f64, + inlinetime: f64, + } + + #[pyattr] + #[pystruct_sequence(name = "profiler_subentry", module = "_lsprof", data = "SubentryData")] + struct ProfilerSubentry; + + #[pyclass(with(PyStructSequence))] + impl ProfilerSubentry {} +} diff --git a/crates/vm/src/stdlib/mod.rs b/crates/vm/src/stdlib/mod.rs index ee66ed322..a198a363b 100644 --- a/crates/vm/src/stdlib/mod.rs +++ b/crates/vm/src/stdlib/mod.rs @@ -7,6 +7,7 @@ pub(crate) use _collections::ordered_dict::ordered_dict::PyOrderedDict; mod _functools; mod _imp; pub mod _io; +mod _lsprof; mod _operator; mod _sre; mod _stat; @@ -137,6 +138,7 @@ pub fn builtin_module_defs(ctx: &Context) -> Vec<&'static PyModuleDef> { msvcrt::module_def(ctx), #[cfg(all(feature = "host_env", windows))] nt::module_def(ctx), + _lsprof::module_def(ctx), _operator::module_def(ctx), #[cfg(all(feature = "host_env", any(unix, target_os = "wasi")))] posix::module_def(ctx), diff --git a/crates/vm/src/stdlib/time.rs b/crates/vm/src/stdlib/time.rs index ec6db117c..02288ca45 100644 --- a/crates/vm/src/stdlib/time.rs +++ b/crates/vm/src/stdlib/time.rs @@ -7,6 +7,7 @@ pub use decl::time; pub(crate) use decl::module_def; +pub(crate) use decl::perf_counter_ns as profiler_time; #[pymodule(name = "time", with(#[cfg(any(unix, windows))] platform))] mod decl { @@ -262,7 +263,7 @@ mod decl { } #[pyfunction] - fn perf_counter_ns(vm: &VirtualMachine) -> PyResult { + pub(crate) fn perf_counter_ns(vm: &VirtualMachine) -> PyResult { Ok(get_perf_time(vm)?.as_nanos()) } diff --git a/crates/vm/src/vm/interpreter.rs b/crates/vm/src/vm/interpreter.rs index 324f17574..e6c4c2852 100644 --- a/crates/vm/src/vm/interpreter.rs +++ b/crates/vm/src/vm/interpreter.rs @@ -7,7 +7,14 @@ use super::{ thread, }; use crate::{ - PyResult, builtins, common::rc::PyRc, frozen::FrozenModule, getpath, py_freeze, stdlib::atexit, + PyResult, builtins, + common::rc::PyRc, + frozen::FrozenModule, + frozen_resources::{ + FrozenModuleRegistry, FrozenRegistration, FrozenResourceBundle, ResourceError, + }, + getpath, py_freeze, + stdlib::atexit, vm::PyBaseExceptionRef, }; use alloc::collections::BTreeMap; @@ -37,7 +44,7 @@ pub struct InterpreterBuilder { settings: Settings, pub ctx: PyRc, module_defs: Vec<&'static builtins::PyModuleDef>, - frozen_modules: Vec<(&'static str, FrozenModule)>, + frozen_modules: Vec, init_hooks: Vec, } @@ -46,7 +53,7 @@ struct InitializeVmOpts<'a> { settings: Settings, ctx: PyRc, module_defs: Vec<&'static builtins::PyModuleDef>, - frozen_modules: Vec<(&'static str, FrozenModule)>, + frozen_modules: Vec, init_hooks: Vec, is_main: bool, whence: InterpreterWhence, @@ -122,12 +129,10 @@ where other => other, } as usize); - let mut frozen: std::collections::HashMap< - &'static str, - FrozenModule, - rapidhash::quality::RandomState, - > = core_frozen_inits().collect(); - frozen.extend(frozen_modules); + let mut frozen: FrozenModuleRegistry = core_frozen_inits().collect(); + for registration in frozen_modules { + frozen.apply_registration(registration); + } (config, all_module_defs, frozen, int_max_str_digits) }; @@ -368,6 +373,8 @@ impl InterpreterBuilder { /// /// Frozen modules are Python modules compiled into the binary. /// This method accepts any iterator of (name, FrozenModule) pairs. + /// It registers code only. To include embedded package data, use + /// [`Self::add_frozen_modules_with_resources`] with the matching resource bundle. /// /// # Example /// ``` @@ -382,10 +389,29 @@ impl InterpreterBuilder { where I: IntoIterator, { - self.frozen_modules.extend(frozen); + self.frozen_modules + .push(FrozenRegistration::code_only(frozen)); self } + /// Add frozen code and resources as one ownership-preserving registration. + /// + /// Every resource owner must be a package in this registration. Replacing a + /// package or module in a later registration never inherits its old resources, + /// even if the replacement contains identical bytecode. + pub fn add_frozen_modules_with_resources( + mut self, + frozen: I, + resources: &'static [FrozenResourceBundle], + ) -> Result + where + I: IntoIterator, + { + self.frozen_modules + .push(FrozenRegistration::with_resources(frozen, resources)?); + Ok(self) + } + /// Build the interpreter. /// /// This consumes the configuration and returns a fully initialized Interpreter. diff --git a/crates/vm/src/vm/mod.rs b/crates/vm/src/vm/mod.rs index 2d9f0d56e..795f3db12 100644 --- a/crates/vm/src/vm/mod.rs +++ b/crates/vm/src/vm/mod.rs @@ -41,7 +41,7 @@ use crate::{ convert::ToPyObject, exceptions::types::{PyBaseException, PyMemoryError}, frame::{ExecutionResult, FrameObject, FrameObjectRef}, - frozen::FrozenModule, + frozen_resources::FrozenModuleRegistry, function::{ArgMapping, FuncArgs, PySetterValue}, import, protocol::{PyIterIter, PyIterReturn}, @@ -59,8 +59,10 @@ use core::{ sync::atomic::{AtomicBool, AtomicI64, AtomicU64, AtomicUsize, Ordering}, }; use crossbeam_utils::atomic::AtomicCell; +#[cfg(feature = "threading")] +use std::collections::HashMap; use std::{ - collections::{HashMap, HashSet}, + collections::HashSet, ffi::{OsStr, OsString}, }; @@ -870,7 +872,7 @@ pub struct PyGlobalState { /// `_interactive_src_count`: numbers the code filenames of interactive inputs. pub(crate) interactive_src_count: AtomicUsize, pub module_defs: BTreeMap<&'static str, &'static builtins::PyModuleDef>, - pub frozen: HashMap<&'static str, FrozenModule, rapidhash::quality::RandomState>, + pub frozen: FrozenModuleRegistry, pub stacksize: AtomicCell, pub thread_count: AtomicCell, /// Registered `atexit` callbacks, newest first. Shared ownership so @@ -1424,7 +1426,7 @@ impl VirtualMachine { }); let guide_message = if cfg!(feature = "freeze-stdlib") { - "`rustpython_pylib` may not be set while using `freeze-stdlib` feature. Try using `rustpython::InterpreterBuilder::init_stdlib` or manually call `builder.add_frozen_modules(rustpython_pylib::FROZEN_STDLIB)` in `rustpython_vm::Interpreter::builder()`." + "`rustpython_pylib` may not be set while using `freeze-stdlib` feature. Try using `rustpython::InterpreterBuilder::init_stdlib` or manually call `builder.add_frozen_modules_with_resources(rustpython_pylib::FROZEN_STDLIB, rustpython_pylib::FROZEN_STDLIB_RESOURCES)` in `rustpython_vm::Interpreter::builder()`." } else if !env_set { "Neither RUSTPYTHONPATH nor PYTHONPATH is set. Try setting one of them to the stdlib directory." } else if path_contains_env { diff --git a/crates/vm/src/vm/thread/critical_section.rs b/crates/vm/src/vm/thread/critical_section.rs index 19cc49e60..ce23e24c2 100644 --- a/crates/vm/src/vm/thread/critical_section.rs +++ b/crates/vm/src/vm/thread/critical_section.rs @@ -12,10 +12,6 @@ use super::lock::PyMutex; use crate::VirtualMachine; /// `Py_BEGIN_CRITICAL_SECTION_MUTEX(m)` ... `Py_END_CRITICAL_SECTION()` -#[cfg_attr( - not(all(test, feature = "threading")), - expect(dead_code, reason = "Removed when the profiler consumer is wired") -)] pub(crate) fn with(vm: &VirtualMachine, m: &PyMutex, f: impl FnOnce() -> R) -> R { #[cfg(feature = "threading")] { diff --git a/crates/vm/src/vm/thread/lock.rs b/crates/vm/src/vm/thread/lock.rs index 0eb96204d..81b9252b0 100644 --- a/crates/vm/src/vm/thread/lock.rs +++ b/crates/vm/src/vm/thread/lock.rs @@ -23,10 +23,6 @@ pub(crate) struct PyMutex { } impl PyMutex { - #[cfg_attr( - not(all(test, feature = "threading")), - expect(dead_code, reason = "Removed when the profiler consumer is wired") - )] pub(crate) const fn new() -> Self { Self { bits: AtomicU8::new(0), diff --git a/crates/wasm/src/vm_class.rs b/crates/wasm/src/vm_class.rs index 4b3d6f4cb..8dd013455 100644 --- a/crates/wasm/src/vm_class.rs +++ b/crates/wasm/src/vm_class.rs @@ -83,7 +83,11 @@ impl StoredVirtualMachine { let defs = rustpython_stdlib::stdlib_module_defs(&builder.ctx); builder = builder .add_native_modules(&defs) - .add_frozen_modules(rustpython_pylib::FROZEN_STDLIB); + .add_frozen_modules_with_resources( + rustpython_pylib::FROZEN_STDLIB, + rustpython_pylib::FROZEN_STDLIB_RESOURCES, + ) + .expect("validated embedded standard-library resources"); } // Browser rustls `_ssl` overrides the rustls-free stdlib `_ssl`. diff --git a/example_projects/frozen_stdlib/src/main.rs b/example_projects/frozen_stdlib/src/main.rs index 8ff316fae..b585fe5be 100644 --- a/example_projects/frozen_stdlib/src/main.rs +++ b/example_projects/frozen_stdlib/src/main.rs @@ -27,7 +27,11 @@ fn interpreter_with_config() { fn interpreter_with_vm() { let interpreter = rustpython_vm::Interpreter::builder(Default::default()) - .add_frozen_modules(rustpython_pylib::FROZEN_STDLIB) + .add_frozen_modules_with_resources( + rustpython_pylib::FROZEN_STDLIB, + rustpython_pylib::FROZEN_STDLIB_RESOURCES, + ) + .expect("validated embedded standard-library resources") .build(); // Use interpreter.enter to reuse the same interpreter later interpreter.run(|vm| run("rustpython_vm::Interpreter::builder", vm)); diff --git a/examples/custom_tls_providers.rs b/examples/custom_tls_providers.rs index 355f75342..65efbd15e 100644 --- a/examples/custom_tls_providers.rs +++ b/examples/custom_tls_providers.rs @@ -5,7 +5,6 @@ use std::env; use rustls::crypto::ring; -use rustpython_pylib::FROZEN_STDLIB; use rustpython_stdlib::{ssl::providers::CryptoExt, stdlib_module_defs}; use rustpython_vm::Interpreter; @@ -56,7 +55,11 @@ fn main() { let defs = stdlib_module_defs(&builder.ctx); let result = builder .add_native_modules(&defs) - .add_frozen_modules(FROZEN_STDLIB) + .add_frozen_modules_with_resources( + rustpython_pylib::FROZEN_STDLIB, + rustpython_pylib::FROZEN_STDLIB_RESOURCES, + ) + .expect("validated embedded standard-library resources") .build() .run(|vm| { let scope = vm.new_scope_with_builtins(); diff --git a/src/interpreter.rs b/src/interpreter.rs index 303b0cb3c..3bb497812 100644 --- a/src/interpreter.rs +++ b/src/interpreter.rs @@ -20,7 +20,11 @@ impl InterpreterBuilderExt for InterpreterBuilder { cfg_select! { feature = "freeze-stdlib" => builder - .add_frozen_modules(rustpython_pylib::FROZEN_STDLIB) + .add_frozen_modules_with_resources( + rustpython_pylib::FROZEN_STDLIB, + rustpython_pylib::FROZEN_STDLIB_RESOURCES, + ) + .expect("validated embedded standard-library resources") .init_hook(set_frozen_stdlib_dir), _ => builder.init_hook(setup_dynamic_stdlib), }
+ +
+
+ + Tachyon + + {{SUBTITLE}} +
+
+ + +
+
+
+ + +
+ + + + +
+
+
+
+ + + +