mirror of
				https://github.com/python/cpython.git
				synced 2025-10-31 13:41:24 +00:00 
			
		
		
		
	gh-135953: Add flamegraph reporter to sampling profiler (#138715)
This commit is contained in:
		
							parent
							
								
									6bc65c30ff
								
							
						
					
					
						commit
						137519a38c
					
				
					 14 changed files with 1446 additions and 6 deletions
				
			
		|  | @ -1,4 +1,9 @@ | |||
| import base64 | ||||
| import collections | ||||
| import functools | ||||
| import importlib.resources | ||||
| import json | ||||
| import linecache | ||||
| import os | ||||
| 
 | ||||
| from .collector import Collector | ||||
|  | @ -41,3 +46,229 @@ def export(self, filename): | |||
|             for stack, count in stack_counter.items(): | ||||
|                 f.write(f"{stack} {count}\n") | ||||
|         print(f"Collapsed stack output written to {filename}") | ||||
| 
 | ||||
| 
 | ||||
| class FlamegraphCollector(StackTraceCollector): | ||||
|     def __init__(self): | ||||
|         super().__init__() | ||||
|         self.stats = {} | ||||
| 
 | ||||
|     def set_stats(self, sample_interval_usec, duration_sec, sample_rate, error_rate=None): | ||||
|         """Set profiling statistics to include in flamegraph data.""" | ||||
|         self.stats = { | ||||
|             "sample_interval_usec": sample_interval_usec, | ||||
|             "duration_sec": duration_sec, | ||||
|             "sample_rate": sample_rate, | ||||
|             "error_rate": error_rate | ||||
|         } | ||||
| 
 | ||||
|     def export(self, filename): | ||||
|         flamegraph_data = self._convert_to_flamegraph_format() | ||||
| 
 | ||||
|         # Debug output | ||||
|         num_functions = len(flamegraph_data.get("children", [])) | ||||
|         total_time = flamegraph_data.get("value", 0) | ||||
|         print( | ||||
|             f"Flamegraph data: {num_functions} root functions, total samples: {total_time}" | ||||
|         ) | ||||
| 
 | ||||
|         if num_functions == 0: | ||||
|             print( | ||||
|                 "Warning: No functions found in profiling data. Check if sampling captured any data." | ||||
|             ) | ||||
|             return | ||||
| 
 | ||||
|         html_content = self._create_flamegraph_html(flamegraph_data) | ||||
| 
 | ||||
|         with open(filename, "w", encoding="utf-8") as f: | ||||
|             f.write(html_content) | ||||
| 
 | ||||
|         print(f"Flamegraph saved to: {filename}") | ||||
| 
 | ||||
|     @functools.lru_cache(maxsize=None) | ||||
|     def _format_function_name(self, func): | ||||
|         filename, lineno, funcname = func | ||||
| 
 | ||||
|         if len(filename) > 50: | ||||
|             parts = filename.split("/") | ||||
|             if len(parts) > 2: | ||||
|                 filename = f".../{'/'.join(parts[-2:])}" | ||||
| 
 | ||||
|         return f"{funcname} ({filename}:{lineno})" | ||||
| 
 | ||||
|     def _convert_to_flamegraph_format(self): | ||||
|         """Convert call trees to d3-flamegraph format with optimized hierarchy building""" | ||||
|         if not self.call_trees: | ||||
|             return {"name": "No Data", "value": 0, "children": []} | ||||
| 
 | ||||
|         unique_functions = set() | ||||
|         for call_tree in self.call_trees: | ||||
|             unique_functions.update(call_tree) | ||||
| 
 | ||||
|         func_to_name = { | ||||
|             func: self._format_function_name(func) for func in unique_functions | ||||
|         } | ||||
| 
 | ||||
|         root = {"name": "root", "children": {}, "samples": 0} | ||||
| 
 | ||||
|         for call_tree in self.call_trees: | ||||
|             current_node = root | ||||
|             current_node["samples"] += 1 | ||||
| 
 | ||||
|             for func in call_tree: | ||||
|                 func_name = func_to_name[func]  # Use pre-computed name | ||||
| 
 | ||||
|                 if func_name not in current_node["children"]: | ||||
|                     current_node["children"][func_name] = { | ||||
|                         "name": func_name, | ||||
|                         "func": func, | ||||
|                         "children": {}, | ||||
|                         "samples": 0, | ||||
|                         "filename": func[0], | ||||
|                         "lineno": func[1], | ||||
|                         "funcname": func[2], | ||||
|                     } | ||||
| 
 | ||||
|                 current_node = current_node["children"][func_name] | ||||
|                 current_node["samples"] += 1 | ||||
| 
 | ||||
|         def convert_node(node, min_samples=1): | ||||
|             if node["samples"] < min_samples: | ||||
|                 return None | ||||
| 
 | ||||
|             source_code = None | ||||
|             if "func" in node: | ||||
|                 source_code = self._get_source_lines(node["func"]) | ||||
| 
 | ||||
|             result = { | ||||
|                 "name": node["name"], | ||||
|                 "value": node["samples"], | ||||
|                 "children": [], | ||||
|             } | ||||
| 
 | ||||
|             if "filename" in node: | ||||
|                 result.update( | ||||
|                     { | ||||
|                         "filename": node["filename"], | ||||
|                         "lineno": node["lineno"], | ||||
|                         "funcname": node["funcname"], | ||||
|                     } | ||||
|                 ) | ||||
| 
 | ||||
|             if source_code: | ||||
|                 result["source"] = source_code | ||||
| 
 | ||||
|             # Recursively convert children | ||||
|             child_nodes = [] | ||||
|             for child_name, child_node in node["children"].items(): | ||||
|                 child_result = convert_node(child_node, min_samples) | ||||
|                 if child_result: | ||||
|                     child_nodes.append(child_result) | ||||
| 
 | ||||
|             # Sort children by sample count (descending) | ||||
|             child_nodes.sort(key=lambda x: x["value"], reverse=True) | ||||
|             result["children"] = child_nodes | ||||
| 
 | ||||
|             return result | ||||
| 
 | ||||
|         # Filter out very small functions (less than 0.1% of total samples) | ||||
|         total_samples = len(self.call_trees) | ||||
|         min_samples = max(1, int(total_samples * 0.001)) | ||||
| 
 | ||||
|         converted_root = convert_node(root, min_samples) | ||||
| 
 | ||||
|         if not converted_root or not converted_root["children"]: | ||||
|             return {"name": "No significant data", "value": 0, "children": []} | ||||
| 
 | ||||
|         # If we only have one root child, make it the root to avoid redundant level | ||||
|         if len(converted_root["children"]) == 1: | ||||
|             main_child = converted_root["children"][0] | ||||
|             main_child["name"] = f"Program Root: {main_child['name']}" | ||||
|             main_child["stats"] = self.stats | ||||
|             return main_child | ||||
| 
 | ||||
|         converted_root["name"] = "Program Root" | ||||
|         converted_root["stats"] = self.stats | ||||
|         return converted_root | ||||
| 
 | ||||
|     def _get_source_lines(self, func): | ||||
|         filename, lineno, funcname = func | ||||
| 
 | ||||
|         try: | ||||
|             # Get several lines around the function definition | ||||
|             lines = [] | ||||
|             start_line = max(1, lineno - 2) | ||||
|             end_line = lineno + 3 | ||||
| 
 | ||||
|             for line_num in range(start_line, end_line): | ||||
|                 line = linecache.getline(filename, line_num) | ||||
|                 if line.strip(): | ||||
|                     marker = "→ " if line_num == lineno else "  " | ||||
|                     lines.append(f"{marker}{line_num}: {line.rstrip()}") | ||||
| 
 | ||||
|             return lines if lines else None | ||||
| 
 | ||||
|         except Exception: | ||||
|             # If we can't get source code, return None | ||||
|             return None | ||||
| 
 | ||||
|     def _create_flamegraph_html(self, data): | ||||
|         data_json = json.dumps(data) | ||||
| 
 | ||||
|         template_dir = importlib.resources.files(__package__) | ||||
|         vendor_dir = template_dir / "_vendor" | ||||
|         assets_dir = template_dir / "_assets" | ||||
| 
 | ||||
|         d3_path = vendor_dir / "d3" / "7.8.5" / "d3.min.js" | ||||
|         d3_flame_graph_dir = vendor_dir /  "d3-flame-graph" / "4.1.3" | ||||
|         fg_css_path = d3_flame_graph_dir / "d3-flamegraph.css" | ||||
|         fg_js_path = d3_flame_graph_dir / "d3-flamegraph.min.js" | ||||
|         fg_tooltip_js_path = d3_flame_graph_dir / "d3-flamegraph-tooltip.min.js" | ||||
| 
 | ||||
|         html_template = (template_dir / "flamegraph_template.html").read_text(encoding="utf-8") | ||||
|         css_content = (template_dir / "flamegraph.css").read_text(encoding="utf-8") | ||||
|         js_content = (template_dir / "flamegraph.js").read_text(encoding="utf-8") | ||||
| 
 | ||||
|         # Inline first-party CSS/JS | ||||
|         html_template = html_template.replace( | ||||
|             "<!-- INLINE_CSS -->", f"<style>\n{css_content}\n</style>" | ||||
|         ) | ||||
|         html_template = html_template.replace( | ||||
|             "<!-- INLINE_JS -->", f"<script>\n{js_content}\n</script>" | ||||
|         ) | ||||
| 
 | ||||
|         png_path = assets_dir / "python-logo-only.png" | ||||
|         b64_logo = base64.b64encode(png_path.read_bytes()).decode("ascii") | ||||
| 
 | ||||
|         # Let CSS control size; keep markup simple | ||||
|         logo_html = f'<img src="data:image/png;base64,{b64_logo}" alt="Python logo"/>' | ||||
|         html_template = html_template.replace("<!-- INLINE_LOGO -->", logo_html) | ||||
| 
 | ||||
|         d3_js = d3_path.read_text(encoding="utf-8") | ||||
|         fg_css = fg_css_path.read_text(encoding="utf-8") | ||||
|         fg_js = fg_js_path.read_text(encoding="utf-8") | ||||
|         fg_tooltip_js = fg_tooltip_js_path.read_text(encoding="utf-8") | ||||
| 
 | ||||
|         html_template = html_template.replace( | ||||
|             "<!-- INLINE_VENDOR_D3_JS -->", | ||||
|             f"<script>\n{d3_js}\n</script>", | ||||
|         ) | ||||
|         html_template = html_template.replace( | ||||
|             "<!-- INLINE_VENDOR_FLAMEGRAPH_CSS -->", | ||||
|             f"<style>\n{fg_css}\n</style>", | ||||
|         ) | ||||
|         html_template = html_template.replace( | ||||
|             "<!-- INLINE_VENDOR_FLAMEGRAPH_JS -->", | ||||
|             f"<script>\n{fg_js}\n</script>", | ||||
|         ) | ||||
|         html_template = html_template.replace( | ||||
|             "<!-- INLINE_VENDOR_FLAMEGRAPH_TOOLTIP_JS -->", | ||||
|             f"<script>\n{fg_tooltip_js}\n</script>", | ||||
|         ) | ||||
| 
 | ||||
|         # Replace the placeholder with actual data | ||||
|         html_content = html_template.replace( | ||||
|             "{{FLAMEGRAPH_DATA}}", data_json | ||||
|         ) | ||||
| 
 | ||||
|         return html_content | ||||
|  |  | |||
		Loading…
	
	Add table
		Add a link
		
	
		Reference in a new issue
	
	 László Kiss Kollár
						László Kiss Kollár