Skip to content

Commit d515f73

Browse files
committed
Random fixes
1 parent 5556396 commit d515f73

File tree

7 files changed

+84
-27
lines changed

7 files changed

+84
-27
lines changed

Doc/library/profiling.sampling.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1041,7 +1041,7 @@ Some call paths may disappear entirely between profiles. These are called
10411041
**elided stacks** and occur when optimizations eliminate code paths or certain
10421042
branches stop executing. If elided stacks are present, an elided toggle appears
10431043
allowing you to switch between the main differential view and an elided-only
1044-
view that shows just the removed paths (colored deep red).
1044+
view that shows just the removed paths (colored purple).
10451045

10461046

10471047
Gecko format

Lib/profiling/sampling/_flamegraph_assets/flamegraph.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,23 @@
2828
--diff-elided: #b39ddb;
2929
}
3030

31+
/* Dark mode differential colors - adjusted for contrast against dark backgrounds */
32+
[data-theme="dark"] {
33+
--diff-regression-deep: #ef5350;
34+
--diff-regression-medium: #e57373;
35+
--diff-regression-light: #ef9a9a;
36+
--diff-regression-verylight: #ffcdd2;
37+
38+
--diff-improvement-deep: #42a5f5;
39+
--diff-improvement-medium: #64b5f6;
40+
--diff-improvement-light: #90caf9;
41+
--diff-improvement-verylight: #bbdefb;
42+
43+
--diff-neutral: #757575;
44+
--diff-new: #b39ddb;
45+
--diff-elided: #ce93d8;
46+
}
47+
3148
/* --------------------------------------------------------------------------
3249
Layout Overrides (Flamegraph-specific)
3350
-------------------------------------------------------------------------- */

Lib/profiling/sampling/_flamegraph_assets/flamegraph.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -986,7 +986,7 @@ function setupElidedToggle(data) {
986986
return;
987987
}
988988

989-
elidedFlamegraphData = resolveStringIndices(elidedFlamegraph, elidedFlamegraph.baseline_strings);
989+
elidedFlamegraphData = resolveStringIndices(elidedFlamegraph, elidedFlamegraph.strings);
990990

991991
const toggleElided = document.getElementById('toggle-elided');
992992
if (toggleElided) {

Lib/profiling/sampling/cli.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -59,8 +59,8 @@ class CustomFormatter(
5959
class DiffFlamegraphAction(argparse.Action):
6060
"""Custom action for --diff-flamegraph that sets both format and baseline path."""
6161
def __call__(self, parser, namespace, values, option_string=None):
62-
setattr(namespace, 'format', 'diff_flamegraph')
63-
setattr(namespace, 'diff_baseline', values)
62+
namespace.format = 'diff_flamegraph'
63+
namespace.diff_baseline = values
6464

6565

6666
_HELP_DESCRIPTION = """Sample a process's stack frames and generate profiling data.
@@ -490,7 +490,7 @@ def _add_format_options(parser, include_compression=True, include_binary=True):
490490
dest="format",
491491
help="Generate high-performance binary format (use 'replay' command to convert)",
492492
)
493-
parser.set_defaults(format="pstats")
493+
parser.set_defaults(format="pstats", diff_baseline=None)
494494

495495
if include_compression:
496496
output_group.add_argument(
@@ -564,7 +564,7 @@ def _create_collector(format_type, sample_interval_usec, skip_idle, opcodes=Fals
564564
"""Create the appropriate collector based on format type.
565565
566566
Args:
567-
format_type: The output format ('pstats', 'collapsed', 'flamegraph', 'gecko', 'heatmap', 'binary')
567+
format_type: The output format ('pstats', 'collapsed', 'flamegraph', 'gecko', 'heatmap', 'binary', 'diff_flamegraph')
568568
sample_interval_usec: Sampling interval in microseconds
569569
skip_idle: Whether to skip idle samples
570570
opcodes: Whether to collect opcode information (only used by gecko format
@@ -981,7 +981,7 @@ def _handle_attach(args):
981981
args.format, args.sample_interval_usec, skip_idle, args.opcodes,
982982
output_file=output_file,
983983
compression=getattr(args, 'compression', 'auto'),
984-
diff_baseline=getattr(args, 'diff_baseline', None)
984+
diff_baseline=args.diff_baseline
985985
)
986986

987987
with _get_child_monitor_context(args, args.pid):
@@ -1060,7 +1060,7 @@ def _handle_run(args):
10601060
args.format, args.sample_interval_usec, skip_idle, args.opcodes,
10611061
output_file=output_file,
10621062
compression=getattr(args, 'compression', 'auto'),
1063-
diff_baseline=getattr(args, 'diff_baseline', None)
1063+
diff_baseline=args.diff_baseline
10641064
)
10651065

10661066
with _get_child_monitor_context(args, process.pid):
@@ -1211,7 +1211,7 @@ def _handle_replay(args):
12111211

12121212
collector = _create_collector(
12131213
args.format, interval, skip_idle=False,
1214-
diff_baseline=getattr(args, 'diff_baseline', None)
1214+
diff_baseline=args.diff_baseline
12151215
)
12161216

12171217
def progress_callback(current, total):

Lib/profiling/sampling/stack_collector.py

Lines changed: 48 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -449,6 +449,8 @@ class DiffFlamegraphCollector(FlamegraphCollector):
449449

450450
def __init__(self, sample_interval_usec, *, baseline_binary_path, skip_idle=False):
451451
super().__init__(sample_interval_usec, skip_idle=skip_idle)
452+
if not os.path.exists(baseline_binary_path):
453+
raise ValueError(f"Baseline file not found: {baseline_binary_path}")
452454
self.baseline_binary_path = baseline_binary_path
453455
self._baseline_collector = None
454456
self._elided_paths = set()
@@ -477,7 +479,8 @@ def _aggregate_path_samples(self, root_node, path=None):
477479
stats = {}
478480

479481
for func, node in root_node["children"].items():
480-
func_key = (func[0], func[2])
482+
filename, _lineno, funcname = func
483+
func_key = (filename, funcname)
481484
path_key = path + (func_key,)
482485

483486
total_samples = node.get("samples", 0)
@@ -508,15 +511,22 @@ def _convert_to_flamegraph_format(self):
508511
self._load_baseline()
509512

510513
current_flamegraph = super()._convert_to_flamegraph_format()
511-
if self._total_samples == 0:
512-
return current_flamegraph
513514

514515
current_stats = self._aggregate_path_samples(self._root)
515516
baseline_stats = self._aggregate_path_samples(self._baseline_collector._root)
516517

517-
# Scale baseline values to make them comparable when sample counts differ
518-
scale = (self._total_samples / self._baseline_collector._total_samples
519-
if self._baseline_collector._total_samples > 0 else 1.0)
518+
# Scale baseline values to make them comparable, accounting for both
519+
# sample count differences and sample interval differences.
520+
baseline_total = self._baseline_collector._total_samples
521+
if baseline_total > 0 and self._total_samples > 0:
522+
current_time = self._total_samples * self.sample_interval_usec
523+
baseline_time = baseline_total * self._baseline_collector.sample_interval_usec
524+
scale = current_time / baseline_time
525+
elif baseline_total > 0:
526+
# Current profile is empty - use interval-based scale for elided display
527+
scale = self.sample_interval_usec / self._baseline_collector.sample_interval_usec
528+
else:
529+
scale = 1.0
520530

521531
self._annotate_nodes_with_diff(current_flamegraph, current_stats, baseline_stats, scale)
522532
self._add_elided_flamegraph(current_flamegraph, current_stats, baseline_stats, scale)
@@ -575,7 +585,7 @@ def _is_promoted_root(self, data):
575585

576586
def _add_elided_flamegraph(self, current_flamegraph, current_stats, baseline_stats, scale):
577587
"""Calculate elided paths and add elided flamegraph to stats."""
578-
self._elided_paths = set(baseline_stats.keys()) - set(current_stats.keys())
588+
self._elided_paths = baseline_stats.keys() - current_stats.keys()
579589

580590
current_flamegraph["stats"]["elided_count"] = len(self._elided_paths)
581591

@@ -585,23 +595,39 @@ def _add_elided_flamegraph(self, current_flamegraph, current_stats, baseline_sta
585595
current_flamegraph["stats"]["elided_flamegraph"] = elided_flamegraph
586596

587597
def _build_elided_flamegraph(self, baseline_stats, scale):
588-
"""Build flamegraph containing only elided paths from baseline."""
598+
"""Build flamegraph containing only elided paths from baseline.
599+
600+
This re-runs the base conversion pipeline on the baseline collector
601+
to produce a complete formatted flamegraph, then prunes it to keep
602+
only elided paths.
603+
"""
589604
if not self._baseline_collector or not self._elided_paths:
590605
return None
591606

592-
baseline_data = self._baseline_collector._convert_to_flamegraph_format()
607+
# Suppress source line collection for elided nodes - these functions
608+
# no longer exist in the current profile, so source lines from the
609+
# current machine's filesystem would be misleading or unavailable.
610+
orig_get_source = self._baseline_collector._get_source_lines
611+
self._baseline_collector._get_source_lines = lambda func: None
612+
try:
613+
baseline_data = self._baseline_collector._convert_to_flamegraph_format()
614+
finally:
615+
self._baseline_collector._get_source_lines = orig_get_source
593616

594617
# Remove non-elided nodes and recalculate values
595618
if not self._extract_elided_nodes(baseline_data, path=()):
596619
return None
597620

598621
self._add_elided_metadata(baseline_data, baseline_stats, scale, path=())
599622

600-
baseline_data["stats"].update(self.stats)
623+
# Merge only profiling metadata, not thread-level stats
624+
for key in ("sample_interval_usec", "duration_sec", "sample_rate",
625+
"error_rate", "missed_samples", "mode"):
626+
if key in self.stats:
627+
baseline_data["stats"][key] = self.stats[key]
601628
baseline_data["stats"]["is_differential"] = True
602629
baseline_data["stats"]["baseline_samples"] = self._baseline_collector._total_samples
603630
baseline_data["stats"]["current_samples"] = self._total_samples
604-
baseline_data["baseline_strings"] = self._baseline_collector._string_table.get_strings()
605631

606632
return baseline_data
607633

@@ -625,8 +651,9 @@ def _extract_elided_nodes(self, node, path):
625651
total_value += child.get("value", 0)
626652
node["children"] = elided_children
627653

628-
# Recalculate value based on remaining children
629-
if elided_children:
654+
# Recalculate value for structural (non-elided) ancestor nodes;
655+
# elided nodes keep their original value to preserve self-samples
656+
if elided_children and not is_elided:
630657
node["value"] = total_value
631658

632659
# Keep this node if it's elided or has elided descendants
@@ -654,7 +681,14 @@ def _add_elided_metadata(self, node, baseline_stats, scale, path):
654681
node["diff"] = 0
655682

656683
node["self_time"] = 0
657-
node["diff_pct"] = -100.0
684+
# Elided paths have zero current self-time, so the change is always
685+
# -100% when there was actual baseline self-time to lose.
686+
# For internal nodes with no baseline self-time, use 0% to avoid
687+
# misleading tooltips.
688+
if baseline_self > 0:
689+
node["diff_pct"] = -100.0
690+
else:
691+
node["diff_pct"] = 0.0
658692

659693
if "children" in node and node["children"]:
660694
for child in node["children"]:

Lib/test/test_profiling/test_sampling_profiler/mocks.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,8 @@ def make_diff_collector_with_mock_baseline(baseline_samples):
105105
for sample in baseline_samples:
106106
baseline.collect(sample)
107107

108-
diff = DiffFlamegraphCollector(1000, baseline_binary_path="baseline.bin")
108+
# Path is unused since we inject _baseline_collector directly;
109+
# use __file__ as a dummy path that passes the existence check.
110+
diff = DiffFlamegraphCollector(1000, baseline_binary_path=__file__)
109111
diff._baseline_collector = baseline
110112
return diff

Lib/test/test_profiling/test_sampling_profiler/test_collectors.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,7 +1386,7 @@ def test_diff_flamegraph_elided_stacks(self):
13861386
self.assertIn("elided_flamegraph", data["stats"])
13871387
elided = data["stats"]["elided_flamegraph"]
13881388
self.assertTrue(elided["stats"]["is_differential"])
1389-
self.assertIn("baseline_strings", elided)
1389+
self.assertIn("strings", elided)
13901390

13911391
elided_strings = elided.get("strings", [])
13921392
children = elided.get("children", [])
@@ -1433,7 +1433,7 @@ def test_diff_flamegraph_function_matched_despite_line_change(self):
14331433
self.assertAlmostEqual(child["diff_pct"], 0.0, places=1)
14341434

14351435
def test_diff_flamegraph_empty_current(self):
1436-
"""Empty current profile returns early without crashing."""
1436+
"""Empty current profile still produces differential metadata and elided paths."""
14371437
baseline_frames = [
14381438
MockInterpreterInfo(0, [
14391439
MockThreadInfo(1, [MockFrameInfo("file.py", 10, "func1")])
@@ -1446,6 +1446,10 @@ def test_diff_flamegraph_empty_current(self):
14461446
data = diff._convert_to_flamegraph_format()
14471447
self.assertIn("name", data)
14481448
self.assertEqual(data["value"], 0)
1449+
# Differential metadata should still be populated
1450+
self.assertTrue(data["stats"]["is_differential"])
1451+
# All baseline paths should be elided since current is empty
1452+
self.assertGreater(data["stats"]["elided_count"], 0)
14491453

14501454
def test_diff_flamegraph_empty_baseline(self):
14511455
"""Empty baseline with non-empty current uses scale=1.0 fallback."""
@@ -1585,7 +1589,7 @@ def test_diff_flamegraph_elided_preserves_metadata(self):
15851589
self.assertIn("per_thread_stats", elided["stats"])
15861590
self.assertIn("baseline_samples", elided["stats"])
15871591
self.assertIn("current_samples", elided["stats"])
1588-
self.assertIn("baseline_strings", elided)
1592+
self.assertIn("strings", elided)
15891593

15901594
elided_strings = elided.get("strings", [])
15911595
children = elided.get("children", [])

0 commit comments

Comments
 (0)