diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index 840acf2c27d120..4288e989bc66b9 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -99,6 +99,11 @@ function getDisplayName(moduleName, filename) { return filename; } +function samplesToMilliseconds(samples, data) { + const intervalUsec = data?.stats?.sample_interval_usec ?? 1000; + return samples * intervalUsec / 1000; +} + function selectFlamegraphData() { const baseData = isShowingElided ? elidedFlamegraphData : normalData; @@ -246,12 +251,13 @@ function setupLogos() { // Status Bar // ============================================================================ -function updateStatusBar(nodeData, rootValue) { +function updateStatusBar(nodeData, 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 = (nodeData.value / 1000).toFixed(2); + const timeMs = samplesToMilliseconds(nodeData.value, data).toFixed(2); + const rootValue = data.value; const percent = rootValue > 0 ? ((nodeData.value / rootValue) * 100).toFixed(1) : "0.0"; const brandEl = document.getElementById('status-brand'); @@ -313,9 +319,9 @@ function createPythonTooltip(data) { .style("opacity", 0); } - const timeMs = (d.data.value / 1000).toFixed(2); + const timeMs = samplesToMilliseconds(d.data.value, data).toFixed(2); const selfSamples = d.data.self || 0; - const selfMs = (selfSamples / 1000).toFixed(2); + const selfMs = samplesToMilliseconds(selfSamples, data).toFixed(2); 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; @@ -399,9 +405,9 @@ function createPythonTooltip(data) { // Differential stats section let diffSection = ""; if (d.data.diff !== undefined && d.data.baseline !== undefined) { - const baselineSelf = (d.data.baseline / 1000).toFixed(2); - const currentSelf = ((d.data.self_time || 0) / 1000).toFixed(2); - const diffMs = (d.data.diff / 1000).toFixed(2); + const baselineSelf = samplesToMilliseconds(d.data.baseline, data).toFixed(2); + const currentSelf = samplesToMilliseconds(d.data.self_time || 0, data).toFixed(2); + const diffMs = samplesToMilliseconds(d.data.diff, data).toFixed(2); 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"); @@ -499,7 +505,7 @@ function createPythonTooltip(data) { .style("opacity", 1); // Update status bar - updateStatusBar(d.data, data.value); + updateStatusBar(d.data, data); }; pythonTooltip.hide = function () { diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index eb1a3fba93cf33..0f34cb0f136222 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -70,7 +70,7 @@ def export(self, filename): class FlamegraphCollector(StackTraceCollector): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.stats = {} + self.stats = {"sample_interval_usec": self.sample_interval_usec} self._root = {"samples": 0, "children": {}, "threads": set()} self._total_samples = 0 self._sample_count = 0 # Track actual number of samples (not thread traces) @@ -550,16 +550,19 @@ def _convert_to_flamegraph_format(self): 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. + # Scale baseline samples to the number of samples in the current + # profile. The sample interval is only needed when converting samples + # to time for display. 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 + scale = self._total_samples / baseline_total 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 + # Express baseline samples in units of the current sample interval + # for the elided display. + scale = ( + self._baseline_collector.sample_interval_usec + / self.sample_interval_usec + ) else: scale = 1.0 @@ -653,6 +656,7 @@ def _build_elided_flamegraph(self, baseline_stats, scale): if not self._extract_elided_nodes(baseline_data, path=()): return None + self._scale_flamegraph_values(baseline_data, scale) self._add_elided_metadata(baseline_data, baseline_stats, scale, path=()) # Merge only profiling metadata, not thread-level stats @@ -666,6 +670,13 @@ def _build_elided_flamegraph(self, baseline_stats, scale): return baseline_data + def _scale_flamegraph_values(self, node, scale): + """Express flamegraph values in units of the current sample interval.""" + node["value"] = node.get("value", 0) * scale + node["self"] = node.get("self", 0) * scale + for child in node.get("children", ()): + self._scale_flamegraph_values(child, scale) + def _extract_elided_nodes(self, node, path): """Remove non-elided nodes and recalculate values bottom-up.""" if not node: diff --git a/Lib/test/test_profiling/test_sampling_profiler/mocks.py b/Lib/test/test_profiling/test_sampling_profiler/mocks.py index 6ac2d08e898d81..128870ffd4d5e4 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/mocks.py +++ b/Lib/test/test_profiling/test_sampling_profiler/mocks.py @@ -93,7 +93,9 @@ 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): +def make_diff_collector_with_mock_baseline( + baseline_samples, *, baseline_interval=1000, current_interval=1000 +): """Create a DiffFlamegraphCollector with baseline injected directly, skipping the binary round-trip that _load_baseline normally does.""" from profiling.sampling.stack_collector import ( @@ -101,12 +103,14 @@ def make_diff_collector_with_mock_baseline(baseline_samples): FlamegraphCollector, ) - baseline = FlamegraphCollector(1000) + baseline = FlamegraphCollector(baseline_interval) 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 = DiffFlamegraphCollector( + current_interval, baseline_binary_path=__file__ + ) diff._baseline_collector = baseline return diff diff --git a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py index 7746811014a9e2..f48be0f6025d75 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -505,6 +505,7 @@ def test_flamegraph_collector_basic(self): 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.""" @@ -1556,6 +1557,57 @@ def test_diff_flamegraph_scale_factor(self): self.assertAlmostEqual(func1_node["diff"], 0.0) self.assertAlmostEqual(func1_node["diff_pct"], 0.0) + def test_diff_flamegraph_scale_factor_with_different_intervals(self): + """Scale factor normalizes profiles sampled at different rates.""" + frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [MockFrameInfo("file.py", 10, "func1")]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline( + [frames] * 10, + baseline_interval=1000, + current_interval=10000, + ) + diff.collect(frames) + + data = diff._convert_to_flamegraph_format() + self.assertAlmostEqual(data["stats"]["baseline_scale"], 0.1) + self.assertEqual(data["stats"]["sample_interval_usec"], 10000) + self.assertAlmostEqual(data["baseline"], 1.0) + self.assertEqual(data["self_time"], 1) + self.assertAlmostEqual(data["diff"], 0.0) + self.assertAlmostEqual(data["diff_pct"], 0.0) + + def test_diff_flamegraph_elided_scale_with_different_intervals(self): + """Elided values use the current profile's sample units.""" + baseline_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [MockFrameInfo("file.py", 10, "old_func")]) + ]) + ] + current_frames = [ + MockInterpreterInfo(0, [ + MockThreadInfo(1, [MockFrameInfo("file.py", 20, "new_func")]) + ]) + ] + + diff = make_diff_collector_with_mock_baseline( + [baseline_frames] * 10, + baseline_interval=1000, + current_interval=10000, + ) + diff.collect(current_frames) + + data = diff._convert_to_flamegraph_format() + elided = data["stats"]["elided_flamegraph"] + self.assertEqual(elided["stats"]["sample_interval_usec"], 10000) + self.assertAlmostEqual(elided["value"], 1.0) + self.assertAlmostEqual(elided["self"], 1.0) + self.assertAlmostEqual(elided["baseline"], 1.0) + self.assertAlmostEqual(elided["diff"], -1.0) + def test_diff_flamegraph_elided_stacks(self): """Paths in baseline but not current produce elided stacks.""" baseline_frames_1 = [ diff --git a/Misc/NEWS.d/next/Library/2026-07-19-00-00-00.gh-issue-154032.d1ffFg.rst b/Misc/NEWS.d/next/Library/2026-07-19-00-00-00.gh-issue-154032.d1ffFg.rst new file mode 100644 index 00000000000000..2b7ddd2068e808 --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-07-19-00-00-00.gh-issue-154032.d1ffFg.rst @@ -0,0 +1,2 @@ +Fix differential flamegraphs incorrectly reporting changes when the baseline +and current profiles use different sampling rates.