twister: get information about memory footprint from build.log

This commit calculate memory footprint from build.log and
proposes an alternative approach to #2465.

Signed-off-by: Katarzyna Giądła <katarzyna.giadla@nordicsemi.no>
This commit is contained in:
Katarzyna Giądła 2022-10-18 15:45:21 +02:00 committed by Anas Nashif
commit 14358aee1c
7 changed files with 361 additions and 86 deletions

View file

@ -370,7 +370,7 @@ if __name__ == "__main__":
logging.info(f'Total nodes to launch: {nodes}') logging.info(f'Total nodes to launch: {nodes}')
header = ['test', 'arch', 'platform', 'status', 'extra_args', 'handler', header = ['test', 'arch', 'platform', 'status', 'extra_args', 'handler',
'handler_time', 'ram_size', 'rom_size'] 'handler_time', 'used_ram', 'used_rom']
# write plan # write plan
if dup_free: if dup_free:

View file

@ -531,7 +531,9 @@ structure in the main Zephyr tree: boards/<arch>/<board_name>/""")
"'--ninja' argument (to use Ninja build generator).") "'--ninja' argument (to use Ninja build generator).")
parser.add_argument( parser.add_argument(
"--show-footprint", action="store_true", "--show-footprint",
action="store_true",
required = "--footprint-from-buildlog" in sys.argv,
help="Show footprint statistics and deltas since last release." help="Show footprint statistics and deltas since last release."
) )
@ -618,6 +620,12 @@ structure in the main Zephyr tree: boards/<arch>/<board_name>/""")
"stdout detailing RAM/ROM sizes on the specified filenames. " "stdout detailing RAM/ROM sizes on the specified filenames. "
"All other command line arguments ignored.") "All other command line arguments ignored.")
parser.add_argument(
"--footprint-from-buildlog",
action = "store_true",
help="Get information about memory footprint from generated build.log. "
"Requires using --show-footprint option.")
options = parser.parse_args(args) options = parser.parse_args(args)
# Very early error handling # Very early error handling

View file

@ -250,8 +250,10 @@ class Reporting:
device_log = os.path.join(instance.build_dir, "device.log") device_log = os.path.join(instance.build_dir, "device.log")
handler_time = instance.metrics.get('handler_time', 0) handler_time = instance.metrics.get('handler_time', 0)
ram_size = instance.metrics.get ("ram_size", 0) used_ram = instance.metrics.get ("used_ram", 0)
rom_size = instance.metrics.get("rom_size",0) used_rom = instance.metrics.get("used_rom",0)
available_ram = instance.metrics.get("available_ram", 0)
available_rom = instance.metrics.get("available_rom", 0)
suite = { suite = {
"name": instance.testsuite.name, "name": instance.testsuite.name,
"arch": instance.platform.arch, "arch": instance.platform.arch,
@ -264,13 +266,17 @@ class Reporting:
if instance.status != 'filtered': if instance.status != 'filtered':
suite["runnable"] = instance.run suite["runnable"] = instance.run
if ram_size: if used_ram:
suite["ram_size"] = ram_size suite["used_ram"] = used_ram
if rom_size: if used_rom:
suite["rom_size"] = rom_size suite["used_rom"] = used_rom
suite['retries'] = instance.retries suite['retries'] = instance.retries
if available_ram:
suite["available_ram"] = available_ram
if available_rom:
suite["available_rom"] = available_rom
if instance.status in ["error", "failed"]: if instance.status in ["error", "failed"]:
suite['status'] = instance.status suite['status'] = instance.status
suite["reason"] = instance.reason suite["reason"] = instance.reason
@ -341,8 +347,8 @@ class Reporting:
def compare_metrics(self, filename): def compare_metrics(self, filename):
# name, datatype, lower results better # name, datatype, lower results better
interesting_metrics = [("ram_size", int, True), interesting_metrics = [("used_ram", int, True),
("rom_size", int, True)] ("used_rom", int, True)]
if not os.path.exists(filename): if not os.path.exists(filename):
logger.error("Cannot compare metrics, %s not found" % filename) logger.error("Cannot compare metrics, %s not found" % filename)

View file

@ -22,6 +22,7 @@ from twisterlib.cmakecache import CMakeCache
from twisterlib.environment import canonical_zephyr_base from twisterlib.environment import canonical_zephyr_base
from twisterlib.log_helper import log_command from twisterlib.log_helper import log_command
from domains import Domains from domains import Domains
from twisterlib.testinstance import TestInstance
logger = logging.getLogger('twister') logger = logging.getLogger('twister')
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
@ -840,31 +841,36 @@ class ProjectBuilder(FilterBuilder):
sys.stdout.flush() sys.stdout.flush()
def gather_metrics(self, instance): def gather_metrics(self, instance: TestInstance):
if self.options.enable_size_report and not self.options.cmake_only: if self.options.enable_size_report and not self.options.cmake_only:
self.calc_one_elf_size(instance) self.calc_size(instance=instance, from_buildlog=self.options.footprint_from_buildlog)
else: else:
instance.metrics["ram_size"] = 0 instance.metrics["used_ram"] = 0
instance.metrics["rom_size"] = 0 instance.metrics["used_rom"] = 0
instance.metrics["available_rom"] = 0
instance.metrics["available_ram"] = 0
instance.metrics["unrecognized"] = [] instance.metrics["unrecognized"] = []
@staticmethod @staticmethod
def calc_one_elf_size(instance): def calc_size(instance: TestInstance, from_buildlog: bool):
if instance.status not in ["error", "failed", "skipped"]: if instance.status not in ["error", "failed", "skipped"]:
if instance.platform.type != "native": if not instance.platform.type in ["native", "qemu"]:
size_calc = instance.calculate_sizes() generate_warning = bool(instance.platform.type == "mcu")
instance.metrics["ram_size"] = size_calc.get_ram_size() size_calc = instance.calculate_sizes(from_buildlog=from_buildlog, generate_warning=generate_warning)
instance.metrics["rom_size"] = size_calc.get_rom_size() instance.metrics["used_ram"] = size_calc.get_used_ram()
instance.metrics["used_rom"] = size_calc.get_used_rom()
instance.metrics["available_rom"] = size_calc.get_available_rom()
instance.metrics["available_ram"] = size_calc.get_available_ram()
instance.metrics["unrecognized"] = size_calc.unrecognized_sections() instance.metrics["unrecognized"] = size_calc.unrecognized_sections()
else: else:
instance.metrics["ram_size"] = 0 instance.metrics["used_ram"] = 0
instance.metrics["rom_size"] = 0 instance.metrics["used_rom"] = 0
instance.metrics["available_rom"] = 0
instance.metrics["available_ram"] = 0
instance.metrics["unrecognized"] = [] instance.metrics["unrecognized"] = []
instance.metrics["handler_time"] = instance.execution_time instance.metrics["handler_time"] = instance.execution_time
class TwisterRunner: class TwisterRunner:
def __init__(self, instances, suites, env=None) -> None: def __init__(self, instances, suites, env=None) -> None:

View file

@ -6,8 +6,14 @@
import subprocess import subprocess
import sys import sys
import os
import re
import typing
import logging
from twisterlib.error import TwisterRuntimeError from twisterlib.error import TwisterRuntimeError
logger = logging.getLogger('twister')
logger.setLevel(logging.DEBUG)
class SizeCalculator: class SizeCalculator:
alloc_sections = [ alloc_sections = [
@ -91,51 +97,42 @@ class SizeCalculator:
"ppp_protocol_handler_area", "ppp_protocol_handler_area",
] ]
def __init__(self, filename, extra_sections): # Variable below is stored for calculating size using build.log
USEFUL_LINES_AMOUNT = 4
def __init__(self, elf_filename: str,\
extra_sections: typing.List[str],\
buildlog_filepath: str = '',\
generate_warning: bool = True):
"""Constructor """Constructor
@param filename Path to the output binary @param elf_filename (str) Path to the output binary
The <filename> is parsed by objdump to determine section sizes parsed by objdump to determine section sizes.
@param extra_sections (list[str]) List of extra,
unexpected sections, which Twister should not
report as error and not include in the
size calculation.
@param buildlog_filepath (str, default: '') Path to the
output build.log
@param generate_warning (bool, default: True) Twister should
(or not) warning about missing the information about
footprint.
""" """
# Make sure this is an ELF binary self.elf_filename = elf_filename
with open(filename, "rb") as f: self.buildlog_filename = buildlog_filepath
magic = f.read(4)
try:
if magic != b'\x7fELF':
raise TwisterRuntimeError("%s is not an ELF binary" % filename)
except Exception as e:
print(str(e))
sys.exit(2)
# Search for CONFIG_XIP in the ELF's list of symbols using NM and AWK.
# GREP can not be used as it returns an error if the symbol is not
# found.
is_xip_command = "nm " + filename + \
" | awk '/CONFIG_XIP/ { print $3 }'"
is_xip_output = subprocess.check_output(
is_xip_command, shell=True, stderr=subprocess.STDOUT).decode(
"utf-8").strip()
try:
if is_xip_output.endswith("no symbols"):
raise TwisterRuntimeError("%s has no symbol information" % filename)
except Exception as e:
print(str(e))
sys.exit(2)
self.is_xip = (len(is_xip_output) != 0)
self.filename = filename
self.sections = [] self.sections = []
self.rom_size = 0 self.used_rom = 0
self.ram_size = 0 self.used_ram = 0
self.available_ram = 0
self.available_rom = 0
self.extra_sections = extra_sections self.extra_sections = extra_sections
self.is_xip = True
self.generate_warning = generate_warning
self._calculate_sizes() self._calculate_sizes()
def size_report(self): def size_report(self):
print(self.filename) print(self.elf_filename)
print("SECTION NAME VMA LMA SIZE HEX SZ TYPE") print("SECTION NAME VMA LMA SIZE HEX SZ TYPE")
for v in self.sections: for v in self.sections:
print("%-17s 0x%08x 0x%08x %8d 0x%05x %-7s" % print("%-17s 0x%08x 0x%08x %8d 0x%05x %-7s" %
@ -143,22 +140,22 @@ class SizeCalculator:
v["type"])) v["type"]))
print("Totals: %d bytes (ROM), %d bytes (RAM)" % print("Totals: %d bytes (ROM), %d bytes (RAM)" %
(self.rom_size, self.ram_size)) (self.used_rom, self.used_ram))
print("") print("")
def get_ram_size(self): def get_used_ram(self):
"""Get the amount of RAM the application will use up on the device """Get the amount of RAM the application will use up on the device
@return amount of RAM, in bytes @return amount of RAM, in bytes
""" """
return self.ram_size return self.used_ram
def get_rom_size(self): def get_used_rom(self):
"""Get the size of the data that this application uses on device's flash """Get the size of the data that this application uses on device's flash
@return amount of ROM, in bytes @return amount of ROM, in bytes
""" """
return self.rom_size return self.used_rom
def unrecognized_sections(self): def unrecognized_sections(self):
"""Get a list of sections inside the binary that weren't recognized """Get a list of sections inside the binary that weren't recognized
@ -171,9 +168,63 @@ class SizeCalculator:
slist.append(v["name"]) slist.append(v["name"])
return slist return slist
def get_available_ram(self) -> int:
"""Get the total available RAM.
@return total available RAM, in bytes (int)
"""
return self.available_ram
def get_available_rom(self) -> int:
"""Get the total available ROM.
@return total available ROM, in bytes (int)
"""
return self.available_rom
def _calculate_sizes(self): def _calculate_sizes(self):
""" Calculate RAM and ROM usage by section """ """ELF file is analyzed, even if the option to read memory
objdump_command = "objdump -h " + self.filename footprint from the build.log file is set.
This is to detect potential problems contained in
unrecognized sections of the file.
"""
self._analyze_elf_file()
if self.buildlog_filename.endswith("build.log"):
self._get_footprint_from_buildlog()
def _check_elf_file(self) -> None:
# Make sure this is an ELF binary
with open(self.elf_filename, "rb") as f:
magic = f.read(4)
try:
if magic != b'\x7fELF':
raise TwisterRuntimeError("%s is not an ELF binary" % self.elf_filename)
except Exception as e:
print(str(e))
sys.exit(2)
def _check_is_xip(self) -> None:
# Search for CONFIG_XIP in the ELF's list of symbols using NM and AWK.
# GREP can not be used as it returns an error if the symbol is not
# found.
is_xip_command = "nm " + self.elf_filename + \
" | awk '/CONFIG_XIP/ { print $3 }'"
is_xip_output = subprocess.check_output(
is_xip_command, shell=True, stderr=subprocess.STDOUT).decode(
"utf-8").strip()
try:
if is_xip_output.endswith("no symbols"):
raise TwisterRuntimeError("%s has no symbol information" % self.elf_filename)
except Exception as e:
print(str(e))
sys.exit(2)
self.is_xip = (len(is_xip_output) != 0)
def _get_info_elf_sections(self) -> None:
"""Calculate RAM and ROM usage and information about issues by section"""
objdump_command = "objdump -h " + self.elf_filename
objdump_output = subprocess.check_output( objdump_output = subprocess.check_output(
objdump_command, shell=True).decode("utf-8").splitlines() objdump_command, shell=True).decode("utf-8").splitlines()
@ -204,23 +255,208 @@ class SizeCalculator:
# Add section to memory use totals (for both non-XIP and XIP scenarios) # Add section to memory use totals (for both non-XIP and XIP scenarios)
# Unrecognized section names are not included in the calculations. # Unrecognized section names are not included in the calculations.
recognized = True recognized = True
if name in SizeCalculator.alloc_sections:
self.ram_size += size # If build.log file exists, check errors (unrecognized sections
stype = "alloc" # in ELF file).
elif name in SizeCalculator.rw_sections: if self.buildlog_filename:
self.ram_size += size if name in SizeCalculator.alloc_sections or\
self.rom_size += size SizeCalculator.rw_sections or\
stype = "rw" SizeCalculator.ro_sections:
elif name in SizeCalculator.ro_sections: continue
self.rom_size += size else:
if not self.is_xip: stype = "unknown"
self.ram_size += size if name not in self.extra_sections:
stype = "ro" recognized = False
else: else:
stype = "unknown" if name in SizeCalculator.alloc_sections:
if name not in self.extra_sections: self.used_ram += size
recognized = False stype = "alloc"
elif name in SizeCalculator.rw_sections:
self.used_ram += size
self.used_rom += size
stype = "rw"
elif name in SizeCalculator.ro_sections:
self.used_rom += size
if not self.is_xip:
self.used_ram += size
stype = "ro"
else:
stype = "unknown"
if name not in self.extra_sections:
recognized = False
self.sections.append({"name": name, "load_addr": load_addr, self.sections.append({"name": name, "load_addr": load_addr,
"size": size, "virt_addr": virt_addr, "size": size, "virt_addr": virt_addr,
"type": stype, "recognized": recognized}) "type": stype, "recognized": recognized})
def _analyze_elf_file(self) -> None:
self._check_elf_file()
self._check_is_xip()
self._get_info_elf_sections()
def _get_buildlog_file_content(self) -> typing.List[str]:
"""Get content of the build.log file.
@return Content of the build.log file (list[str])
"""
if os.path.exists(path=self.buildlog_filename):
with open(file=self.buildlog_filename, mode='r') as file:
file_content = file.readlines()
else:
if self.generate_warning:
logger.error(msg=f"Incorrect path to build.log file to analyze footprints. Please check the path {self.buildlog_filename}.")
file_content = []
return file_content
def _find_offset_of_last_pattern_occurrence(self, file_content: typing.List[str]) -> int:
"""Find the offset from which the information about the memory footprint is read.
@param file_content (list[str]) Content of build.log.
@return Offset with information about the memory footprint (int)
"""
result = -1
if len(file_content) == 0:
logger.warning("Build.log file is empty.")
else:
# Pattern to first line with information about memory footprint
PATTERN_SEARCHED_LINE = "Memory region"
# Check the file in reverse order.
for idx, line in enumerate(reversed(file_content)):
# Condition is fulfilled if the pattern matches with the start of the line.
if re.match(pattern=PATTERN_SEARCHED_LINE, string=line):
result = idx + 1
break
# If the file does not contain information about memory footprint, the warning is raised.
if result == -1:
logger.warning(msg=f"Information about memory footprint for this test configuration is not found. Please check file {self.buildlog_filename}.")
return result
def _get_lines_with_footprint(self, start_offset: int, file_content: typing.List[str]) -> typing.List[str]:
"""Get lines from the file with a memory footprint.
@param start_offset (int) Offset with the first line of the information about memory footprint.
@param file_content (list[str]) Content of the build.log file.
@return Lines with information about memory footprint (list[str])
"""
if len(file_content) == 0:
result = []
else:
if start_offset > len(file_content) or start_offset <= 0:
info_line_idx_start = len(file_content) - 1
else:
info_line_idx_start = len(file_content) - start_offset
info_line_idx_stop = info_line_idx_start + self.USEFUL_LINES_AMOUNT
if info_line_idx_stop > len(file_content):
info_line_idx_stop = len(file_content)
result = file_content[info_line_idx_start:info_line_idx_stop]
return result
def _clear_whitespaces_from_lines(self, text_lines: typing.List[str]) -> typing.List[str]:
"""Clear text lines from whitespaces.
@param text_lines (list[str]) Lines with useful information.
@return Cleared text lines with information (list[str])
"""
return [line.strip("\n").rstrip("%") for line in text_lines] if text_lines else []
def _divide_text_lines_into_columns(self, text_lines: typing.List[str]) -> typing.List[typing.List[str]]:
"""Divide lines of text into columns.
@param lines (list[list[str]]) Lines with information about memory footprint.
@return Lines divided into columns (list[list[str]])
"""
if text_lines:
result = []
PATTERN_SPLIT_COLUMNS = " +"
for line in text_lines:
line = [column.rstrip(":") for column in re.split(pattern=PATTERN_SPLIT_COLUMNS, string=line)]
result.append(list(filter(None, line)))
else:
result = [[]]
return result
def _unify_prefixes_on_all_values(self, data_lines: typing.List[typing.List[str]]) -> typing.List[typing.List[str]]:
"""Convert all values in the table to unified order of magnitude.
@param data_lines (list[list[str]]) Lines with information about memory footprint.
@return Lines with unified values (list[list[str]])
"""
if len(data_lines) != self.USEFUL_LINES_AMOUNT:
data_lines = [[]]
if self.generate_warning:
logger.warning(msg=f"Incomplete information about memory footprint. Please check file {self.buildlog_filename}")
else:
for idx, line in enumerate(data_lines):
# Line with description of the columns
if idx == 0:
continue
line_to_replace = list(map(self._binary_prefix_converter, line))
data_lines[idx] = line_to_replace
return data_lines
def _binary_prefix_converter(self, value: str) -> str:
"""Convert specific value to particular prefix.
@param value (str) Value to convert.
@return Converted value to output prefix (str)
"""
PATTERN_VALUE = r"([0-9]?\s.?B\Z)"
if not re.search(pattern=PATTERN_VALUE, string=value):
converted_value = value.rstrip()
else:
PREFIX_POWER = {'B': 0, 'KB': 10, 'MB': 20, 'GB': 30}
DEFAULT_DATA_PREFIX = 'B'
data_parts = value.split()
numeric_value = int(data_parts[0])
unit = data_parts[1]
shift = PREFIX_POWER.get(unit, 0) - PREFIX_POWER.get(DEFAULT_DATA_PREFIX, 0)
unit_predictor = pow(2, shift)
converted_value = str(numeric_value * unit_predictor)
return converted_value
def _create_data_table(self) -> typing.List[typing.List[str]]:
"""Create table with information about memory footprint.
@return Table with information about memory usage (list[list[str]])
"""
file_content = self._get_buildlog_file_content()
data_line_start_idx = self._find_offset_of_last_pattern_occurrence(file_content=file_content)
if data_line_start_idx < 0:
data_from_content = [[]]
else:
# Clean lines and separate information to columns
information_lines = self._get_lines_with_footprint(start_offset=data_line_start_idx, file_content=file_content)
information_lines = self._clear_whitespaces_from_lines(text_lines=information_lines)
data_from_content = self._divide_text_lines_into_columns(text_lines=information_lines)
data_from_content = self._unify_prefixes_on_all_values(data_lines=data_from_content)
return data_from_content
def _get_footprint_from_buildlog(self) -> None:
"""Get memory footprint from build.log"""
data_from_file = self._create_data_table()
if data_from_file == [[]] or not data_from_file:
self.used_ram = 0
self.used_rom = 0
self.available_ram = 0
self.available_rom = 0
if self.generate_warning:
logger.warning(msg=f"Missing information about memory footprint. Check file {self.buildlog_filename}.")
else:
ROW_RAM_IDX = 2
ROW_ROM_IDX = 1
COLUMN_USED_SIZE_IDX = 1
COLUMN_AVAILABLE_SIZE_IDX = 2
self.used_ram = int(data_from_file[ROW_RAM_IDX][COLUMN_USED_SIZE_IDX])
self.used_rom = int(data_from_file[ROW_ROM_IDX][COLUMN_USED_SIZE_IDX])
self.available_ram = int(data_from_file[ROW_RAM_IDX][COLUMN_AVAILABLE_SIZE_IDX])
self.available_rom = int(data_from_file[ROW_ROM_IDX][COLUMN_AVAILABLE_SIZE_IDX])

View file

@ -16,7 +16,6 @@ from twisterlib.error import BuildError
from twisterlib.size_calc import SizeCalculator from twisterlib.size_calc import SizeCalculator
from twisterlib.handlers import Handler, SimulationHandler, BinaryHandler, QEMUHandler, DeviceHandler, SUPPORTED_SIMS from twisterlib.handlers import Handler, SimulationHandler, BinaryHandler, QEMUHandler, DeviceHandler, SUPPORTED_SIMS
logger = logging.getLogger('twister') logger = logging.getLogger('twister')
logger.setLevel(logging.DEBUG) logger.setLevel(logging.DEBUG)
@ -236,7 +235,7 @@ class TestInstance:
return content return content
def calculate_sizes(self): def calculate_sizes(self, from_buildlog: bool = False, generate_warning: bool = True) -> SizeCalculator:
"""Get the RAM/ROM sizes of a test case. """Get the RAM/ROM sizes of a test case.
This can only be run after the instance has been executed by This can only be run after the instance has been executed by
@ -244,13 +243,31 @@ class TestInstance:
@return A SizeCalculator object @return A SizeCalculator object
""" """
elf_filepath = self.get_elf_file()
buildlog_filepath = self.get_buildlog_file() if from_buildlog else ''
return SizeCalculator(elf_filename=elf_filepath,
extra_sections=self.testsuite.extra_sections,
buildlog_filepath=buildlog_filepath,
generate_warning=generate_warning)
def get_elf_file(self) -> str:
fns = glob.glob(os.path.join(self.build_dir, "zephyr", "*.elf")) fns = glob.glob(os.path.join(self.build_dir, "zephyr", "*.elf"))
fns.extend(glob.glob(os.path.join(self.build_dir, "zephyr", "*.exe"))) fns.extend(glob.glob(os.path.join(self.build_dir, "zephyr", "*.exe")))
fns = [x for x in fns if '_pre' not in x] fns = [x for x in fns if '_pre' not in x]
if len(fns) != 1: if len(fns) != 1:
raise BuildError("Missing/multiple output ELF binary") raise BuildError("Missing/multiple output ELF binary")
return fns[0]
return SizeCalculator(fns[0], self.testsuite.extra_sections) def get_buildlog_file(self) -> str:
"""Get path to build.log file.
@raises BuildError: Incorrect amount (!=1) of build logs.
@return: Path to build.log (str).
"""
buildlog_paths = glob.glob(os.path.join(self.build_dir, "build.log"))
if len(buildlog_paths) != 1:
raise BuildError("Missing/multiple build.log file.")
return buildlog_paths[0]
def __repr__(self): def __repr__(self):
return "<TestSuite %s on %s>" % (self.testsuite.name, self.platform.name) return "<TestSuite %s on %s>" % (self.testsuite.name, self.platform.name)

View file

@ -518,8 +518,10 @@ class TestPlan:
) )
instance.metrics['handler_time'] = ts.get('execution_time', 0) instance.metrics['handler_time'] = ts.get('execution_time', 0)
instance.metrics['ram_size'] = ts.get("ram_size", 0) instance.metrics['used_ram'] = ts.get("used_ram", 0)
instance.metrics['rom_size'] = ts.get("rom_size",0) instance.metrics['used_rom'] = ts.get("used_rom",0)
instance.metrics['available_ram'] = ts.get('available_ram', 0)
instance.metrics['available_rom'] = ts.get('available_rom', 0)
status = ts.get('status', None) status = ts.get('status', None)
reason = ts.get("reason", "Unknown") reason = ts.get("reason", "Unknown")