File indexing completed on 2026-11-14 09:38:43
0001
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
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
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
0119
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
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()