bobframes 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. bobframes/__init__.py +3 -0
  2. bobframes/_version.py +1 -0
  3. bobframes/catalog.py +154 -0
  4. bobframes/cli.py +266 -0
  5. bobframes/derive_post_merge.py +365 -0
  6. bobframes/derives/__init__.py +0 -0
  7. bobframes/derives/pass_class_breakdown.py +102 -0
  8. bobframes/derives/texture_usage.py +121 -0
  9. bobframes/discovery.py +132 -0
  10. bobframes/global_entities.py +99 -0
  11. bobframes/html/__init__.py +0 -0
  12. bobframes/html/template.py +1056 -0
  13. bobframes/lint.py +114 -0
  14. bobframes/manifest.py +127 -0
  15. bobframes/parquetize.py +282 -0
  16. bobframes/parsers/__init__.py +0 -0
  17. bobframes/parsers/derive_program_transitions.py +73 -0
  18. bobframes/parsers/parse_init_state.py +675 -0
  19. bobframes/paths.py +111 -0
  20. bobframes/probes/__init__.py +0 -0
  21. bobframes/probes/whatif.py +165 -0
  22. bobframes/qrd_harness.py +119 -0
  23. bobframes/query_examples.py +222 -0
  24. bobframes/rdcmd.py +72 -0
  25. bobframes/replay/__init__.py +26 -0
  26. bobframes/replay/replay_main.py +2305 -0
  27. bobframes/reports/__init__.py +0 -0
  28. bobframes/reports/_dashboard.py +425 -0
  29. bobframes/reports/ab.py +88 -0
  30. bobframes/reports/base.py +114 -0
  31. bobframes/reports/cache.py +147 -0
  32. bobframes/reports/chrome.py +1306 -0
  33. bobframes/reports/cli.py +99 -0
  34. bobframes/reports/delta.py +167 -0
  35. bobframes/reports/discovery.py +118 -0
  36. bobframes/reports/draws_by_class.py +165 -0
  37. bobframes/reports/formatters.py +122 -0
  38. bobframes/reports/instancing_opportunities.py +276 -0
  39. bobframes/reports/orchestrator.py +59 -0
  40. bobframes/reports/overdraw.py +293 -0
  41. bobframes/reports/pass_gpu.py +190 -0
  42. bobframes/reports/shader_hotlist.py +240 -0
  43. bobframes/reports/trend_table.py +444 -0
  44. bobframes/resource_labels.py +162 -0
  45. bobframes/run.py +480 -0
  46. bobframes/schemas.py +426 -0
  47. bobframes/stable_keys.py +83 -0
  48. bobframes/tests/__init__.py +0 -0
  49. bobframes/tests/_render_util.py +84 -0
  50. bobframes/tests/data/golden/_reports/draws_by_class.html +323 -0
  51. bobframes/tests/data/golden/_reports/drill/District 01/2026-05-28_r110600/index.html +1560 -0
  52. bobframes/tests/data/golden/_reports/index.html +264 -0
  53. bobframes/tests/data/golden/_reports/instancing_opportunities.html +266 -0
  54. bobframes/tests/data/golden/_reports/overdraw.html +275 -0
  55. bobframes/tests/data/golden/_reports/pass_gpu.html +277 -0
  56. bobframes/tests/data/golden/_reports/shader_hotlist.html +265 -0
  57. bobframes/tests/data/golden/_reports/trend_table.html +390 -0
  58. bobframes/tests/data/golden/index.html +1175 -0
  59. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/_manifest.json +51 -0
  60. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/buffers.parquet +0 -0
  61. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/clears.parquet +0 -0
  62. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/counters_per_event.parquet +0 -0
  63. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/descriptor_access.parquet +0 -0
  64. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/dispatches.parquet +0 -0
  65. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draw_bindings.parquet +0 -0
  66. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draws.parquet +0 -0
  67. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/events.parquet +0 -0
  68. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/fbos.parquet +0 -0
  69. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/frame_totals.parquet +0 -0
  70. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/ibo_samples.parquet +0 -0
  71. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/indirect_args.parquet +0 -0
  72. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/passes.parquet +0 -0
  73. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/pixel_history.parquet +0 -0
  74. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/post_vs_samples.parquet +0 -0
  75. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/program_transitions.parquet +0 -0
  76. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/programs.parquet +0 -0
  77. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/render_targets.parquet +0 -0
  78. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/resource_creation.parquet +0 -0
  79. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/rt_event_timeline.parquet +0 -0
  80. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/samplers.parquet +0 -0
  81. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/shaders.parquet +0 -0
  82. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/state_change_events.parquet +0 -0
  83. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/texture_samples.parquet +0 -0
  84. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/textures.parquet +0 -0
  85. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vbo_samples.parquet +0 -0
  86. bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vertex_inputs.parquet +0 -0
  87. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/_manifest.json +51 -0
  88. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/buffers.parquet +0 -0
  89. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/clears.parquet +0 -0
  90. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/counters_per_event.parquet +0 -0
  91. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/descriptor_access.parquet +0 -0
  92. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/dispatches.parquet +0 -0
  93. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draw_bindings.parquet +0 -0
  94. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draws.parquet +0 -0
  95. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/events.parquet +0 -0
  96. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/fbos.parquet +0 -0
  97. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/frame_totals.parquet +0 -0
  98. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/ibo_samples.parquet +0 -0
  99. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/indirect_args.parquet +0 -0
  100. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/passes.parquet +0 -0
  101. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/pixel_history.parquet +0 -0
  102. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/post_vs_samples.parquet +0 -0
  103. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/program_transitions.parquet +0 -0
  104. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/programs.parquet +0 -0
  105. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/render_targets.parquet +0 -0
  106. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/resource_creation.parquet +0 -0
  107. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/rt_event_timeline.parquet +0 -0
  108. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/samplers.parquet +0 -0
  109. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/shaders.parquet +0 -0
  110. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/state_change_events.parquet +0 -0
  111. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/texture_samples.parquet +0 -0
  112. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/textures.parquet +0 -0
  113. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vbo_samples.parquet +0 -0
  114. bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vertex_inputs.parquet +0 -0
  115. bobframes/tests/make_synthetic.py +171 -0
  116. bobframes/tests/smoke.py +199 -0
  117. bobframes/tests/test_determinism.py +19 -0
  118. bobframes/tests/test_discovery.py +97 -0
  119. bobframes/tests/test_hardening.py +142 -0
  120. bobframes/tests/test_parity.py +22 -0
  121. bobframes/tests/test_perf.py +18 -0
  122. bobframes/tests/test_replay_drift.py +115 -0
  123. bobframes/tests/test_schemas.py +26 -0
  124. bobframes/tests/test_schemas_unit.py +55 -0
  125. bobframes/tests/test_stable_keys.py +61 -0
  126. bobframes-0.1.0.dist-info/METADATA +144 -0
  127. bobframes-0.1.0.dist-info/RECORD +130 -0
  128. bobframes-0.1.0.dist-info/WHEEL +4 -0
  129. bobframes-0.1.0.dist-info/entry_points.txt +2 -0
  130. bobframes-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,276 @@
1
+ """Top mesh_hashes by repeat_count + sister 'material batching' section.
2
+
3
+ Reads _reports/_cache/draws_summary_per_drop.parquet when present; falls back
4
+ to live scan of **/draws.parquet.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import statistics
11
+ import sys
12
+ from collections import Counter, defaultdict
13
+
14
+ import pyarrow.parquet as papq
15
+
16
+ from . import base
17
+
18
+
19
+ _DRAWS_COLS = [
20
+ 'area', 'drop_date', 'drop_label', 'capture',
21
+ 'mesh_hash', 'program_id', 'vs_shader_id', 'fs_shader_id',
22
+ 'parent_pass_path_norm', 'draw_class', 'num_indices', 'num_instances',
23
+ ]
24
+
25
+
26
+ def _iter_draws(root: str, drops: list):
27
+ """Yield row dicts from cache, else live-scan each drop."""
28
+ cache = base.cache_path(root, 'draws_summary')
29
+ if os.path.exists(cache):
30
+ try:
31
+ t = papq.read_table(cache)
32
+ cols = {c: t.column(c).to_pylist() for c in t.column_names}
33
+ wanted_keys = {(d.date, d.label) for d in drops}
34
+ for i in range(t.num_rows):
35
+ if (cols['drop_date'][i], cols['drop_label'][i]) not in wanted_keys:
36
+ continue
37
+ yield {c: cols[c][i] for c in cols}
38
+ return
39
+ except Exception:
40
+ pass
41
+ for d in drops:
42
+ for r in d.rows:
43
+ p = os.path.join(r.drop_dir, 'draws.parquet')
44
+ if not os.path.exists(p):
45
+ continue
46
+ try:
47
+ t = papq.read_table(p, columns=_DRAWS_COLS)
48
+ except Exception:
49
+ continue
50
+ cols = {c: t.column(c).to_pylist() for c in t.column_names}
51
+ for i in range(t.num_rows):
52
+ row = {c: cols[c][i] for c in cols}
53
+ row['drop_date'] = r.drop_date
54
+ row['drop_label'] = r.drop_label
55
+ yield row
56
+
57
+
58
+ def _drop_dir_for(drops: list, drop_date, drop_label, area) -> str:
59
+ for d in drops:
60
+ if d.date == drop_date and d.label == drop_label:
61
+ for r in d.rows:
62
+ if r.area == area:
63
+ return r.drop_dir
64
+ return ''
65
+
66
+
67
+ def build(root: str, *, drops: list | None = None, ab=None) -> str:
68
+ if drops is None:
69
+ drops = base.discover_drops(root)
70
+ out_path = base.output_path(root, 'instancing_opportunities', ab)
71
+ out_dir = os.path.dirname(out_path)
72
+
73
+ drop_keys = [d.key for d in drops]
74
+
75
+ per_mesh: dict = defaultdict(lambda: {
76
+ 'repeat_by_drop': Counter(),
77
+ 'pass_paths': Counter(),
78
+ 'draw_classes': Counter(),
79
+ 'program_ids': Counter(),
80
+ 'num_indices': [],
81
+ 'captures': set(),
82
+ 'areas': set(),
83
+ 'rep_row': None,
84
+ 'rep_drop': None,
85
+ })
86
+
87
+ batching_groups: Counter = Counter()
88
+ batching_meshes: dict = defaultdict(set)
89
+ batching_drops: dict = defaultdict(set)
90
+
91
+ for row in _iter_draws(root, drops):
92
+ mh = row.get('mesh_hash')
93
+ n_idx = row.get('num_indices') or 0
94
+ prog = row.get('program_id') or 0
95
+ if not mh or n_idx <= 0 or prog == 0:
96
+ continue
97
+ drop_key = f"{row['drop_date']}_{row['drop_label']}"
98
+ m = per_mesh[mh]
99
+ m['repeat_by_drop'][drop_key] += 1
100
+ pass_norm = row.get('parent_pass_path_norm') or ''
101
+ cls = row.get('draw_class') or 'other'
102
+ m['pass_paths'][pass_norm] += 1
103
+ m['draw_classes'][cls] += 1
104
+ m['program_ids'][prog] += 1
105
+ m['num_indices'].append(n_idx)
106
+ m['captures'].add((row['area'], row['drop_date'], row.get('capture')))
107
+ m['areas'].add(row['area'])
108
+ if m['rep_row'] is None:
109
+ m['rep_row'] = row
110
+ m['rep_drop'] = (row['drop_date'], row['drop_label'])
111
+
112
+ inst = row.get('num_instances') or 1
113
+ if cls in ('opaque', 'prepass') and inst <= 1:
114
+ fs = row.get('fs_shader_id') or 0
115
+ key = (pass_norm, fs, cls)
116
+ batching_groups[key] += 1
117
+ batching_meshes[key].add(mh)
118
+ batching_drops[key].add(drop_key)
119
+
120
+ ranked = sorted(
121
+ per_mesh.items(),
122
+ key=lambda kv: max(kv[1]['repeat_by_drop'].values()) if kv[1]['repeat_by_drop'] else 0,
123
+ reverse=True,
124
+ )[:50]
125
+
126
+ top_repeat = (max((max(m['repeat_by_drop'].values()) for _, m in ranked
127
+ if m['repeat_by_drop']), default=0)
128
+ if ranked else 0)
129
+ n_unique_meshes = len(per_mesh)
130
+ max_wasted = 0
131
+ for mh, m in ranked:
132
+ n_idx_list = m['num_indices']
133
+ if not n_idx_list:
134
+ continue
135
+ n_typ = sorted(n_idx_list)[len(n_idx_list) // 2]
136
+ max_r = max(m['repeat_by_drop'].values())
137
+ max_wasted = max(max_wasted, (max_r - 1) * n_typ)
138
+ pct_deduped = (1.0 - (n_unique_meshes / sum(sum(m['repeat_by_drop'].values())
139
+ for _, m in per_mesh.items())
140
+ if per_mesh else 0)) * 100.0 if per_mesh else 0.0
141
+
142
+ parts = [base.page_open('instancing opportunities', hdr_offset_px=120)]
143
+ parts.append(base.header(
144
+ 'instancing opportunities',
145
+ drops=len(drops),
146
+ captures=sum(d.n_captures for d in drops),
147
+ build_ts=base.now_iso(),
148
+ crumb_depth=base.crumb_depth(ab),
149
+ ))
150
+ parts.append(base.ab_strip(ab))
151
+ parts.append(base.ab_picker_for(root, 'instancing_opportunities', ab=ab))
152
+
153
+ # Summary bar: top 3 meshes by repeat
154
+ top3 = []
155
+ for rank_i, (mh, m) in enumerate(ranked[:3], 1):
156
+ try:
157
+ n_typ = int(statistics.median(m['num_indices'])) if m['num_indices'] else 0
158
+ except statistics.StatisticsError:
159
+ n_typ = 0
160
+ max_repeat = max(m['repeat_by_drop'].values()) if m['repeat_by_drop'] else 0
161
+ dominant_pass = m['pass_paths'].most_common(1)[0][0] if m['pass_paths'] else ''
162
+ dominant_cls = m['draw_classes'].most_common(1)[0][0] if m['draw_classes'] else ''
163
+ suffix = base.pass_suffix(dominant_pass) or '?'
164
+ hash_tag = str(mh)[-4:] if mh else ''
165
+ label = f'{dominant_cls}/{suffix}/{n_typ}v#{hash_tag}'
166
+ top3.append((label, max_repeat))
167
+ if top3:
168
+ headline = f'{top3[0][0]} (repeat {base.fmt_int(top3[0][1])})'
169
+ sub_bits = [f'{lbl} x{rep}' for lbl, rep in top3[1:]]
170
+ parts.append(base.summary_bar(
171
+ 'top batch candidates',
172
+ headline,
173
+ sub='next: ' + ', '.join(sub_bits) if sub_bits else None,
174
+ link_href='#top_meshes',
175
+ link_text='table',
176
+ tone='neutral',
177
+ ))
178
+
179
+ sec1 = []
180
+ single = len(drop_keys) == 1
181
+ sec1.append('<h2 id="top_meshes">top meshes by repeat</h2>')
182
+ sec1.append('<div class="table-wrap"><rdc-sortable-table>')
183
+ sec1.append('<table class="report"><thead><tr>')
184
+ sec1.append('<th>mesh</th>')
185
+ for i, k in enumerate(drop_keys):
186
+ head = 'repeat' if single else f'repeat@{base.h(k)}'
187
+ sec1.append(f'<th class="num">{head}</th>')
188
+ if i > 0:
189
+ latest = ' delta-latest' if i == len(drop_keys) - 1 else ''
190
+ sec1.append(f'<th class="num{latest}">delta</th>')
191
+ if len(drop_keys) >= 3:
192
+ sec1.append('<th class="num">trend</th>')
193
+ sec1.extend([
194
+ '<th>areas</th>',
195
+ '<th>dominant pass</th>',
196
+ '<th class="num">indices typical</th>',
197
+ '<th class="num">wasted indices</th>',
198
+ '</tr></thead><tbody>',
199
+ ])
200
+
201
+ for rank_i, (mh, m) in enumerate(ranked, 1):
202
+ max_repeat = max(m['repeat_by_drop'].values()) if m['repeat_by_drop'] else 0
203
+ try:
204
+ n_typ = int(statistics.median(m['num_indices'])) if m['num_indices'] else 0
205
+ except statistics.StatisticsError:
206
+ n_typ = 0
207
+ wasted = (max_repeat - 1) * n_typ
208
+ rep_row = m['rep_row'] or {}
209
+ rep_drop_dir = _drop_dir_for(drops, rep_row.get('drop_date'),
210
+ rep_row.get('drop_label'), rep_row.get('area'))
211
+ dominant_pass = m['pass_paths'].most_common(1)[0][0] if m['pass_paths'] else ''
212
+ dominant_cls = m['draw_classes'].most_common(1)[0][0] if m['draw_classes'] else ''
213
+ suffix = base.pass_suffix(dominant_pass) or '?'
214
+ hash_tag = str(mh)[-4:] if mh else ''
215
+ mesh_label = f'{dominant_cls}/{suffix}/{n_typ}v#{hash_tag}'
216
+
217
+ sec1.append('<tr>')
218
+ link = base.rel_path_to_drop_index(out_dir, rep_drop_dir, 'draws') if rep_drop_dir else '#'
219
+ rp = base.rank_pill(rank_i) if rank_i <= 3 else ''
220
+ sec1.append(
221
+ f'<td>{rp}<a href="{base.h(link)}" data-link-kind="drill">{base.h(mesh_label)}</a></td>'
222
+ )
223
+ prev = None
224
+ series = []
225
+ for i, k in enumerate(drop_keys):
226
+ v = m['repeat_by_drop'].get(k, 0)
227
+ series.append(v)
228
+ sec1.append(f'<td class="num">{base.fmt_int(v)}</td>')
229
+ if i > 0:
230
+ sec1.append(base.delta_cell(v, prev,
231
+ lower_is_better=True, fmt='{:+,.0f}',
232
+ regression_threshold_pct=20.0))
233
+ prev = v
234
+ if len(drop_keys) >= 3:
235
+ sec1.append(f'<td class="num">{base.sparkline_svg(series)}</td>')
236
+
237
+ areas_str = ', '.join(sorted(m['areas']))
238
+ sec1.append(f'<td>{base.h(areas_str)}</td>')
239
+ sec1.append(f'<td>{base.h(base.pass_short(dominant_pass))}</td>')
240
+ sec1.append(f'<td class="num">{base.fmt_int(n_typ)}</td>')
241
+ bar = base.inline_bar(wasted, max_wasted) if max_wasted > 0 else ''
242
+ sec1.append(f'<td class="num">{base.fmt_int(wasted)}{bar}</td>')
243
+ sec1.append('</tr>')
244
+ sec1.append('</tbody></table></rdc-sortable-table></div>')
245
+ parts.append(''.join(sec1))
246
+
247
+ sec2 = []
248
+ sec2.append('<h2 id="batching">potential material batching</h2>')
249
+ sec2.append('<div class="table-wrap"><rdc-sortable-table>')
250
+ sec2.append('<table class="report"><thead><tr>')
251
+ sec2.append('<th>pass</th>')
252
+ sec2.append('<th>class</th>')
253
+ sec2.append('<th class="num">repeat</th>')
254
+ sec2.append('<th class="num">distinct meshes</th>')
255
+ sec2.append('<th>drops</th>')
256
+ sec2.append('</tr></thead><tbody>')
257
+ top_batch = [(k, v) for k, v in batching_groups.items() if v >= 4]
258
+ top_batch.sort(key=lambda kv: kv[1], reverse=True)
259
+ for (pass_norm, fs, cls), n in top_batch[:30]:
260
+ sec2.append('<tr>')
261
+ sec2.append(f'<td>{base.h(base.pass_short(pass_norm))}</td>')
262
+ sec2.append(f'<td>{base.h(cls)}</td>')
263
+ sec2.append(f'<td class="num">{base.fmt_int(n)}</td>')
264
+ sec2.append(f'<td class="num">{base.fmt_int(len(batching_meshes[(pass_norm, fs, cls)]))}</td>')
265
+ sec2.append(f'<td>{base.h(", ".join(sorted(batching_drops[(pass_norm, fs, cls)])))}</td>')
266
+ sec2.append('</tr>')
267
+ sec2.append('</tbody></table></rdc-sortable-table></div>')
268
+ parts.append(''.join(sec2))
269
+
270
+ parts.append(base.page_close())
271
+
272
+ return base.write_report(out_path, parts)
273
+
274
+
275
+ if __name__ == '__main__':
276
+ sys.exit(base.run_report(build, module_name='instancing_opportunities'))
@@ -0,0 +1,59 @@
1
+ """Coordinate cache build + per-report build + dashboard + root-index render."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import time
6
+
7
+ from .. import lint
8
+ from ..html import template
9
+ from . import (
10
+ _dashboard as report_dashboard,
11
+ base as reports_base,
12
+ draws_by_class as report_draws_by_class,
13
+ instancing_opportunities as report_instancing,
14
+ overdraw as report_overdraw,
15
+ pass_gpu as report_pass_gpu,
16
+ shader_hotlist as report_shader,
17
+ trend_table as report_trend,
18
+ )
19
+
20
+ _REPORT_MODULES = (
21
+ report_draws_by_class,
22
+ report_trend,
23
+ report_instancing,
24
+ report_pass_gpu,
25
+ report_shader,
26
+ report_overdraw,
27
+ )
28
+
29
+
30
+ def render_all_reports(root: str, log) -> int:
31
+ """Build cache, all 6 reports, dashboard, root index. Returns 0 on success."""
32
+ t0 = time.monotonic()
33
+ cache_out = reports_base.build_per_drop_cache(root)
34
+ log(f' built per-drop cache: {cache_out} ({time.monotonic()-t0:.1f}s)')
35
+
36
+ for mod in _REPORT_MODULES:
37
+ try:
38
+ rep = mod.build(root)
39
+ log(f' built report: {rep}')
40
+ except Exception as e:
41
+ log(f' {mod.__name__} FAILED: {e}')
42
+ return 1
43
+
44
+ try:
45
+ dash = report_dashboard.build(root)
46
+ log(f' built dashboard: {dash}')
47
+ except Exception as e:
48
+ log(f' dashboard FAILED: {e}')
49
+ return 1
50
+
51
+ log('rendering root index')
52
+ root_idx = template.render_root(root)
53
+ root_hits = lint.lint_file(root_idx)
54
+ if root_hits:
55
+ for lineno, label, snip in root_hits:
56
+ log(f' LINT FAIL {root_idx}:{lineno}: [{label}] {snip}')
57
+ return 1
58
+ log(f' -> {root_idx}')
59
+ return 0
@@ -0,0 +1,293 @@
1
+ """Per-RT pixel_history aggregation. Color bar of rejection causes.
2
+
3
+ Aggregation key: (area, rt_label). Falls back to (area, rt_id) when label empty.
4
+ Gracefully renders 'no data' when pixel_history absent for a drop.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import sys
11
+ from collections import defaultdict
12
+
13
+ import pyarrow.parquet as papq
14
+
15
+ from . import base
16
+
17
+
18
+ _PH_COLS = ['area', 'drop_date', 'drop_label', 'capture', 'rt_id',
19
+ 'passed', 'backface_culled', 'depth_test_failed',
20
+ 'stencil_test_failed', 'scissor_clipped', 'shader_discarded']
21
+
22
+ _RT_COLS = ['area', 'drop_date', 'drop_label', 'capture',
23
+ 'rt_id', 'format', 'width', 'height', 'label',
24
+ 'is_swap_chain_target']
25
+
26
+
27
+ def _read_rts(drop: base.DropSet) -> dict:
28
+ """{(area, capture, rt_id): {label, format, width, height, is_swap}}"""
29
+ out: dict = {}
30
+ for r in drop.rows:
31
+ p = os.path.join(r.drop_dir, 'render_targets.parquet')
32
+ if not os.path.exists(p):
33
+ continue
34
+ try:
35
+ schema_cols = set(papq.read_schema(p).names)
36
+ want = [c for c in _RT_COLS if c in schema_cols]
37
+ t = papq.read_table(p, columns=want)
38
+ except Exception:
39
+ continue
40
+ cols = {c: t.column(c).to_pylist() for c in t.column_names}
41
+ for i in range(t.num_rows):
42
+ key = (cols.get('area', [''])[i] if 'area' in cols else r.area,
43
+ cols['capture'][i],
44
+ cols['rt_id'][i])
45
+ out[key] = {
46
+ 'label': cols.get('label', [''])[i] if 'label' in cols else '',
47
+ 'format': cols.get('format', [''])[i] if 'format' in cols else '',
48
+ 'width': cols.get('width', [0])[i] if 'width' in cols else 0,
49
+ 'height': cols.get('height', [0])[i] if 'height' in cols else 0,
50
+ 'is_swap': cols.get('is_swap_chain_target', [False])[i] if 'is_swap_chain_target' in cols else False,
51
+ }
52
+ return out
53
+
54
+
55
+ def _agg_pixel_history(drop: base.DropSet, rt_meta: dict) -> dict:
56
+ """{(area, group_key): {n_samples, n_passed, n_depth_failed, ...,
57
+ 'format', 'width', 'height', 'is_swap'}}."""
58
+ out: dict = defaultdict(lambda: {
59
+ 'n_samples': 0, 'n_passed': 0, 'n_depth_failed': 0,
60
+ 'n_discarded': 0, 'n_scissor': 0, 'n_backface': 0, 'n_stencil': 0,
61
+ 'format': '', 'width': 0, 'height': 0, 'is_swap': False,
62
+ 'rt_id': 0,
63
+ })
64
+ any_data = False
65
+ for r in drop.rows:
66
+ p = os.path.join(r.drop_dir, 'pixel_history.parquet')
67
+ if not os.path.exists(p):
68
+ continue
69
+ try:
70
+ schema_cols = set(papq.read_schema(p).names)
71
+ want = [c for c in _PH_COLS if c in schema_cols]
72
+ t = papq.read_table(p, columns=want)
73
+ except Exception:
74
+ continue
75
+ if t.num_rows == 0:
76
+ continue
77
+ any_data = True
78
+ cols = {c: t.column(c).to_pylist() for c in t.column_names}
79
+ for i in range(t.num_rows):
80
+ cap = cols.get('capture', [''])[i] if 'capture' in cols else ''
81
+ rt_id = cols.get('rt_id', [0])[i] if 'rt_id' in cols else 0
82
+ meta = rt_meta.get((r.area, cap, rt_id), {})
83
+ label = meta.get('label') or f'rt_{rt_id}'
84
+ key = (r.area, label)
85
+ bucket = out[key]
86
+ bucket['n_samples'] += 1
87
+ bucket['rt_id'] = rt_id
88
+ if cols.get('passed', [False])[i]:
89
+ bucket['n_passed'] += 1
90
+ if cols.get('depth_test_failed', [False])[i]:
91
+ bucket['n_depth_failed'] += 1
92
+ if cols.get('shader_discarded', [False])[i]:
93
+ bucket['n_discarded'] += 1
94
+ if cols.get('scissor_clipped', [False])[i]:
95
+ bucket['n_scissor'] += 1
96
+ if cols.get('backface_culled', [False])[i]:
97
+ bucket['n_backface'] += 1
98
+ if cols.get('stencil_test_failed', [False])[i]:
99
+ bucket['n_stencil'] += 1
100
+ if not bucket['format'] and meta:
101
+ bucket['format'] = meta.get('format', '')
102
+ bucket['width'] = meta.get('width', 0)
103
+ bucket['height'] = meta.get('height', 0)
104
+ bucket['is_swap'] = meta.get('is_swap', False)
105
+ return dict(out) if any_data else {}
106
+
107
+
108
+ def _rejection_bar(b: dict) -> str:
109
+ n = b['n_samples']
110
+ if n <= 0:
111
+ return ''
112
+ weights = {
113
+ 'opaque': b['n_passed'],
114
+ 'prepass': b['n_depth_failed'],
115
+ 'ui': b['n_discarded'],
116
+ 'other': b['n_scissor'],
117
+ 'shadow': b['n_backface'],
118
+ 'translucent': b['n_stencil'],
119
+ }
120
+ accounted = sum(weights.values())
121
+ remainder = n - accounted
122
+ if remainder > 0:
123
+ weights['additive'] = remainder
124
+ return base.class_segments_bar(weights, n)
125
+
126
+
127
+ def build(root: str, *, drops: list | None = None, ab=None) -> str:
128
+ if drops is None:
129
+ drops = base.discover_drops(root)
130
+ out_path = base.output_path(root, 'overdraw', ab)
131
+
132
+ drop_keys = [d.key for d in drops]
133
+ per_drop_data: dict = {}
134
+ for d in drops:
135
+ meta = _read_rts(d)
136
+ per_drop_data[d.key] = _agg_pixel_history(d, meta)
137
+
138
+ all_keys: set = set()
139
+ for agg in per_drop_data.values():
140
+ all_keys.update(agg.keys())
141
+
142
+ by_area: dict = defaultdict(list)
143
+ for area, label in all_keys:
144
+ by_area[area].append(label)
145
+
146
+ parts = [base.page_open('overdraw', hdr_offset_px=120)]
147
+ parts.append(base.header(
148
+ 'overdraw',
149
+ drops=len(drops),
150
+ captures=sum(d.n_captures for d in drops),
151
+ build_ts=base.now_iso(),
152
+ crumb_depth=base.crumb_depth(ab),
153
+ ))
154
+ parts.append(base.ab_strip(ab))
155
+ parts.append(base.ab_picker_for(root, 'overdraw', ab=ab))
156
+
157
+ # Summary bar: worst shadow RT rejection % (or worst RT rejection if no shadow)
158
+ worst_rt = None
159
+ worst_pct = -1.0
160
+ for area_key, label in all_keys:
161
+ # Pick the rep bucket (first seen across drops)
162
+ rep = None
163
+ for k in drop_keys:
164
+ b = per_drop_data.get(k, {}).get((area_key, label))
165
+ if b is not None:
166
+ rep = b
167
+ break
168
+ if not rep:
169
+ continue
170
+ ns = rep.get('n_samples', 0)
171
+ if ns <= 0:
172
+ continue
173
+ passed = rep.get('n_passed', 0)
174
+ reject_pct = 100.0 * (1.0 - passed / ns)
175
+ label_str = str(label or '').lower()
176
+ is_shadow = 'shadow' in label_str
177
+ # Prefer shadow RTs; fall back to highest reject overall
178
+ score = reject_pct + (10000.0 if is_shadow else 0.0)
179
+ if score > worst_pct:
180
+ worst_pct = score
181
+ worst_rt = (area_key, label or '?', reject_pct, is_shadow)
182
+ if worst_rt is not None:
183
+ area_w, label_w, pct_w, is_shadow_w = worst_rt
184
+ kind = 'shadow rejection' if is_shadow_w else 'rt rejection'
185
+ tone = 'alarm' if pct_w >= 70 else ('warn' if pct_w >= 40 else 'neutral')
186
+ parts.append(base.summary_bar(
187
+ f'worst {kind}',
188
+ f'{area_w} / {label_w}',
189
+ sub=f'{base.fmt_float(pct_w, 1)}% rejected',
190
+ link_href=f'#{base.h(area_w)}',
191
+ link_text='area',
192
+ tone=tone,
193
+ ))
194
+
195
+ parts.append('<div class="legend">')
196
+ for cls, name in [('opaque', 'passed'), ('prepass', 'depth failed'),
197
+ ('ui', 'discarded'), ('other', 'scissor'),
198
+ ('shadow', 'backface'), ('translucent', 'stencil'),
199
+ ('additive', 'other')]:
200
+ parts.append(f'<span class="chip"><span class="swatch" '
201
+ f'style="background: {base.class_color_var(cls)}"></span>{base.h(name)}</span>')
202
+ parts.append('</div>')
203
+
204
+ drops_without_data = [k for k in drop_keys if not per_drop_data.get(k)]
205
+ if drops_without_data:
206
+ msg = ', '.join(base.h(k) for k in drops_without_data)
207
+ parts.append(f'<p class="note">no pixel_history rows in drops: {msg}</p>')
208
+
209
+ if not by_area:
210
+ parts.append('<p class="note">no pixel_history data across all drops</p>')
211
+ else:
212
+ for area in sorted(by_area.keys()):
213
+ rows = []
214
+ for label in set(by_area[area]):
215
+ rep = None
216
+ for k in drop_keys:
217
+ b = per_drop_data.get(k, {}).get((area, label))
218
+ if b is not None:
219
+ rep = b
220
+ break
221
+ max_samples = max((per_drop_data.get(k, {}).get((area, label), {}).get('n_samples', 0)
222
+ for k in drop_keys), default=0)
223
+ rows.append((label, rep, max_samples))
224
+ rows.sort(key=lambda x: x[2], reverse=True)
225
+
226
+ sec = []
227
+ sec.append('<table class="report"><thead><tr>')
228
+ sec.append('<th>rt label</th>')
229
+ sec.append('<th>format</th>')
230
+ sec.append('<th>dims</th>')
231
+ sec.append('<th class="num">samples (latest)</th>')
232
+ sec.append('<th class="num">passed</th>')
233
+ sec.append('<th class="num">depth failed</th>')
234
+ sec.append('<th class="num">discarded</th>')
235
+ sec.append('<th class="num">scissor</th>')
236
+ sec.append('<th class="num">backface</th>')
237
+ sec.append('<th>rejection bar</th>')
238
+ for i, k in enumerate(drop_keys):
239
+ sec.append(f'<th class="num">samples@{base.h(k)}</th>')
240
+ if i > 0:
241
+ latest_cls = ' delta-latest' if i == len(drop_keys) - 1 else ''
242
+ sec.append(f'<th class="num{latest_cls}">delta</th>')
243
+ sec.append('</tr></thead><tbody>')
244
+
245
+ for label, rep, _ in rows:
246
+ latest_bucket = None
247
+ for k in reversed(drop_keys):
248
+ b = per_drop_data.get(k, {}).get((area, label))
249
+ if b is not None:
250
+ latest_bucket = b
251
+ break
252
+ if latest_bucket is None:
253
+ continue
254
+ n = latest_bucket['n_samples']
255
+ pct = lambda v, total=n: (v / total * 100.0) if total > 0 else 0.0
256
+ swap = ' (swap)' if latest_bucket.get('is_swap') else ''
257
+ dims = f'{latest_bucket["width"]}x{latest_bucket["height"]}' if latest_bucket['width'] else ''
258
+
259
+ sec.append('<tr>')
260
+ sec.append(f'<td>{base.h(label)}{base.h(swap)}</td>')
261
+ sec.append(f'<td>{base.h(latest_bucket.get("format") or "")}</td>')
262
+ sec.append(f'<td class="num">{base.h(dims)}</td>')
263
+ sec.append(f'<td class="num">{base.fmt_int(n)}</td>')
264
+ sec.append(f'<td class="num">{base.fmt_pct(pct(latest_bucket["n_passed"]))}</td>')
265
+ sec.append(f'<td class="num">{base.fmt_pct(pct(latest_bucket["n_depth_failed"]))}</td>')
266
+ sec.append(f'<td class="num">{base.fmt_pct(pct(latest_bucket["n_discarded"]))}</td>')
267
+ sec.append(f'<td class="num">{base.fmt_pct(pct(latest_bucket["n_scissor"]))}</td>')
268
+ sec.append(f'<td class="num">{base.fmt_pct(pct(latest_bucket["n_backface"]))}</td>')
269
+ sec.append(f'<td>{_rejection_bar(latest_bucket)}</td>')
270
+
271
+ prev_n = None
272
+ for i, k in enumerate(drop_keys):
273
+ bb = per_drop_data.get(k, {}).get((area, label))
274
+ cur = bb['n_samples'] if bb else None
275
+ sec.append(f'<td class="num">{base.fmt_int(cur) if cur is not None else ""}</td>')
276
+ if i > 0:
277
+ sec.append(base.delta_cell(
278
+ cur if cur is not None else 0,
279
+ prev_n,
280
+ lower_is_better=None, fmt='{:+,.0f}'))
281
+ prev_n = cur
282
+ sec.append('</tr>')
283
+ sec.append('</tbody></table>')
284
+ parts.append(f'<h2 id="{base.h(area)}">{base.h(area)}</h2>')
285
+ parts.append(f'<div class="table-wrap"><rdc-sortable-table>{"".join(sec)}</rdc-sortable-table></div>')
286
+
287
+ parts.append(base.page_close())
288
+
289
+ return base.write_report(out_path, parts)
290
+
291
+
292
+ if __name__ == '__main__':
293
+ sys.exit(base.run_report(build, module_name='overdraw'))