dstrack 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- dstrack/__init__.py +8 -0
- dstrack/_benchmark/__init__.py +54 -0
- dstrack/_benchmark/_cli.py +114 -0
- dstrack/_benchmark/_environment.py +144 -0
- dstrack/_benchmark/_profiling.py +207 -0
- dstrack/_benchmark/_report.py +152 -0
- dstrack/_benchmark/_runner.py +196 -0
- dstrack/_benchmark/_synthetic.py +158 -0
- dstrack/_cli.py +105 -0
- dstrack/_log.py +268 -0
- dstrack/_log_render.py +282 -0
- dstrack/_logging.py +60 -0
- dstrack/_store_template.py +58 -0
- dstrack/_track.py +144 -0
- dstrack/cache.py +448 -0
- dstrack/console.py +47 -0
- dstrack/errors.py +22 -0
- dstrack/paths.py +88 -0
- dstrack/readers/__init__.py +69 -0
- dstrack/readers/_csv.py +361 -0
- dstrack/readers/_protocol.py +120 -0
- dstrack/readers/_registry.py +241 -0
- dstrack/readers/_resolve.py +140 -0
- dstrack/schemas/snapshot_v1.json +299 -0
- dstrack/snapshot/__init__.py +46 -0
- dstrack/snapshot/_builder.py +114 -0
- dstrack/snapshot/_metadata.py +154 -0
- dstrack/snapshot/_stats.py +487 -0
- dstrack/snapshot/_version.py +13 -0
- dstrack/store.py +765 -0
- dstrack/utils.py +6 -0
- dstrack-0.1.0.dist-info/METADATA +123 -0
- dstrack-0.1.0.dist-info/RECORD +36 -0
- dstrack-0.1.0.dist-info/WHEEL +4 -0
- dstrack-0.1.0.dist-info/entry_points.txt +4 -0
- dstrack-0.1.0.dist-info/licenses/LICENSE +21 -0
dstrack/__init__.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Benchmark for dstrack's snapshot creation pipeline.
|
|
2
|
+
|
|
3
|
+
Generates a synthetic CSV in a temporary directory, then times how long it
|
|
4
|
+
takes to build snapshot metadata and statistics for it, alongside a summary of
|
|
5
|
+
the environment the benchmark ran in and a call tree of dstrack's own methods.
|
|
6
|
+
|
|
7
|
+
Layers
|
|
8
|
+
------
|
|
9
|
+
[dstrack._benchmark._synthetic][]
|
|
10
|
+
Describes and writes the synthetic dataset.
|
|
11
|
+
|
|
12
|
+
[dstrack._benchmark._runner][]
|
|
13
|
+
Runs the snapshot pipeline against it and times each phase.
|
|
14
|
+
|
|
15
|
+
[dstrack._benchmark._profiling][]
|
|
16
|
+
Reduces the ``cProfile`` run to a call tree of dstrack's own methods.
|
|
17
|
+
|
|
18
|
+
[dstrack._benchmark._environment][]
|
|
19
|
+
Describes the machine the benchmark ran on.
|
|
20
|
+
|
|
21
|
+
[dstrack._benchmark._report][], [dstrack._benchmark._cli][]
|
|
22
|
+
Render runs to a console, and wire the whole thing to ``dstrack-benchmark``.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from ._cli import app
|
|
26
|
+
from ._environment import EnvironmentInfo, collect_environment_info
|
|
27
|
+
from ._profiling import CallGraph, CallNode, CallScope
|
|
28
|
+
from ._report import ConsoleReporter
|
|
29
|
+
from ._runner import (
|
|
30
|
+
BenchmarkObserver,
|
|
31
|
+
BenchmarkResult,
|
|
32
|
+
BenchmarkRun,
|
|
33
|
+
BenchmarkRunner,
|
|
34
|
+
SilentObserver,
|
|
35
|
+
)
|
|
36
|
+
from ._synthetic import SyntheticColumn, SyntheticCsvSpec, write_synthetic_csv
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"BenchmarkObserver",
|
|
40
|
+
"BenchmarkResult",
|
|
41
|
+
"BenchmarkRun",
|
|
42
|
+
"BenchmarkRunner",
|
|
43
|
+
"CallGraph",
|
|
44
|
+
"CallNode",
|
|
45
|
+
"CallScope",
|
|
46
|
+
"ConsoleReporter",
|
|
47
|
+
"EnvironmentInfo",
|
|
48
|
+
"SilentObserver",
|
|
49
|
+
"SyntheticColumn",
|
|
50
|
+
"SyntheticCsvSpec",
|
|
51
|
+
"app",
|
|
52
|
+
"collect_environment_info",
|
|
53
|
+
"write_synthetic_csv",
|
|
54
|
+
]
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
"""Command-line entry point for the snapshot benchmark."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
import tempfile
|
|
5
|
+
from collections.abc import Iterator
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Annotated
|
|
9
|
+
|
|
10
|
+
import typer
|
|
11
|
+
|
|
12
|
+
from dstrack._benchmark._report import ConsoleReporter
|
|
13
|
+
from dstrack._benchmark._runner import BenchmarkRunner
|
|
14
|
+
from dstrack._benchmark._synthetic import SyntheticCsvSpec
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
no_args_is_help=True,
|
|
18
|
+
pretty_exceptions_show_locals=False,
|
|
19
|
+
help="Benchmark dstrack's snapshot creation pipeline against a synthetic CSV.",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
_DEFAULTS = SyntheticCsvSpec()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@app.callback()
|
|
26
|
+
def _main() -> None:
|
|
27
|
+
"""Keep ``run`` a named subcommand so a bare invocation shows help.
|
|
28
|
+
|
|
29
|
+
Typer promotes a lone command to the top level, which quietly discards
|
|
30
|
+
``no_args_is_help``; an explicit callback keeps the command group intact so
|
|
31
|
+
running ``dstrack-benchmark`` with no arguments prints help instead of
|
|
32
|
+
silently kicking off a full-size benchmark.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@contextmanager
|
|
37
|
+
def _workspace(reporter: ConsoleReporter, *, keep_csv: bool) -> Iterator[Path]:
|
|
38
|
+
"""Yield a path for the synthetic CSV, discarding it unless ``keep_csv``."""
|
|
39
|
+
tmp_dir = Path(tempfile.mkdtemp(prefix="dstrack-benchmark-"))
|
|
40
|
+
csv_path = tmp_dir / "synthetic_dataset.csv"
|
|
41
|
+
try:
|
|
42
|
+
yield csv_path
|
|
43
|
+
finally:
|
|
44
|
+
if keep_csv:
|
|
45
|
+
reporter.csv_kept(csv_path)
|
|
46
|
+
else:
|
|
47
|
+
shutil.rmtree(tmp_dir, ignore_errors=True)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@app.command(help="Generate a synthetic CSV and benchmark snapshot creation on it.")
|
|
51
|
+
def run(
|
|
52
|
+
rows: Annotated[
|
|
53
|
+
int, typer.Option(min=0, help="Number of data rows to generate.")
|
|
54
|
+
] = _DEFAULTS.num_rows,
|
|
55
|
+
numeric_cols: Annotated[
|
|
56
|
+
int, typer.Option(min=0, help="Number of numeric (float) columns.")
|
|
57
|
+
] = _DEFAULTS.num_numeric_cols,
|
|
58
|
+
string_cols: Annotated[
|
|
59
|
+
int, typer.Option(min=0, help="Number of string columns.")
|
|
60
|
+
] = _DEFAULTS.num_string_cols,
|
|
61
|
+
datetime_cols: Annotated[
|
|
62
|
+
int, typer.Option(min=0, help="Number of datetime columns.")
|
|
63
|
+
] = _DEFAULTS.num_datetime_cols,
|
|
64
|
+
bool_cols: Annotated[
|
|
65
|
+
int, typer.Option(min=0, help="Number of boolean columns.")
|
|
66
|
+
] = _DEFAULTS.num_bool_cols,
|
|
67
|
+
null_rate: Annotated[
|
|
68
|
+
float,
|
|
69
|
+
typer.Option(min=0.0, max=1.0, help="Fraction of non-id cells left null."),
|
|
70
|
+
] = _DEFAULTS.null_rate,
|
|
71
|
+
seed: Annotated[
|
|
72
|
+
int, typer.Option(help="Random seed for reproducible synthetic data.")
|
|
73
|
+
] = _DEFAULTS.seed,
|
|
74
|
+
keep_csv: Annotated[
|
|
75
|
+
bool,
|
|
76
|
+
typer.Option(
|
|
77
|
+
help="Keep the generated CSV instead of deleting it after the run."
|
|
78
|
+
),
|
|
79
|
+
] = False,
|
|
80
|
+
profile: Annotated[
|
|
81
|
+
bool,
|
|
82
|
+
typer.Option(
|
|
83
|
+
help="Profile dstrack's own methods while building the snapshot. "
|
|
84
|
+
"Adds cProfile overhead to the reported metadata/stats timings."
|
|
85
|
+
),
|
|
86
|
+
] = True,
|
|
87
|
+
profile_limit: Annotated[
|
|
88
|
+
int,
|
|
89
|
+
typer.Option(
|
|
90
|
+
min=0,
|
|
91
|
+
help="Max number of child methods shown under each node of the "
|
|
92
|
+
"profile call tree, ranked by cumulative time.",
|
|
93
|
+
),
|
|
94
|
+
] = 20,
|
|
95
|
+
) -> None:
|
|
96
|
+
spec = SyntheticCsvSpec(
|
|
97
|
+
num_rows=rows,
|
|
98
|
+
num_numeric_cols=numeric_cols,
|
|
99
|
+
num_string_cols=string_cols,
|
|
100
|
+
num_datetime_cols=datetime_cols,
|
|
101
|
+
num_bool_cols=bool_cols,
|
|
102
|
+
null_rate=null_rate,
|
|
103
|
+
seed=seed,
|
|
104
|
+
)
|
|
105
|
+
reporter = ConsoleReporter()
|
|
106
|
+
runner = BenchmarkRunner(spec, observer=reporter, profile=profile)
|
|
107
|
+
|
|
108
|
+
with _workspace(reporter, keep_csv=keep_csv) as csv_path:
|
|
109
|
+
benchmark_run = runner.run(csv_path)
|
|
110
|
+
reporter.report(benchmark_run, profile_limit=profile_limit)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
if __name__ == "__main__":
|
|
114
|
+
app()
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Describes the machine and runtime a benchmark executed under.
|
|
2
|
+
|
|
3
|
+
Hardware facts have no portable API, so each is read by a per-OS probe. Every
|
|
4
|
+
probe is best-effort: it returns ``None`` (or raises, which
|
|
5
|
+
[_attempt()][dstrack._benchmark._environment._attempt] swallows) when the
|
|
6
|
+
platform will not give up the answer, and the caller falls back to something
|
|
7
|
+
always available.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import platform
|
|
12
|
+
import subprocess
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TypeVar
|
|
17
|
+
|
|
18
|
+
from dstrack import __version__
|
|
19
|
+
|
|
20
|
+
T = TypeVar("T")
|
|
21
|
+
|
|
22
|
+
_PROBE_TIMEOUT_SECONDS = 5
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class EnvironmentInfo:
|
|
27
|
+
"""Snapshot of the machine and runtime a benchmark executed under."""
|
|
28
|
+
|
|
29
|
+
os_name: str
|
|
30
|
+
os_release: str
|
|
31
|
+
os_version: str
|
|
32
|
+
machine: str
|
|
33
|
+
cpu_model: str
|
|
34
|
+
cpu_count: int | None
|
|
35
|
+
total_memory_gb: float | None
|
|
36
|
+
python_implementation: str
|
|
37
|
+
python_version: str
|
|
38
|
+
dstrack_version: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _attempt(probe: Callable[[], T | None]) -> T | None:
|
|
42
|
+
"""Run a probe, returning ``None`` if the platform refuses to answer."""
|
|
43
|
+
try:
|
|
44
|
+
return probe()
|
|
45
|
+
except (OSError, subprocess.SubprocessError, ValueError):
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _command_output(*args: str) -> str:
|
|
50
|
+
"""Gets the stdout of a command, raising if it fails or times out."""
|
|
51
|
+
result = subprocess.run(
|
|
52
|
+
args,
|
|
53
|
+
capture_output=True,
|
|
54
|
+
text=True,
|
|
55
|
+
timeout=_PROBE_TIMEOUT_SECONDS,
|
|
56
|
+
check=True,
|
|
57
|
+
)
|
|
58
|
+
return result.stdout.strip()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _first_matching_line(path: Path, prefix: str) -> str | None:
|
|
62
|
+
"""First line of ``path`` starting with ``prefix`` (case-insensitive)."""
|
|
63
|
+
if not path.is_file():
|
|
64
|
+
return None
|
|
65
|
+
for line in path.read_text().splitlines():
|
|
66
|
+
if line.lower().startswith(prefix.lower()):
|
|
67
|
+
return line
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _linux_cpu_model() -> str | None:
|
|
72
|
+
line = _first_matching_line(Path("/proc/cpuinfo"), "model name")
|
|
73
|
+
return line.split(":", 1)[1].strip() if line else None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _darwin_cpu_model() -> str | None:
|
|
77
|
+
return _command_output("sysctl", "-n", "machdep.cpu.brand_string")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _windows_cpu_model() -> str | None:
|
|
81
|
+
lines = [
|
|
82
|
+
line.strip()
|
|
83
|
+
for line in _command_output("wmic", "cpu", "get", "name").splitlines()
|
|
84
|
+
if line.strip()
|
|
85
|
+
]
|
|
86
|
+
return lines[1] if len(lines) >= 2 else None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _linux_total_memory() -> int | None:
|
|
90
|
+
line = _first_matching_line(Path("/proc/meminfo"), "MemTotal:")
|
|
91
|
+
return int(line.split()[1]) * 1024 if line else None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _darwin_total_memory() -> int | None:
|
|
95
|
+
return int(_command_output("sysctl", "-n", "hw.memsize"))
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _sysconf_total_memory() -> int | None:
|
|
99
|
+
"""Page size times page count, on any POSIX platform exposing sysconf."""
|
|
100
|
+
if not hasattr(os, "sysconf") or "SC_PAGE_SIZE" not in os.sysconf_names:
|
|
101
|
+
return None
|
|
102
|
+
return os.sysconf("SC_PAGE_SIZE") * os.sysconf("SC_PHYS_PAGES")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
_CPU_MODEL_PROBES: dict[str, Callable[[], str | None]] = {
|
|
106
|
+
"Linux": _linux_cpu_model,
|
|
107
|
+
"Darwin": _darwin_cpu_model,
|
|
108
|
+
"Windows": _windows_cpu_model,
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
_TOTAL_MEMORY_PROBES: dict[str, Callable[[], int | None]] = {
|
|
112
|
+
"Linux": _linux_total_memory,
|
|
113
|
+
"Darwin": _darwin_total_memory,
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _cpu_model() -> str:
|
|
118
|
+
"""Best-effort human-readable CPU model string for the current machine."""
|
|
119
|
+
probe = _CPU_MODEL_PROBES.get(platform.system())
|
|
120
|
+
model = _attempt(probe) if probe is not None else None
|
|
121
|
+
return model or platform.processor() or platform.machine() or "unknown"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _total_memory_bytes() -> int | None:
|
|
125
|
+
"""Best-effort total physical memory in bytes, or None if it can't be read."""
|
|
126
|
+
probe = _TOTAL_MEMORY_PROBES.get(platform.system(), _sysconf_total_memory)
|
|
127
|
+
return _attempt(probe)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def collect_environment_info() -> EnvironmentInfo:
|
|
131
|
+
"""Collect OS, hardware, and runtime details describing the current machine."""
|
|
132
|
+
total_memory = _total_memory_bytes()
|
|
133
|
+
return EnvironmentInfo(
|
|
134
|
+
os_name=platform.system(),
|
|
135
|
+
os_release=platform.release(),
|
|
136
|
+
os_version=platform.version(),
|
|
137
|
+
machine=platform.machine(),
|
|
138
|
+
cpu_model=_cpu_model(),
|
|
139
|
+
cpu_count=os.cpu_count(),
|
|
140
|
+
total_memory_gb=total_memory / (1024**3) if total_memory is not None else None,
|
|
141
|
+
python_implementation=platform.python_implementation(),
|
|
142
|
+
python_version=platform.python_version(),
|
|
143
|
+
dstrack_version=__version__,
|
|
144
|
+
)
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
"""Reduces a ``cProfile`` run to a call tree of dstrack's own methods.
|
|
2
|
+
|
|
3
|
+
This module is pure model: it turns raw ``pstats`` frames into
|
|
4
|
+
[CallNode][dstrack._benchmark._profiling.CallNode] trees and knows nothing about
|
|
5
|
+
how they are displayed. Rendering lives in [dstrack._benchmark._report][].
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import cProfile
|
|
9
|
+
import pstats
|
|
10
|
+
from collections.abc import Iterable
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TypeAlias
|
|
14
|
+
|
|
15
|
+
# (filename, lineno, funcname) - the key pstats uses to identify a profiled frame.
|
|
16
|
+
FrameKey: TypeAlias = tuple[str, int, str]
|
|
17
|
+
# (primitive_calls, total_calls, total_time, cumulative_time)
|
|
18
|
+
_FrameTotals: TypeAlias = tuple[int, int, float, float]
|
|
19
|
+
# What pstats.Stats.stats maps each frame to: its totals plus its callers.
|
|
20
|
+
_RawStats: TypeAlias = dict[
|
|
21
|
+
FrameKey, tuple[int, int, float, float, dict[FrameKey, _FrameTotals]]
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class CallScope:
|
|
27
|
+
"""The source files whose profiled frames are worth reporting.
|
|
28
|
+
|
|
29
|
+
A frame is in scope when it lives under ``root`` and under none of the
|
|
30
|
+
``exclude`` paths, which keeps the tree focused on the code being measured
|
|
31
|
+
instead of the interpreter and the benchmark harness itself.
|
|
32
|
+
|
|
33
|
+
Attributes:
|
|
34
|
+
root: Directory every reported frame must live under.
|
|
35
|
+
exclude: Sub-directories of ``root`` to leave out.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
root: Path
|
|
39
|
+
exclude: tuple[Path, ...] = ()
|
|
40
|
+
|
|
41
|
+
def contains(self, filename: str) -> bool:
|
|
42
|
+
"""True if frames from ``filename`` belong in the call tree."""
|
|
43
|
+
try:
|
|
44
|
+
resolved = Path(filename).resolve()
|
|
45
|
+
except OSError:
|
|
46
|
+
return False
|
|
47
|
+
if not resolved.is_relative_to(self.root):
|
|
48
|
+
return False
|
|
49
|
+
return not any(resolved.is_relative_to(path) for path in self.exclude)
|
|
50
|
+
|
|
51
|
+
def location(self, filename: str, lineno: int) -> str:
|
|
52
|
+
"""Render a frame's source location relative to ``root``."""
|
|
53
|
+
return f"{Path(filename).resolve().relative_to(self.root)}:{lineno}"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def dstrack_scope() -> CallScope:
|
|
57
|
+
"""Scope covering dstrack's own code, minus this benchmark package."""
|
|
58
|
+
benchmark_package = Path(__file__).resolve().parent
|
|
59
|
+
return CallScope(root=benchmark_package.parent, exclude=(benchmark_package,))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class CallNode:
|
|
64
|
+
"""One method in the call tree, with the callees it was measured calling.
|
|
65
|
+
|
|
66
|
+
Attributes:
|
|
67
|
+
funcname: Name of the profiled function.
|
|
68
|
+
location: Source location, relative to the scope root.
|
|
69
|
+
num_calls: Total number of calls, including recursive ones.
|
|
70
|
+
total_seconds: Time spent in the function itself.
|
|
71
|
+
cumulative_seconds: Time spent in the function and everything it called.
|
|
72
|
+
children: Callees, ordered by cumulative time, descending.
|
|
73
|
+
hidden_children: Callees omitted to respect the tree's child limit.
|
|
74
|
+
recursive: True if this node already appears higher up the same path,
|
|
75
|
+
in which case its callees are not expanded again.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
funcname: str
|
|
79
|
+
location: str
|
|
80
|
+
num_calls: int
|
|
81
|
+
total_seconds: float
|
|
82
|
+
cumulative_seconds: float
|
|
83
|
+
children: tuple["CallNode", ...] = ()
|
|
84
|
+
hidden_children: int = 0
|
|
85
|
+
recursive: bool = False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass
|
|
89
|
+
class CallGraph:
|
|
90
|
+
"""Caller/callee edges among the profiled frames that fall within a scope.
|
|
91
|
+
|
|
92
|
+
``pstats`` records, for every frame, the frames that called it. This
|
|
93
|
+
inverts those edges so a method that calls other in-scope methods (e.g.
|
|
94
|
+
[StatsComputer.compute()][dstrack.snapshot._stats.StatsComputer.compute]
|
|
95
|
+
calling ``_build_dataset_stats``) becomes their parent, then projects the
|
|
96
|
+
result into trees rooted at the methods nothing in scope called.
|
|
97
|
+
"""
|
|
98
|
+
|
|
99
|
+
_scope: CallScope
|
|
100
|
+
_totals: dict[FrameKey, _FrameTotals] = field(default_factory=dict)
|
|
101
|
+
_children: dict[FrameKey, list[FrameKey]] = field(default_factory=dict)
|
|
102
|
+
_roots: list[FrameKey] = field(default_factory=list)
|
|
103
|
+
|
|
104
|
+
@classmethod
|
|
105
|
+
def from_profile(cls, profiler: cProfile.Profile, scope: CallScope) -> "CallGraph":
|
|
106
|
+
"""Build the in-scope call graph of a profiler that has finished running."""
|
|
107
|
+
raw: _RawStats = pstats.Stats(profiler).stats # type: ignore[attr-defined]
|
|
108
|
+
graph = cls(_scope=scope)
|
|
109
|
+
frames = {key for key in raw if scope.contains(key[0])}
|
|
110
|
+
callees: set[FrameKey] = set()
|
|
111
|
+
entrypoints: set[FrameKey] = set()
|
|
112
|
+
|
|
113
|
+
for key in frames:
|
|
114
|
+
primitive_calls, num_calls, total_time, cumulative_time, callers = raw[key]
|
|
115
|
+
graph._totals[key] = (
|
|
116
|
+
primitive_calls,
|
|
117
|
+
num_calls,
|
|
118
|
+
total_time,
|
|
119
|
+
cumulative_time,
|
|
120
|
+
)
|
|
121
|
+
for caller in callers:
|
|
122
|
+
if caller in frames:
|
|
123
|
+
graph._children.setdefault(caller, []).append(key)
|
|
124
|
+
callees.add(key)
|
|
125
|
+
else:
|
|
126
|
+
entrypoints.add(key)
|
|
127
|
+
|
|
128
|
+
# A frame is a root if nothing in scope called it, or if it was also
|
|
129
|
+
# entered from outside the scope. The latter keeps external entry
|
|
130
|
+
# points into a recursive component (f -> g -> f) as roots, which
|
|
131
|
+
# ``frames - callees`` alone would drop, leaving the tree empty.
|
|
132
|
+
graph._roots = graph._by_cumulative((frames - callees) | entrypoints)
|
|
133
|
+
return graph
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def is_empty(self) -> bool:
|
|
137
|
+
"""True if the profiler captured no in-scope frames at all."""
|
|
138
|
+
return not self._totals
|
|
139
|
+
|
|
140
|
+
def trees(self, *, max_children: int) -> list[CallNode]:
|
|
141
|
+
"""Project the graph into call trees, hottest root first.
|
|
142
|
+
|
|
143
|
+
Args:
|
|
144
|
+
max_children: Maximum callees expanded under each node, ranked by
|
|
145
|
+
cumulative time. The rest are counted in ``hidden_children``.
|
|
146
|
+
|
|
147
|
+
Raises:
|
|
148
|
+
ValueError: If ``max_children`` is negative, which would slice the
|
|
149
|
+
callee list from the end rather than cap it as documented.
|
|
150
|
+
"""
|
|
151
|
+
if max_children < 0:
|
|
152
|
+
raise ValueError(f"max_children must be >= 0, got {max_children}")
|
|
153
|
+
return [
|
|
154
|
+
self._expand(root, ancestors=frozenset({root}), max_children=max_children)
|
|
155
|
+
for root in self._roots
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
def _by_cumulative(self, keys: Iterable[FrameKey]) -> list[FrameKey]:
|
|
159
|
+
return sorted(keys, key=lambda key: self._totals[key][3], reverse=True)
|
|
160
|
+
|
|
161
|
+
def _expand(
|
|
162
|
+
self,
|
|
163
|
+
key: FrameKey,
|
|
164
|
+
*,
|
|
165
|
+
ancestors: frozenset[FrameKey],
|
|
166
|
+
max_children: int,
|
|
167
|
+
) -> CallNode:
|
|
168
|
+
"""Build the node for ``key``, expanding its hottest callees.
|
|
169
|
+
|
|
170
|
+
``ancestors`` guards against (mutual) recursion: a callee already on the
|
|
171
|
+
current path is emitted as a leaf flagged ``recursive`` rather than
|
|
172
|
+
expanded again, which would otherwise never terminate.
|
|
173
|
+
"""
|
|
174
|
+
callees = self._by_cumulative(self._children.get(key, []))
|
|
175
|
+
shown, hidden = callees[:max_children], callees[max_children:]
|
|
176
|
+
children = tuple(
|
|
177
|
+
self._node(child, recursive=True)
|
|
178
|
+
if child in ancestors
|
|
179
|
+
else self._expand(
|
|
180
|
+
child,
|
|
181
|
+
ancestors=ancestors | {child},
|
|
182
|
+
max_children=max_children,
|
|
183
|
+
)
|
|
184
|
+
for child in shown
|
|
185
|
+
)
|
|
186
|
+
return self._node(key, children=children, hidden_children=len(hidden))
|
|
187
|
+
|
|
188
|
+
def _node(
|
|
189
|
+
self,
|
|
190
|
+
key: FrameKey,
|
|
191
|
+
*,
|
|
192
|
+
children: tuple[CallNode, ...] = (),
|
|
193
|
+
hidden_children: int = 0,
|
|
194
|
+
recursive: bool = False,
|
|
195
|
+
) -> CallNode:
|
|
196
|
+
filename, lineno, funcname = key
|
|
197
|
+
_primitive_calls, num_calls, total_time, cumulative_time = self._totals[key]
|
|
198
|
+
return CallNode(
|
|
199
|
+
funcname=funcname,
|
|
200
|
+
location=self._scope.location(filename, lineno),
|
|
201
|
+
num_calls=num_calls,
|
|
202
|
+
total_seconds=total_time,
|
|
203
|
+
cumulative_seconds=cumulative_time,
|
|
204
|
+
children=children,
|
|
205
|
+
hidden_children=hidden_children,
|
|
206
|
+
recursive=recursive,
|
|
207
|
+
)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Renders benchmark runs to a Rich console.
|
|
2
|
+
|
|
3
|
+
This is the only layer that knows what a benchmark looks like on screen. It
|
|
4
|
+
implements [BenchmarkObserver][dstrack._benchmark._runner.BenchmarkObserver], so
|
|
5
|
+
the same object reports progress during a run and the tables after it.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from dstrack._benchmark._environment import EnvironmentInfo
|
|
14
|
+
from dstrack._benchmark._profiling import CallGraph, CallNode
|
|
15
|
+
from dstrack._benchmark._runner import BenchmarkResult, BenchmarkRun
|
|
16
|
+
from dstrack.console import console as default_console
|
|
17
|
+
|
|
18
|
+
_LAST_CONNECTOR = "└── "
|
|
19
|
+
_MID_CONNECTOR = "├── "
|
|
20
|
+
_LAST_GUIDE = " "
|
|
21
|
+
_MID_GUIDE = "│ "
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ConsoleReporter:
|
|
25
|
+
"""Prints a benchmark's progress and results as Rich tables.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
console: Destination console. Defaults to dstrack's shared console;
|
|
29
|
+
inject another to capture the output in tests.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(self, console: Console | None = None) -> None:
|
|
33
|
+
self._console = console or default_console
|
|
34
|
+
|
|
35
|
+
def generating_csv(self, path: Path, num_rows: int) -> None:
|
|
36
|
+
self._console.print(
|
|
37
|
+
f"Generating synthetic CSV at {path} ({num_rows:,} rows)..."
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def building_metadata(self) -> None:
|
|
41
|
+
self._console.print("Building snapshot metadata...")
|
|
42
|
+
|
|
43
|
+
def computing_stats(self) -> None:
|
|
44
|
+
self._console.print("Computing snapshot statistics...")
|
|
45
|
+
|
|
46
|
+
def report(self, run: BenchmarkRun, *, profile_limit: int) -> None:
|
|
47
|
+
"""Print the environment, the timings, and the profile call tree.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
run: The finished benchmark run.
|
|
51
|
+
profile_limit: Max callees shown under each node of the call tree.
|
|
52
|
+
"""
|
|
53
|
+
self._console.print(_environment_table(run.environment))
|
|
54
|
+
self._console.print(_results_table(run.result))
|
|
55
|
+
if run.call_graph is not None:
|
|
56
|
+
self._console.print(_call_tree_table(run.call_graph, profile_limit))
|
|
57
|
+
|
|
58
|
+
def csv_kept(self, path: Path) -> None:
|
|
59
|
+
"""Report that the generated CSV was left on disk."""
|
|
60
|
+
self._console.print(f"Synthetic CSV kept at {path}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _environment_table(env: EnvironmentInfo) -> Table:
|
|
64
|
+
table = Table(title="Environment")
|
|
65
|
+
table.add_column("Field")
|
|
66
|
+
table.add_column("Value")
|
|
67
|
+
table.add_row("OS", f"{env.os_name} {env.os_release}")
|
|
68
|
+
table.add_row("OS version", env.os_version)
|
|
69
|
+
table.add_row("Machine", env.machine)
|
|
70
|
+
table.add_row("CPU model", env.cpu_model)
|
|
71
|
+
table.add_row("CPU count", str(env.cpu_count) if env.cpu_count else "unknown")
|
|
72
|
+
table.add_row(
|
|
73
|
+
"Total memory",
|
|
74
|
+
f"{env.total_memory_gb:.2f} GB"
|
|
75
|
+
if env.total_memory_gb is not None
|
|
76
|
+
else "unknown",
|
|
77
|
+
)
|
|
78
|
+
table.add_row("Python", f"{env.python_implementation} {env.python_version}")
|
|
79
|
+
table.add_row("dstrack version", env.dstrack_version)
|
|
80
|
+
return table
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _results_table(result: BenchmarkResult) -> Table:
|
|
84
|
+
table = Table(title="Snapshot benchmark")
|
|
85
|
+
table.add_column("Metric")
|
|
86
|
+
table.add_column("Value")
|
|
87
|
+
table.add_row("Rows generated", f"{result.num_rows:,}")
|
|
88
|
+
table.add_row("Columns", str(result.num_columns))
|
|
89
|
+
table.add_row("CSV size", f"{result.csv_size_bytes / (1024**2):.2f} MB")
|
|
90
|
+
table.add_row("CSV generation time", f"{result.generation_seconds:.3f} s")
|
|
91
|
+
table.add_row("Metadata build time", f"{result.metadata_seconds:.3f} s")
|
|
92
|
+
table.add_row("Stats compute time", f"{result.stats_seconds:.3f} s")
|
|
93
|
+
table.add_row("Total snapshot time", f"{result.total_snapshot_seconds:.3f} s")
|
|
94
|
+
table.add_row("Throughput", f"{result.rows_per_second:,.0f} rows/s")
|
|
95
|
+
table.add_row("schema_hash", result.schema_hash)
|
|
96
|
+
return table
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _call_tree_table(graph: CallGraph, profile_limit: int) -> Table:
|
|
100
|
+
"""Render dstrack's call tree, hottest root first.
|
|
101
|
+
|
|
102
|
+
Guide characters (``├──``, ``└──``, ``│``) are baked into the "Method" cell
|
|
103
|
+
as plain text so the Calls/Total/Cumulative columns line up as a normal
|
|
104
|
+
table regardless of tree depth.
|
|
105
|
+
"""
|
|
106
|
+
table = Table(title="dstrack call tree (by cumulative time)")
|
|
107
|
+
table.add_column("Method", overflow="fold", ratio=1)
|
|
108
|
+
table.add_column("Calls", justify="right", no_wrap=True)
|
|
109
|
+
table.add_column("Total (s)", justify="right", no_wrap=True)
|
|
110
|
+
table.add_column("Cumulative (s)", justify="right", no_wrap=True)
|
|
111
|
+
|
|
112
|
+
if graph.is_empty:
|
|
113
|
+
table.add_row("(no dstrack frames captured)", "-", "-", "-")
|
|
114
|
+
return table
|
|
115
|
+
|
|
116
|
+
roots = graph.trees(max_children=profile_limit)
|
|
117
|
+
for i, root in enumerate(roots):
|
|
118
|
+
_add_node_rows(table, root, prefix="", is_last=i == len(roots) - 1)
|
|
119
|
+
return table
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _add_node_rows(table: Table, node: CallNode, *, prefix: str, is_last: bool) -> None:
|
|
123
|
+
"""Add a row for ``node`` and, recursively, for the callees under it."""
|
|
124
|
+
connector = _LAST_CONNECTOR if is_last else _MID_CONNECTOR
|
|
125
|
+
table.add_row(
|
|
126
|
+
f"{prefix}{connector}{_format_node(node)}",
|
|
127
|
+
str(node.num_calls),
|
|
128
|
+
f"{node.total_seconds:.4f}",
|
|
129
|
+
f"{node.cumulative_seconds:.4f}",
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
child_prefix = prefix + (_LAST_GUIDE if is_last else _MID_GUIDE)
|
|
133
|
+
for i, child in enumerate(node.children):
|
|
134
|
+
child_is_last = i == len(node.children) - 1 and not node.hidden_children
|
|
135
|
+
_add_node_rows(table, child, prefix=child_prefix, is_last=child_is_last)
|
|
136
|
+
|
|
137
|
+
if node.hidden_children:
|
|
138
|
+
table.add_row(
|
|
139
|
+
f"{child_prefix}{_LAST_CONNECTOR}[dim]... {node.hidden_children} more "
|
|
140
|
+
"(raise --profile-limit to see them)[/dim]",
|
|
141
|
+
"-",
|
|
142
|
+
"-",
|
|
143
|
+
"-",
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _format_node(node: CallNode) -> str:
|
|
148
|
+
"""Render a node's function name and source location."""
|
|
149
|
+
label = f"[bold]{node.funcname}[/bold] [dim]{node.location}[/dim]"
|
|
150
|
+
if node.recursive:
|
|
151
|
+
label += " [dim italic](recursive, not expanded)[/dim italic]"
|
|
152
|
+
return label
|