Add a size-display script for binaries. (#35942)

* Add a size-display script for binaries.

I am currently looking to investigate sizes of our code given
the CodegenDataModel work, so adding a script that can
display a nice treemap of things received from NM.

It is generally hacked-up to display ok data for matter binaries.
(i.e. it splits emberAf and Matter as separate entities).

It is currently a best-effort.

* Restyled by autopep8

* Restyled by isort

* Support some zoom and better parenting

* Restyled by autopep8

* Update parenting and fix up auto-format

* Fix up call suffixes if they contain namespaces

* Remove debug print, fix up vtable and thunk

* Allow stripping of entire sections - the C section is large and generally not that useful

* Strip C by default

* Undo default strip: we likely should show the full size because C libs are non-trivial in size

---------

Co-authored-by: Restyled.io <commits@restyled.io>
diff --git a/scripts/tools/file_size_from_nm.py b/scripts/tools/file_size_from_nm.py
new file mode 100755
index 0000000..a5d53b2
--- /dev/null
+++ b/scripts/tools/file_size_from_nm.py
@@ -0,0 +1,432 @@
+#!/usr/bin/env -S python3 -B
+#
+#    Copyright (c) 2024 Project CHIP Authors
+#
+#    Licensed under the Apache License, Version 2.0 (the "License");
+#    you may not use this file except in compliance with the License.
+#    You may obtain a copy of the License at
+#
+#        http://www.apache.org/licenses/LICENSE-2.0
+#
+#    Unless required by applicable law or agreed to in writing, software
+#    distributed under the License is distributed on an "AS IS" BASIS,
+#    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#    See the License for the specific language governing permissions and
+#    limitations under the License.
+#
+
+# Displays a treemap code size as read by `nm` over a binary
+#
+# Example call:
+#
+# scripts/tools/file_size_from_nm.py \
+#     --max-depth 5                  \
+#     out/nrf-nrf52840dk-light-data-model-enabled/nrfconnect/zephyr/zephyr.elf
+#
+
+# Requires:
+#    click
+#    cxxfilt
+#    coloredlogs
+#    pandas
+#    plotly
+
+import logging
+import subprocess
+from dataclasses import dataclass
+from enum import Enum, auto
+from pathlib import Path
+from typing import Optional
+
+import click
+import coloredlogs
+import cxxfilt
+import plotly.express as px
+import plotly.graph_objects as go
+
+# Supported log levels, mapping string values required for argument
+# parsing into logging constants
+__LOG_LEVELS__ = {
+    "debug": logging.DEBUG,
+    "info": logging.INFO,
+    "warn": logging.WARN,
+    "fatal": logging.FATAL,
+}
+
+
+class ChartStyle(Enum):
+    TREE_MAP = auto()
+    SUNBURST = auto()
+
+
+__CHART_STYLES__ = {
+    "treemap": ChartStyle.TREE_MAP,
+    "sunburst": ChartStyle.SUNBURST,
+}
+
+
+@dataclass
+class Symbol:
+    name: str
+    symbol_type: str
+    offset: int
+    size: int
+
+
+def tree_display_name(name: str) -> list[str]:
+    """
+    Convert the given name from NM into a tree path.
+
+    It splits the name by C++ namespaces, however it also specifically handles
+    'emberAf' prefixes to make them common and uses 'vtable for' information
+    """
+
+    name = cxxfilt.demangle(name)
+
+    if name.startswith("non-virtual thunk to "):
+        name = name[21:]
+    if name.startswith("vtable for "):
+        name = name[11:]
+
+    # These are C-style methods really, we have no top-level namespaces named
+    # like this but still want to see these differently
+    for special_prefix in {"emberAf", "Matter"}:
+        if name.startswith(special_prefix):
+            return [special_prefix, name]
+
+    # If the first element contains a space, it is either within `<>` for templates or it means it is a
+    # separator of the type. Try to find the type separator
+    #
+    # Logic:
+    #   - try to find the first space OUTSIDE <> and before '('
+    space_pos = 0
+    indent = 0
+    type_prefix = ""
+    while space_pos < len(name):
+        c = name[space_pos]
+        if c == "<":
+            indent += 1
+        elif c == ">":
+            indent -= 1
+        elif c == " " and indent == 0:
+            # FOUND A SPACE, move it to the last
+            type_prefix = name[:space_pos] + " "
+            space_pos += 1
+            name = name[space_pos:]
+            break
+        elif c == "(" and indent == 0:
+            # a bracket not within templates means we are done!
+            break
+        space_pos += 1
+
+    # completely skip any arguments ... i.e. anything after (
+    brace_pos = 0
+    indent = 0
+    type_suffix = ""
+    while brace_pos < len(name):
+        c = name[brace_pos]
+        if c == "<":
+            indent += 1
+        elif c == ">":
+            indent -= 1
+        elif c == "(" and indent == 0:
+            # FOUND A SPACE, move it to the last
+            type_suffix = name[brace_pos:]
+            name = name[:brace_pos]
+            break
+        brace_pos += 1
+
+    # name may be split by namespace and looks like foo::bar::baz
+    # HOWEVER for templates we want to split foo::Bar<x::y>::Baz into
+    #   [foo, Bar<x::y>::Baz]
+    #
+    # General way things look like:
+    #  TYPE FUNC    # notice the space
+    #  CONSTRUCTOR()
+    result = []
+    while "::" in name:
+        ns_idx = name.find("::")
+        less_idx = name.find("<")
+        if less_idx >= 0 and ns_idx > less_idx:
+            # at this point, we have to find the matched `>` for this, assuming there ARE
+            # nested `>` entries, including multiple of them
+            pos = less_idx + 1
+            indent = 1
+            while indent > 0:
+                if name[pos] == ">":
+                    indent -= 1
+                elif name[pos] == "<":
+                    indent += 1
+                pos += 1
+                if pos == len(name):
+                    break
+            result.append(name[:pos])
+            name = name[pos:]
+            if name.startswith("::"):
+                name = name[2:]
+        else:
+            result.append(name[:ns_idx])
+            ns_idx += 2
+            name = name[ns_idx:]
+    result.append(type_prefix + name + type_suffix)
+
+    if len(result) == 1:
+        if result[0].startswith("ot"):  # Show openthread methods a bit grouped
+            result = ["ot"] + result
+        return ["C"] + result
+
+    return result
+
+
+# TO run the test, install pytest and do
+# pytest file_size_from_nm.py
+def test_tree_display_name():
+    assert tree_display_name("fooBar") == ["C", "fooBar"]
+    assert tree_display_name("emberAfTest") == ["emberAf", "emberAfTest"]
+    assert tree_display_name("MatterSomeCall") == ["Matter", "MatterSomeCall"]
+    assert tree_display_name("chip::Some::Constructor()") == [
+        "chip",
+        "Some",
+        "Constructor()",
+    ]
+
+    assert tree_display_name("chip::Some::Constructor(int arg1, int arg2)") == [
+        "chip",
+        "Some",
+        "Constructor(int arg1, int arg2)",
+    ]
+
+    assert tree_display_name(
+        "chip::Some<a::b::C>::Constructor(int arg1, int arg2)"
+    ) == [
+        "chip",
+        "Some<a::b::C>",
+        "Constructor(int arg1, int arg2)",
+    ]
+
+    assert tree_display_name("void my::function::call()") == [
+        "my",
+        "function",
+        "void call()",
+    ]
+    assert tree_display_name("chip::ChipError my::function::call()") == [
+        "my",
+        "function",
+        "chip::ChipError call()",
+    ]
+    assert tree_display_name("chip::test<foo::bar>::baz my::function::call()") == [
+        "my",
+        "function",
+        "chip::test<foo::bar>::baz call()",
+    ]
+    assert tree_display_name(
+        "chip::test<foo::bar, 1, 2>::baz my::function::call()"
+    ) == [
+        "my",
+        "function",
+        "chip::test<foo::bar, 1, 2>::baz call()",
+    ]
+    assert tree_display_name(
+        "chip::app::CommandIsFabricScoped(unsigned int, unsigned int)"
+    ) == ["chip", "app", "CommandIsFabricScoped(unsigned int, unsigned int)"]
+    assert tree_display_name("chip::app::AdvertiseAsOperational()") == [
+        "chip",
+        "app",
+        "AdvertiseAsOperational()",
+    ]
+
+    assert tree_display_name(
+        "void foo::bar<baz>::method(my::arg name, other::arg::type)"
+    ) == ["foo", "bar<baz>", "void method(my::arg name, other::arg::type)"]
+
+
+def build_treemap(
+    name: str,
+    symbols: list[Symbol],
+    style: ChartStyle,
+    max_depth: int,
+    zoom: Optional[str],
+    strip: Optional[str],
+):
+    # A treemap is based on parents (with title)
+
+    # Naming rules:
+    #   namespaces/prefixes are "::<name>(::<name>)"
+    #   Actual names will be parented by their suffixes
+
+    root = f"FILE: {name}"
+    if zoom:
+        root = root + f" (FILTER: {zoom})"
+    data: dict[str, list] = dict(name=[root], parent=[""], size=[0], hover=[""])
+
+    known_parents: set[str] = set()
+    total_sizes: dict = {}
+
+    for symbol in symbols:
+        tree_name = tree_display_name(symbol.name)
+
+        if zoom is not None:
+            partial = ""
+            # try to filter out the tree name. If it contains the zoom item, keep it, otherwise discard
+            while tree_name and partial != zoom:
+                partial += "::" + tree_name[0]
+                tree_name = tree_name[1:]
+            if not tree_name:
+                continue
+
+        if strip is not None:
+            partial = ""
+            for part_name in tree_name:
+                partial = "::" + part_name
+                if partial == strip:
+                    break
+            if partial == strip:
+                continue
+
+        partial = ""
+        for name in tree_name[:-1]:
+            next_value = partial + "::" + name
+            if next_value not in known_parents:
+                known_parents.add(next_value)
+                data["name"].append(next_value)
+                data["parent"].append(partial if partial else root)
+                data["size"].append(0)
+                data["hover"].append(next_value)
+            total_sizes[next_value] = total_sizes.get(next_value, 0) + symbol.size
+            partial = next_value
+
+        # the name MUST be added
+        data["name"].append(cxxfilt.demangle(symbol.name))
+        data["parent"].append(partial if partial else root)
+        data["size"].append(symbol.size)
+        data["hover"].append(f"{symbol.name} of type {symbol.symbol_type}")
+
+    for idx, label in enumerate(data["name"]):
+        if data["size"][idx] == 0:
+            data["hover"][idx] = f"{label}: {total_sizes.get(label, 0)}"
+
+    if style == ChartStyle.TREE_MAP:
+        fig = go.Figure(
+            go.Treemap(
+                labels=data["name"],
+                parents=data["parent"],
+                values=data["size"],
+                textinfo="label+value+percent parent",
+                hovertext=data["hover"],
+                maxdepth=max_depth,
+            )
+        )
+    else:
+        fig = px.sunburst(
+            data,
+            names="name",
+            parents="parent",
+            values="size",
+            maxdepth=max_depth,
+        )
+
+    fig.update_traces(root_color="lightgray")
+    fig.show()
+
+
+@click.command()
+@click.option(
+    "--log-level",
+    default="INFO",
+    show_default=True,
+    type=click.Choice(list(__LOG_LEVELS__.keys()), case_sensitive=False),
+    help="Determines the verbosity of script output.",
+)
+@click.option(
+    "--display-type",
+    default="TREEMAP",
+    show_default=True,
+    type=click.Choice(list(__CHART_STYLES__.keys()), case_sensitive=False),
+    help="Style of the chart",
+)
+@click.option(
+    "--max-depth",
+    default=4,
+    show_default=True,
+    type=int,
+    help="Display depth by default",
+)
+@click.option(
+    "--zoom",
+    default=None,
+    help="Zoom in the graph to ONLY the specified path as root (e.g. ::chip::app)",
+)
+@click.option(
+    "--strip",
+    default=None,
+    help="Strip out a tree subset (e.g. ::C)",
+)
+@click.argument("elf-file", type=Path)
+def main(
+    log_level,
+    elf_file: Path,
+    display_type: str,
+    max_depth: int,
+    zoom: Optional[str],
+    strip: Optional[str],
+):
+    log_fmt = "%(asctime)s %(levelname)-7s %(message)s"
+    coloredlogs.install(level=__LOG_LEVELS__[log_level], fmt=log_fmt)
+
+    items = subprocess.check_output(
+        [
+            "nm",
+            "--print-size",
+            "--size-sort",  # Filters out empty entries
+            "--radix=d",
+            elf_file.absolute().as_posix(),
+        ]
+    ).decode("utf8")
+
+    symbols = []
+
+    # OUTPUT FORMAT:
+    # <offset> <size> <type> <name>
+    for line in items.split("\n"):
+        if not line.strip():
+            continue
+        offset, size, t, name = line.split(" ")
+
+        size = int(size, 10)
+        offset = int(offset, 10)
+
+        if t in {
+            # Text section
+            "t",
+            "T",
+            # Weak defines
+            "w",
+            "W",
+            # Initialized data
+            "d",
+            "D",
+            # Readonly
+            "r",
+            "R",
+            # Weak object
+            "v",
+            "V",
+        }:
+            logging.debug("Found %s of size %d", name, size)
+            symbols.append(Symbol(name=name, symbol_type=t, offset=offset, size=size))
+        elif t in {
+            # BSS - 0-initialized, not code
+            "b",
+            "B",
+        }:
+            pass
+        else:
+            logging.error("SKIPPING SECTION %s", t)
+
+    build_treemap(
+        elf_file.name, symbols, __CHART_STYLES__[display_type], max_depth, zoom, strip
+    )
+
+
+if __name__ == "__main__":
+    main(auto_envvar_prefix="CHIP")