trainq 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.
trainq/__init__.py ADDED
File without changes
@@ -0,0 +1,34 @@
1
+ """Checkpoint watcher CLI entry point.
2
+
3
+ python -m trainq.checkpoint_watcher --ckpt-dir DIR [--on-checkpoint CMD] [--hf-repo-id REPO]
4
+
5
+ The implementation lives in the trainq.watcher package.
6
+ """
7
+
8
+ from trainq.watcher import (
9
+ WatchConfig,
10
+ checkpoint_name,
11
+ extract_step,
12
+ find_checkpoints,
13
+ load_state,
14
+ save_state,
15
+ upload_checkpoint,
16
+ watch,
17
+ )
18
+ from trainq.watcher.daemon import main
19
+
20
+ __all__ = [
21
+ "WatchConfig",
22
+ "watch",
23
+ "find_checkpoints",
24
+ "checkpoint_name",
25
+ "extract_step",
26
+ "load_state",
27
+ "save_state",
28
+ "upload_checkpoint",
29
+ "main",
30
+ ]
31
+
32
+
33
+ if __name__ == "__main__":
34
+ main()
trainq/client.py ADDED
@@ -0,0 +1,383 @@
1
+ """Job queue client CLI.
2
+
3
+ Usage:
4
+ python -m trainq.client submit --command "python train.py ..." \
5
+ --ckpt-dir /data/ckpt --log-path /data/log.txt \
6
+ --gpu-count 2 --priority 3
7
+
8
+ python -m trainq.client list
9
+ python -m trainq.client status <job_id>
10
+ python -m trainq.client cancel <job_id>
11
+ python -m trainq.client clear
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import getpass
18
+ import sys
19
+
20
+ import redis
21
+
22
+ from trainq.config import (
23
+ REDIS_HOST, REDIS_PORT, REDIS_DB, REDIS_PASSWORD,
24
+ QUEUE_KEY, JOB_DETAIL_KEY, RUNNING_KEY,
25
+ )
26
+ from datetime import datetime, timezone
27
+
28
+ from trainq.progress import parse_training_progress
29
+ from trainq.schema import Job, JobStatus
30
+
31
+ AGING_HOURS_PER_POINT = 30.0 # -1 per 30h waited (matches scheduler.py)
32
+
33
+
34
+ def _redis_client() -> redis.Redis:
35
+ return redis.Redis(
36
+ host=REDIS_HOST, port=REDIS_PORT, db=REDIS_DB,
37
+ password=REDIS_PASSWORD, decode_responses=True,
38
+ )
39
+
40
+
41
+ # ── Commands ──────────────────────────────────────────────
42
+
43
+ def cmd_submit(args: argparse.Namespace) -> None:
44
+ r = _redis_client()
45
+
46
+ # warn about a missing distributed launcher for multi-GPU (torchrun/accelerate/deepspeed/etc.)
47
+ _LAUNCHERS = ("torchrun", "accelerate", "deepspeed", "mpirun", "torch.distributed")
48
+ if args.gpu_count >= 2 and not any(l in args.command for l in _LAUNCHERS):
49
+ print(" ⚠️ gpu-count >= 2 but no distributed launcher found in the command.")
50
+ print(" Multi-GPU usually needs torchrun --nproc_per_node=N (or accelerate/deepspeed).")
51
+ print(" e.g. torchrun --nproc_per_node=2 train.py ...")
52
+ print(" Add --force to submit anyway. Aborting.")
53
+ if not getattr(args, 'force', False):
54
+ sys.exit(1)
55
+
56
+ gpu_ids = None
57
+ if args.gpu_ids:
58
+ gpu_ids = [int(x.strip()) for x in args.gpu_ids.split(",")]
59
+
60
+ if not args.description.strip():
61
+ print(" ❌ --description is required. Briefly describe the job.")
62
+ sys.exit(1)
63
+
64
+ job = Job(
65
+ command=args.command,
66
+ ckpt_dir=args.ckpt_dir,
67
+ log_path=args.log_path,
68
+ description=args.description.strip(),
69
+ gpu_count=args.gpu_count,
70
+ gpu_ids=gpu_ids,
71
+ priority=args.priority,
72
+ max_retries=args.max_retries,
73
+ estimated_hours=args.estimated_hours,
74
+ estimated_vram_mb=args.estimated_vram_mb,
75
+ checkpoint_glob=args.checkpoint_glob,
76
+ on_checkpoint=args.on_checkpoint,
77
+ hf_repo_id=args.hf_repo_id,
78
+ watch_gpu=args.watch_gpu,
79
+ on_complete=args.on_complete,
80
+ user=getpass.getuser(),
81
+ )
82
+ r.set(f"{JOB_DETAIL_KEY}:{job.job_id}", job.to_json())
83
+ r.zadd(QUEUE_KEY, {job.job_id: job.priority})
84
+ parts = [f"Submitted: {job.job_id} priority={job.priority} gpu={args.gpu_count}"]
85
+ if args.estimated_hours > 0:
86
+ parts.append(f"est={args.estimated_hours}h")
87
+ if args.estimated_vram_mb > 0:
88
+ parts.append(f"vram={args.estimated_vram_mb}MB")
89
+ if args.hf_repo_id:
90
+ parts.append(f"hf={args.hf_repo_id}")
91
+ if args.on_checkpoint:
92
+ parts.append("hook=on_checkpoint")
93
+ print(" ".join(parts))
94
+ if args.estimated_vram_mb == 0:
95
+ print(" ⚠️ estimated-vram-mb not set → only a fully idle GPU is used (may wait longer)")
96
+
97
+
98
+ def cmd_list(args: argparse.Namespace) -> None:
99
+ r = _redis_client()
100
+
101
+ # pending (sorted set)
102
+ pending_ids = r.zrange(QUEUE_KEY, 0, -1, withscores=True)
103
+ running_ids = r.smembers(RUNNING_KEY)
104
+
105
+ # collect all job keys
106
+ all_keys = r.keys(f"{JOB_DETAIL_KEY}:*")
107
+ jobs: list[Job] = []
108
+ for k in all_keys:
109
+ raw = r.get(k)
110
+ if raw:
111
+ jobs.append(Job.from_json(raw))
112
+
113
+ if not jobs:
114
+ print("No jobs.")
115
+ return
116
+
117
+ # effective priority — frozen at started_at/finished_at once set.
118
+ # while running: wait fixed by started_at. When finished (completed/failed/
119
+ # cancelled): allow finished_at as the snapshot end in case it ended without started_at.
120
+ def _wait_hours(j: Job) -> float:
121
+ if j.started_at is not None:
122
+ return (
123
+ datetime.fromisoformat(j.started_at) - datetime.fromisoformat(j.created_at)
124
+ ).total_seconds() / 3600
125
+ if j.finished_at is not None:
126
+ return (
127
+ datetime.fromisoformat(j.finished_at) - datetime.fromisoformat(j.created_at)
128
+ ).total_seconds() / 3600
129
+ return (datetime.now(timezone.utc) - datetime.fromisoformat(j.created_at)).total_seconds() / 3600
130
+
131
+ def eff_pri(j: Job) -> float:
132
+ return j.priority - (_wait_hours(j) / AGING_HOURS_PER_POINT)
133
+
134
+ def wait_h(j: Job) -> float:
135
+ return _wait_hours(j)
136
+
137
+ def elapsed_str(j: Job) -> str:
138
+ """Elapsed time. Up to now while running, frozen at finished_at once ended."""
139
+ if j.started_at is None:
140
+ return "-"
141
+ end = (
142
+ datetime.fromisoformat(j.finished_at)
143
+ if j.finished_at is not None
144
+ else datetime.now(timezone.utc)
145
+ )
146
+ elapsed = (end - datetime.fromisoformat(j.started_at)).total_seconds() / 3600
147
+ if elapsed < 1:
148
+ return f"{elapsed * 60:.0f}m"
149
+ return f"{elapsed:.1f}h"
150
+
151
+ def progress_str(j: Job) -> str:
152
+ """Log-parsed progress + ETA."""
153
+ if j.status != JobStatus.RUNNING or j.started_at is None:
154
+ return "-"
155
+ elapsed = (datetime.now(timezone.utc) - datetime.fromisoformat(j.started_at)).total_seconds() / 3600
156
+ prog = parse_training_progress(j.log_path, j.command, j.ckpt_dir)
157
+ if prog is not None and prog.progress > 0:
158
+ eta = prog.estimate_remaining_hours(elapsed)
159
+ return f"{prog.progress:.0%} {eta:.1f}h"
160
+ # fallback: estimated_hours based
161
+ if j.estimated_hours > 0:
162
+ rem = max(0, j.estimated_hours - elapsed)
163
+ return f"~{rem:.1f}h"
164
+ return "?"
165
+
166
+ # sort by status
167
+ order = {JobStatus.RUNNING: 0, JobStatus.PENDING: 1, JobStatus.FAILED: 2, JobStatus.COMPLETED: 3, JobStatus.CANCELLED: 4}
168
+ jobs.sort(key=lambda j: (order.get(j.status, 9), eff_pri(j)))
169
+
170
+ def gpu_idx(j):
171
+ # actually assigned GPUs (running) first, else the allow-set (gpu_ids), else all ("all")
172
+ if j.assigned_gpus:
173
+ return ",".join(str(g) for g in j.assigned_gpus)
174
+ if j.gpu_ids:
175
+ return ",".join(str(g) for g in j.gpu_ids)
176
+ return "all"
177
+
178
+ DESC_WIDTH = 60
179
+ fmt = "{:<14} {:<10} {:>5} {:>4} {:>8} {:>6} {:>5} {:>6} {:>8} {:<9} {}"
180
+ print(fmt.format("JOB_ID", "STATUS", "EFF", "GPUs", "GPUIDX", "VRAM", "WAIT", "ELAPS", "ETA", "USER", "DESC"))
181
+ print("-" * (87 + DESC_WIDTH))
182
+ for j in jobs:
183
+ ep = f"{eff_pri(j):.1f}"
184
+ wh = f"{wait_h(j):.1f}h"
185
+ vram = f"{j.estimated_vram_mb/1024:.0f}GB" if j.estimated_vram_mb > 0 else "-"
186
+ if j.status == JobStatus.RUNNING:
187
+ elaps = elapsed_str(j)
188
+ eta = progress_str(j)
189
+ elif j.status == JobStatus.COMPLETED:
190
+ elaps = elapsed_str(j)
191
+ eta = "-"
192
+ else: # pending, failed, cancelled
193
+ elaps = "-"
194
+ eta = f"{j.estimated_hours:.1f}h" if j.estimated_hours > 0 else "-"
195
+ desc = j.description[:DESC_WIDTH]
196
+ print(fmt.format(j.job_id, j.status.value, ep, j.gpu_count, gpu_idx(j), vram, wh, elaps, eta, (j.user or "-"), desc))
197
+
198
+
199
+ def cmd_status(args: argparse.Namespace) -> None:
200
+ r = _redis_client()
201
+ raw = r.get(f"{JOB_DETAIL_KEY}:{args.job_id}")
202
+ if raw is None:
203
+ print(f"Job {args.job_id} not found.")
204
+ sys.exit(1)
205
+ job = Job.from_json(raw)
206
+ for field_name in [
207
+ "job_id", "status", "description", "command", "gpu_count", "gpu_ids", "assigned_gpus",
208
+ "priority", "estimated_hours", "estimated_vram_mb", "max_retries", "retry_count",
209
+ "ckpt_dir", "log_path",
210
+ "created_at", "started_at", "finished_at", "error_msg",
211
+ ]:
212
+ val = getattr(job, field_name)
213
+ if isinstance(val, JobStatus):
214
+ val = val.value
215
+ print(f" {field_name:16s}: {val}")
216
+
217
+
218
+ def cmd_cancel(args: argparse.Namespace) -> None:
219
+ r = _redis_client()
220
+ raw = r.get(f"{JOB_DETAIL_KEY}:{args.job_id}")
221
+ if raw is None:
222
+ print(f"Job {args.job_id} not found.")
223
+ sys.exit(1)
224
+ job = Job.from_json(raw)
225
+ if job.status != JobStatus.PENDING:
226
+ print(f"Cannot cancel: status is {job.status.value}")
227
+ sys.exit(1)
228
+ job.status = JobStatus.CANCELLED
229
+ r.set(f"{JOB_DETAIL_KEY}:{job.job_id}", job.to_json())
230
+ r.zrem(QUEUE_KEY, job.job_id)
231
+ print(f"Cancelled: {job.job_id}")
232
+
233
+
234
+ def cmd_clear(args: argparse.Namespace) -> None:
235
+ r = _redis_client()
236
+ all_keys = r.keys(f"{JOB_DETAIL_KEY}:*")
237
+ removed = 0
238
+ for k in all_keys:
239
+ raw = r.get(k)
240
+ if raw:
241
+ job = Job.from_json(raw)
242
+ if job.status in (JobStatus.COMPLETED, JobStatus.FAILED, JobStatus.CANCELLED):
243
+ r.delete(k)
244
+ removed += 1
245
+ print(f"Cleared {removed} finished jobs.")
246
+
247
+
248
+ def cmd_priority(args: argparse.Namespace) -> None:
249
+ r = _redis_client()
250
+ raw = r.get(f"{JOB_DETAIL_KEY}:{args.job_id}")
251
+ if raw is None:
252
+ print(f"Job {args.job_id} not found.")
253
+ sys.exit(1)
254
+ job = Job.from_json(raw)
255
+ if job.status != JobStatus.PENDING:
256
+ print(f"Cannot change priority: status is {job.status.value} (only pending can be changed)")
257
+ sys.exit(1)
258
+ old = job.priority
259
+ job.priority = args.priority
260
+ r.set(f"{JOB_DETAIL_KEY}:{job.job_id}", job.to_json())
261
+ r.zadd(QUEUE_KEY, {job.job_id: args.priority})
262
+ print(f"Priority updated: {job.job_id} {old} -> {args.priority}")
263
+
264
+
265
+ # ── Main ──────────────────────────────────────────────────
266
+
267
+ USAGE = """\
268
+ trainq — GPU job queue client
269
+
270
+ Usage:
271
+ trainq list list all jobs
272
+ trainq status <job_id> show job details
273
+ trainq submit --command "..." ... submit a job
274
+ trainq cancel <job_id> cancel a pending job
275
+ trainq priority <job_id> <val> change a pending job's priority (float ok; lower runs first)
276
+ trainq clear remove completed/failed/cancelled jobs
277
+
278
+ Minimal submit:
279
+ trainq submit --command "..." --ckpt-dir <dir> --log-path <path> --gpu-count 1 \\
280
+ --description "resnet50 finetune lr1e-4"
281
+
282
+ Recommended submit (enables backfill + GPU sharing):
283
+ trainq submit --command "..." --ckpt-dir <dir> --log-path <path> \\
284
+ --gpu-count 1 --estimated-hours 2.0 --estimated-vram-mb 15000 \\
285
+ --description "eval sweep seed=0"
286
+
287
+ ⚠️ estimated-vram-mb not set → only a fully idle GPU is used (may wait longer)
288
+ ⚠️ gpu-count >= 2 → the command needs a distributed launcher (torchrun, etc.)
289
+ ⚠️ --gpu-ids is an allow-set (not a pin) → trainq picks --gpu-count from it.
290
+ e.g. --gpu-ids 2,3 --gpu-count 1 = GPU 2 or 3, whichever has VRAM room (not both).
291
+ To restrict, pass it per submit. There is no global setting.
292
+
293
+ Checkpoint hooks (checkpoint watcher):
294
+ trainq submit ... --checkpoint-glob 'checkpoint-*' \\
295
+ --on-checkpoint 'python eval.py {checkpoint}' --watch-gpu 2
296
+ trainq submit ... --hf-repo-id <user>/<repo> # auto-back up each new checkpoint to the HF Hub
297
+
298
+ Live ETA (the ETA column in `list`: progress + remaining time):
299
+ Progress is read from logs/files. Auto-detected formats: fairseq2, HF Trainer,
300
+ PaddleOCR(epoch), metrics.jsonl(iter), 'step X/Y'. If none match, it estimates from --estimated-hours ('~Xh').
301
+ ★For format-agnostic real-time ETA: have the training script periodically write
302
+ {"step": <current>, "total": <total>} to {--ckpt-dir}/progress.json (buffering-independent, most robust).
303
+ """
304
+
305
+
306
+ def main() -> None:
307
+ parser = argparse.ArgumentParser(
308
+ prog="trainq",
309
+ description="GPU job queue client",
310
+ usage=USAGE,
311
+ formatter_class=argparse.RawDescriptionHelpFormatter,
312
+ )
313
+ sub = parser.add_subparsers(dest="action")
314
+
315
+ # print usage when run without arguments
316
+ if len(sys.argv) == 1:
317
+ print(USAGE)
318
+ sys.exit(0)
319
+
320
+ # submit
321
+ p_sub = sub.add_parser("submit", help="Submit a job")
322
+ p_sub.add_argument("--command", required=True, help="Command to execute")
323
+ p_sub.add_argument("--ckpt-dir", required=True, help="Checkpoint / output root directory")
324
+ p_sub.add_argument("--log-path", required=True, help="Log file path")
325
+ p_sub.add_argument("--description", required=True, help="Job description, shown in `trainq list`")
326
+ p_sub.add_argument("--gpu-count", type=int, default=1, help="Number of GPUs needed (default: 1)")
327
+ p_sub.add_argument("--gpu-ids", default=None, help="Allowed GPU set, NOT forced: --gpu-count picks that many from it (VRAM-aware). Comma-separated, e.g. '2,3' with --gpu-count 1 uses GPU 2 or 3, whichever has room. No global setting; pass per submit to restrict")
328
+ p_sub.add_argument(
329
+ "--priority", type=int, default=5,
330
+ help=(
331
+ "Priority — 0 (highest) .. 9 (lowest) at submit, default: 5. "
332
+ "The effective priority drops below 0 (negative) via aging "
333
+ "(-1 per 30h waited, starvation guard — gentle so explicit priorities stay meaningful)."
334
+ ),
335
+ )
336
+ p_sub.add_argument("--max-retries", type=int, default=1, help="Max retry count on failure (default: 1)")
337
+ p_sub.add_argument("--estimated-hours", type=float, default=0.0, help="Estimated time in hours (optional, 0=unknown → backfill disabled)")
338
+ p_sub.add_argument("--estimated-vram-mb", type=int, default=0, help="Estimated VRAM per GPU in MB (optional, 0=require fully free GPU)")
339
+ p_sub.add_argument("--checkpoint-glob", default=None,
340
+ help="Glob for new checkpoints (under ckpt_dir), e.g. 'checkpoint-*', 'ws_*/checkpoints/step_*'")
341
+ p_sub.add_argument("--on-checkpoint", default=None,
342
+ help="Shell command per new checkpoint ({checkpoint} {name} {step} {ckpt_dir} substituted)")
343
+ p_sub.add_argument("--hf-repo-id", default=None,
344
+ help="If set, auto-upload each new checkpoint to this HF Hub repo (requires HF_TOKEN)")
345
+ p_sub.add_argument("--watch-gpu", type=int, default=None,
346
+ help="Dedicated GPU for the on_checkpoint command (separate from job GPUs)")
347
+ p_sub.add_argument("--on-complete", default=None,
348
+ help="Shell command after the job ends ({ckpt_dir} {log_path} substituted, env TRAINQ_JOB_STATUS)")
349
+ p_sub.add_argument("--force", action="store_true",
350
+ help="Skip validation checks and submit anyway")
351
+
352
+ # list
353
+ sub.add_parser("list", help="List all jobs")
354
+
355
+ # status
356
+ p_st = sub.add_parser("status", help="Show job details")
357
+ p_st.add_argument("job_id")
358
+
359
+ # cancel
360
+ p_ca = sub.add_parser("cancel", help="Cancel a pending job")
361
+ p_ca.add_argument("job_id")
362
+
363
+ # clear
364
+ sub.add_parser("clear", help="Remove completed/failed/cancelled jobs")
365
+
366
+ # priority
367
+ p_pri = sub.add_parser("priority", help="Change a pending job's priority")
368
+ p_pri.add_argument("job_id")
369
+ p_pri.add_argument("priority", type=float, help="New priority (float ok, e.g. 5.5). Lower runs first")
370
+
371
+ args = parser.parse_args()
372
+ {
373
+ "submit": cmd_submit,
374
+ "list": cmd_list,
375
+ "status": cmd_status,
376
+ "cancel": cmd_cancel,
377
+ "clear": cmd_clear,
378
+ "priority": cmd_priority,
379
+ }[args.action](args)
380
+
381
+
382
+ if __name__ == "__main__":
383
+ main()
trainq/config.py ADDED
@@ -0,0 +1,21 @@
1
+ """Queue system configuration constants."""
2
+
3
+ import os
4
+
5
+ # Redis
6
+ REDIS_HOST = os.getenv("TRAINQ_REDIS_HOST", "localhost")
7
+ REDIS_PORT = int(os.getenv("TRAINQ_REDIS_PORT", "6379"))
8
+ REDIS_DB = int(os.getenv("TRAINQ_REDIS_DB", "0"))
9
+ REDIS_PASSWORD = os.getenv("TRAINQ_REDIS_PASSWORD", None)
10
+
11
+ # Redis key prefix
12
+ KEY_PREFIX = "trainq"
13
+ QUEUE_KEY = f"{KEY_PREFIX}:jobs" # sorted set (priority)
14
+ JOB_DETAIL_KEY = f"{KEY_PREFIX}:job" # hash per job → {KEY_PREFIX}:job:{job_id}
15
+ RUNNING_KEY = f"{KEY_PREFIX}:running" # set of running job_ids
16
+
17
+ # Server polling
18
+ POLL_INTERVAL_SEC = int(os.getenv("TRAINQ_POLL_SEC", "10"))
19
+
20
+ # GPU
21
+ GPU_VRAM_FREE_THRESHOLD_MB = int(os.getenv("TRAINQ_VRAM_THRESHOLD_MB", "1000"))