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 000000000..03e67823d Binary files /dev/null and b/Lib/profiling/sampling/_assets/tachyon-logo.png differ 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}} + + + + + + + +
+ +
+
+ + Tachyon + + {{SUBTITLE}} +
+
+ + +
+
+ + + + + + + + +
+
+ + +
+ + + + +
+
+
+
+ + +
+ + Tachyon Profiler + + + Python Sampling Profiler + + + + + +
+
+ + + + 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), }