Back to home page

EIC code displayed by LXR

 
 

    


File indexing completed on 2026-11-14 09:38:43

0001 #!/usr/bin/env python3
0002 """Map the on-disk storage taken up by the collections in a podio RNTuple file.
0003 
0004 The breakdown comes from ROOT's RNTuple treemap
0005 (ROOT::Experimental::CreateTreeMapFromRNTuple), with the podio relation,
0006 vector-member and subset-index fields grouped under the collection they belong
0007 to. If a reference file is given, every collection and field is compared to it.
0008 
0009 Writes to the output directory:
0010   storage.html         interactive treemap and per-collection table
0011   storage_treemap.svg  ROOT's own treemap rendering, colored by column type
0012   storage_summary.txt  one-line summary for the PR comment, e.g. "size Δ: +10.2 kB"
0013 """
0014 import argparse
0015 import html
0016 import json
0017 import os
0018 
0019 import ROOT
0020 
0021 ROOT.gROOT.SetBatch(True)
0022 ROOT.gInterpreter.Declare("#include <ROOT/RNTupleTreeMap.hxx>")
0023 
0024 # Depth of the treemap below the collections: field, then member.
0025 MAX_DEPTH = 2
0026 
0027 
0028 def fmt_bytes(n, sign=False):
0029     for unit, scale in (("MB", 1e6), ("kB", 1e3)):
0030         if abs(n) >= scale:
0031             return f"{n / scale:{'+' if sign else ''}.1f} {unit}"
0032     return f"{n:{'+' if sign and n else ''}d} B"
0033 
0034 
0035 def fmt_rel(new, ref):
0036     if ref is None:
0037         return "new"
0038     if new is None:
0039         return "removed"
0040     if ref == 0:
0041         return "" if new == 0 else "new"
0042     return f"{(new - ref) / ref:+.1%}"
0043 
0044 
0045 def collection_of(field, collections):
0046     """Collection that a top-level podio field belongs to."""
0047     if not field.startswith("_"):
0048         return field.removesuffix("_objIdx")
0049     # Relations and vector members: _<collection>_<name>
0050     owners = [c for c in collections if field.startswith(f"_{c}_")]
0051     return max(owners, key=len) if owners else field
0052 
0053 
0054 def subfields(nodes, idx):
0055     """Field (not column) children of a treemap node, looking through the
0056     '_0' item fields that RNTuple inserts for vectors and arrays."""
0057     node = nodes[idx]
0058     for c in range(node.fChildrenIdx, node.fChildrenIdx + node.fNChildren):
0059         child = nodes[c]
0060         if str(child.fType):
0061             continue
0062         if str(child.fName) == "_0":
0063             yield from subfields(nodes, c)
0064         else:
0065             yield c
0066 
0067 
0068 def flatten(nodes, idx, path, depth, sizes):
0069     sizes[path] = nodes[idx].fSize
0070     if depth > 0:
0071         for c in subfields(nodes, idx):
0072             flatten(nodes, c, path + (str(nodes[c].fName),), depth - 1, sizes)
0073 
0074 
0075 def load(path, ntuple):
0076     """Compressed sizes keyed by (collection, field, member, ...) paths."""
0077     insp = ROOT.Experimental.RNTupleInspector.Create(ntuple, path)
0078     treemap = ROOT.Experimental.CreateTreeMapFromRNTuple(insp)
0079     nodes = treemap.fNodes
0080     top = list(subfields(nodes, 0))
0081     names = [str(nodes[i].fName) for i in top]
0082     collections = {n.removesuffix("_objIdx") for n in names if not n.startswith("_")}
0083 
0084     sizes = {(): nodes[0].fSize}
0085     uncompressed = {}
0086     for idx, field in zip(top, names):
0087         coll = collection_of(field, collections)
0088         sizes[(coll,)] = sizes.get((coll,), 0) + nodes[idx].fSize
0089         uncompressed[coll] = (
0090             uncompressed.get(coll, 0)
0091             + insp.GetFieldTreeInspector(field).GetUncompressedSize()
0092         )
0093         flatten(nodes, idx, (coll, field), MAX_DEPTH - 1, sizes)
0094 
0095     return {
0096         "file": os.path.basename(path),
0097         "file_size": os.path.getsize(path),
0098         "entries": insp.GetDescriptor().GetNEntries(),
0099         "compressed": insp.GetCompressedSize(),
0100         "uncompressed": insp.GetUncompressedSize(),
0101         "sizes": sizes,
0102         "collection_uncompressed": uncompressed,
0103         "treemap": treemap,
0104     }
0105 
0106 
0107 def write_root_svg(treemap, filename):
0108     ROOT.SetOwnership(treemap, False)
0109     canvas = ROOT.TCanvas("storage", "storage", 1600, 1000)
0110     treemap.Draw()
0111     canvas.SaveAs(filename)
0112 
0113 
0114 def write_html(new, ref, ntuple, filename):
0115     sizes = new["sizes"]
0116     ref_sizes = ref["sizes"] if ref else {}
0117 
0118     # Nodes as parallel arrays with parent indices; labels, hover text and
0119     # colors are built in the browser to keep the page small.
0120     paths = sorted(sizes)
0121     index = {path: i for i, path in enumerate(paths)}
0122     nodes = {
0123         "label": [path[-1] if path else ntuple for path in paths],
0124         "parent": [index[path[:-1]] if path else -1 for path in paths],
0125         "size": [sizes[path] for path in paths],
0126         "ref": [ref_sizes.get(path) for path in paths] if ref else None,
0127     }
0128 
0129     # Per-collection table, including collections that were removed
0130     collections = sorted({p[0] for p in sizes if len(p) == 1} | {p[0] for p in ref_sizes if len(p) == 1})
0131     rows = []
0132     for coll in collections:
0133         size = sizes.get((coll,))
0134         ref_size = ref_sizes.get((coll,)) if ref else None
0135         unc = new["collection_uncompressed"].get(coll)
0136         cells = [
0137             (coll, coll),
0138             (size or 0, fmt_bytes(size) if size is not None else "–"),
0139             (size or 0, f"{(size or 0) / new['compressed']:.2%}"),
0140             ((size or 0) / max(new["entries"], 1), fmt_bytes(round((size or 0) / max(new["entries"], 1)))),
0141             (unc / size if unc and size else 0, f"{unc / size:.1f}" if unc and size else "–"),
0142         ]
0143         if ref:
0144             delta = (size or 0) - (ref_size or 0)
0145             cells += [
0146                 (ref_size or 0, fmt_bytes(ref_size) if ref_size is not None else "–"),
0147                 (delta, fmt_bytes(delta, sign=True) if delta else ""),
0148                 (delta / ref_size if ref_size else ("Infinity" if delta else 0), fmt_rel(size, ref_size) if delta else ""),
0149             ]
0150         cls = ""
0151         if ref and size != ref_size:
0152             cls = ' class="grew"' if (size or 0) > (ref_size or 0) else ' class="shrank"'
0153         rows.append(
0154             f"<tr{cls}>"
0155             + "".join(
0156                 f'<td data-sort="{html.escape(str(key))}">{html.escape(str(text))}</td>'
0157                 for key, text in cells
0158             )
0159             + "</tr>"
0160         )
0161     headers = ["collection", "size", "share", "per event", "compression"]
0162     if ref:
0163         headers += ["reference", "Δ", "Δ (%)"]
0164 
0165     def summary_row(label, key, fmt=fmt_bytes):
0166         cells = f"<th>{label}</th><td>{fmt(new[key])}</td>"
0167         if ref:
0168             delta = new[key] - ref[key]
0169             cells += f"<td>{fmt(ref[key])}</td><td>{fmt(delta, sign=True)}</td>"
0170         return f"<tr>{cells}</tr>"
0171 
0172     summary = "".join([
0173         "<tr><th></th><th>this file</th>" + ("<th>reference</th><th>Δ</th>" if ref else "") + "</tr>",
0174         summary_row("file size", "file_size"),
0175         summary_row(f"'{html.escape(ntuple)}' compressed", "compressed"),
0176         summary_row(f"'{html.escape(ntuple)}' uncompressed", "uncompressed"),
0177         summary_row("entries", "entries", lambda n, sign=False: f"{n:{'+' if sign and n else ''}d}"),
0178     ])
0179     if ref and ref["entries"] != new["entries"]:
0180         summary += '<tr><td colspan="4" class="grew">Entry counts differ: sizes are not directly comparable.</td></tr>'
0181 
0182     title = f"Storage map of {new['file']}"
0183     reference = f"Compared to reference {html.escape(ref['file'])}. " if ref else "No reference file to compare to. "
0184     page = f"""<!DOCTYPE html>
0185 <html lang="en">
0186 <head>
0187 <meta charset="utf-8">
0188 <meta name="viewport" content="width=device-width, initial-scale=1">
0189 <title>{html.escape(title)}</title>
0190 <script src="https://cdn.jsdelivr.net/npm/plotly.js-dist-min@2.35.2/plotly.min.js"></script>
0191 <style>
0192   body {{ font-family: system-ui, sans-serif; margin: 1em 2em; color: #222; background: #fff; }}
0193   h1 {{ overflow-wrap: anywhere; }}
0194   .scroll {{ overflow-x: auto; }}
0195   table {{ border-collapse: collapse; font-size: 0.9em; }}
0196   th, td {{ padding: 2px 8px; text-align: right; border-bottom: 1px solid #eee; }}
0197   th:first-child, td:first-child {{ text-align: left; }}
0198   #collections th {{ cursor: pointer; position: sticky; top: 0; background: #f6f6f6; }}
0199   tr.grew td {{ background: #fde0dd; }}
0200   tr.shrank td {{ background: #deebf7; }}
0201   #treemap {{ height: 80vh; }}
0202 </style>
0203 </head>
0204 <body>
0205 <h1>{html.escape(title)}</h1>
0206 <p>Compressed on-disk size of the collections in the '{html.escape(ntuple)}' RNTuple, from ROOT's
0207 RNTuple treemap with podio relation, vector-member and subset fields grouped under their collection.
0208 {reference}Click a box to zoom in; click the top bar to zoom out.
0209 See also <a href="storage_treemap.svg">ROOT's treemap rendering</a>, colored by column type.</p>
0210 <div class="scroll"><table>{summary}</table></div>
0211 <div id="treemap"></div>
0212 <h2>Collections</h2>
0213 <p>Compression is uncompressed / compressed size. Click a column header to sort.</p>
0214 <div class="scroll"><table id="collections">
0215 <thead><tr>{''.join(f'<th>{h}</th>' for h in headers)}</tr></thead>
0216 <tbody>
0217 {chr(10).join(rows)}
0218 </tbody>
0219 </table></div>
0220 <script>
0221 const nodes = {json.dumps(nodes, ensure_ascii=False, separators=(",", ":")).replace("</", "<\\/")};
0222 const fmt = b => Math.abs(b) >= 1e6 ? (b / 1e6).toFixed(1) + " MB"
0223   : Math.abs(b) >= 1e3 ? (b / 1e3).toFixed(1) + " kB" : b + " B";
0224 const signed = (v, text) => (v > 0 ? "+" : "") + text;
0225 const paths = [];
0226 nodes.label.forEach((label, i) => {{
0227   const p = nodes.parent[i];
0228   paths.push(p < 0 ? label : paths[p] + "/" + label);
0229 }});
0230 const trace = {{
0231   type: "treemap",
0232   ids: nodes.label.map((_, i) => String(i)),
0233   parents: nodes.parent.map(p => p < 0 ? "" : String(p)),
0234   labels: nodes.label,
0235   values: nodes.size,
0236   branchvalues: "total",
0237   maxdepth: 3,
0238   texttemplate: "%{{label}}<br>%{{customdata[0]}}",
0239   hovertemplate: "<b>%{{customdata[1]}}</b><br>%{{customdata[0]}} (%{{percentRoot:.2%}} of total)"
0240     + (nodes.ref ? "<br>reference: %{{customdata[2]}}<br>Δ: %{{customdata[3]}}" : "") + "<extra></extra>",
0241 }};
0242 trace.customdata = nodes.size.map((s, i) => {{
0243   const r = nodes.ref ? nodes.ref[i] : null;
0244   return [fmt(s), paths[i], r === null ? "–" : fmt(r),
0245     r === null ? "new" : signed(s - r, fmt(s - r)) + (r ? " (" + signed(s - r, (100 * (s - r) / r).toFixed(1)) + "%)" : "")];
0246 }});
0247 if (nodes.ref) {{
0248   // Relative change on a log scale: 1% -> 1/3, 10% -> 2/3, 100% -> 1
0249   const scale = (s, r) => r === null || (r === 0 && s) ? 1 : r === 0 ? 0
0250     : Math.sign(s - r) * Math.min(Math.log10(1 + 100 * Math.abs((s - r) / r)) / 3, 1);
0251   trace.marker = {{
0252     colors: nodes.size.map((s, i) => scale(s, nodes.ref[i])),
0253     colorscale: "RdBu", cmin: -1, cmax: 1, cmid: 0, showscale: true,
0254     colorbar: {{
0255       title: {{text: "size Δ"}},
0256       tickvals: [-1, -2 / 3, -1 / 3, 0, 1 / 3, 2 / 3, 1],
0257       ticktext: ["≤−100%", "−10%", "−1%", "0", "+1%", "+10%", "≥+100%"],
0258     }},
0259   }};
0260 }}
0261 Plotly.newPlot("treemap", [trace], {{margin: {{t: 30, l: 0, r: 0, b: 0}}}}, {{responsive: true}});
0262 const table = document.getElementById("collections");
0263 table.querySelectorAll("th").forEach((th, col) => th.addEventListener("click", () => {{
0264   const desc = th.dataset.desc !== "true";
0265   th.dataset.desc = desc;
0266   const key = tr => {{
0267     const v = tr.children[col].dataset.sort;
0268     return col === 0 ? v : parseFloat(v);
0269   }};
0270   const rows = [...table.tBodies[0].rows].sort((a, b) =>
0271     (key(a) > key(b) ? 1 : key(a) < key(b) ? -1 : 0) * (desc ? -1 : 1));
0272   table.tBodies[0].append(...rows);
0273 }}));
0274 </script>
0275 </body>
0276 </html>
0277 """
0278     with open(filename, "w") as f:
0279         f.write(page)
0280 
0281 
0282 def main():
0283     ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
0284     ap.add_argument("file", help="podio RNTuple file")
0285     ap.add_argument("reference", nargs="?", help="podio RNTuple file to compare to")
0286     ap.add_argument("-o", "--output-dir", default=".", help="directory to write to")
0287     ap.add_argument("--ntuple", default="events", help="RNTuple to map (default: %(default)s)")
0288     args = ap.parse_args()
0289 
0290     new = load(args.file, args.ntuple)
0291     ref = load(args.reference, args.ntuple) if args.reference else None
0292 
0293     os.makedirs(args.output_dir, exist_ok=True)
0294     write_html(new, ref, args.ntuple, os.path.join(args.output_dir, "storage.html"))
0295     if ref:
0296         summary = f"size Δ: {fmt_bytes(new['compressed'] - ref['compressed'], sign=True)}"
0297     else:
0298         summary = f"size: {fmt_bytes(new['compressed'])}"
0299     with open(os.path.join(args.output_dir, "storage_summary.txt"), "w") as f:
0300         f.write(summary + "\n")
0301     print(summary)
0302 
0303     write_root_svg(new["treemap"], os.path.join(args.output_dir, "storage_treemap.svg"))
0304 
0305 
0306 if __name__ == "__main__":
0307     main()