mirror of
https://github.com/esphome/esphome.git
synced 2025-10-25 13:13:48 +01:00
[ci] Add automated memory impact analysis for pull requests (#11242)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
This commit is contained in:
281
script/ci_memory_impact_extract.py
Executable file
281
script/ci_memory_impact_extract.py
Executable file
@@ -0,0 +1,281 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Extract memory usage statistics from ESPHome build output.
|
||||
|
||||
This script parses the PlatformIO build output to extract RAM and flash
|
||||
usage statistics for a compiled component. It's used by the CI workflow to
|
||||
compare memory usage between branches.
|
||||
|
||||
The script reads compile output from stdin and looks for the standard
|
||||
PlatformIO output format:
|
||||
RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)
|
||||
Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)
|
||||
|
||||
Optionally performs detailed memory analysis if a build directory is provided.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
# Add esphome to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
# pylint: disable=wrong-import-position
|
||||
from esphome.analyze_memory import MemoryAnalyzer
|
||||
from esphome.platformio_api import IDEData
|
||||
from script.ci_helpers import write_github_output
|
||||
|
||||
# Regex patterns for extracting memory usage from PlatformIO output
|
||||
_RAM_PATTERN = re.compile(r"RAM:\s+\[.*?\]\s+\d+\.\d+%\s+\(used\s+(\d+)\s+bytes")
|
||||
_FLASH_PATTERN = re.compile(r"Flash:\s+\[.*?\]\s+\d+\.\d+%\s+\(used\s+(\d+)\s+bytes")
|
||||
_BUILD_PATH_PATTERN = re.compile(r"Build path: (.+)")
|
||||
|
||||
|
||||
def extract_from_compile_output(
|
||||
output_text: str,
|
||||
) -> tuple[int | None, int | None, str | None]:
|
||||
"""Extract memory usage and build directory from PlatformIO compile output.
|
||||
|
||||
Supports multiple builds (for component groups or isolated components).
|
||||
When test_build_components.py creates multiple builds, this sums the
|
||||
memory usage across all builds.
|
||||
|
||||
Looks for lines like:
|
||||
RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)
|
||||
Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)
|
||||
|
||||
Also extracts build directory from lines like:
|
||||
INFO Compiling app... Build path: /path/to/build
|
||||
|
||||
Args:
|
||||
output_text: Compile output text (may contain multiple builds)
|
||||
|
||||
Returns:
|
||||
Tuple of (total_ram_bytes, total_flash_bytes, build_dir) or (None, None, None) if not found
|
||||
"""
|
||||
# Find all RAM and Flash matches (may be multiple builds)
|
||||
ram_matches = _RAM_PATTERN.findall(output_text)
|
||||
flash_matches = _FLASH_PATTERN.findall(output_text)
|
||||
|
||||
if not ram_matches or not flash_matches:
|
||||
return None, None, None
|
||||
|
||||
# Sum all builds (handles multiple component groups)
|
||||
total_ram = sum(int(match) for match in ram_matches)
|
||||
total_flash = sum(int(match) for match in flash_matches)
|
||||
|
||||
# Extract build directory from ESPHome's explicit build path output
|
||||
# Look for: INFO Compiling app... Build path: /path/to/build
|
||||
# Note: Multiple builds reuse the same build path (each overwrites the previous)
|
||||
build_dir = None
|
||||
if match := _BUILD_PATH_PATTERN.search(output_text):
|
||||
build_dir = match.group(1).strip()
|
||||
|
||||
return total_ram, total_flash, build_dir
|
||||
|
||||
|
||||
def run_detailed_analysis(build_dir: str) -> dict | None:
|
||||
"""Run detailed memory analysis on build directory.
|
||||
|
||||
Args:
|
||||
build_dir: Path to ESPHome build directory
|
||||
|
||||
Returns:
|
||||
Dictionary with analysis results or None if analysis fails
|
||||
"""
|
||||
build_path = Path(build_dir)
|
||||
if not build_path.exists():
|
||||
print(f"Build directory not found: {build_dir}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Find firmware.elf
|
||||
elf_path = None
|
||||
for elf_candidate in [
|
||||
build_path / "firmware.elf",
|
||||
build_path / ".pioenvs" / build_path.name / "firmware.elf",
|
||||
]:
|
||||
if elf_candidate.exists():
|
||||
elf_path = str(elf_candidate)
|
||||
break
|
||||
|
||||
if not elf_path:
|
||||
print(f"firmware.elf not found in {build_dir}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
# Find idedata.json - check multiple locations
|
||||
device_name = build_path.name
|
||||
idedata_candidates = [
|
||||
# In .pioenvs for test builds
|
||||
build_path / ".pioenvs" / device_name / "idedata.json",
|
||||
# In .esphome/idedata for regular builds
|
||||
Path.home() / ".esphome" / "idedata" / f"{device_name}.json",
|
||||
# Check parent directories for .esphome/idedata (for test_build_components)
|
||||
build_path.parent.parent.parent / "idedata" / f"{device_name}.json",
|
||||
]
|
||||
|
||||
idedata = None
|
||||
for idedata_path in idedata_candidates:
|
||||
if not idedata_path.exists():
|
||||
continue
|
||||
try:
|
||||
with open(idedata_path, encoding="utf-8") as f:
|
||||
raw_data = json.load(f)
|
||||
idedata = IDEData(raw_data)
|
||||
print(f"Loaded idedata from: {idedata_path}", file=sys.stderr)
|
||||
break
|
||||
except (json.JSONDecodeError, OSError) as e:
|
||||
print(
|
||||
f"Warning: Failed to load idedata from {idedata_path}: {e}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
analyzer = MemoryAnalyzer(elf_path, idedata=idedata)
|
||||
components = analyzer.analyze()
|
||||
|
||||
# Convert to JSON-serializable format
|
||||
result = {
|
||||
"components": {
|
||||
name: {
|
||||
"text": mem.text_size,
|
||||
"rodata": mem.rodata_size,
|
||||
"data": mem.data_size,
|
||||
"bss": mem.bss_size,
|
||||
"flash_total": mem.flash_total,
|
||||
"ram_total": mem.ram_total,
|
||||
"symbol_count": mem.symbol_count,
|
||||
}
|
||||
for name, mem in components.items()
|
||||
},
|
||||
"symbols": {},
|
||||
}
|
||||
|
||||
# Build symbol map
|
||||
for section in analyzer.sections.values():
|
||||
for symbol_name, size, _ in section.symbols:
|
||||
if size > 0:
|
||||
demangled = analyzer._demangle_symbol(symbol_name)
|
||||
result["symbols"][demangled] = size
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Main entry point."""
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Extract memory usage from ESPHome build output"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-env",
|
||||
action="store_true",
|
||||
help="Output to GITHUB_OUTPUT environment file",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--build-dir",
|
||||
help="Optional build directory for detailed memory analysis (overrides auto-detection)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-json",
|
||||
help="Optional path to save detailed analysis JSON",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--output-build-dir",
|
||||
help="Optional path to write the detected build directory",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Read compile output from stdin
|
||||
compile_output = sys.stdin.read()
|
||||
|
||||
# Extract memory usage and build directory
|
||||
ram_bytes, flash_bytes, detected_build_dir = extract_from_compile_output(
|
||||
compile_output
|
||||
)
|
||||
|
||||
if ram_bytes is None or flash_bytes is None:
|
||||
print("Failed to extract memory usage from compile output", file=sys.stderr)
|
||||
print("Expected lines like:", file=sys.stderr)
|
||||
print(
|
||||
" RAM: [==== ] 36.1% (used 29548 bytes from 81920 bytes)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
" Flash: [=== ] 34.0% (used 348511 bytes from 1023984 bytes)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
# Count how many builds were found
|
||||
num_builds = len(_RAM_PATTERN.findall(compile_output))
|
||||
|
||||
if num_builds > 1:
|
||||
print(
|
||||
f"Found {num_builds} builds - summing memory usage across all builds",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"WARNING: Detailed analysis will only cover the last build",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
print(f"Total RAM: {ram_bytes} bytes", file=sys.stderr)
|
||||
print(f"Total Flash: {flash_bytes} bytes", file=sys.stderr)
|
||||
|
||||
# Determine which build directory to use (explicit arg overrides auto-detection)
|
||||
build_dir = args.build_dir or detected_build_dir
|
||||
|
||||
if detected_build_dir:
|
||||
print(f"Detected build directory: {detected_build_dir}", file=sys.stderr)
|
||||
if num_builds > 1:
|
||||
print(
|
||||
f" (using last of {num_builds} builds for detailed analysis)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
# Write build directory to file if requested
|
||||
if args.output_build_dir and build_dir:
|
||||
build_dir_path = Path(args.output_build_dir)
|
||||
build_dir_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
build_dir_path.write_text(build_dir)
|
||||
print(f"Wrote build directory to {args.output_build_dir}", file=sys.stderr)
|
||||
|
||||
# Run detailed analysis if build directory available
|
||||
detailed_analysis = None
|
||||
if build_dir:
|
||||
print(f"Running detailed analysis on {build_dir}", file=sys.stderr)
|
||||
detailed_analysis = run_detailed_analysis(build_dir)
|
||||
|
||||
# Save JSON output if requested
|
||||
if args.output_json:
|
||||
output_data = {
|
||||
"ram_bytes": ram_bytes,
|
||||
"flash_bytes": flash_bytes,
|
||||
"detailed_analysis": detailed_analysis,
|
||||
}
|
||||
|
||||
output_path = Path(args.output_json)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(output_path, "w", encoding="utf-8") as f:
|
||||
json.dump(output_data, f, indent=2)
|
||||
print(f"Saved analysis to {args.output_json}", file=sys.stderr)
|
||||
|
||||
if args.output_env:
|
||||
# Output to GitHub Actions
|
||||
write_github_output(
|
||||
{
|
||||
"ram_usage": ram_bytes,
|
||||
"flash_usage": flash_bytes,
|
||||
}
|
||||
)
|
||||
else:
|
||||
print(f"{ram_bytes},{flash_bytes}")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user