From ec2ca8f083b23968afe7649d050178c4176008b5 Mon Sep 17 00:00:00 2001 From: abhinavmir Date: Tue, 11 Aug 2026 13:59:30 -0700 Subject: [PATCH 1/3] gh-155591: Fix flame graph sample-time conversion in profiling.sampling The flame graph tooltips and status bar divided node values by 1000 and labelled the result as milliseconds. Node values are sample counts, so the displayed times were wrong by a factor of the sampling interval (1000x at the default 1 kHz rate). Convert sample counts using the sampling interval instead, and always record that interval in the exported flamegraph data. --- .../sampling/_flamegraph_assets/flamegraph.js | 21 +++++++++++----- Lib/profiling/sampling/stack_collector.py | 2 ++ .../test_sampling_profiler/test_collectors.py | 25 +++++++++++++++++++ ...-08-11-21-40-00.gh-issue-155591.Kq3vRt.rst | 5 ++++ 4 files changed, 47 insertions(+), 6 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-08-11-21-40-00.gh-issue-155591.Kq3vRt.rst diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index f1cdf5142fa3949..4af76a4fc699298 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -2,6 +2,8 @@ const EMBEDDED_DATA = {{FLAMEGRAPH_DATA}}; // Global string table for resolving string indices let stringTable = []; +// Duration of a single sample, in microseconds; node values are sample counts. +let sampleIntervalUsec = EMBEDDED_DATA?.stats?.sample_interval_usec; let normalData = null; let invertedData = null; let currentThreadFilter = 'all'; @@ -9,6 +11,13 @@ let isInverted = false; let useModuleNames = true; let zoomedNodeValue = null; +// Convert a sample count into wall-clock milliseconds. Returns "--" when the +// sampling interval is unknown, since a sample count is not a time. +function formatSampleMs(samples) { + if (!(sampleIntervalUsec > 0)) return "--"; + return ((samples * sampleIntervalUsec) / 1000).toFixed(2); +} + // Heat colors are now defined in CSS variables (--heat-1 through --heat-8) // and automatically switch with theme changes - no JS color arrays needed! @@ -260,7 +269,7 @@ function updateStatusBar(nodeData, rootValue) { const filename = resolveString(nodeData.filename) || ""; const moduleName = resolveString(nodeData.module) || ""; const lineno = nodeData.lineno; - const timeMs = (nodeData.value / 1000).toFixed(2); + const timeMs = formatSampleMs(nodeData.value); const percent = rootValue > 0 ? ((nodeData.value / rootValue) * 100).toFixed(1) : "0.0"; const brandEl = document.getElementById('status-brand'); @@ -322,9 +331,9 @@ function createPythonTooltip(data) { .style("opacity", 0); } - const timeMs = (d.data.value / 1000).toFixed(2); + const timeMs = formatSampleMs(d.data.value); const selfSamples = d.data.self || 0; - const selfMs = (selfSamples / 1000).toFixed(2); + const selfMs = formatSampleMs(selfSamples); 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; @@ -408,9 +417,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 = formatSampleMs(d.data.baseline); + const currentSelf = formatSampleMs(d.data.self_time || 0); + const diffMs = formatSampleMs(d.data.diff); 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"); diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index ace0e1a12290131..6c6dbe4163d80cb 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -371,6 +371,7 @@ def convert_children(children, min_samples, path_info): 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"] = { + "sample_interval_usec": self.sample_interval_usec, **self.stats, "thread_stats": thread_stats, "per_thread_stats": per_thread_stats_with_pct @@ -387,6 +388,7 @@ def convert_children(children, min_samples, path_info): "value": total_samples, "children": root_children, "stats": { + "sample_interval_usec": self.sample_interval_usec, **self.stats, "thread_stats": thread_stats, "per_thread_stats": per_thread_stats_with_pct 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 1aba1572cc89d38..161024f2e61eb49 100644 --- a/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py +++ b/Lib/test/test_profiling/test_sampling_profiler/test_collectors.py @@ -1336,6 +1336,31 @@ def test_flamegraph_collector_json_structure_includes_stats(self): self.assertIn("gc_pct", thread_data) self.assertIn("total", thread_data) + def test_flamegraph_stats_always_include_sample_interval(self): + """The sampling interval is needed to turn sample counts into times.""" + collector = FlamegraphCollector(sample_interval_usec=250) + + stack_frames = [ + MockInterpreterInfo( + 0, + [MockThreadInfo(1, [MockFrameInfo("a.py", 1, "func_a")])], + ) + ] + collector.collect(stack_frames) + + # No set_stats() call: the interval must still be reported. + data = collector._convert_to_flamegraph_format() + self.assertEqual(data["stats"]["sample_interval_usec"], 250) + + collector.set_stats( + sample_interval_usec=250, + duration_sec=1.0, + sample_rate=4000.0, + mode=PROFILING_MODE_WALL, + ) + data = collector._convert_to_flamegraph_format() + self.assertEqual(data["stats"]["sample_interval_usec"], 250) + def test_flamegraph_nodes_include_per_thread_values(self): collector = FlamegraphCollector(sample_interval_usec=1000) root = MockFrameInfo("app.py", 1, "main") diff --git a/Misc/NEWS.d/next/Library/2026-08-11-21-40-00.gh-issue-155591.Kq3vRt.rst b/Misc/NEWS.d/next/Library/2026-08-11-21-40-00.gh-issue-155591.Kq3vRt.rst new file mode 100644 index 000000000000000..e7d1c83854ac82d --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-08-11-21-40-00.gh-issue-155591.Kq3vRt.rst @@ -0,0 +1,5 @@ +Fix the times shown in :mod:`profiling.sampling` flame graph tooltips and status +bar. Sample counts were divided by 1000 and labelled as milliseconds, so the +reported times were wrong by a factor of the sampling interval. They are now +converted using the actual interval, which is always recorded in the exported +data. From adda2e29be81d6ec12fb99b49e3f0a0bef0d7f64 Mon Sep 17 00:00:00 2001 From: august Date: Tue, 11 Aug 2026 14:22:59 -0700 Subject: [PATCH 2/3] Update Lib/profiling/sampling/_flamegraph_assets/flamegraph.js Co-authored-by: Eduardo Villalpando Mello --- Lib/profiling/sampling/_flamegraph_assets/flamegraph.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index 4af76a4fc699298..f1e1cd209888b95 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -14,7 +14,7 @@ let zoomedNodeValue = null; // Convert a sample count into wall-clock milliseconds. Returns "--" when the // sampling interval is unknown, since a sample count is not a time. function formatSampleMs(samples) { - if (!(sampleIntervalUsec > 0)) return "--"; + if (sampleIntervalUsec <= 0) return "--"; return ((samples * sampleIntervalUsec) / 1000).toFixed(2); } From 011879aaf8ae0484aa8b7cde0e1be4eb7d4d2293 Mon Sep 17 00:00:00 2001 From: august Date: Tue, 11 Aug 2026 14:23:09 -0700 Subject: [PATCH 3/3] Update Lib/profiling/sampling/_flamegraph_assets/flamegraph.js Co-authored-by: Eduardo Villalpando Mello --- Lib/profiling/sampling/_flamegraph_assets/flamegraph.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index f1e1cd209888b95..00f0a155db50913 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -15,7 +15,7 @@ let zoomedNodeValue = null; // sampling interval is unknown, since a sample count is not a time. function formatSampleMs(samples) { if (sampleIntervalUsec <= 0) return "--"; - return ((samples * sampleIntervalUsec) / 1000).toFixed(2); + return ((samples * sampleIntervalUsec) / 1_000).toFixed(2); } // Heat colors are now defined in CSS variables (--heat-1 through --heat-8)