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,365 @@
|
|
|
1
|
+
"""Post-merge derivations: augment existing parquets with derived columns.
|
|
2
|
+
|
|
3
|
+
Computes columns that the replay didn't fill (or filled in an older schema
|
|
4
|
+
version), purely from data already in the drop's Parquet files. Safe to
|
|
5
|
+
re-run idempotently.
|
|
6
|
+
|
|
7
|
+
Added in SCHEMA_VERSION 2:
|
|
8
|
+
- draws.draw_class (from blend / depth_write / marker)
|
|
9
|
+
- draws.parent_pass_path_norm (strip 'Frame N/' prefix)
|
|
10
|
+
- passes.marker_path_norm (same)
|
|
11
|
+
- events.parent_marker_path_norm (same)
|
|
12
|
+
|
|
13
|
+
Run via: python -m bobframes.derive_post_merge <out_dir>
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import sys
|
|
21
|
+
|
|
22
|
+
import pyarrow as pa
|
|
23
|
+
import pyarrow.csv as pacsv
|
|
24
|
+
import pyarrow.parquet as papq
|
|
25
|
+
|
|
26
|
+
_RE_FRAME_PREFIX = re.compile(r'^Frame\s+\d+/?')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _strip_frame(path: str) -> str:
|
|
30
|
+
if not path:
|
|
31
|
+
return ''
|
|
32
|
+
return _RE_FRAME_PREFIX.sub('', path)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _classify_draw(blend_enable: int, depth_write: int,
|
|
36
|
+
marker_path: str, blend_src_color: str, blend_dst_color: str) -> str:
|
|
37
|
+
mp = (marker_path or '').lower()
|
|
38
|
+
if 'shadow' in mp:
|
|
39
|
+
return 'shadow'
|
|
40
|
+
if 'prepass' in mp or 'depthonly' in mp:
|
|
41
|
+
return 'prepass'
|
|
42
|
+
if 'slate' in mp or '/ui' in mp or mp.endswith('ui'):
|
|
43
|
+
return 'ui'
|
|
44
|
+
if 'postprocess' in mp or 'tonemap' in mp or 'bloom' in mp or 'eyeadapt' in mp:
|
|
45
|
+
return 'postprocess'
|
|
46
|
+
if 'decal' in mp:
|
|
47
|
+
return 'decal'
|
|
48
|
+
if 'translucen' in mp:
|
|
49
|
+
return 'translucent'
|
|
50
|
+
if int(blend_enable or 0):
|
|
51
|
+
bs = (blend_src_color or '').lower()
|
|
52
|
+
bd = (blend_dst_color or '').lower()
|
|
53
|
+
if bs == 'one' and bd == 'one':
|
|
54
|
+
return 'additive'
|
|
55
|
+
return 'translucent'
|
|
56
|
+
# MobileBasePass / BasePass draws are scene opaques even when depth_write=0
|
|
57
|
+
# (prepass already wrote depth with EarlyZPass=2).
|
|
58
|
+
if 'basepass' in mp:
|
|
59
|
+
return 'opaque'
|
|
60
|
+
if int(depth_write or 0):
|
|
61
|
+
return 'opaque'
|
|
62
|
+
return 'other'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _derive_draws(out_dir: str) -> bool:
|
|
66
|
+
pq_path = os.path.join(out_dir, 'draws.parquet')
|
|
67
|
+
csv_path = os.path.join(out_dir, 'draws.csv')
|
|
68
|
+
if not os.path.exists(pq_path):
|
|
69
|
+
return False
|
|
70
|
+
t = papq.read_table(pq_path)
|
|
71
|
+
cols = t.column_names
|
|
72
|
+
|
|
73
|
+
parent = t.column('parent_pass_path').to_pylist() if 'parent_pass_path' in cols else []
|
|
74
|
+
norm = [_strip_frame(p) for p in parent]
|
|
75
|
+
|
|
76
|
+
blend_en = t.column('blend_enable').to_pylist() if 'blend_enable' in cols else [0] * t.num_rows
|
|
77
|
+
depth_w = t.column('depth_write_enable').to_pylist() if 'depth_write_enable' in cols else [0] * t.num_rows
|
|
78
|
+
bsc = t.column('blend_src_color').to_pylist() if 'blend_src_color' in cols else [''] * t.num_rows
|
|
79
|
+
bdc = t.column('blend_dst_color').to_pylist() if 'blend_dst_color' in cols else [''] * t.num_rows
|
|
80
|
+
classes = [_classify_draw(be, dw, p, sc, dc) for be, dw, p, sc, dc
|
|
81
|
+
in zip(blend_en, depth_w, parent, bsc, bdc)]
|
|
82
|
+
|
|
83
|
+
# Build new table in schema order
|
|
84
|
+
from . import schemas
|
|
85
|
+
target_cols = list(schemas.DRAWS_COLS)
|
|
86
|
+
new_arrays: dict[str, pa.Array] = {}
|
|
87
|
+
for c in target_cols:
|
|
88
|
+
if c == 'parent_pass_path_norm':
|
|
89
|
+
new_arrays[c] = pa.array(norm, type=pa.string())
|
|
90
|
+
elif c == 'draw_class':
|
|
91
|
+
new_arrays[c] = pa.array(classes, type=pa.string())
|
|
92
|
+
elif c in cols:
|
|
93
|
+
new_arrays[c] = t.column(c)
|
|
94
|
+
else:
|
|
95
|
+
# missing source column; emit empty
|
|
96
|
+
dt = schemas.infer_dtype(c)
|
|
97
|
+
default = 0 if dt in ('int', 'bool') else (0.0 if dt == 'float' else '')
|
|
98
|
+
new_arrays[c] = pa.array([default] * t.num_rows)
|
|
99
|
+
|
|
100
|
+
out_t = pa.table(new_arrays)
|
|
101
|
+
papq.write_table(out_t, pq_path, compression='snappy')
|
|
102
|
+
pacsv.write_csv(out_t, csv_path)
|
|
103
|
+
return True
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _derive_path_norm(out_dir: str, table: str, src_col: str, dst_col: str) -> bool:
|
|
107
|
+
pq_path = os.path.join(out_dir, f'{table}.parquet')
|
|
108
|
+
csv_path = os.path.join(out_dir, f'{table}.csv')
|
|
109
|
+
if not os.path.exists(pq_path):
|
|
110
|
+
return False
|
|
111
|
+
t = papq.read_table(pq_path)
|
|
112
|
+
cols = t.column_names
|
|
113
|
+
if src_col not in cols:
|
|
114
|
+
return False
|
|
115
|
+
|
|
116
|
+
norm = [_strip_frame(p) for p in t.column(src_col).to_pylist()]
|
|
117
|
+
|
|
118
|
+
from . import schemas
|
|
119
|
+
schema_attr = {
|
|
120
|
+
'passes': schemas.PASSES_COLS,
|
|
121
|
+
'events': schemas.EVENTS_COLS,
|
|
122
|
+
}[table]
|
|
123
|
+
target_cols = list(schema_attr)
|
|
124
|
+
new_arrays: dict[str, pa.Array] = {}
|
|
125
|
+
for c in target_cols:
|
|
126
|
+
if c == dst_col:
|
|
127
|
+
new_arrays[c] = pa.array(norm, type=pa.string())
|
|
128
|
+
elif c in cols:
|
|
129
|
+
new_arrays[c] = t.column(c)
|
|
130
|
+
else:
|
|
131
|
+
dt = schemas.infer_dtype(c)
|
|
132
|
+
default = 0 if dt in ('int', 'bool') else (0.0 if dt == 'float' else '')
|
|
133
|
+
new_arrays[c] = pa.array([default] * t.num_rows)
|
|
134
|
+
|
|
135
|
+
out_t = pa.table(new_arrays)
|
|
136
|
+
papq.write_table(out_t, pq_path, compression='snappy')
|
|
137
|
+
pacsv.write_csv(out_t, csv_path)
|
|
138
|
+
return True
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
# --- Texture estimated bytes ------------------------------------------------
|
|
142
|
+
|
|
143
|
+
# Bytes per pixel by RD-style format Name(). Falls back to 4 on unknown.
|
|
144
|
+
_BYTES_PER_PIXEL = {
|
|
145
|
+
'R8G8B8A8_UNORM': 4, 'R8G8B8A8_SRGB': 4, 'R8G8B8A8_SNORM': 4,
|
|
146
|
+
'R8G8B8_UNORM': 3, 'R8G8B8_SRGB': 3,
|
|
147
|
+
'B8G8R8A8_UNORM': 4, 'B8G8R8A8_SRGB': 4,
|
|
148
|
+
'R8G8_UNORM': 2, 'R8G8_SNORM': 2,
|
|
149
|
+
'R8_UNORM': 1, 'R8_SNORM': 1,
|
|
150
|
+
'R16G16B16A16_FLOAT': 8, 'R16G16B16A16_UNORM': 8,
|
|
151
|
+
'R16G16_FLOAT': 4, 'R16_FLOAT': 2,
|
|
152
|
+
'R32G32B32A32_FLOAT': 16, 'R32_FLOAT': 4,
|
|
153
|
+
'R11G11B10_FLOAT': 4, 'R10G10B10A2_UNORM': 4,
|
|
154
|
+
'R5G6B5_UNORM': 2, 'R5G5B5A1_UNORM': 2,
|
|
155
|
+
'D24_UNORM_S8_UINT': 4, 'D32_FLOAT_S8_UINT': 8,
|
|
156
|
+
'D16_UNORM': 2, 'D32_FLOAT': 4, 'D24_UNORM': 3,
|
|
157
|
+
# Compressed: 0.5 bpp (4-bit) for BC1/ETC1; 1 bpp for BC3/ETC2_RGBA/ASTC_4x4
|
|
158
|
+
'BC1_UNORM': 0.5, 'BC1_SRGB': 0.5,
|
|
159
|
+
'BC3_UNORM': 1.0, 'BC3_SRGB': 1.0,
|
|
160
|
+
'BC4_UNORM': 0.5, 'BC5_UNORM': 1.0,
|
|
161
|
+
'BC7_UNORM': 1.0, 'BC7_SRGB': 1.0,
|
|
162
|
+
'ETC1_RGB8': 0.5, 'ETC2_RGB8': 0.5, 'ETC2_RGB8_SRGB': 0.5,
|
|
163
|
+
'ETC2_RGB8A1': 0.5, 'ETC2_RGBA8': 1.0, 'ETC2_RGBA8_SRGB': 1.0,
|
|
164
|
+
'EAC_R11_UNORM': 0.5, 'EAC_RG11_UNORM': 1.0,
|
|
165
|
+
'ASTC_4x4_UNORM': 1.0, 'ASTC_4x4_SRGB': 1.0,
|
|
166
|
+
'ASTC_5x4_UNORM': 0.8, 'ASTC_5x5_UNORM': 0.64,
|
|
167
|
+
'ASTC_6x5_UNORM': 0.533, 'ASTC_6x6_UNORM': 0.444,
|
|
168
|
+
'ASTC_8x5_UNORM': 0.4, 'ASTC_8x6_UNORM': 0.333,
|
|
169
|
+
'ASTC_8x8_UNORM': 0.25, 'ASTC_10x10_UNORM': 0.16,
|
|
170
|
+
'ASTC_12x12_UNORM': 0.111,
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _est_bytes_for_texture(format_str: str, width: int, height: int,
|
|
175
|
+
depth: int, mip_levels: int, sample_count: int,
|
|
176
|
+
kind: str) -> int:
|
|
177
|
+
if width <= 0 or height <= 0:
|
|
178
|
+
return 0
|
|
179
|
+
bpp = _BYTES_PER_PIXEL.get(format_str, 4)
|
|
180
|
+
base = width * height * max(depth, 1) * bpp
|
|
181
|
+
if mip_levels and mip_levels > 1:
|
|
182
|
+
base *= 4.0 / 3.0
|
|
183
|
+
samples = max(sample_count, 1)
|
|
184
|
+
base *= samples
|
|
185
|
+
if kind and 'cube' in kind.lower():
|
|
186
|
+
base *= 6
|
|
187
|
+
return int(round(base))
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _derive_est_bytes(out_dir: str, table: str) -> bool:
|
|
191
|
+
pq_path = os.path.join(out_dir, f'{table}.parquet')
|
|
192
|
+
csv_path = os.path.join(out_dir, f'{table}.csv')
|
|
193
|
+
if not os.path.exists(pq_path):
|
|
194
|
+
return False
|
|
195
|
+
t = papq.read_table(pq_path)
|
|
196
|
+
cols = t.column_names
|
|
197
|
+
if 'est_bytes' not in cols or 'format' not in cols:
|
|
198
|
+
return False
|
|
199
|
+
fmts = t.column('format').to_pylist()
|
|
200
|
+
widths = t.column('width').to_pylist() if 'width' in cols else [0] * t.num_rows
|
|
201
|
+
heights = t.column('height').to_pylist() if 'height' in cols else [0] * t.num_rows
|
|
202
|
+
depths = t.column('depth').to_pylist() if 'depth' in cols else [0] * t.num_rows
|
|
203
|
+
mips = t.column('mip_levels').to_pylist() if 'mip_levels' in cols else [1] * t.num_rows
|
|
204
|
+
samps = t.column('sample_count').to_pylist() if 'sample_count' in cols else [1] * t.num_rows
|
|
205
|
+
kinds = t.column('kind').to_pylist() if 'kind' in cols else [''] * t.num_rows
|
|
206
|
+
|
|
207
|
+
new_eb = [_est_bytes_for_texture(fmts[i] or '', int(widths[i] or 0),
|
|
208
|
+
int(heights[i] or 0), int(depths[i] or 0),
|
|
209
|
+
int(mips[i] or 1), int(samps[i] or 1),
|
|
210
|
+
kinds[i] or '')
|
|
211
|
+
for i in range(t.num_rows)]
|
|
212
|
+
|
|
213
|
+
from . import schemas
|
|
214
|
+
target_cols = list(schemas.TEXTURES_COLS if table == 'textures' else schemas.RENDER_TARGETS_COLS)
|
|
215
|
+
new_arrays: dict[str, pa.Array] = {}
|
|
216
|
+
for c in target_cols:
|
|
217
|
+
if c == 'est_bytes':
|
|
218
|
+
new_arrays[c] = pa.array(new_eb, type=pa.int64())
|
|
219
|
+
elif c in cols:
|
|
220
|
+
new_arrays[c] = t.column(c)
|
|
221
|
+
else:
|
|
222
|
+
dt = schemas.infer_dtype(c)
|
|
223
|
+
default = 0 if dt in ('int', 'bool') else (0.0 if dt == 'float' else '')
|
|
224
|
+
new_arrays[c] = pa.array([default] * t.num_rows)
|
|
225
|
+
|
|
226
|
+
out_t = pa.table(new_arrays)
|
|
227
|
+
papq.write_table(out_t, pq_path, compression='snappy')
|
|
228
|
+
pacsv.write_csv(out_t, csv_path)
|
|
229
|
+
return True
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# --- Shader complexity_score -------------------------------------------------
|
|
233
|
+
|
|
234
|
+
def _derive_complexity_score(out_dir: str) -> bool:
|
|
235
|
+
pq_path = os.path.join(out_dir, 'shaders.parquet')
|
|
236
|
+
csv_path = os.path.join(out_dir, 'shaders.csv')
|
|
237
|
+
if not os.path.exists(pq_path):
|
|
238
|
+
return False
|
|
239
|
+
t = papq.read_table(pq_path)
|
|
240
|
+
cols = t.column_names
|
|
241
|
+
def _col(name):
|
|
242
|
+
return t.column(name).to_pylist() if name in cols else [0] * t.num_rows
|
|
243
|
+
ts = _col('total_texture_samples')
|
|
244
|
+
br = _col('total_branches')
|
|
245
|
+
lo = _col('total_loops')
|
|
246
|
+
di = _col('total_discards')
|
|
247
|
+
df = _col('total_dfdx_dfdy')
|
|
248
|
+
m4 = _col('total_mat4_constructors')
|
|
249
|
+
sl = _col('src_len')
|
|
250
|
+
scores = [
|
|
251
|
+
float(ts[i] or 0) * 2.0
|
|
252
|
+
+ float(br[i] or 0) * 0.5
|
|
253
|
+
+ float(lo[i] or 0) * 2.0
|
|
254
|
+
+ float(di[i] or 0) * 0.3
|
|
255
|
+
+ float(df[i] or 0) * 0.5
|
|
256
|
+
+ float(m4[i] or 0) * 0.3
|
|
257
|
+
+ min(float(sl[i] or 0) / 100.0, 50.0)
|
|
258
|
+
for i in range(t.num_rows)
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
from . import schemas
|
|
262
|
+
target_cols = list(schemas.SHADERS_COLS)
|
|
263
|
+
new_arrays: dict[str, pa.Array] = {}
|
|
264
|
+
for c in target_cols:
|
|
265
|
+
if c == 'complexity_score':
|
|
266
|
+
new_arrays[c] = pa.array(scores, type=pa.float64())
|
|
267
|
+
elif c in cols:
|
|
268
|
+
new_arrays[c] = t.column(c)
|
|
269
|
+
else:
|
|
270
|
+
dt = schemas.infer_dtype(c)
|
|
271
|
+
default = 0 if dt in ('int', 'bool') else (0.0 if dt == 'float' else '')
|
|
272
|
+
new_arrays[c] = pa.array([default] * t.num_rows)
|
|
273
|
+
|
|
274
|
+
out_t = pa.table(new_arrays)
|
|
275
|
+
papq.write_table(out_t, pq_path, compression='snappy')
|
|
276
|
+
pacsv.write_csv(out_t, csv_path)
|
|
277
|
+
return True
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _derive_frame_totals_bytes(out_dir: str) -> bool:
|
|
281
|
+
"""After textures.est_bytes is filled, update frame_totals byte aggregates."""
|
|
282
|
+
ft_path = os.path.join(out_dir, 'frame_totals.parquet')
|
|
283
|
+
ft_csv = os.path.join(out_dir, 'frame_totals.csv')
|
|
284
|
+
tx_path = os.path.join(out_dir, 'textures.parquet')
|
|
285
|
+
bf_path = os.path.join(out_dir, 'buffers.parquet')
|
|
286
|
+
if not os.path.exists(ft_path):
|
|
287
|
+
return False
|
|
288
|
+
|
|
289
|
+
ft = papq.read_table(ft_path)
|
|
290
|
+
cols = ft.column_names
|
|
291
|
+
|
|
292
|
+
# Per-capture aggregates
|
|
293
|
+
tex_by_cap: dict[str, int] = {}
|
|
294
|
+
if os.path.exists(tx_path):
|
|
295
|
+
tx = papq.read_table(tx_path, columns=['capture', 'est_bytes'])
|
|
296
|
+
cap = tx.column('capture').to_pylist()
|
|
297
|
+
eb = tx.column('est_bytes').to_pylist()
|
|
298
|
+
for c, b in zip(cap, eb):
|
|
299
|
+
tex_by_cap[c] = tex_by_cap.get(c, 0) + int(b or 0)
|
|
300
|
+
|
|
301
|
+
vbo_by_cap: dict[str, int] = {}
|
|
302
|
+
ibo_by_cap: dict[str, int] = {}
|
|
303
|
+
ubo_by_cap: dict[str, int] = {}
|
|
304
|
+
if os.path.exists(bf_path):
|
|
305
|
+
bf = papq.read_table(bf_path, columns=['capture', 'allocated_size_bytes',
|
|
306
|
+
'used_as_vbo', 'used_as_ibo', 'used_as_ubo'])
|
|
307
|
+
cap = bf.column('capture').to_pylist()
|
|
308
|
+
sz = bf.column('allocated_size_bytes').to_pylist()
|
|
309
|
+
v = bf.column('used_as_vbo').to_pylist()
|
|
310
|
+
i = bf.column('used_as_ibo').to_pylist()
|
|
311
|
+
u = bf.column('used_as_ubo').to_pylist()
|
|
312
|
+
for ci, s, vi, ii, ui in zip(cap, sz, v, i, u):
|
|
313
|
+
if vi: vbo_by_cap[ci] = vbo_by_cap.get(ci, 0) + int(s or 0)
|
|
314
|
+
if ii: ibo_by_cap[ci] = ibo_by_cap.get(ci, 0) + int(s or 0)
|
|
315
|
+
if ui: ubo_by_cap[ci] = ubo_by_cap.get(ci, 0) + int(s or 0)
|
|
316
|
+
|
|
317
|
+
cap_col = ft.column('capture').to_pylist()
|
|
318
|
+
new_tex = [tex_by_cap.get(c, 0) for c in cap_col]
|
|
319
|
+
new_vbo = [vbo_by_cap.get(c, 0) for c in cap_col]
|
|
320
|
+
new_ibo = [ibo_by_cap.get(c, 0) for c in cap_col]
|
|
321
|
+
new_ubo = [ubo_by_cap.get(c, 0) for c in cap_col]
|
|
322
|
+
|
|
323
|
+
from . import schemas
|
|
324
|
+
target = list(schemas.FRAME_TOTALS_COLS)
|
|
325
|
+
new_arrays: dict[str, pa.Array] = {}
|
|
326
|
+
for c in target:
|
|
327
|
+
if c == 'total_texture_bytes_allocated':
|
|
328
|
+
new_arrays[c] = pa.array(new_tex, type=pa.int64())
|
|
329
|
+
elif c == 'total_vbo_bytes_uploaded':
|
|
330
|
+
new_arrays[c] = pa.array(new_vbo, type=pa.int64())
|
|
331
|
+
elif c == 'total_ibo_bytes_uploaded':
|
|
332
|
+
new_arrays[c] = pa.array(new_ibo, type=pa.int64())
|
|
333
|
+
elif c == 'total_ubo_bytes_uploaded':
|
|
334
|
+
new_arrays[c] = pa.array(new_ubo, type=pa.int64())
|
|
335
|
+
elif c in cols:
|
|
336
|
+
new_arrays[c] = ft.column(c)
|
|
337
|
+
else:
|
|
338
|
+
dt = schemas.infer_dtype(c)
|
|
339
|
+
default = 0 if dt in ('int', 'bool') else (0.0 if dt == 'float' else '')
|
|
340
|
+
new_arrays[c] = pa.array([default] * ft.num_rows)
|
|
341
|
+
|
|
342
|
+
out_t = pa.table(new_arrays)
|
|
343
|
+
papq.write_table(out_t, ft_path, compression='snappy')
|
|
344
|
+
pacsv.write_csv(out_t, ft_csv)
|
|
345
|
+
return True
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def derive(out_dir: str) -> dict[str, bool]:
|
|
349
|
+
"""Run all post-merge derivations on an _analysis_out directory."""
|
|
350
|
+
results = {}
|
|
351
|
+
results['draws'] = _derive_draws(out_dir)
|
|
352
|
+
results['passes'] = _derive_path_norm(out_dir, 'passes', 'marker_path', 'marker_path_norm')
|
|
353
|
+
results['events'] = _derive_path_norm(out_dir, 'events', 'parent_marker_path', 'parent_marker_path_norm')
|
|
354
|
+
results['textures_est_bytes'] = _derive_est_bytes(out_dir, 'textures')
|
|
355
|
+
results['render_targets_est_bytes'] = _derive_est_bytes(out_dir, 'render_targets')
|
|
356
|
+
results['shaders_complexity'] = _derive_complexity_score(out_dir)
|
|
357
|
+
results['frame_totals_bytes'] = _derive_frame_totals_bytes(out_dir)
|
|
358
|
+
return results
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
if __name__ == '__main__':
|
|
362
|
+
if len(sys.argv) != 2:
|
|
363
|
+
print('usage: derive_post_merge.py <out_dir>', file=sys.stderr)
|
|
364
|
+
sys.exit(2)
|
|
365
|
+
print(derive(sys.argv[1]))
|
|
File without changes
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Per-pass × draw_class aggregation.
|
|
2
|
+
|
|
3
|
+
Reads draws.parquet + counters_per_event.parquet, joins on event_id for
|
|
4
|
+
gpu_duration_s, groups by (area, drop_date, drop_label, capture,
|
|
5
|
+
parent_pass_path_norm, draw_class). Emits one row per group.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
|
|
13
|
+
import pyarrow as pa
|
|
14
|
+
import pyarrow.csv as pacsv
|
|
15
|
+
import pyarrow.parquet as papq
|
|
16
|
+
|
|
17
|
+
from .. import schemas
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build(out_dir: str) -> int:
|
|
21
|
+
draws_path = os.path.join(out_dir, 'draws.parquet')
|
|
22
|
+
counters_path = os.path.join(out_dir, 'counters_per_event.parquet')
|
|
23
|
+
if not os.path.exists(draws_path):
|
|
24
|
+
return 0
|
|
25
|
+
|
|
26
|
+
draws = papq.read_table(draws_path, columns=[
|
|
27
|
+
'area', 'drop_date', 'drop_label', 'capture',
|
|
28
|
+
'event_id', 'parent_pass_path_norm', 'draw_class',
|
|
29
|
+
'num_indices', 'num_instances',
|
|
30
|
+
])
|
|
31
|
+
|
|
32
|
+
durations: dict[tuple, float] = {}
|
|
33
|
+
if os.path.exists(counters_path):
|
|
34
|
+
ct = papq.read_table(counters_path, columns=[
|
|
35
|
+
'area', 'drop_date', 'drop_label', 'capture',
|
|
36
|
+
'event_id', 'counter_name', 'value_double',
|
|
37
|
+
])
|
|
38
|
+
ar = ct.column('area').to_pylist()
|
|
39
|
+
dd = ct.column('drop_date').to_pylist()
|
|
40
|
+
dl = ct.column('drop_label').to_pylist()
|
|
41
|
+
cp = ct.column('capture').to_pylist()
|
|
42
|
+
ev = ct.column('event_id').to_pylist()
|
|
43
|
+
cn = ct.column('counter_name').to_pylist()
|
|
44
|
+
vd = ct.column('value_double').to_pylist()
|
|
45
|
+
for i in range(ct.num_rows):
|
|
46
|
+
if cn[i] == 'GPU Duration':
|
|
47
|
+
durations[(ar[i], dd[i], dl[i], cp[i], ev[i])] = float(vd[i] or 0.0)
|
|
48
|
+
|
|
49
|
+
agg: dict[tuple, dict] = defaultdict(lambda: {
|
|
50
|
+
'n_draws': 0, 'n_dispatches': 0,
|
|
51
|
+
'sum_pre_vs_vertices': 0, 'sum_gpu_duration_s': 0.0,
|
|
52
|
+
})
|
|
53
|
+
d_ar = draws.column('area').to_pylist()
|
|
54
|
+
d_dd = draws.column('drop_date').to_pylist()
|
|
55
|
+
d_dl = draws.column('drop_label').to_pylist()
|
|
56
|
+
d_cp = draws.column('capture').to_pylist()
|
|
57
|
+
d_ev = draws.column('event_id').to_pylist()
|
|
58
|
+
d_pp = draws.column('parent_pass_path_norm').to_pylist()
|
|
59
|
+
d_cl = draws.column('draw_class').to_pylist()
|
|
60
|
+
d_ni = draws.column('num_indices').to_pylist()
|
|
61
|
+
d_ic = draws.column('num_instances').to_pylist()
|
|
62
|
+
|
|
63
|
+
for i in range(draws.num_rows):
|
|
64
|
+
key = (d_ar[i], d_dd[i], d_dl[i], d_cp[i], d_pp[i] or '', d_cl[i] or 'other')
|
|
65
|
+
a = agg[key]
|
|
66
|
+
a['n_draws'] += 1
|
|
67
|
+
a['sum_pre_vs_vertices'] += int(d_ni[i] or 0) * max(int(d_ic[i] or 1), 1)
|
|
68
|
+
dur = durations.get((d_ar[i], d_dd[i], d_dl[i], d_cp[i], d_ev[i]), 0.0)
|
|
69
|
+
a['sum_gpu_duration_s'] += dur
|
|
70
|
+
|
|
71
|
+
cols_target = list(schemas.PASS_CLASS_BREAKDOWN_COLS)
|
|
72
|
+
out_rows = []
|
|
73
|
+
for key, vals in sorted(agg.items()):
|
|
74
|
+
out_rows.append({
|
|
75
|
+
'area': key[0], 'drop_date': key[1], 'drop_label': key[2],
|
|
76
|
+
'capture': key[3], 'marker_path_norm': key[4], 'draw_class': key[5],
|
|
77
|
+
'n_draws': vals['n_draws'], 'n_dispatches': vals['n_dispatches'],
|
|
78
|
+
'sum_pre_vs_vertices': vals['sum_pre_vs_vertices'],
|
|
79
|
+
'sum_gpu_duration_s': vals['sum_gpu_duration_s'],
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
arrays: dict[str, pa.Array] = {}
|
|
83
|
+
for c in cols_target:
|
|
84
|
+
dt = schemas.infer_dtype(c)
|
|
85
|
+
vs = [r.get(c) for r in out_rows]
|
|
86
|
+
if dt == 'int':
|
|
87
|
+
arrays[c] = pa.array([int(v or 0) for v in vs], type=pa.int64())
|
|
88
|
+
elif dt == 'float':
|
|
89
|
+
arrays[c] = pa.array([float(v or 0.0) for v in vs], type=pa.float64())
|
|
90
|
+
else:
|
|
91
|
+
arrays[c] = pa.array([str(v or '') for v in vs], type=pa.string())
|
|
92
|
+
table = pa.table(arrays)
|
|
93
|
+
papq.write_table(table, os.path.join(out_dir, 'pass_class_breakdown.parquet'),
|
|
94
|
+
compression='snappy')
|
|
95
|
+
pacsv.write_csv(table, os.path.join(out_dir, 'pass_class_breakdown.csv'))
|
|
96
|
+
return table.num_rows
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
if __name__ == '__main__':
|
|
100
|
+
import sys
|
|
101
|
+
p = sys.argv[1] if len(sys.argv) > 1 else '.'
|
|
102
|
+
print(f'wrote {build(p)} rows')
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Per-texture usage heat.
|
|
2
|
+
|
|
3
|
+
Reads descriptor_access.parquet filtered to ReadOnlyResource (textures),
|
|
4
|
+
groups by (area, drop_date, drop_label, capture, resource_id), counts
|
|
5
|
+
unique events sampled + total accesses + first/last event. Joins into
|
|
6
|
+
textures.parquet to attach stable_key + label + format.
|
|
7
|
+
|
|
8
|
+
Lets reports answer: which texture is sampled the most? Which is bound but
|
|
9
|
+
never sampled? Memory-vs-usage ratio per texture?
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from collections import defaultdict
|
|
16
|
+
|
|
17
|
+
import pyarrow as pa
|
|
18
|
+
import pyarrow.csv as pacsv
|
|
19
|
+
import pyarrow.parquet as papq
|
|
20
|
+
|
|
21
|
+
from .. import schemas
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build(out_dir: str) -> int:
|
|
25
|
+
da_path = os.path.join(out_dir, 'descriptor_access.parquet')
|
|
26
|
+
tx_path = os.path.join(out_dir, 'textures.parquet')
|
|
27
|
+
if not os.path.exists(da_path):
|
|
28
|
+
return 0
|
|
29
|
+
|
|
30
|
+
da = papq.read_table(da_path, columns=[
|
|
31
|
+
'area', 'drop_date', 'drop_label', 'capture',
|
|
32
|
+
'event_id', 'descriptor_kind', 'resource_id',
|
|
33
|
+
])
|
|
34
|
+
ar = da.column('area').to_pylist()
|
|
35
|
+
dd = da.column('drop_date').to_pylist()
|
|
36
|
+
dl = da.column('drop_label').to_pylist()
|
|
37
|
+
cp = da.column('capture').to_pylist()
|
|
38
|
+
ev = da.column('event_id').to_pylist()
|
|
39
|
+
kn = da.column('descriptor_kind').to_pylist()
|
|
40
|
+
rid = da.column('resource_id').to_pylist()
|
|
41
|
+
|
|
42
|
+
agg: dict[tuple, dict] = defaultdict(lambda: {
|
|
43
|
+
'events': set(), 'count': 0,
|
|
44
|
+
'first_event_id': -1, 'last_event_id': -1,
|
|
45
|
+
})
|
|
46
|
+
# Arm RD's GLES backend uses 'ImageSampler' (sampled tex+sampler combo)
|
|
47
|
+
# plus 'ReadOnlyResource' on other backends. Accept both.
|
|
48
|
+
TEX_KINDS = {'ImageSampler', 'ReadOnlyResource'}
|
|
49
|
+
for i in range(da.num_rows):
|
|
50
|
+
if kn[i] not in TEX_KINDS:
|
|
51
|
+
continue
|
|
52
|
+
r = rid[i]
|
|
53
|
+
if not r:
|
|
54
|
+
continue
|
|
55
|
+
key = (ar[i], dd[i], dl[i], cp[i], int(r))
|
|
56
|
+
a = agg[key]
|
|
57
|
+
e = int(ev[i] or 0)
|
|
58
|
+
a['events'].add(e)
|
|
59
|
+
a['count'] += 1
|
|
60
|
+
if a['first_event_id'] < 0 or e < a['first_event_id']:
|
|
61
|
+
a['first_event_id'] = e
|
|
62
|
+
if e > a['last_event_id']:
|
|
63
|
+
a['last_event_id'] = e
|
|
64
|
+
|
|
65
|
+
# Build textures lookup: (area, drop_date, drop_label, capture, tex_id) -> {stable_key, label, format}
|
|
66
|
+
tex_info: dict[tuple, dict] = {}
|
|
67
|
+
if os.path.exists(tx_path):
|
|
68
|
+
tx = papq.read_table(tx_path, columns=[
|
|
69
|
+
'area', 'drop_date', 'drop_label', 'capture',
|
|
70
|
+
'tex_id', 'stable_key', 'label', 'format',
|
|
71
|
+
])
|
|
72
|
+
for i in range(tx.num_rows):
|
|
73
|
+
tex_info[(
|
|
74
|
+
tx.column('area')[i].as_py(),
|
|
75
|
+
tx.column('drop_date')[i].as_py(),
|
|
76
|
+
tx.column('drop_label')[i].as_py(),
|
|
77
|
+
tx.column('capture')[i].as_py(),
|
|
78
|
+
int(tx.column('tex_id')[i].as_py() or 0),
|
|
79
|
+
)] = {
|
|
80
|
+
'stable_key': tx.column('stable_key')[i].as_py() or '',
|
|
81
|
+
'label': tx.column('label')[i].as_py() or '',
|
|
82
|
+
'format': tx.column('format')[i].as_py() or '',
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
out_rows = []
|
|
86
|
+
for key, vals in sorted(agg.items(), key=lambda kv: (kv[0][0], kv[0][1], kv[0][2], kv[0][3], -len(kv[1]['events']))):
|
|
87
|
+
info = tex_info.get(key, {'stable_key': '', 'label': '', 'format': ''})
|
|
88
|
+
out_rows.append({
|
|
89
|
+
'area': key[0], 'drop_date': key[1], 'drop_label': key[2],
|
|
90
|
+
'capture': key[3], 'tex_id': key[4],
|
|
91
|
+
'stable_key': info['stable_key'],
|
|
92
|
+
'label': info['label'],
|
|
93
|
+
'format': info['format'],
|
|
94
|
+
'n_unique_events_sampled': len(vals['events']),
|
|
95
|
+
'n_descriptor_accesses': vals['count'],
|
|
96
|
+
'first_event_id': vals['first_event_id'],
|
|
97
|
+
'last_event_id': vals['last_event_id'],
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
cols_target = list(schemas.TEXTURE_USAGE_COLS)
|
|
101
|
+
arrays: dict[str, pa.Array] = {}
|
|
102
|
+
for c in cols_target:
|
|
103
|
+
dt = schemas.infer_dtype(c)
|
|
104
|
+
vs = [r.get(c) for r in out_rows]
|
|
105
|
+
if dt == 'int':
|
|
106
|
+
arrays[c] = pa.array([int(v or 0) for v in vs], type=pa.int64())
|
|
107
|
+
elif dt == 'float':
|
|
108
|
+
arrays[c] = pa.array([float(v or 0.0) for v in vs], type=pa.float64())
|
|
109
|
+
else:
|
|
110
|
+
arrays[c] = pa.array([str(v or '') for v in vs], type=pa.string())
|
|
111
|
+
table = pa.table(arrays)
|
|
112
|
+
papq.write_table(table, os.path.join(out_dir, 'texture_usage.parquet'),
|
|
113
|
+
compression='snappy')
|
|
114
|
+
pacsv.write_csv(table, os.path.join(out_dir, 'texture_usage.csv'))
|
|
115
|
+
return table.num_rows
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == '__main__':
|
|
119
|
+
import sys
|
|
120
|
+
p = sys.argv[1] if len(sys.argv) > 1 else '.'
|
|
121
|
+
print(f'wrote {build(p)} rows')
|