| #!/usr/bin/env python |
| # MIT license; Copyright (c) 2024 Angus Gratton |
| # |
| # This is a utility script for MicroPython maintainers, similar to tools/metrics.py |
| # but particular to this port. It's for measuring the impact of an ESP-IDF update or |
| # config change at a high level. |
| # |
| # Specifically, it builds the esp32 MicroPython port for a collection of boards |
| # and outputs a Markdown table of binary sizes, static IRAM size, and static |
| # DRAM size (the latter generally inversely correlates to free heap at runtime.) |
| # |
| # To use: |
| # |
| # 1) Need to not be in an ESP-IDF venv already (i.e. don't source export.sh), |
| # but IDF_PATH has to be set. |
| # |
| # 2) Choose the versions you want to test and the board/variant pairs by |
| # editing the tuples below. |
| # |
| # 3) The IDF install script sometimes fails if it has to downgrade a package |
| # within a minor version. The "nuclear option" is to delete all the install |
| # environments and have this script recreate them as it runs: |
| # rm -rf ~/.espressif/python_env/* |
| # |
| # 4) Run this script from the ports/esp32 directory, i.e.: |
| # ./tools/metrics_esp32.py |
| # |
| # 5) If all goes well, it will run for a while and then print a Markdown |
| # formatted table of binary sizes, sorted by board+variant. |
| # |
| # Note that for ESP32-S3 and C3, IRAM and DRAM are exchangeable so the IRAM size |
| # column of the table is really D/IRAM. |
| import os |
| import re |
| import shutil |
| import sys |
| import subprocess |
| from dataclasses import dataclass |
| |
| IDF_VERS = ("v5.4.1",) |
| |
| BUILDS = ( |
| ("ESP32_GENERIC", ""), |
| ("ESP32_GENERIC", "D2WD"), |
| ("ESP32_GENERIC", "SPIRAM"), |
| ("ESP32_GENERIC_S3", ""), |
| ("ESP32_GENERIC_S3", "SPIRAM_OCT"), |
| ) |
| |
| |
| def rmtree(path): |
| try: |
| shutil.rmtree(path) |
| except FileNotFoundError: |
| pass |
| |
| |
| @dataclass |
| class BuildSizes: |
| idf_ver: str |
| board: str |
| variant: str |
| bin_size: str = "" |
| dram_size: str = "" |
| iram_size: str = "" |
| |
| def print_summary(self, include_ver=False): |
| print(f"BOARD={self.board} BOARD_VARIANT={self.variant}") |
| if include_ver: |
| print(f"IDF_VER {self.idf_ver}") |
| print(f"Binary size: {self.bin_size}") |
| print(f"IRAM size: {self.iram_size}") |
| print(f"DRAM size: {self.dram_size}") |
| |
| def print_table_heading(): |
| print( |
| "| BOARD | BOARD_VARIANT | IDF Version | Binary Size | Static IRAM Size | Static DRAM Size |" |
| ) |
| print( |
| "|-------|---------------|-------------|-------------|------------------|------------------|" |
| ) |
| |
| def print_table_row(self, print_board): |
| print( |
| "| " |
| + " | ".join( |
| ( |
| self.board if print_board else "", |
| self.variant if print_board else "", |
| self.idf_ver, |
| self.bin_size, |
| self.iram_size, |
| self.dram_size, |
| ) |
| ) |
| + " |" |
| ) |
| |
| def __lt__(self, other): |
| """sort by board, then variant, then IDF version to get an easy |
| to compare table""" |
| return (self.board, self.variant, self.idf_ver) < ( |
| other.board, |
| other.variant, |
| other.idf_ver, |
| ) |
| |
| def build_dir(self): |
| if self.variant: |
| return f"build-{self.board}-{self.variant}" |
| else: |
| return f"build-{self.board}" |
| |
| def run_make(self, target): |
| env = dict(os.environ) |
| env["BOARD"] = self.board |
| env["BOARD_VARIANT"] = self.variant |
| |
| try: |
| # IDF version changes as we go, so re-export the environment each time |
| cmd = f"source $IDF_PATH/export.sh; make {target}" |
| return subprocess.check_output( |
| cmd, shell=True, env=env, stderr=subprocess.STDOUT |
| ).decode() |
| except subprocess.CalledProcessError as e: |
| err_file = f"{self.build_dir()}/make-{target}-failed-{self.idf_ver}.log" |
| print(f"'make {target}' failed, writing to log to {err_file}", file=sys.stderr) |
| with open(err_file, "w") as f: |
| f.write(e.output.decode()) |
| raise |
| |
| def make_size(self): |
| try: |
| size_out = self.run_make("size") |
| try: |
| # pre IDF v5.4 size output |
| # "Used static DRAM:" or "Used stat D/IRAM:" |
| RE_DRAM = r"Used stat(?:ic)? D.*: *(\d+) bytes" |
| RE_IRAM = r"Used static IRAM: *(\d+) bytes" |
| self.dram_size = re.search(RE_DRAM, size_out).group(1) |
| self.iram_size = re.search(RE_IRAM, size_out).group(1) |
| except AttributeError: |
| # IDF v5.4 size output is much nicer formatted |
| # Note the pipes in these expressions are not the ASCII/RE | |
| RE_DRAM = r"│ *DI?RAM *│ *(\d+)" |
| RE_IRAM = r"│ *IRAM *│ *(\d+)" |
| self.dram_size = re.search(RE_DRAM, size_out).group(1) |
| self.iram_size = re.search(RE_IRAM, size_out).group(1) |
| |
| # This line is the same on before/after versions |
| RE_BIN = r"Total image size: *(\d+) bytes" |
| self.bin_size = re.search(RE_BIN, size_out).group(1) |
| except subprocess.CalledProcessError: |
| self.bin_size = "build failed" |
| |
| |
| def main(do_clean): |
| if "IDF_PATH" not in os.environ: |
| raise RuntimeError("IDF_PATH must be set") |
| |
| if not os.path.exists("Makefile"): |
| raise RuntimeError( |
| "This script must be run from the ports/esp32 directory, i.e. as ./tools/metrics_esp32.py" |
| ) |
| |
| if "IDF_PYTHON_ENV_PATH" in os.environ: |
| raise RuntimeError( |
| "Run this script without any existing ESP-IDF environment active/exported." |
| ) |
| |
| sizes = [] |
| for idf_ver in IDF_VERS: |
| switch_ver(idf_ver) |
| rmtree("managed_components") |
| for board, variant in BUILDS: |
| print(f"Building '{board}'/'{variant}'...", file=sys.stderr) |
| result = BuildSizes(idf_ver, board, variant) |
| # Rather than running the 'clean' target, delete the build directory to avoid |
| # environment version mismatches, etc. |
| rmtree(result.build_dir()) |
| result.make_size() |
| result.print_summary() |
| sizes.append(result) |
| |
| # print everything again as a table sorted by board+variant |
| last_bv = "" |
| BuildSizes.print_table_heading() |
| for build_sizes in sorted(sizes): |
| bv = (build_sizes.board, build_sizes.variant) |
| build_sizes.print_table_row(last_bv != bv) |
| last_bv = bv |
| |
| |
| def idf_git(*commands): |
| try: |
| subprocess.check_output( |
| ["git"] + list(commands), cwd=os.environ["IDF_PATH"], stderr=subprocess.STDOUT |
| ) |
| except subprocess.CalledProcessError as e: |
| print(f"git {' '.join(commands)} failed:") |
| print(e.output.decode()) |
| raise |
| |
| |
| def idf_install(): |
| try: |
| subprocess.check_output( |
| ["bash", "install.sh"], cwd=os.environ["IDF_PATH"], stderr=subprocess.STDOUT |
| ) |
| except subprocess.CalledProcessError as e: |
| print("IDF install.sh failed:") |
| print(e.output.decode()) |
| raise |
| |
| |
| def switch_ver(idf_ver): |
| print(f"Switching version to {idf_ver}...", file=sys.stderr) |
| idf_git("switch", "--detach", idf_ver) |
| idf_git("submodule", "update", "--init", "--recursive") |
| idf_install() |
| |
| |
| if __name__ == "__main__": |
| main("--no-clean" not in sys.argv) |