[tools] Add binary size tool.

Compared to (Chromium's) third_party/binary_size, this tool better simplifies paths and can handle `dart:` and `package:` "paths".

Cf. e32d98cd0608adda0bfb9bd983d3a566fa030561.

Change-Id: I9e757d9ba8422024584586899481279f7fd6542d
Reviewed-on: https://dart-review.googlesource.com/c/sdk/+/387840
Reviewed-by: Brian Quinlan <bquinlan@google.com>
Commit-Queue: Ryan Macnak <rmacnak@google.com>
diff --git a/runtime/tools/binary_size b/runtime/tools/binary_size
new file mode 100755
index 0000000..4ea8f89
--- /dev/null
+++ b/runtime/tools/binary_size
@@ -0,0 +1,427 @@
+#!/usr/bin/env dart
+// Copyright (c) 2022, the Dart project authors.  Please see the AUTHORS file
+// for details. All rights reserved. Use of this source code is governed by a
+// BSD-style license that can be found in the LICENSE file.
+
+import "dart:io";
+
+class Symbol {
+  String? name;
+  String? type;
+  int? shallowSize;
+  int? retainedSize;
+  List<Symbol> children = <Symbol>[];
+
+  Symbol compressTrivialPaths() {
+    for (var i = 0; i < children.length; i++) {
+      children[i] = children[i].compressTrivialPaths();
+    }
+    if ((type == "path") && (children.length == 1)) {
+      return children[0];
+    }
+    return this;
+  }
+
+  int computeRetainedSize() {
+    var s = shallowSize!;
+    for (var child in children) {
+      s += child.computeRetainedSize();
+    }
+    retainedSize = s;
+    return s;
+  }
+
+  writeJson(StringBuffer out) {
+    out.write("{");
+    out.write('"name": "$name",');
+    out.write('"shallowSize": $shallowSize,');
+    out.write('"retainedSize": $retainedSize,');
+    out.write('"type": "$type",');
+    out.write('"children": [');
+    bool first = true;
+    for (var child in children) {
+      if (first) {
+        first = false;
+      } else {
+        out.write(",");
+      }
+      child.writeJson(out);
+    }
+    out.write("]}");
+  }
+}
+
+const filteredPathComponents = <String>[
+  "",
+  ".",
+  "..",
+  "out",
+  "xcodebuild",
+  "src",
+  "source",
+  "third_party",
+  "DebugX64",
+  "ReleaseX64",
+  "ProductX64",
+  "DebugARM64",
+  "ReleaseARM64",
+  "ProductARM64",
+  "DebugXARM64",
+  "ReleaseXARM64",
+  "ProductXARM64",
+];
+
+var cwd = Directory.current.path;
+String prettyPath(String path) {
+  if (path.startsWith(cwd)) {
+    path = path.substring(cwd.length);
+  }
+  return path
+      .split("/")
+      .where((component) => !filteredPathComponents.contains(component))
+      .join("/");
+}
+
+main(List<String> args) {
+  if (args.isEmpty) {
+    print("Usage: binary_size <binaries>");
+    exit(1);
+  }
+
+  for (var arg in args) {
+    analyze(arg);
+  }
+}
+
+analyze(String binaryPath) {
+  var nmExec = "nm";
+  var nmArgs = ["-a", "--demangle", "--line-numbers",
+                "--print-size", binaryPath];
+  var nmResult = Process.runSync(nmExec, nmArgs);
+  if (nmResult.exitCode != 0) {
+    print("+ ${nmExec} ${nmArgs.join(' ')}");
+    print(nmResult.exitCode);
+    print(nmResult.stdout);
+    print(nmResult.stderr);
+    exit(1);
+  }
+
+  var root = new Symbol();
+  root.name = binaryPath;
+  root.type = "path";
+  root.shallowSize = 0;
+  var paths = new Map<String, Symbol>();
+  paths[""] = root;
+  addToPath(Symbol s, String path) {
+    Symbol? p = paths[path];
+    if (p == null) {
+      p = new Symbol();
+      p.name = path;
+      p.type = "path";
+      p.shallowSize = 0;
+      paths[path] = p;
+
+      var i = path.lastIndexOf("/");
+      if (i != -1) {
+        p.name = path.substring(i + 1);
+        addToPath(p, path.substring(0, i));
+      } else {
+        root.children.add(p);
+      }
+    }
+    p.children.add(s);
+  }
+
+  var lines = nmResult.stdout.split("\n");
+  var regex = new RegExp("([0-9a-f]+) ([0-9a-f]+) ([a-zA-z]) (.*)");
+  for (var line in lines) {
+    print(line);
+    var match = regex.firstMatch(line);
+    if (match == null) {
+      continue;
+    }
+
+    var address = int.parse(match[1]!, radix: 16);
+    var size = int.parse(match[2]!, radix: 16);
+    var type = match[3];
+    if (type == "b" || type == "B") {
+      // Uninitialized data does not contribute to file size.
+      continue;
+    }
+
+    var nameAndPath = match[4]!.split("\t");
+    var name = nameAndPath[0].trim();
+    var path = nameAndPath.length == 1 ? "" : nameAndPath[1].trim();
+    var colon = path.lastIndexOf(":");
+    if (colon > 0) {
+      path = path.substring(0, colon);
+    }
+    path = prettyPath(path);
+
+    for (var prefix in const ["vtable for ",
+                              "typeinfo for ",
+                              "typeinfo name for "]) {
+      if (name.startsWith(prefix)) {
+        name = name.substring(prefix.length);
+        path = prefix;
+      }
+    }
+
+    var s = new Symbol();
+    s.name = name;
+    s.type = type;
+    s.shallowSize = size;
+    addToPath(s, path);
+  }
+
+  root.compressTrivialPaths();
+  root.computeRetainedSize();
+
+  var json = new StringBuffer();
+  root.writeJson(json);
+
+  var html = viewer.replaceAll("__DATA__", json.toString());
+  new File("${binaryPath}.html").writeAsStringSync(html);
+
+  // This written as a URL instead of path because some terminals will
+  // automatically recognize it and make it a link.
+  var url = Directory.current.uri.resolve("${binaryPath}.html");
+  print("Wrote $url");
+}
+
+var viewer = """
+<html>
+<head>
+<style>
+.treemapTile {
+    position: absolute;
+    box-sizing: border-box;
+    border: solid 1px;
+    font-size: 13;
+    text-align: center;
+    overflow: hidden;
+    white-space: nowrap;
+    cursor: default;
+}
+</style>
+</head>
+<body>
+<script>
+var root = __DATA__;
+
+function hash(string) {
+  // Jenkin's one_at_a_time.
+  let h = string.length;
+  for (let i = 0; i < string.length; i++) {
+    h += string.charCodeAt(i);
+    h += h << 10;
+    h ^= h >> 6;
+  }
+  h += h << 3;
+  h ^= h >> 11;
+  h += h << 15;
+  return h;
+}
+
+function color(string) {
+  let hue = hash(string) % 360;
+  return "hsl(" + hue + ",90%,80%)";
+}
+
+function prettySize(size) {
+  if (size < 1024) return size + "B";
+  size /= 1024;
+  if (size < 1024) return size.toFixed(1) + "KiB";
+  size /= 1024;
+  if (size < 1024) return size.toFixed(1) + "MiB";
+  size /= 1024;
+  return size.toFixed(1) + "GiB";
+}
+
+function createTreemapTile(v, width, height, depth) {
+  let div = document.createElement("div");
+  div.className = "treemapTile";
+  div.style["background-color"] = color(v.type);
+  div.ondblclick = function(event) {
+    event.stopPropagation();
+    if (depth == 0) {
+      let parent = v.parent;
+      if (parent === undefined) {
+        // Already at root.
+      } else {
+        showDominatorTree(parent);  // Zoom out.
+      }
+    } else {
+      showDominatorTree(v);  // Zoom in.
+    }
+  };
+
+  let left = 0;
+  let top = 0;
+
+  const kPadding = 5;
+  const kBorder = 1;
+  left += kPadding - kBorder;
+  top += kPadding - kBorder;
+  width -= 2 * kPadding;
+  height -= 2 * kPadding;
+
+  div.title =
+    v.name +
+    " \\ntype: " + v.type +
+    " \\nretained: " + v.retainedSize +
+    " \\nshallow: " + v.shallowSize;
+
+  if (width < 10 || height < 10) {
+    // Too small: don't render label or children.
+    return div;
+  }
+
+  let label = v.name + " [" + prettySize(v.retainedSize) + "]";
+  div.appendChild(document.createTextNode(label));
+  const kLabelHeight = 13;
+  top += kLabelHeight;
+  height -= kLabelHeight;
+
+  if (depth > 2) {
+    // Too deep: don't render children.
+    return div;
+  }
+  if (width < 4 || height < 4) {
+    // Too small: don't render children.
+    return div;
+  }
+
+  let children = new Array();
+  v.children.forEach(function(c) {
+    // Size 0 children seem to confuse the layout algorithm (accumulating
+    // rounding errors?).
+    if (c.retainedSize > 0) {
+      children.push(c);
+    }
+  });
+  children.sort(function (a, b) {
+    return b.retainedSize - a.retainedSize;
+  });
+
+  const scale = width * height / v.retainedSize;
+
+  // Bruls M., Huizing K., van Wijk J.J. (2000) Squarified Treemaps. In: de
+  // Leeuw W.C., van Liere R. (eds) Data Visualization 2000. Eurographics.
+  // Springer, Vienna.
+  for (let rowStart = 0;  // Index of first child in the next row.
+       rowStart < children.length;) {
+    // Prefer wider rectangles, the better to fit text labels.
+    const GOLDEN_RATIO = 1.61803398875;
+    let verticalSplit = (width / height) > GOLDEN_RATIO;
+
+    let space;
+    if (verticalSplit) {
+      space = height;
+    } else {
+      space = width;
+    }
+
+    let rowMin = children[rowStart].retainedSize * scale;
+    let rowMax = rowMin;
+    let rowSum = 0;
+    let lastRatio = 0;
+
+    let rowEnd;  // One after index of last child in the next row.
+    for (rowEnd = rowStart; rowEnd < children.length; rowEnd++) {
+      let size = children[rowEnd].retainedSize * scale;
+      if (size < rowMin) rowMin = size;
+      if (size > rowMax) rowMax = size;
+      rowSum += size;
+
+      let ratio = Math.max((space * space * rowMax) / (rowSum * rowSum),
+                           (rowSum * rowSum) / (space * space * rowMin));
+      if ((lastRatio != 0) && (ratio > lastRatio)) {
+        // Adding the next child makes the aspect ratios worse: remove it and
+        // add the row.
+        rowSum -= size;
+        break;
+      }
+      lastRatio = ratio;
+    }
+
+    let rowLeft = left;
+    let rowTop = top;
+    let rowSpace = rowSum / space;
+
+    for (let i = rowStart; i < rowEnd; i++) {
+      let child = children[i];
+      let size = child.retainedSize * scale;
+
+      let childWidth;
+      let childHeight;
+      if (verticalSplit) {
+        childWidth = rowSpace;
+        childHeight = size / childWidth;
+      } else {
+        childHeight = rowSpace;
+        childWidth = size / childHeight;
+      }
+
+      let childDiv = createTreemapTile(child, childWidth, childHeight, depth + 1);
+      childDiv.style.left = rowLeft + "px";
+      childDiv.style.top = rowTop + "px";
+      // Oversize the final div by kBorder to make the borders overlap.
+      childDiv.style.width = (childWidth + kBorder) + "px";
+      childDiv.style.height = (childHeight + kBorder) + "px";
+      div.appendChild(childDiv);
+
+      if (verticalSplit)
+        rowTop += childHeight;
+      else
+        rowLeft += childWidth;
+    }
+
+    if (verticalSplit) {
+      left += rowSpace;
+      width -= rowSpace;
+    } else {
+      top += rowSpace;
+      height -= rowSpace;
+    }
+
+    rowStart = rowEnd;
+  }
+
+  return div;
+}
+
+function showDominatorTree(v) {
+  // Add the content div to the document first so the browser will calculate
+  // the available width and height.
+  let w = document.body.offsetWidth;
+  let h = document.body.offsetHeight;
+
+  let topTile = createTreemapTile(v, w, h, 0);
+  topTile.style.width = w;
+  topTile.style.height = h;
+  setBody(topTile);
+}
+
+function setBody(div) {
+  let body = document.body;
+  while (body.firstChild) {
+    body.removeChild(body.firstChild);
+  }
+  body.appendChild(div);
+}
+
+function setParents(v) {
+  v.children.forEach(function (child) {
+    child.parent = v;
+    setParents(child);
+  });
+}
+
+setParents(root);
+showDominatorTree(root);
+
+</script>
+</body>
+</html>
+""";