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.
- bobframes/__init__.py +3 -0
- bobframes/_version.py +1 -0
- bobframes/catalog.py +154 -0
- bobframes/cli.py +266 -0
- bobframes/derive_post_merge.py +365 -0
- bobframes/derives/__init__.py +0 -0
- bobframes/derives/pass_class_breakdown.py +102 -0
- bobframes/derives/texture_usage.py +121 -0
- bobframes/discovery.py +132 -0
- bobframes/global_entities.py +99 -0
- bobframes/html/__init__.py +0 -0
- bobframes/html/template.py +1056 -0
- bobframes/lint.py +114 -0
- bobframes/manifest.py +127 -0
- bobframes/parquetize.py +282 -0
- bobframes/parsers/__init__.py +0 -0
- bobframes/parsers/derive_program_transitions.py +73 -0
- bobframes/parsers/parse_init_state.py +675 -0
- bobframes/paths.py +111 -0
- bobframes/probes/__init__.py +0 -0
- bobframes/probes/whatif.py +165 -0
- bobframes/qrd_harness.py +119 -0
- bobframes/query_examples.py +222 -0
- bobframes/rdcmd.py +72 -0
- bobframes/replay/__init__.py +26 -0
- bobframes/replay/replay_main.py +2305 -0
- bobframes/reports/__init__.py +0 -0
- bobframes/reports/_dashboard.py +425 -0
- bobframes/reports/ab.py +88 -0
- bobframes/reports/base.py +114 -0
- bobframes/reports/cache.py +147 -0
- bobframes/reports/chrome.py +1306 -0
- bobframes/reports/cli.py +99 -0
- bobframes/reports/delta.py +167 -0
- bobframes/reports/discovery.py +118 -0
- bobframes/reports/draws_by_class.py +165 -0
- bobframes/reports/formatters.py +122 -0
- bobframes/reports/instancing_opportunities.py +276 -0
- bobframes/reports/orchestrator.py +59 -0
- bobframes/reports/overdraw.py +293 -0
- bobframes/reports/pass_gpu.py +190 -0
- bobframes/reports/shader_hotlist.py +240 -0
- bobframes/reports/trend_table.py +444 -0
- bobframes/resource_labels.py +162 -0
- bobframes/run.py +480 -0
- bobframes/schemas.py +426 -0
- bobframes/stable_keys.py +83 -0
- bobframes/tests/__init__.py +0 -0
- bobframes/tests/_render_util.py +84 -0
- bobframes/tests/data/golden/_reports/draws_by_class.html +323 -0
- bobframes/tests/data/golden/_reports/drill/District 01/2026-05-28_r110600/index.html +1560 -0
- bobframes/tests/data/golden/_reports/index.html +264 -0
- bobframes/tests/data/golden/_reports/instancing_opportunities.html +266 -0
- bobframes/tests/data/golden/_reports/overdraw.html +275 -0
- bobframes/tests/data/golden/_reports/pass_gpu.html +277 -0
- bobframes/tests/data/golden/_reports/shader_hotlist.html +265 -0
- bobframes/tests/data/golden/_reports/trend_table.html +390 -0
- bobframes/tests/data/golden/index.html +1175 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/_manifest.json +51 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/buffers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/clears.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/counters_per_event.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/descriptor_access.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/dispatches.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draw_bindings.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/draws.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/fbos.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/frame_totals.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/ibo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/indirect_args.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/passes.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/pixel_history.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/post_vs_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/program_transitions.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/programs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/render_targets.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/resource_creation.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/rt_event_timeline.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/samplers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/shaders.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/state_change_events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/texture_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/textures.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vbo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-27_r110565/vertex_inputs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/_manifest.json +51 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/buffers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/clears.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/counters_per_event.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/descriptor_access.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/dispatches.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draw_bindings.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/draws.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/fbos.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/frame_totals.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/ibo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/indirect_args.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/passes.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/pixel_history.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/post_vs_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/program_transitions.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/programs.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/render_targets.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/resource_creation.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/rt_event_timeline.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/samplers.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/shaders.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/state_change_events.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/texture_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/textures.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vbo_samples.parquet +0 -0
- bobframes/tests/data/synthetic/_data/District 01/2026-05-28_r110600/vertex_inputs.parquet +0 -0
- bobframes/tests/make_synthetic.py +171 -0
- bobframes/tests/smoke.py +199 -0
- bobframes/tests/test_determinism.py +19 -0
- bobframes/tests/test_discovery.py +97 -0
- bobframes/tests/test_hardening.py +142 -0
- bobframes/tests/test_parity.py +22 -0
- bobframes/tests/test_perf.py +18 -0
- bobframes/tests/test_replay_drift.py +115 -0
- bobframes/tests/test_schemas.py +26 -0
- bobframes/tests/test_schemas_unit.py +55 -0
- bobframes/tests/test_stable_keys.py +61 -0
- bobframes-0.1.0.dist-info/METADATA +144 -0
- bobframes-0.1.0.dist-info/RECORD +130 -0
- bobframes-0.1.0.dist-info/WHEEL +4 -0
- bobframes-0.1.0.dist-info/entry_points.txt +2 -0
- 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'))
|