Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions Lib/profiling/sampling/_flamegraph_assets/flamegraph.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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');
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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 () {
Expand Down
27 changes: 19 additions & 8 deletions Lib/profiling/sampling/stack_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand Down
10 changes: 7 additions & 3 deletions Lib/test/test_profiling/test_sampling_profiler/mocks.py
Original file line number Diff line number Diff line change
Expand Up @@ -93,20 +93,24 @@ 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 (
DiffFlamegraphCollector,
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
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down Expand Up @@ -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 = [
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Fix differential flamegraphs incorrectly reporting changes when the baseline
and current profiles use different sampling rates.
Loading