Skip to content

Commit 38b6d15

Browse files
committed
Add training step count report
1 parent 1d1e9ef commit 38b6d15

2 files changed

Lines changed: 372 additions & 0 deletions

File tree

scripts/report_training_steps.py

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
#!/usr/bin/env python3
2+
"""Report usable parquet token totals and training steps by context length.
3+
4+
This is intentionally narrow: it reads packed parquet counters
5+
(`valid_token_count`, `trained_token_count`, `num_docs`) without loading token
6+
or sidecar payload columns, then reports how many full optimizer steps the
7+
current data supports for a batch-size schedule.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import argparse
13+
import json
14+
from concurrent.futures import ThreadPoolExecutor, as_completed
15+
from dataclasses import asdict, dataclass
16+
from pathlib import Path
17+
from typing import Iterable
18+
19+
import pyarrow.parquet as pq
20+
from pyarrow.lib import ArrowInvalid
21+
22+
23+
DEFAULT_BATCH_BY_LENGTH = {
24+
1024: 192,
25+
2048: 96,
26+
4096: 48,
27+
8192: 24,
28+
16384: 12,
29+
}
30+
31+
DEFAULT_CODE_ROOT = Path("outputs/reindexed")
32+
DEFAULT_COMMIT_ROOT = Path("outputs/reindexed_commits")
33+
DEFAULT_PR_ROOT = Path("outputs/reindexed_pr")
34+
35+
COUNTER_COLUMNS = ("valid_token_count", "trained_token_count", "num_docs")
36+
_CONCURRENT_READ_ERRORS = (FileNotFoundError, ArrowInvalid, OSError)
37+
38+
39+
@dataclass
40+
class TokenStats:
41+
files: int = 0
42+
skipped_files: int = 0
43+
rows: int = 0
44+
docs: int = 0
45+
valid_tokens: int = 0
46+
trained_tokens: int = 0
47+
48+
def add(self, other: "TokenStats") -> None:
49+
self.files += other.files
50+
self.skipped_files += other.skipped_files
51+
self.rows += other.rows
52+
self.docs += other.docs
53+
self.valid_tokens += other.valid_tokens
54+
self.trained_tokens += other.trained_tokens
55+
56+
57+
@dataclass(frozen=True)
58+
class ReportRow:
59+
kind: str
60+
length: int
61+
batch_size: int
62+
tokens_per_step: int
63+
files: int
64+
skipped_files: int
65+
rows: int
66+
docs: int
67+
valid_tokens: int
68+
trained_tokens: int
69+
steps_by_trained_tokens: int
70+
steps_by_valid_tokens: int
71+
72+
73+
def parse_batch_schedule(value: str) -> dict[int, int]:
74+
"""Parse `1024=192,2048=96` into an int mapping."""
75+
if not value.strip():
76+
return dict(DEFAULT_BATCH_BY_LENGTH)
77+
result: dict[int, int] = {}
78+
for part in value.split(","):
79+
key, sep, val = part.strip().partition("=")
80+
if not sep:
81+
raise ValueError(f"invalid --batch-schedule item {part!r}; expected LENGTH=BATCH")
82+
length = int(key)
83+
batch = int(val)
84+
if length <= 0 or batch <= 0:
85+
raise ValueError(f"invalid non-positive schedule item {part!r}")
86+
result[length] = batch
87+
return result
88+
89+
90+
def _length_dirs(root: Path) -> list[Path]:
91+
if not root.exists():
92+
return []
93+
return sorted(
94+
(p for p in root.iterdir() if p.is_dir() and p.name.isdigit()),
95+
key=lambda p: int(p.name),
96+
)
97+
98+
99+
def _read_file_stats(path: Path, *, allow_concurrent_skips: bool) -> TokenStats:
100+
try:
101+
parquet_file = pq.ParquetFile(path)
102+
names = set(parquet_file.schema_arrow.names)
103+
missing = [name for name in COUNTER_COLUMNS[:2] if name not in names]
104+
if missing:
105+
raise ValueError(f"{path} missing required counter columns: {missing}")
106+
columns = [name for name in COUNTER_COLUMNS if name in names]
107+
table = parquet_file.read(columns=columns)
108+
except _CONCURRENT_READ_ERRORS:
109+
if allow_concurrent_skips:
110+
return TokenStats(skipped_files=1)
111+
raise
112+
113+
stats = TokenStats(files=1, rows=table.num_rows)
114+
stats.valid_tokens = int(table.column("valid_token_count").to_numpy(zero_copy_only=False).sum())
115+
stats.trained_tokens = int(table.column("trained_token_count").to_numpy(zero_copy_only=False).sum())
116+
if "num_docs" in table.column_names:
117+
stats.docs = int(table.column("num_docs").to_numpy(zero_copy_only=False).sum())
118+
return stats
119+
120+
121+
def collect_length_stats(
122+
root: Path,
123+
*,
124+
max_workers: int,
125+
allow_concurrent_skips: bool,
126+
) -> dict[int, TokenStats]:
127+
"""Collect stats for every numeric bucket directory under `root`."""
128+
results: dict[int, TokenStats] = {}
129+
for length_dir in _length_dirs(root):
130+
total = TokenStats()
131+
files = sorted(length_dir.glob("*.parquet"))
132+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
133+
futures = [
134+
executor.submit(
135+
_read_file_stats,
136+
path,
137+
allow_concurrent_skips=allow_concurrent_skips,
138+
)
139+
for path in files
140+
]
141+
for future in as_completed(futures):
142+
total.add(future.result())
143+
results[int(length_dir.name)] = total
144+
return results
145+
146+
147+
def _row(kind: str, length: int, stats: TokenStats, batch_by_length: dict[int, int]) -> ReportRow:
148+
batch = batch_by_length.get(length, 0)
149+
tokens_per_step = batch * length if batch else 0
150+
return ReportRow(
151+
kind=kind,
152+
length=length,
153+
batch_size=batch,
154+
tokens_per_step=tokens_per_step,
155+
files=stats.files,
156+
skipped_files=stats.skipped_files,
157+
rows=stats.rows,
158+
docs=stats.docs,
159+
valid_tokens=stats.valid_tokens,
160+
trained_tokens=stats.trained_tokens,
161+
steps_by_trained_tokens=stats.trained_tokens // tokens_per_step if tokens_per_step else 0,
162+
steps_by_valid_tokens=stats.valid_tokens // tokens_per_step if tokens_per_step else 0,
163+
)
164+
165+
166+
def combine_stats(*items: TokenStats | None) -> TokenStats:
167+
total = TokenStats()
168+
for item in items:
169+
if item is not None:
170+
total.add(item)
171+
return total
172+
173+
174+
def build_report(
175+
*,
176+
code_root: Path,
177+
commit_root: Path,
178+
pr_root: Path,
179+
batch_by_length: dict[int, int],
180+
max_workers: int,
181+
allow_concurrent_skips: bool,
182+
) -> list[ReportRow]:
183+
code = collect_length_stats(
184+
code_root,
185+
max_workers=max_workers,
186+
allow_concurrent_skips=allow_concurrent_skips,
187+
)
188+
commits = collect_length_stats(
189+
commit_root,
190+
max_workers=max_workers,
191+
allow_concurrent_skips=allow_concurrent_skips,
192+
)
193+
pr = collect_length_stats(
194+
pr_root,
195+
max_workers=max_workers,
196+
allow_concurrent_skips=allow_concurrent_skips,
197+
)
198+
199+
lengths = sorted(set(code) | set(commits) | set(pr) | set(batch_by_length))
200+
rows: list[ReportRow] = []
201+
for length in lengths:
202+
if length in code:
203+
rows.append(_row("code_only", length, code[length], batch_by_length))
204+
if length in commits:
205+
rows.append(_row("commits_with_pr_docstring", length, commits[length], batch_by_length))
206+
main = combine_stats(code.get(length), commits.get(length))
207+
if main.files or main.skipped_files:
208+
rows.append(_row("main_code_plus_commits", length, main, batch_by_length))
209+
if length in pr:
210+
rows.append(_row("standalone_pr_side_stream", length, pr[length], batch_by_length))
211+
with_pr = combine_stats(main, pr[length])
212+
rows.append(_row("main_plus_standalone_pr", length, with_pr, batch_by_length))
213+
return rows
214+
215+
216+
def _format_int(value: int) -> str:
217+
return f"{value:,}"
218+
219+
220+
def _print_markdown(rows: Iterable[ReportRow]) -> None:
221+
print(
222+
"| kind | length | bs | tokens/step | files | skipped | rows | docs | trained tokens | steps(trained) | valid tokens | steps(valid) |"
223+
)
224+
print("|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|---:|")
225+
for row in rows:
226+
print(
227+
"| "
228+
+ " | ".join(
229+
[
230+
row.kind,
231+
str(row.length),
232+
str(row.batch_size),
233+
_format_int(row.tokens_per_step),
234+
_format_int(row.files),
235+
_format_int(row.skipped_files),
236+
_format_int(row.rows),
237+
_format_int(row.docs),
238+
_format_int(row.trained_tokens),
239+
_format_int(row.steps_by_trained_tokens),
240+
_format_int(row.valid_tokens),
241+
_format_int(row.steps_by_valid_tokens),
242+
]
243+
)
244+
+ " |"
245+
)
246+
247+
248+
def _print_text(rows: Iterable[ReportRow]) -> None:
249+
for row in rows:
250+
print(
251+
f"{row.kind:28s} len={row.length:5d} bs={row.batch_size:4d} "
252+
f"tokens/step={row.tokens_per_step:9d} files={row.files:5d} "
253+
f"skipped={row.skipped_files:3d} rows={row.rows:9d} docs={row.docs:9d} "
254+
f"trained={row.trained_tokens:14d} steps={row.steps_by_trained_tokens:7d} "
255+
f"valid={row.valid_tokens:14d} valid_steps={row.steps_by_valid_tokens:7d}"
256+
)
257+
258+
259+
def main() -> int:
260+
parser = argparse.ArgumentParser(description=__doc__)
261+
parser.add_argument("--code-root", type=Path, default=DEFAULT_CODE_ROOT)
262+
parser.add_argument("--commit-root", type=Path, default=DEFAULT_COMMIT_ROOT)
263+
parser.add_argument("--pr-root", type=Path, default=DEFAULT_PR_ROOT)
264+
parser.add_argument(
265+
"--batch-schedule",
266+
default=",".join(f"{k}={v}" for k, v in DEFAULT_BATCH_BY_LENGTH.items()),
267+
help="Comma-separated LENGTH=BATCH entries.",
268+
)
269+
parser.add_argument("--jobs", type=int, default=8)
270+
parser.add_argument(
271+
"--allow-concurrent-skips",
272+
action="store_true",
273+
help="Skip files that disappear or are unreadable during live conveyor writes, and report the skip count.",
274+
)
275+
parser.add_argument("--format", choices=("text", "markdown", "json"), default="text")
276+
args = parser.parse_args()
277+
278+
rows = build_report(
279+
code_root=args.code_root,
280+
commit_root=args.commit_root,
281+
pr_root=args.pr_root,
282+
batch_by_length=parse_batch_schedule(args.batch_schedule),
283+
max_workers=max(args.jobs, 1),
284+
allow_concurrent_skips=args.allow_concurrent_skips,
285+
)
286+
if args.format == "json":
287+
print(json.dumps([asdict(row) for row in rows], indent=2, sort_keys=True))
288+
elif args.format == "markdown":
289+
_print_markdown(rows)
290+
else:
291+
_print_text(rows)
292+
return 0
293+
294+
295+
if __name__ == "__main__":
296+
raise SystemExit(main())
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
from __future__ import annotations
2+
3+
from pathlib import Path
4+
5+
import pyarrow as pa
6+
import pyarrow.parquet as pq
7+
8+
from scripts.report_training_steps import build_report, parse_batch_schedule
9+
10+
11+
def _write_counter_parquet(path: Path, rows: list[dict[str, int]]) -> None:
12+
path.parent.mkdir(parents=True, exist_ok=True)
13+
table = pa.table(
14+
{
15+
"valid_token_count": pa.array([row["valid"] for row in rows], pa.int64()),
16+
"trained_token_count": pa.array([row["trained"] for row in rows], pa.int64()),
17+
"num_docs": pa.array([row["docs"] for row in rows], pa.int64()),
18+
}
19+
)
20+
pq.write_table(table, path)
21+
22+
23+
def test_report_keeps_code_commits_main_and_pr_stream_distinct(tmp_path: Path) -> None:
24+
code = tmp_path / "code"
25+
commits = tmp_path / "commits"
26+
pr = tmp_path / "pr"
27+
_write_counter_parquet(
28+
code / "1024" / "code.parquet",
29+
[{"valid": 1000, "trained": 900, "docs": 2}],
30+
)
31+
_write_counter_parquet(
32+
commits / "1024" / "commits.parquet",
33+
[{"valid": 2200, "trained": 2100, "docs": 3}],
34+
)
35+
_write_counter_parquet(
36+
pr / "1024" / "pr.parquet",
37+
[{"valid": 700, "trained": 600, "docs": 1}],
38+
)
39+
40+
rows = build_report(
41+
code_root=code,
42+
commit_root=commits,
43+
pr_root=pr,
44+
batch_by_length={1024: 3},
45+
max_workers=1,
46+
allow_concurrent_skips=False,
47+
)
48+
by_kind = {row.kind: row for row in rows}
49+
50+
assert by_kind["code_only"].trained_tokens == 900
51+
assert by_kind["code_only"].steps_by_trained_tokens == 0
52+
assert by_kind["commits_with_pr_docstring"].trained_tokens == 2100
53+
assert by_kind["commits_with_pr_docstring"].steps_by_trained_tokens == 0
54+
55+
main = by_kind["main_code_plus_commits"]
56+
assert main.trained_tokens == 3000
57+
assert main.valid_tokens == 3200
58+
assert main.docs == 5
59+
assert main.tokens_per_step == 3072
60+
assert main.steps_by_trained_tokens == 0
61+
assert main.steps_by_valid_tokens == 1
62+
63+
assert by_kind["standalone_pr_side_stream"].trained_tokens == 600
64+
assert by_kind["main_plus_standalone_pr"].trained_tokens == 3600
65+
assert by_kind["main_plus_standalone_pr"].steps_by_trained_tokens == 1
66+
67+
68+
def test_parse_batch_schedule_rejects_bad_items() -> None:
69+
assert parse_batch_schedule("1024=192,2048=96") == {1024: 192, 2048: 96}
70+
71+
try:
72+
parse_batch_schedule("1024")
73+
except ValueError as exc:
74+
assert "expected LENGTH=BATCH" in str(exc)
75+
else: # pragma: no cover
76+
raise AssertionError("expected invalid schedule to raise")

0 commit comments

Comments
 (0)