diff --git a/Lib/contextlib.py b/Lib/contextlib.py index efc02bfa9243da6..f1b958b551af15b 100644 --- a/Lib/contextlib.py +++ b/Lib/contextlib.py @@ -199,22 +199,20 @@ def __enter__(self): # do not keep args and kwds alive unnecessarily # they are only needed for recreation, which is not possible anymore del self.args, self.kwds, self.func - try: - return next(self.gen) - except StopIteration: - raise RuntimeError("generator didn't yield") from None + # Slightly faster way to return next(self.gen): + for once in self.gen: + return once + raise RuntimeError("generator didn't yield") def __exit__(self, typ, value, traceback): if typ is None: - try: - next(self.gen) - except StopIteration: - return False - else: + # Faster way to run next(self.gen) and check for StopIteration: + for _ in self.gen: try: raise RuntimeError("generator didn't stop") finally: self.gen.close() + return False else: if value is None: # Need to force instantiation so we can reliably diff --git a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js index 840acf2c27d1201..f1cdf5142fa3949 100644 --- a/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js +++ b/Lib/profiling/sampling/_flamegraph_assets/flamegraph.js @@ -99,13 +99,23 @@ function getDisplayName(moduleName, filename) { return filename; } -function selectFlamegraphData() { - const baseData = isShowingElided ? elidedFlamegraphData : normalData; +function selectFlamegraphData(selectedThreadId = null) { + let baseData = isShowingElided ? elidedFlamegraphData : normalData; + + if (selectedThreadId !== null) { + baseData = filterDataByThread(baseData, selectedThreadId); + } if (!isInverted) { return baseData; } + // Thread-filtered trees have different values, so invert them after filtering + // instead of using the cached all-thread tree. + if (selectedThreadId !== null) { + return generateInvertedFlamegraph(baseData); + } + if (isShowingElided) { if (!invertedElidedData) { invertedElidedData = generateInvertedFlamegraph(baseData); @@ -120,12 +130,11 @@ function selectFlamegraphData() { } function updateFlamegraphView() { - const selectedData = selectFlamegraphData(); const selectedThreadId = currentThreadFilter !== 'all' ? parseInt(currentThreadFilter, 10) : null; - const filteredData = selectedThreadId !== null ? filterDataByThread(selectedData, selectedThreadId) : selectedData; - const tooltip = createPythonTooltip(filteredData); - const chart = createFlamegraph(tooltip, filteredData.value, filteredData); - renderFlamegraph(chart, filteredData); + const selectedData = selectFlamegraphData(selectedThreadId); + const tooltip = createPythonTooltip(selectedData); + const chart = createFlamegraph(tooltip, selectedData.value, selectedData); + renderFlamegraph(chart, selectedData); populateThreadStats(selectedData, selectedThreadId); } @@ -937,7 +946,9 @@ function formatDuration(seconds) { function populateProfileSummary(data) { const stats = data.stats || {}; - const totalSamples = stats.total_samples || data.value || 0; + const totalSamples = currentThreadFilter !== 'all' + ? (data.value ?? 0) + : (stats.total_samples ?? data.value ?? 0); const duration = stats.duration_sec || 0; const sampleRate = stats.sample_rate || (duration > 0 ? totalSamples / duration : 0); const errorRate = stats.error_rate || 0; @@ -1209,7 +1220,7 @@ function initThreadFilter(data) { const threadFilter = document.getElementById('thread-filter'); const threadSection = document.getElementById('thread-section'); - if (!threadFilter || !data.threads) return; + if (!threadFilter || !data.threads || data.stats?.is_differential) return; threadFilter.innerHTML = ''; @@ -1238,11 +1249,23 @@ function filterByThread() { function filterDataByThread(data, threadId) { function filterNode(node) { - if (!node.threads || !node.threads.includes(threadId)) { + const threadValues = node.thread_values?.[threadId]; + if (!threadValues) { return null; } - const filteredNode = { ...node, children: [] }; + const { + thread_values: _threadValues, + thread_opcodes: threadOpcodes, + ...sharedNode + } = node; + const filteredNode = { + ...sharedNode, + value: threadValues[0], + self: threadValues[1], + opcodes: threadOpcodes?.[threadId] ?? {}, + children: [] + }; if (node.children && Array.isArray(node.children)) { filteredNode.children = node.children @@ -1253,25 +1276,7 @@ function filterDataByThread(data, threadId) { return filteredNode; } - function recalculateValue(node) { - if (!node.children || node.children.length === 0) { - return node.value || 0; - } - const childrenValue = node.children.reduce((sum, child) => sum + recalculateValue(child), 0); - node.value = Math.max(node.value || 0, childrenValue); - return node.value; - } - - const filteredRoot = { ...data, children: [] }; - - if (data.children && Array.isArray(data.children)) { - filteredRoot.children = data.children - .map(child => filterNode(child)) - .filter(child => child !== null); - } - - recalculateValue(filteredRoot); - return filteredRoot; + return filterNode(data); } // ============================================================================ diff --git a/Lib/profiling/sampling/stack_collector.py b/Lib/profiling/sampling/stack_collector.py index eb1a3fba93cf33b..ace0e1a12290131 100644 --- a/Lib/profiling/sampling/stack_collector.py +++ b/Lib/profiling/sampling/stack_collector.py @@ -71,7 +71,13 @@ class FlamegraphCollector(StackTraceCollector): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.stats = {} - self._root = {"samples": 0, "children": {}, "threads": set()} + self._root = { + "samples": 0, + "children": {}, + "threads": set(), + "thread_samples": collections.Counter(), + "thread_self": collections.Counter(), + } self._total_samples = 0 self._sample_count = 0 # Track actual number of samples (not thread traces) self._func_intern = {} @@ -220,7 +226,18 @@ def convert_children(children, min_samples, path_info): out = [] for func, node in children.items(): samples = node["samples"] - if samples < min_samples: + significant_for_thread = any( + thread_samples >= max( + 1, + int( + self._root["thread_samples"][thread_id] + * 0.001 + ), + ) + for thread_id, thread_samples + in node["thread_samples"].items() + ) + if samples < min_samples and not significant_for_thread: continue # Intern all string components for maximum efficiency @@ -243,6 +260,15 @@ def convert_children(children, min_samples, path_info): "lineno": func[1], "funcname": funcname_idx, "threads": sorted(list(node.get("threads", set()))), + "thread_values": { + thread_id: [ + samples, + node["thread_self"].get(thread_id, 0), + ] + for thread_id, samples in sorted( + node["thread_samples"].items() + ) + }, } source = self._get_source_lines(func) @@ -255,6 +281,14 @@ def convert_children(children, min_samples, path_info): opcodes = node.get("opcodes", {}) if opcodes: child_entry["opcodes"] = dict(opcodes) + thread_opcodes = node.get("thread_opcodes") + if thread_opcodes: + child_entry["thread_opcodes"] = { + thread_id: dict(counts) + for thread_id, counts in sorted( + thread_opcodes.items() + ) + } # Recurse child_entry["children"] = convert_children( @@ -311,7 +345,25 @@ def convert_children(children, min_samples, path_info): opcode_mapping = get_opcode_mapping() # If we only have one root child, make it the root to avoid redundant level - if len(root_children) == 1: + root_thread_values = { + thread_id: [samples, 0] + for thread_id, samples in sorted( + self._root["thread_samples"].items() + ) + } + sole_root_covers_profile = ( + len(root_children) == 1 + and root_children[0]["value"] == total_samples + and { + thread_id: values[0] + for thread_id, values + in root_children[0]["thread_values"].items() + } == { + thread_id: values[0] + for thread_id, values in root_thread_values.items() + } + ) + if sole_root_covers_profile: main_child = root_children[0] # Update name and label to indicate it's the program root old_name = self._string_table.get_string(main_child["name"]) @@ -340,6 +392,7 @@ def convert_children(children, min_samples, path_info): "per_thread_stats": per_thread_stats_with_pct }, "threads": sorted(list(self._all_threads)), + "thread_values": root_thread_values, "strings": self._string_table.get_strings(), "opcode_mapping": opcode_mapping } @@ -356,6 +409,7 @@ def process_frames(self, frames, thread_id, weight=1): """ # Reverse to root->leaf order for tree building self._root["samples"] += weight + self._root["thread_samples"][thread_id] += weight self._total_samples += weight self._root["threads"].add(thread_id) self._all_threads.add(thread_id) @@ -368,18 +422,32 @@ def process_frames(self, frames, thread_id, weight=1): node = current["children"].get(func) if node is None: - node = {"samples": 0, "children": {}, "threads": set(), "opcodes": collections.Counter(), "self": 0} + node = { + "samples": 0, + "children": {}, + "threads": set(), + "thread_samples": collections.Counter(), + "thread_self": collections.Counter(), + "opcodes": collections.Counter(), + "self": 0, + } current["children"][func] = node node["samples"] += weight + node["thread_samples"][thread_id] += weight node["threads"].add(thread_id) if opcode is not None: node["opcodes"][opcode] += weight + thread_opcodes = node.setdefault("thread_opcodes", {}) + thread_opcodes.setdefault( + thread_id, collections.Counter() + )[opcode] += weight current = node if current is not self._root: current["self"] += weight + current["thread_self"][thread_id] += weight def _get_source_lines(self, func): filename, lineno, _ = func 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 7746811014a9e2f..1aba1572cc89d38 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,87 @@ def test_flamegraph_collector_json_structure_includes_stats(self): self.assertIn("gc_pct", thread_data) self.assertIn("total", thread_data) + def test_flamegraph_nodes_include_per_thread_values(self): + collector = FlamegraphCollector(sample_interval_usec=1000) + root = MockFrameInfo("app.py", 1, "main") + collector.process_frames( + [MockFrameInfo("app.py", 10, "worker_a"), root], + thread_id=1, + weight=2, + ) + collector.process_frames( + [MockFrameInfo("app.py", 20, "worker_b"), root], + thread_id=2, + weight=3, + ) + + data = collector._convert_to_flamegraph_format() + + self.assertEqual(data["thread_values"], {1: [2, 0], 2: [3, 0]}) + children_by_line = {child["lineno"]: child for child in data["children"]} + self.assertEqual(children_by_line[10]["thread_values"], {1: [2, 2]}) + self.assertEqual(children_by_line[20]["thread_values"], {2: [3, 3]}) + + def test_flamegraph_pruning_preserves_low_volume_thread(self): + collector = FlamegraphCollector(sample_interval_usec=1000) + collector.process_frames( + [MockFrameInfo("app.py", 10, "busy")], + thread_id=1, + weight=1999, + ) + collector.process_frames( + [MockFrameInfo("app.py", 20, "rare")], + thread_id=2, + ) + + data = collector._convert_to_flamegraph_format() + + self.assertEqual(data["thread_values"], {1: [1999, 0], 2: [1, 0]}) + children_by_line = {child["lineno"]: child for child in data["children"]} + self.assertEqual(children_by_line[10]["thread_values"], {1: [1999, 1999]}) + self.assertEqual(children_by_line[20]["thread_values"], {2: [1, 1]}) + + def test_flamegraph_does_not_promote_incomplete_root(self): + collector = FlamegraphCollector(sample_interval_usec=1000) + collector.process_frames( + [MockFrameInfo("app.py", 1, "busy")], + thread_id=1, + weight=2000, + ) + for line in range(2, 2002): + collector.process_frames( + [MockFrameInfo("app.py", line, f"fragment_{line}")], + thread_id=2, + ) + + data = collector._convert_to_flamegraph_format() + + self.assertNotIn("filename", data) + self.assertEqual(data["thread_values"], {1: [2000, 0], 2: [2000, 0]}) + self.assertEqual(len(data["children"]), 1) + self.assertEqual(data["children"][0]["thread_values"], {1: [2000, 2000]}) + + def test_flamegraph_nodes_include_per_thread_opcodes(self): + collector = FlamegraphCollector(sample_interval_usec=1000) + collector.process_frames( + [MockFrameInfo("app.py", 10, "worker", opcode=100)], + thread_id=1, + weight=2, + ) + collector.process_frames( + [MockFrameInfo("app.py", 10, "worker", opcode=101)], + thread_id=2, + weight=3, + ) + + data = collector._convert_to_flamegraph_format() + + self.assertEqual(data["opcodes"], {100: 2, 101: 3}) + self.assertEqual( + data["thread_opcodes"], + {1: {100: 2}, 2: {101: 3}}, + ) + def test_flamegraph_collector_per_thread_gc_percentage(self): """Test that per-thread GC percentage uses total samples as denominator.""" collector = FlamegraphCollector(sample_interval_usec=1000) diff --git a/Lib/test/test_wave.py b/Lib/test/test_wave.py index d3723c04820d9d4..c482de12f7829b0 100644 --- a/Lib/test/test_wave.py +++ b/Lib/test/test_wave.py @@ -172,6 +172,19 @@ def test__all__(self): not_exported = {'KSDATAFORMAT_SUBTYPE_PCM'} support.check__all__(self, wave, not_exported=not_exported) + def test_getfp(self): + fp = io.BytesIO() + with wave.open(fp, 'wb') as w: + w.setnchannels(1) + w.setsampwidth(1) + w.setframerate(11025) + fp.seek(0) + with wave.open(fp) as r: + chunk = r.getfp() + self.assertIsNotNone(chunk) + self.assertIs(chunk.file, fp) + self.assertEqual(chunk.chunkname, b'RIFF') + class WaveLowLevelTest(unittest.TestCase): @@ -474,6 +487,184 @@ def test_open_pathlike(self): with wave.open(fake_path, 'rb') as f: pass + def test_open_invalid_mode(self): + with self.assertRaisesRegex(wave.Error, "mode must be"): + wave.open(io.BytesIO(), 'xb') + + +class WaveReadErrorTest(unittest.TestCase): + """Cover error and edge paths of Wave_read, and wave.open().""" + + FMT_PCM = struct.pack('/dev/null) ;; diff --git a/configure.ac b/configure.ac index 3a9841b1e7d0747..36568288388555a 100644 --- a/configure.ac +++ b/configure.ac @@ -1202,6 +1202,7 @@ AS_CASE([$ac_sys_system], [Darwin*], [MULTIARCH=""], [iOS], [MULTIARCH=""], [FreeBSD*], [MULTIARCH=""], + [OpenBSD*], [MULTIARCH=""], [MULTIARCH=$($CC --print-multiarch 2>/dev/null)] ) AC_SUBST([MULTIARCH])