interactive_timertree.pyΒΆ

interactive_timertree.py reads the timing information output by Carpet (as XML files) are prepares a webpage with an interactive visualization on which functions took most of the time.

The webpage has to be rendered in a webserver, which is automatically (but optionally) started by interactive_timertree.py. On a remote cluster, you may need to copy the file locally.

This is what it looks like:

#!/usr/bin/env python3
# PYTHON_ARGCOMPLETE_OK

# Copyright (C) 2022-2024 Gabriele Bozzola and Observables HQ
#
# This program is free software; you can redistribute it and/or modify it under
# the terms of the GNU General Public License as published by the Free Software
# Foundation; either version 3 of the License, or (at your option) any later
# version.
#
# This program is distributed in the hope that it will be useful, but WITHOUT
# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
# FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
# details.
#
# You should have received a copy of the GNU General Public License along with
# this program; if not, see <https://www.gnu.org/licenses/>.

import http.server
import logging
import socketserver
from string import Template

from kuibit import argparse_helper as kah
from kuibit.simdir import SimDir

html_template = """
<!DOCTYPE html>
<html>
  <head>
    <title>Timing information</title>
    <script src="https://unpkg.com/d3@7.0.4/dist/d3.min.js"></script>
  </head>
  <style>
      body {
        overflow: hidden;
      }
    </style>
  <body></body>
  <script>
    (function (d3$$1) {
      'use strict';

      function rectHeight(d) {
        return d.x1 - d.x0 - Math.min(1, (d.x1 - d.x0) / 2);
      }

      function labelVisible(d) {
        return d.y1 <= width && d.y0 >= 0 && d.x1 - d.x0 > 21;
      }

      const width = window.innerWidth;
      const height = window.innerHeight;

      function iciclePartition(data) {
        const root = d3
          .hierarchy(data)
          .sum((d) => d.value)
          .sort(
            (a, b) => b.height - a.height || b.value - a.value
          );
        return d3$$1.partition().size([
          height,
          ((root.height + 1) * width) / 3,
        ])(root);
      }

      function render(data) {
        const color = d3$$1.scaleOrdinal(
          d3$$1.quantize(d3$$1.interpolateRainbow, data.children.length + 1)
        );

        const root = iciclePartition(data);
        let focus = root;

        const svg = d3$$1.select('body')
          .append('svg')
          .attr('viewBox', [0, 0, width, height])
          .style('font', '20px sans-serif');

        const cell = svg
          .selectAll('g')
          .data(root.descendants())
          .join('g')
          .attr('transform', (d) => `translate($${d.y0},$${d.x0})`);

        const rect = cell
          .append('rect')
          .attr('width', (d) => d.y1 - d.y0 - 1)
          .attr('height', (d) => rectHeight(d))
          .attr('fill-opacity', 0.6)
          .attr('fill', (d) => {
            if (!d.depth) return '#ccc';
            while (d.depth > 1) d = d.parent;
            return color(d.data.name);
          })
          .style('cursor', 'pointer')
          .on('click', clicked);

        const text = cell
          .append('text')
          .style('user-select', 'none')
          .attr('pointer-events', 'none')
          .attr('x', 4)
          .attr('y', 19)
          .attr('fill-opacity', (d) => +labelVisible(d));

        text.append('tspan').text((d) => d.data.name);

        const tspan = text
          .append('tspan')
          .attr('fill-opacity', (d) => labelVisible(d) * 0.7)
          .text((d) => ` $${d3$$1.format(".4s")(d.value)} seconds`);

        cell.append('title').text(
          (d) =>
            `$${d
            .ancestors()
            .map((d) => d.data.name)
            .reverse()
            .join('/')}\n$${d3$$1.format("e")(d.value)} seconds`
        );

        function clicked(event, p) {
          focus = focus === p ? (p = p.parent) : p;

          root.each(
            (d) =>
              (d.target = {
                x0: ((d.x0 - p.x0) / (p.x1 - p.x0)) * height,
                x1: ((d.x1 - p.x0) / (p.x1 - p.x0)) * height,
                y0: d.y0 - p.y0,
                y1: d.y1 - p.y0,
              })
          );

          const t = cell
            .transition()
            .duration(750)
            .attr(
              'transform',
              (d) => `translate($${d.target.y0},$${d.target.x0})`
            );

          rect
            .transition(t)
            .attr('height', (d) => rectHeight(d.target));
          text
            .transition(t)
            .attr('fill-opacity', (d) => +labelVisible(d.target));
          tspan
            .transition(t)
            .attr(
              'fill-opacity',
              (d) => labelVisible(d.target) * 0.7
            );
        }
      }

      d3$$1.json("$json_path").then(render);

    }(d3));
</script>
</html>
"""


PORT = 8001

if __name__ == "__main__":
    desc = f"""\
{kah.get_program_name()} reads timers and prepares an interactive webpage with the profiling information."""

    parser = kah.init_argparse(desc)
    parser.add_argument(
        "--port",
        type=int,
        default=8000,
        help="Part at which to serve the page.",
    )
    parser.add_argument(
        "--html-path",
        default="index.html",
        help="Where to save the HTML file that displays the data."
        " Existing data will be overwritten.",
    )
    parser.add_argument(
        "--json-path",
        default="data.json",
        help="Where to save the JSON file with the data."
        " Existing data will be overwritten.",
    )
    parser.add_argument(
        "--no-server",
        action="store_true",
        help="Do not start a web server.",
    )
    parser.add_argument(
        "--only-server",
        action="store_true",
        help="Only start the server.",
    )

    args = kah.get_args(parser)

    logger = logging.getLogger(__name__)

    if args.verbose:
        logging.basicConfig(format="%(asctime)s - %(message)s")
        logger.setLevel(logging.DEBUG)

    if not args.only_server:
        with SimDir(
            args.datadir,
            ignore_symlinks=args.ignore_symlinks,
            pickle_file=args.pickle_file,
        ) as sim:
            logger.debug("Prepared SimDir")
            timers = sim.timers.average
            logger.debug("Timers read")

        with open(args.html_path, "w") as file_:
            file_.write(
                Template(html_template).substitute(
                    {"json_path": args.json_path}
                )
            )
            logger.debug(f"{args.html_path} written")

        with open(args.json_path, "w") as file_:
            file_.write(timers.to_json())
            logger.debug(f"{args.json_path} written")

    if not args.no_server:
        with socketserver.TCPServer(
            ("", args.port), http.server.SimpleHTTPRequestHandler
        ) as httpd:
            print(f"Serving at port {args.port}. Terminate with C-c.")
            httpd.serve_forever()