Skip to content

Commit e27894e

Browse files
[3.15] gh-156545: Fix flamegraph export RecursionError on deeply recursive programs (GH-156546) (#158851)
Backport of GH-156546. Co-authored-by: tonghuaroot (童话) <tonghuaroot@gmail.com> (cherry picked from commit 0ac7217)
1 parent 43e640c commit e27894e

3 files changed

Lines changed: 71 additions & 24 deletions

File tree

‎Lib/profiling/sampling/stack_collector.py‎

Lines changed: 34 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,10 @@ def export(self, filename):
6868
return True
6969

7070

71+
# Bounded by the unwinder's maximum captured stack depth (MAX_FRAMES).
72+
_FLAMEGRAPH_RECURSION_MARGIN = 2000
73+
74+
7175
class FlamegraphCollector(StackTraceCollector):
7276
def __init__(self, *args, **kwargs):
7377
super().__init__(*args, **kwargs)
@@ -166,34 +170,41 @@ def set_mode(self, mode):
166170
self.stats["mode"] = mode
167171

168172
def export(self, filename):
169-
flamegraph_data = self._convert_to_flamegraph_format()
170-
171-
# Debug output with string table statistics
172-
num_functions = len(flamegraph_data.get("children", []))
173-
total_time = flamegraph_data.get("value", 0)
174-
string_count = len(self._string_table)
175-
s1 = "" if num_functions == 1 else "s"
176-
s2 = "" if total_time == 1 else "s"
177-
s3 = "" if string_count == 1 else "s"
178-
print(
179-
f"Flamegraph data: {num_functions} root function{s1}, "
180-
f"{total_time} total sample{s2}, "
181-
f"{string_count} unique string{s3}"
182-
)
183-
184-
if num_functions == 0:
173+
# Converting the call tree recurses to the sampled stack depth.
174+
old_limit = sys.getrecursionlimit()
175+
sys.setrecursionlimit(old_limit + _FLAMEGRAPH_RECURSION_MARGIN)
176+
try:
177+
flamegraph_data = self._convert_to_flamegraph_format()
178+
179+
# Debug output with string table statistics
180+
num_functions = len(flamegraph_data.get("children", []))
181+
total_time = flamegraph_data.get("value", 0)
182+
string_count = len(self._string_table)
183+
s1 = "" if num_functions == 1 else "s"
184+
s2 = "" if total_time == 1 else "s"
185+
s3 = "" if string_count == 1 else "s"
185186
print(
186-
"Warning: No functions found in profiling data. Check if sampling captured any data."
187+
f"Flamegraph data: {num_functions} root function{s1}, "
188+
f"{total_time} total sample{s2}, "
189+
f"{string_count} unique string{s3}"
187190
)
188-
return False
189191

190-
html_content = self._create_flamegraph_html(flamegraph_data)
192+
if num_functions == 0:
193+
print(
194+
"Warning: No functions found in profiling data. "
195+
"Check if sampling captured any data."
196+
)
197+
return False
191198

192-
with open(filename, "w", encoding="utf-8") as f:
193-
f.write(html_content)
199+
html_content = self._create_flamegraph_html(flamegraph_data)
194200

195-
print(f"Flamegraph saved to: {filename}")
196-
return True
201+
with open(filename, "w", encoding="utf-8") as f:
202+
f.write(html_content)
203+
204+
print(f"Flamegraph saved to: {filename}")
205+
return True
206+
finally:
207+
sys.setrecursionlimit(old_limit)
197208

198209
@staticmethod
199210
@functools.lru_cache(maxsize=None)

‎Lib/test/test_profiling/test_sampling_profiler/test_collectors.py‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,11 @@
44
import marshal
55
import opcode
66
import os
7+
import sys
78
import tempfile
89
import unittest
910

10-
from test.support import is_emscripten
11+
from test.support import is_emscripten, set_recursion_limit
1112

1213
try:
1314
import _remote_debugging # noqa: F401
@@ -610,6 +611,38 @@ def test_flamegraph_collector_empty_export_fails(self):
610611
self.assertFalse(export_ok)
611612
self.assertEqual(os.path.getsize(flamegraph_out.name), 0)
612613

614+
def test_flamegraph_deep_stack_export(self):
615+
flamegraph_out = tempfile.NamedTemporaryFile(
616+
suffix=".html", delete=False
617+
)
618+
self.addCleanup(close_and_unlink, flamegraph_out)
619+
620+
collector = FlamegraphCollector(1000)
621+
# Deeper than the default recursion limit.
622+
frames = [MockFrameInfo("f.py", i + 1, f"f{i}") for i in range(1536)]
623+
collector.collect(
624+
[MockInterpreterInfo(0, [MockThreadInfo(1, frames)])])
625+
626+
with set_recursion_limit(1000), captured_stdout(), captured_stderr():
627+
export_ok = collector.export(flamegraph_out.name)
628+
self.assertEqual(sys.getrecursionlimit(), 1000)
629+
630+
self.assertTrue(export_ok)
631+
self.assertGreater(os.path.getsize(flamegraph_out.name), 0)
632+
633+
def test_flamegraph_export_restores_recursion_limit(self):
634+
collector = FlamegraphCollector(1000)
635+
frame = MockFrameInfo("f.py", 1, "f")
636+
with set_recursion_limit(500), captured_stdout(), captured_stderr():
637+
self.assertFalse(collector.export(None))
638+
self.assertEqual(sys.getrecursionlimit(), 500)
639+
collector.collect([
640+
MockInterpreterInfo(0, [MockThreadInfo(1, [
641+
frame, MockFrameInfo("f.py", 2, "caller")])])])
642+
with self.assertRaises(TypeError):
643+
collector.export(None)
644+
self.assertEqual(sys.getrecursionlimit(), 500)
645+
613646
def test_gecko_collector_basic(self):
614647
"""Test basic GeckoCollector functionality."""
615648
collector = GeckoCollector(1000)
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
Fix the sampling profiler's flamegraph export so that profiling a deeply
2+
recursive program no longer fails with :exc:`RecursionError` instead of
3+
producing a flamegraph. Patch by tonghuaroot.

0 commit comments

Comments
 (0)