diff --git a/esphome/analyze_memory/__init__.py b/esphome/analyze_memory/__init__.py index 1d511875b3e..7954c228224 100644 --- a/esphome/analyze_memory/__init__.py +++ b/esphome/analyze_memory/__init__.py @@ -46,7 +46,7 @@ _READELF_SECTION_PATTERN = re.compile( # ARM: bl/blx # Captures the mangled symbol name inside angle brackets. _CALL_TARGET_PATTERN = re.compile( - r"\t(?:call[x]?[048c]|call12|callx12|bl[x]?)\s+[\da-fA-F]+ <([^>]+)>" + r"\t(?:call(?:0|4|8|12)|callx(?:0|4|8|12)|blx?)\s+[\da-fA-F]+ <([^>]+)>" ) # Component category prefixes diff --git a/esphome/analyze_memory/cli.py b/esphome/analyze_memory/cli.py index fa3fd30638a..acaf5f45621 100644 --- a/esphome/analyze_memory/cli.py +++ b/esphome/analyze_memory/cli.py @@ -233,41 +233,53 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): # Number of top called functions to show TOP_CALLS_LIMIT: int = 50 + # Number of inlining candidates to show + INLINE_CANDIDATES_LIMIT: int = 25 + # Maximum function size in bytes to consider for inlining + INLINE_SIZE_THRESHOLD: int = 16 - def _add_function_call_analysis(self, lines: list[str]) -> None: - """Add function call frequency analysis section. - - Shows the most frequently called functions by call site count, - helping identify inlining candidates. Includes function size - when available from the symbol table. - """ - self._add_section_header(lines, "Top Called Functions (inlining candidates)") - - # Build a size lookup from all component symbols: mangled_name -> size - symbol_sizes: dict[str, int] = { + def _build_symbol_sizes(self) -> dict[str, int]: + """Build a size lookup from all component symbols: mangled_name -> size.""" + return { symbol: size for symbols in self._component_symbols.values() for symbol, _, size, _ in symbols } + def _format_call_row( + self, index: int, mangled: str, count: int, symbol_sizes: dict[str, int] + ) -> str: + """Format a single row for call frequency tables.""" + demangled = self._demangle_cache.get(mangled, mangled) + if len(demangled) > 80: + demangled = f"{demangled[:77]}..." + size = symbol_sizes.get(mangled) + size_str = f"{size:>5,} B" if size is not None else " ?" + return f"{index:>3} {count:>5} {size_str} {demangled}" + + def _add_call_table_header(self, lines: list[str]) -> None: + """Add the header row for call frequency tables.""" + lines.append(f"{'#':>3} {'Calls':>5} {'Size':>7} Function") + lines.append(f"{'---':>3} {'-----':>5} {'-------':>7} {'-' * 60}") + + def _add_function_call_analysis(self, lines: list[str]) -> None: + """Add function call frequency analysis section. + + Shows the most frequently called functions by call site count. + """ + self._add_section_header(lines, "Top Called Functions") + + symbol_sizes = self._build_symbol_sizes() + # Sort by call count descending sorted_calls = sorted( self._function_call_counts.items(), key=lambda x: x[1], reverse=True ) - lines.append(f"{'#':>3} {'Calls':>5} {'Size':>7} Function") - lines.append(f"{'---':>3} {'-----':>5} {'-------':>7} {'-' * 60}") + self._add_call_table_header(lines) for i, (mangled, count) in enumerate(sorted_calls[: self.TOP_CALLS_LIMIT]): - # Look up demangled name - demangled = self._demangle_cache.get(mangled, mangled) - # Truncate long names - if len(demangled) > 80: - demangled = f"{demangled[:77]}..." - # Look up size - size = symbol_sizes.get(mangled) - size_str = f"{size:>5,} B" if size is not None else " ?" - lines.append(f"{i + 1:>3} {count:>5} {size_str} {demangled}") + lines.append(self._format_call_row(i + 1, mangled, count, symbol_sizes)) total_calls = sum(self._function_call_counts.values()) lines.append("") @@ -277,6 +289,52 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): ) lines.append("") + def _add_inline_candidates(self, lines: list[str]) -> None: + """Add inlining candidates section. + + Shows frequently called functions that are small enough to benefit + from inlining (< 16 bytes). These are the best candidates for + reducing call overhead. + """ + self._add_section_header( + lines, + f"Inlining Candidates (<{self.INLINE_SIZE_THRESHOLD} B, by call count)", + ) + + symbol_sizes = self._build_symbol_sizes() + + # Filter to small functions with known size, sort by call count + candidates = sorted( + ( + (mangled, count) + for mangled, count in self._function_call_counts.items() + if mangled in symbol_sizes + and symbol_sizes[mangled] < self.INLINE_SIZE_THRESHOLD + ), + key=lambda x: x[1], + reverse=True, + ) + + if not candidates: + lines.append("No candidates found.") + lines.append("") + return + + self._add_call_table_header(lines) + + for i, (mangled, count) in enumerate( + candidates[: self.INLINE_CANDIDATES_LIMIT] + ): + lines.append(self._format_call_row(i + 1, mangled, count, symbol_sizes)) + + lines.append("") + lines.append( + f"Showing top {min(len(candidates), self.INLINE_CANDIDATES_LIMIT)} " + f"of {len(candidates)} functions under " + f"{self.INLINE_SIZE_THRESHOLD} B" + ) + lines.append("") + def generate_report(self, detailed: bool = False) -> str: """Generate a formatted memory report.""" components = sorted( @@ -582,6 +640,7 @@ class MemoryAnalyzerCLI(MemoryAnalyzer): # Function call frequency analysis if self._function_call_counts: self._add_function_call_analysis(lines) + self._add_inline_candidates(lines) lines.append( "Note: This analysis covers symbols in the ELF file. Some runtime allocations may not be included."