1#!/usr/bin/env python3
2#
3# This file is part of the MicroPython project, http://micropython.org/
4#
5# The MIT License (MIT)
6#
7# Copyright (c) 2020 Damien P. George
8#
9# Permission is hereby granted, free of charge, to any person obtaining a copy
10# of this software and associated documentation files (the "Software"), to deal
11# in the Software without restriction, including without limitation the rights
12# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
13# copies of the Software, and to permit persons to whom the Software is
14# furnished to do so, subject to the following conditions:
15#
16# The above copyright notice and this permission notice shall be included in
17# all copies or substantial portions of the Software.
18#
19# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
20# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
21# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
22# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
23# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
24# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
25# THE SOFTWARE.
26
27"""
28This script is used to compute metrics, like code size, of the various ports.
29
30Typical usage is:
31
32    $ ./tools/metrics.py build | tee size0
33    <wait for build to complete>
34    $ git switch new-feature-branch
35    $ ./tools/metrics.py build | tee size1
36    <wait for build to complete>
37    $ ./tools/metrics.py diff size0 size1
38
39Other commands:
40
41    $ ./tools/metrics.py sizes # print all firmware sizes
42    $ ./tools/metrics.py clean # clean all ports
43
44"""
45
46import collections, sys, re, subprocess
47
48MAKE_FLAGS = ["-j3", "CFLAGS_EXTRA=-DNDEBUG"]
49
50
51class PortData:
52    def __init__(self, name, dir, output, make_flags=None):
53        self.name = name
54        self.dir = dir
55        self.output = output
56        self.make_flags = make_flags
57        self.needs_mpy_cross = dir not in ("bare-arm", "minimal")
58
59
60port_data = {
61    "b": PortData("bare-arm", "bare-arm", "build/firmware.elf"),
62    "m": PortData("minimal x86", "minimal", "build/firmware.elf"),
63    "u": PortData("unix x64", "unix", "micropython"),
64    "n": PortData("unix nanbox", "unix", "micropython-nanbox", "VARIANT=nanbox"),
65    "s": PortData("stm32", "stm32", "build-PYBV10/firmware.elf", "BOARD=PYBV10"),
66    "c": PortData("cc3200", "cc3200", "build/WIPY/release/application.axf", "BTARGET=application"),
67    "8": PortData("esp8266", "esp8266", "build-GENERIC/firmware.elf"),
68    "3": PortData("esp32", "esp32", "build-GENERIC/micropython.elf"),
69    "r": PortData("nrf", "nrf", "build-pca10040/firmware.elf"),
70    "p": PortData("rp2", "rp2", "build-PICO/firmware.elf"),
71    "d": PortData("samd", "samd", "build-ADAFRUIT_ITSYBITSY_M4_EXPRESS/firmware.elf"),
72}
73
74
75def syscmd(*args):
76    sys.stdout.flush()
77    a2 = []
78    for a in args:
79        if isinstance(a, str):
80            a2.append(a)
81        elif a:
82            a2.extend(a)
83    subprocess.check_call(a2)
84
85
86def parse_port_list(args):
87    if not args:
88        return list(port_data.values())
89    else:
90        ports = []
91        for arg in args:
92            for port_char in arg:
93                try:
94                    ports.append(port_data[port_char])
95                except KeyError:
96                    print("unknown port:", port_char)
97                    sys.exit(1)
98        return ports
99
100
101def read_build_log(filename):
102    data = collections.OrderedDict()
103    lines = []
104    found_sizes = False
105    with open(filename) as f:
106        for line in f:
107            line = line.strip()
108            if line.strip() == "COMPUTING SIZES":
109                found_sizes = True
110            elif found_sizes:
111                lines.append(line)
112    is_size_line = False
113    for line in lines:
114        if is_size_line:
115            fields = line.split()
116            data[fields[-1]] = [int(f) for f in fields[:-2]]
117            is_size_line = False
118        else:
119            is_size_line = line.startswith("text\t ")
120    return data
121
122
123def do_diff(args):
124    """Compute the difference between firmware sizes."""
125
126    # Parse arguments.
127    error_threshold = None
128    if len(args) >= 2 and args[0] == "--error-threshold":
129        args.pop(0)
130        error_threshold = int(args.pop(0))
131
132    if len(args) != 2:
133        print("usage: %s diff [--error-threshold <x>] <out1> <out2>" % sys.argv[0])
134        sys.exit(1)
135
136    data1 = read_build_log(args[0])
137    data2 = read_build_log(args[1])
138
139    max_delta = None
140    for key, value1 in data1.items():
141        value2 = data2[key]
142        for port in port_data.values():
143            if key == "ports/{}/{}".format(port.dir, port.output):
144                name = port.name
145                break
146        data = [v2 - v1 for v1, v2 in zip(value1, value2)]
147        warn = ""
148        board = re.search(r"/build-([A-Za-z0-9_]+)/", key)
149        if board:
150            board = board.group(1)
151        else:
152            board = ""
153        if name == "cc3200":
154            delta = data[0]
155            percent = 100 * delta / value1[0]
156            if data[1] != 0:
157                warn += " %+u(data)" % data[1]
158        else:
159            delta = data[3]
160            percent = 100 * delta / value1[3]
161            if data[1] != 0:
162                warn += " %+u(data)" % data[1]
163            if data[2] != 0:
164                warn += " %+u(bss)" % data[2]
165        if warn:
166            warn = "[incl%s]" % warn
167        print("%11s: %+5u %+.3f%% %s%s" % (name, delta, percent, board, warn))
168        max_delta = delta if max_delta is None else max(max_delta, delta)
169
170    if error_threshold is not None and max_delta is not None:
171        if max_delta > error_threshold:
172            sys.exit(1)
173
174
175def do_clean(args):
176    """Clean ports."""
177
178    ports = parse_port_list(args)
179
180    print("CLEANING")
181    for port in ports:
182        syscmd("make", "-C", "ports/{}".format(port.dir), port.make_flags, "clean")
183
184
185def do_build(args):
186    """Build ports and print firmware sizes."""
187
188    ports = parse_port_list(args)
189
190    if any(port.needs_mpy_cross for port in ports):
191        print("BUILDING MPY-CROSS")
192        syscmd("make", "-C", "mpy-cross", MAKE_FLAGS)
193
194    print("BUILDING PORTS")
195    for port in ports:
196        syscmd("make", "-C", "ports/{}".format(port.dir), MAKE_FLAGS, port.make_flags)
197
198    do_sizes(args)
199
200
201def do_sizes(args):
202    """Compute and print sizes of firmware."""
203
204    ports = parse_port_list(args)
205
206    print("COMPUTING SIZES")
207    for port in ports:
208        syscmd("size", "ports/{}/{}".format(port.dir, port.output))
209
210
211def main():
212    # Get command to execute
213    if len(sys.argv) == 1:
214        print("Available commands:")
215        for cmd in globals():
216            if cmd.startswith("do_"):
217                print("   {:9} {}".format(cmd[3:], globals()[cmd].__doc__))
218        sys.exit(1)
219    cmd = sys.argv.pop(1)
220
221    # Dispatch to desired command
222    try:
223        cmd = globals()["do_{}".format(cmd)]
224    except KeyError:
225        print("{}: unknown command '{}'".format(sys.argv[0], cmd))
226        sys.exit(1)
227    cmd(sys.argv[1:])
228
229
230if __name__ == "__main__":
231    main()
232