cortex-training 0.0.3__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.
- cortex_training/__init__.py +49 -0
- cortex_training/__main__.py +20 -0
- cortex_training/_cli.py +1050 -0
- cortex_training/cli.py +103 -0
- cortex_training/client.py +2866 -0
- cortex_training/peft.py +112 -0
- cortex_training/telemetry.py +526 -0
- cortex_training/tui/__init__.py +23 -0
- cortex_training/tui/__main__.py +109 -0
- cortex_training/tui/app.py +646 -0
- cortex_training/tui/format.py +389 -0
- cortex_training/tui/log_cache.py +415 -0
- cortex_training/wire.py +414 -0
- cortex_training-0.0.3.dist-info/METADATA +143 -0
- cortex_training-0.0.3.dist-info/RECORD +19 -0
- cortex_training-0.0.3.dist-info/WHEEL +5 -0
- cortex_training-0.0.3.dist-info/entry_points.txt +3 -0
- cortex_training-0.0.3.dist-info/licenses/LICENSE +177 -0
- cortex_training-0.0.3.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,2866 @@
|
|
|
1
|
+
# Copyright 2025 Snowflake Inc.
|
|
2
|
+
# SPDX-License-Identifier: Apache-2.0
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""HTTP client for the Cortex Training REST API.
|
|
17
|
+
|
|
18
|
+
Single-purpose, production-shaped client:
|
|
19
|
+
|
|
20
|
+
- One auth path: Programmatic Access Token (PAT) — see ``CortexTrainingClient.from_pat``.
|
|
21
|
+
- One CreateJob shape: a list of typed :class:`SubJobConfig` (each carries either
|
|
22
|
+
a :class:`TrainingConfig` or an :class:`InferenceConfig`). The client-side
|
|
23
|
+
validators mirror the server's own required-field checks so an invalid job
|
|
24
|
+
fails locally instead of after a round trip.
|
|
25
|
+
- One wire format: ``{job_id?, sub_job_configs:[SubJobConfig]}`` with per-sub-job
|
|
26
|
+
``training_config`` / ``inference_config`` blocks. See
|
|
27
|
+
``docs/reference/rest-api.md`` for the full schema.
|
|
28
|
+
|
|
29
|
+
The server keeps the config blocks open (``additionalProperties``), so unknown
|
|
30
|
+
keys pass through unchanged. The strict, typed shape lives here in the client;
|
|
31
|
+
use the ``extra`` fields for anything the typed layer does not model yet.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
from __future__ import annotations
|
|
35
|
+
|
|
36
|
+
import base64
|
|
37
|
+
import functools
|
|
38
|
+
import gzip
|
|
39
|
+
import hashlib
|
|
40
|
+
import inspect
|
|
41
|
+
import json
|
|
42
|
+
import logging
|
|
43
|
+
import os
|
|
44
|
+
import re
|
|
45
|
+
import shutil
|
|
46
|
+
import tempfile
|
|
47
|
+
import threading
|
|
48
|
+
import time
|
|
49
|
+
from collections.abc import Iterator
|
|
50
|
+
from collections.abc import Mapping
|
|
51
|
+
from dataclasses import dataclass
|
|
52
|
+
from dataclasses import field
|
|
53
|
+
from enum import Enum
|
|
54
|
+
from pathlib import Path
|
|
55
|
+
from pathlib import PurePosixPath
|
|
56
|
+
from typing import Any
|
|
57
|
+
|
|
58
|
+
import requests
|
|
59
|
+
from requests.adapters import HTTPAdapter
|
|
60
|
+
from tenacity import Retrying
|
|
61
|
+
from tenacity import retry_if_exception
|
|
62
|
+
from tenacity import stop_after_attempt
|
|
63
|
+
from tenacity import wait_exponential_jitter
|
|
64
|
+
from urllib3.exceptions import NewConnectionError
|
|
65
|
+
|
|
66
|
+
from cortex_training import wire
|
|
67
|
+
from cortex_training.telemetry import CachedSessionTokenProvider
|
|
68
|
+
from cortex_training.telemetry import OtlpMetricEmitter
|
|
69
|
+
|
|
70
|
+
logger = logging.getLogger(__name__)
|
|
71
|
+
|
|
72
|
+
_STDOUT_CHUNK_RE = re.compile(
|
|
73
|
+
r"^console\.(?P<timestamp>\d{8}-\d{6})\.(?P<suffix>[^./\\]+)\.gz$"
|
|
74
|
+
)
|
|
75
|
+
_GPU_METRICS_CHUNK_RE = re.compile(
|
|
76
|
+
r"^gpu\.(?P<timestamp>\d{8}-\d{6})\.(?P<suffix>[^./\\]+)\.gz$"
|
|
77
|
+
)
|
|
78
|
+
_STREAM_COPY_BUFFER_BYTES = 1024 * 1024
|
|
79
|
+
|
|
80
|
+
# Env var that unlocks create-job debug options. These are an internal-only
|
|
81
|
+
# capability and are deliberately not documented for external use: the client
|
|
82
|
+
# refuses to forward a create-job request carrying a `debug` block unless this
|
|
83
|
+
# is set to a truthy value. The server gates them independently.
|
|
84
|
+
DEBUG_OPTIONS_ENV = "CORTEX_TRAINING_ENABLE_DEBUG_OPTIONS"
|
|
85
|
+
DISABLE_TELEMETRY_ENV = "CORTEX_TRAINING_DISABLE_TELEMETRY"
|
|
86
|
+
ENABLE_SUCCESS_TELEMETRY_ENV = "CORTEX_TRAINING_ENABLE_SUCCESS_TELEMETRY"
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _env_flag_enabled(name: str) -> bool:
|
|
90
|
+
return os.environ.get(name, "").strip().lower() in {
|
|
91
|
+
"1",
|
|
92
|
+
"true",
|
|
93
|
+
"yes",
|
|
94
|
+
"on",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _debug_options_enabled() -> bool:
|
|
99
|
+
"""True when create-job debug options are explicitly enabled via env var."""
|
|
100
|
+
return _env_flag_enabled(DEBUG_OPTIONS_ENV)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _telemetry_disabled() -> bool:
|
|
104
|
+
"""True when OTLP client metrics are explicitly disabled via env var."""
|
|
105
|
+
return _env_flag_enabled(DISABLE_TELEMETRY_ENV)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _success_telemetry_enabled() -> bool:
|
|
109
|
+
"""True when successful automatic operation outcomes should be emitted."""
|
|
110
|
+
return _env_flag_enabled(ENABLE_SUCCESS_TELEMETRY_ENV)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# HTTP statuses worth retrying: the request was well-formed, so the same call
|
|
114
|
+
# may succeed once transient load/infra conditions clear.
|
|
115
|
+
# 429 Too Many Requests - rate limited; back off and retry
|
|
116
|
+
# 500 Internal Server Err - transient server-side failure
|
|
117
|
+
# 502 Bad Gateway - upstream/proxy hiccup in front of the backend
|
|
118
|
+
# 503 Service Unavailable - server temporarily overloaded or restarting
|
|
119
|
+
# 504 Gateway Timeout - upstream didn't respond in time
|
|
120
|
+
# 409 Conflict - the target zone is restarting
|
|
121
|
+
# 404 Not Found - a just-created job may not be visible yet, so a
|
|
122
|
+
# valid id can 404 briefly and succeed on retry.
|
|
123
|
+
# Note the trade-off on 404: a *mistyped* job id is also retried (up to
|
|
124
|
+
# `max_retries`, with backoff) before the error surfaces. Every other 4xx is
|
|
125
|
+
# excluded because it signals a client/config error that won't fix itself.
|
|
126
|
+
_TRANSIENT_STATUSES = {429, 500, 502, 503, 504, 404, 409}
|
|
127
|
+
_CHUNK_GROUP_RESTART_REQUIRED = "chunk_group_restart_required"
|
|
128
|
+
_CHUNK_GROUP_ERROR_CODES = {
|
|
129
|
+
_CHUNK_GROUP_RESTART_REQUIRED,
|
|
130
|
+
"chunk_group_conflict",
|
|
131
|
+
"chunk_group_missing_chunks",
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class ChunkGroupError(requests.exceptions.HTTPError):
|
|
136
|
+
"""Base class for structured chunk-group failures."""
|
|
137
|
+
|
|
138
|
+
def __init__(self, message: str, *, response: requests.Response, detail: dict):
|
|
139
|
+
super().__init__(
|
|
140
|
+
message,
|
|
141
|
+
response=response,
|
|
142
|
+
request=getattr(response, "request", None),
|
|
143
|
+
)
|
|
144
|
+
self.detail = detail
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ChunkGroupRestartError(ChunkGroupError):
|
|
148
|
+
"""The server lost an incomplete group and the bounded replay also failed."""
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class ChunkGroupConflictError(ChunkGroupError):
|
|
152
|
+
"""The same chunk-group identity was reused inconsistently."""
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _is_transient(exc: BaseException) -> bool:
|
|
156
|
+
if isinstance(exc, (requests.exceptions.ConnectionError, requests.exceptions.Timeout)):
|
|
157
|
+
return True
|
|
158
|
+
if isinstance(exc, requests.exceptions.HTTPError):
|
|
159
|
+
resp = exc.response
|
|
160
|
+
return resp is not None and resp.status_code in _TRANSIENT_STATUSES
|
|
161
|
+
return False
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _iter_error_dicts(value: Any, *, depth: int = 0) -> Iterator[dict]:
|
|
165
|
+
"""Yield dictionaries from direct or service-wrapped JSON error bodies."""
|
|
166
|
+
if depth > 8:
|
|
167
|
+
return
|
|
168
|
+
if isinstance(value, dict):
|
|
169
|
+
yield value
|
|
170
|
+
for child in value.values():
|
|
171
|
+
yield from _iter_error_dicts(child, depth=depth + 1)
|
|
172
|
+
return
|
|
173
|
+
if isinstance(value, list):
|
|
174
|
+
for child in value:
|
|
175
|
+
yield from _iter_error_dicts(child, depth=depth + 1)
|
|
176
|
+
return
|
|
177
|
+
if not isinstance(value, str):
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
decoder = json.JSONDecoder()
|
|
181
|
+
for index, char in enumerate(value):
|
|
182
|
+
if char not in "[{":
|
|
183
|
+
continue
|
|
184
|
+
try:
|
|
185
|
+
decoded, _ = decoder.raw_decode(value[index:])
|
|
186
|
+
except (TypeError, ValueError):
|
|
187
|
+
continue
|
|
188
|
+
yield from _iter_error_dicts(decoded, depth=depth + 1)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _chunk_group_error_detail(response: requests.Response | None) -> dict | None:
|
|
192
|
+
if response is None:
|
|
193
|
+
return None
|
|
194
|
+
try:
|
|
195
|
+
body = response.json()
|
|
196
|
+
except Exception:
|
|
197
|
+
return None
|
|
198
|
+
for candidate in _iter_error_dicts(body):
|
|
199
|
+
if candidate.get("code") in _CHUNK_GROUP_ERROR_CODES:
|
|
200
|
+
return candidate
|
|
201
|
+
message = str(candidate.get("message") or "")
|
|
202
|
+
if candidate.get("chunk_group_id") and message == "request chunk group is missing chunks":
|
|
203
|
+
return {
|
|
204
|
+
**candidate,
|
|
205
|
+
"code": "chunk_group_missing_chunks",
|
|
206
|
+
}
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
_METRIC_IDENTITY_FIELDS = (
|
|
211
|
+
"job_id",
|
|
212
|
+
"sub_job_id",
|
|
213
|
+
"sub_job_type",
|
|
214
|
+
"source_job_id",
|
|
215
|
+
"source_sub_job_id",
|
|
216
|
+
"target_sub_job_id",
|
|
217
|
+
"target_sub_job_ids",
|
|
218
|
+
"request_id",
|
|
219
|
+
"checkpoint_id",
|
|
220
|
+
)
|
|
221
|
+
_REQUEST_ID_RESULT_OPERATIONS = {
|
|
222
|
+
"forward_backward",
|
|
223
|
+
"forward",
|
|
224
|
+
"generate",
|
|
225
|
+
"generate_stream",
|
|
226
|
+
"step",
|
|
227
|
+
"save",
|
|
228
|
+
"load",
|
|
229
|
+
"weight_sync",
|
|
230
|
+
"bootstrap_router_replay",
|
|
231
|
+
"router_replay_discard",
|
|
232
|
+
"reset_prefix_cache",
|
|
233
|
+
"cancel_request",
|
|
234
|
+
}
|
|
235
|
+
_METRIC_ERROR_MESSAGE_LIMIT = 512
|
|
236
|
+
_SECRET_IN_ERROR_RE = re.compile(
|
|
237
|
+
r"(?i)(authorization[\"']?\s*[:=]\s*"
|
|
238
|
+
r"(?:bearer\s+|snowflake\s+token\s*=\s*)?|"
|
|
239
|
+
r"bearer\s+|snowflake\s+token\s*=\s*|"
|
|
240
|
+
r"(?:password|secret|credential|pat|api[_-]?key|private[_-]?key|"
|
|
241
|
+
r"access[_-]?token|refresh[_-]?token|session[_-]?token|token)"
|
|
242
|
+
r"[\"']?\s*[:=]\s*)"
|
|
243
|
+
r"(?:\"[^\"]*\"|'[^']*'|\S+)"
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _safe_metric_error_message(exc: BaseException) -> str:
|
|
248
|
+
"""Return a bounded, single-line error message with obvious secrets redacted."""
|
|
249
|
+
message = " ".join(str(exc).split())
|
|
250
|
+
message = _SECRET_IN_ERROR_RE.sub(r"\1<redacted>", message)
|
|
251
|
+
return message[:_METRIC_ERROR_MESSAGE_LIMIT]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _metric_error_attributes(exc: BaseException) -> dict[str, Any]:
|
|
255
|
+
attributes: dict[str, Any] = {"error.type": type(exc).__name__}
|
|
256
|
+
response = getattr(exc, "response", None)
|
|
257
|
+
if response is None:
|
|
258
|
+
return attributes
|
|
259
|
+
status_code = getattr(response, "status_code", None)
|
|
260
|
+
if isinstance(status_code, int):
|
|
261
|
+
attributes["error.http_status"] = status_code
|
|
262
|
+
headers = getattr(response, "headers", {}) or {}
|
|
263
|
+
request_id = headers.get("x-snowflake-request-id")
|
|
264
|
+
if isinstance(request_id, str) and request_id:
|
|
265
|
+
attributes["snowflake.request_id"] = request_id
|
|
266
|
+
try:
|
|
267
|
+
body = response.json()
|
|
268
|
+
except Exception:
|
|
269
|
+
return attributes
|
|
270
|
+
for candidate in _iter_error_dicts(body):
|
|
271
|
+
code = candidate.get("code") or candidate.get("error_code")
|
|
272
|
+
if code is not None:
|
|
273
|
+
attributes["error.code"] = str(code)
|
|
274
|
+
break
|
|
275
|
+
return attributes
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def _metric_identity(
|
|
279
|
+
operation: str,
|
|
280
|
+
arguments: Mapping[str, Any],
|
|
281
|
+
result: Any = None,
|
|
282
|
+
) -> dict[str, Any]:
|
|
283
|
+
attributes = {}
|
|
284
|
+
for field in _METRIC_IDENTITY_FIELDS:
|
|
285
|
+
value = arguments.get(field)
|
|
286
|
+
if value is not None:
|
|
287
|
+
attributes[field] = (
|
|
288
|
+
list(value) if isinstance(value, (list, tuple)) else value
|
|
289
|
+
)
|
|
290
|
+
if operation == "create_job" and "job_id" not in attributes:
|
|
291
|
+
if isinstance(result, str):
|
|
292
|
+
attributes["job_id"] = result
|
|
293
|
+
elif isinstance(result, dict) and result.get("job_id") is not None:
|
|
294
|
+
attributes["job_id"] = result["job_id"]
|
|
295
|
+
if operation in _REQUEST_ID_RESULT_OPERATIONS and "request_id" not in attributes:
|
|
296
|
+
if isinstance(result, str):
|
|
297
|
+
attributes["request_id"] = result
|
|
298
|
+
elif isinstance(result, dict) and result.get("request_id") is not None:
|
|
299
|
+
attributes["request_id"] = result["request_id"]
|
|
300
|
+
return attributes
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _track_operation(operation: str):
|
|
304
|
+
"""Record one outcome metric around a public client operation."""
|
|
305
|
+
|
|
306
|
+
def decorate(method):
|
|
307
|
+
signature = inspect.signature(method)
|
|
308
|
+
|
|
309
|
+
@functools.wraps(method)
|
|
310
|
+
def wrapped(self, *args, **kwargs):
|
|
311
|
+
if getattr(self, "_metric_emitter", None) is None:
|
|
312
|
+
return method(self, *args, **kwargs)
|
|
313
|
+
state = getattr(self, "_operation_metric_state", None)
|
|
314
|
+
if state is None:
|
|
315
|
+
return method(self, *args, **kwargs)
|
|
316
|
+
active = getattr(state, "active", set())
|
|
317
|
+
if active:
|
|
318
|
+
return method(self, *args, **kwargs)
|
|
319
|
+
try:
|
|
320
|
+
bound = signature.bind(self, *args, **kwargs)
|
|
321
|
+
bound.apply_defaults()
|
|
322
|
+
except TypeError:
|
|
323
|
+
return method(self, *args, **kwargs)
|
|
324
|
+
except Exception:
|
|
325
|
+
logger.debug("client operation metric setup failed", exc_info=True)
|
|
326
|
+
return method(self, *args, **kwargs)
|
|
327
|
+
active = set()
|
|
328
|
+
active.add(operation)
|
|
329
|
+
state.active = active
|
|
330
|
+
state.attempt_count = 0
|
|
331
|
+
state.request_count = 0
|
|
332
|
+
started = time.monotonic()
|
|
333
|
+
try:
|
|
334
|
+
result = method(self, *args, **kwargs)
|
|
335
|
+
except Exception as exc:
|
|
336
|
+
self._emit_operation_outcome(
|
|
337
|
+
operation,
|
|
338
|
+
started,
|
|
339
|
+
bound.arguments,
|
|
340
|
+
error=exc,
|
|
341
|
+
)
|
|
342
|
+
raise
|
|
343
|
+
else:
|
|
344
|
+
self._emit_operation_outcome(
|
|
345
|
+
operation,
|
|
346
|
+
started,
|
|
347
|
+
bound.arguments,
|
|
348
|
+
result=result,
|
|
349
|
+
)
|
|
350
|
+
return result
|
|
351
|
+
finally:
|
|
352
|
+
state.active = set()
|
|
353
|
+
state.attempt_count = 0
|
|
354
|
+
state.request_count = 0
|
|
355
|
+
|
|
356
|
+
return wrapped
|
|
357
|
+
|
|
358
|
+
return decorate
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _is_chunk_post_transient(exc: BaseException) -> bool:
|
|
362
|
+
if isinstance(exc, requests.exceptions.HTTPError):
|
|
363
|
+
if _chunk_group_error_detail(exc.response) is not None:
|
|
364
|
+
return False
|
|
365
|
+
return _is_transient(exc)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _is_connect_error(exc: BaseException) -> bool:
|
|
369
|
+
"""Only failures proving the request never reached the server (safe for create_job)."""
|
|
370
|
+
if isinstance(exc, requests.exceptions.ConnectTimeout):
|
|
371
|
+
return True
|
|
372
|
+
if isinstance(exc, requests.exceptions.ConnectionError):
|
|
373
|
+
cause = exc.args[0] if exc.args else None
|
|
374
|
+
reason = getattr(cause, "reason", None)
|
|
375
|
+
return isinstance(cause, NewConnectionError) or isinstance(reason, NewConnectionError)
|
|
376
|
+
return False
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
# ─── Canonical types ─────────────────────────────────────────────────────
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
class JobType(str, Enum):
|
|
383
|
+
"""Sub-job types supported by Cortex Training.
|
|
384
|
+
|
|
385
|
+
Matches the ``job_type`` enum in the REST schema; see
|
|
386
|
+
``docs/reference/rest-api.md`` section 8.1.
|
|
387
|
+
"""
|
|
388
|
+
|
|
389
|
+
TRAINING = "training"
|
|
390
|
+
SAMPLING = "sampling"
|
|
391
|
+
LOG_PROBABILITY = "log_probability"
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
class Hardware(str, Enum):
|
|
395
|
+
"""GPU hardware for create job and capacity.
|
|
396
|
+
|
|
397
|
+
Wire values match the REST ``hardware`` field; see
|
|
398
|
+
``docs/reference/rest-api.md`` sections 5.1 and 5.4. Omitting it defaults to
|
|
399
|
+
H200 on the server. Unknown values are rejected client-side.
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
H200 = "H200"
|
|
403
|
+
B200 = "B200"
|
|
404
|
+
B300 = "B300"
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _hardware_wire(hardware: Hardware | str) -> str:
|
|
408
|
+
"""Return the wire spelling for ``hardware``."""
|
|
409
|
+
if isinstance(hardware, Hardware):
|
|
410
|
+
return hardware.value
|
|
411
|
+
try:
|
|
412
|
+
return Hardware(hardware).value
|
|
413
|
+
except ValueError as exc:
|
|
414
|
+
allowed = ", ".join(member.value for member in Hardware)
|
|
415
|
+
raise ValueError(f"hardware must be one of {allowed}, got {hardware!r}") from exc
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def _effective_primerl_config(extra: dict) -> dict:
|
|
419
|
+
prime_rl = extra.get("prime_rl")
|
|
420
|
+
if isinstance(prime_rl, dict):
|
|
421
|
+
# The training runtime expands the nested prime_rl block over the training
|
|
422
|
+
# config, so validate the same effective values here.
|
|
423
|
+
return {**extra, **prime_rl}
|
|
424
|
+
return extra
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _validate_primerl_lm_head_config(extra: dict, *, location: str) -> None:
|
|
428
|
+
cfg = _effective_primerl_config(extra)
|
|
429
|
+
fused_cross_entropy = cfg.get("fused_cross_entropy", "liger")
|
|
430
|
+
if fused_cross_entropy is False:
|
|
431
|
+
return
|
|
432
|
+
|
|
433
|
+
token_chunk_size = cfg.get("fused_lm_head_token_chunk_size")
|
|
434
|
+
token_chunked_lm_head = isinstance(token_chunk_size, int) and not isinstance(token_chunk_size, bool)
|
|
435
|
+
if cfg.get("fp32_lm_head", False) or token_chunked_lm_head:
|
|
436
|
+
raise ValueError(
|
|
437
|
+
f"{location} cannot combine fused_cross_entropy with "
|
|
438
|
+
"fp32_lm_head or fused_lm_head_token_chunk_size. "
|
|
439
|
+
"Set fused_cross_entropy=false to use fp32/chunked LM head, "
|
|
440
|
+
"or remove fp32/chunked LM-head knobs to use fused CE."
|
|
441
|
+
)
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
@dataclass
|
|
445
|
+
class TrainingConfig:
|
|
446
|
+
"""Training hyperparameters for a training sub-job.
|
|
447
|
+
|
|
448
|
+
Required fields mirror the server-side validator: ``max_seq_len > 0``,
|
|
449
|
+
``train_batch_size > 0``, ``n_gpus > 0``, and a non-empty ``optimizer``.
|
|
450
|
+
|
|
451
|
+
``extra`` carries any additional fields the training worker consumes. The
|
|
452
|
+
server keeps this block open (additionalProperties), so unknown keys flow
|
|
453
|
+
through unchanged. See ``docs/reference/rest-api.md`` section 8.2.
|
|
454
|
+
"""
|
|
455
|
+
|
|
456
|
+
optimizer: dict
|
|
457
|
+
max_seq_len: int
|
|
458
|
+
train_batch_size: int
|
|
459
|
+
n_gpus: int
|
|
460
|
+
gradient_clipping: float | None = None
|
|
461
|
+
multiplex_job_id: str | None = None
|
|
462
|
+
# When False, resuming from a checkpoint loads weights only and starts the
|
|
463
|
+
# optimizer fresh. Required to change DP size (the DP-sharded optimizer
|
|
464
|
+
# cannot be resized); None leaves the server default (True).
|
|
465
|
+
load_optimizer_states: bool | None = None
|
|
466
|
+
extra: dict = field(default_factory=dict)
|
|
467
|
+
|
|
468
|
+
def validate(self) -> None:
|
|
469
|
+
if not isinstance(self.optimizer, dict) or not self.optimizer:
|
|
470
|
+
raise ValueError("training.optimizer is required and must be a non-empty dict")
|
|
471
|
+
if self.max_seq_len <= 0:
|
|
472
|
+
raise ValueError("training.max_seq_len must be > 0")
|
|
473
|
+
if self.train_batch_size <= 0:
|
|
474
|
+
raise ValueError("training.train_batch_size must be > 0")
|
|
475
|
+
if self.n_gpus <= 0:
|
|
476
|
+
raise ValueError("training.n_gpus must be > 0")
|
|
477
|
+
_validate_primerl_lm_head_config(self.extra, location="training.extra")
|
|
478
|
+
prime_rl = self.extra.get("prime_rl")
|
|
479
|
+
if isinstance(prime_rl, dict):
|
|
480
|
+
_validate_primerl_lm_head_config(prime_rl, location="training.extra.prime_rl")
|
|
481
|
+
|
|
482
|
+
def to_wire(self) -> dict:
|
|
483
|
+
out: dict = {
|
|
484
|
+
"optimizer": self.optimizer,
|
|
485
|
+
"max_seq_len": self.max_seq_len,
|
|
486
|
+
"train_batch_size": self.train_batch_size,
|
|
487
|
+
"n_gpus": self.n_gpus,
|
|
488
|
+
}
|
|
489
|
+
if self.gradient_clipping is not None:
|
|
490
|
+
out["gradient_clipping"] = self.gradient_clipping
|
|
491
|
+
if self.multiplex_job_id is not None:
|
|
492
|
+
out["multiplex_job_id"] = self.multiplex_job_id
|
|
493
|
+
if self.load_optimizer_states is not None:
|
|
494
|
+
out["load_optimizer_states"] = self.load_optimizer_states
|
|
495
|
+
for k, v in self.extra.items():
|
|
496
|
+
out.setdefault(k, v)
|
|
497
|
+
return out
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
@dataclass
|
|
501
|
+
class InferenceConfig:
|
|
502
|
+
"""Sampling/log-probability config for an inference sub-job.
|
|
503
|
+
|
|
504
|
+
Required fields mirror the server-side validator: ``max_seq_len > 0`` and
|
|
505
|
+
``n_gpus > 0``. ``extra`` carries vLLM-style passthrough keys (e.g.
|
|
506
|
+
``gpu_memory_utilization``). See ``docs/reference/rest-api.md`` section 8.3.
|
|
507
|
+
"""
|
|
508
|
+
|
|
509
|
+
max_seq_len: int
|
|
510
|
+
n_gpus: int
|
|
511
|
+
multiplex_job_id: str | None = None
|
|
512
|
+
extra: dict = field(default_factory=dict)
|
|
513
|
+
|
|
514
|
+
def validate(self) -> None:
|
|
515
|
+
if self.max_seq_len <= 0:
|
|
516
|
+
raise ValueError("sampling.max_seq_len must be > 0")
|
|
517
|
+
if self.n_gpus <= 0:
|
|
518
|
+
raise ValueError("sampling.n_gpus must be > 0")
|
|
519
|
+
|
|
520
|
+
def to_wire(self) -> dict:
|
|
521
|
+
out: dict = {"max_seq_len": self.max_seq_len, "n_gpus": self.n_gpus}
|
|
522
|
+
if self.multiplex_job_id is not None:
|
|
523
|
+
out["multiplex_job_id"] = self.multiplex_job_id
|
|
524
|
+
for k, v in self.extra.items():
|
|
525
|
+
out.setdefault(k, v)
|
|
526
|
+
return out
|
|
527
|
+
|
|
528
|
+
|
|
529
|
+
@dataclass
|
|
530
|
+
class SubJobConfig:
|
|
531
|
+
"""One sub-job within a CreateJob request.
|
|
532
|
+
|
|
533
|
+
Matches the ``SubJobConfig`` schema in ``docs/reference/rest-api.md``
|
|
534
|
+
section 8.1.
|
|
535
|
+
|
|
536
|
+
Exactly one of ``training`` or ``sampling`` must be set, matching the
|
|
537
|
+
sub-job's ``job_type`` (see :meth:`validate`).
|
|
538
|
+
"""
|
|
539
|
+
|
|
540
|
+
job_type: JobType
|
|
541
|
+
model_name: str
|
|
542
|
+
training: TrainingConfig | None = None
|
|
543
|
+
sampling: InferenceConfig | None = None
|
|
544
|
+
global_batch_size: int | None = None
|
|
545
|
+
dtype: str | None = None
|
|
546
|
+
seed: int | None = None
|
|
547
|
+
model_post_init: list[str] | None = None
|
|
548
|
+
source_checkpoint_info: dict | None = None
|
|
549
|
+
|
|
550
|
+
@classmethod
|
|
551
|
+
def training_job(
|
|
552
|
+
cls,
|
|
553
|
+
model_name: str,
|
|
554
|
+
*,
|
|
555
|
+
optimizer: dict,
|
|
556
|
+
max_seq_len: int,
|
|
557
|
+
train_batch_size: int,
|
|
558
|
+
n_gpus: int,
|
|
559
|
+
gradient_clipping: float | None = None,
|
|
560
|
+
multiplex_job_id: str | None = None,
|
|
561
|
+
load_optimizer_states: bool | None = None,
|
|
562
|
+
extra_training: dict | None = None,
|
|
563
|
+
global_batch_size: int | None = None,
|
|
564
|
+
dtype: str | None = None,
|
|
565
|
+
seed: int | None = None,
|
|
566
|
+
model_post_init: list[str] | None = None,
|
|
567
|
+
source_checkpoint_info: dict | None = None,
|
|
568
|
+
) -> "SubJobConfig":
|
|
569
|
+
"""Build a training :class:`SubJobConfig`. All training fields are explicit.
|
|
570
|
+
|
|
571
|
+
Args:
|
|
572
|
+
model_name: Model identifier (e.g., "Qwen/Qwen3-1.7B").
|
|
573
|
+
optimizer: Optimizer config dict (e.g., {"name": "AdamW", "lr": 0.0001}).
|
|
574
|
+
max_seq_len: Maximum sequence length for training.
|
|
575
|
+
train_batch_size: Per-device batch size.
|
|
576
|
+
n_gpus: Number of GPUs (DP size).
|
|
577
|
+
gradient_clipping: Optional gradient clipping threshold.
|
|
578
|
+
multiplex_job_id: Optional multiplex job identifier.
|
|
579
|
+
load_optimizer_states: Whether to load optimizer states from
|
|
580
|
+
checkpoints. Set to ``False`` when loading across different DP
|
|
581
|
+
sizes (``n_gpus``) since optimizer states are DP-sharded and
|
|
582
|
+
cannot be resized. When ``None``, uses server default (True).
|
|
583
|
+
**This is a creation-time setting and cannot be changed later.**
|
|
584
|
+
extra_training: Additional training config keys (passthrough).
|
|
585
|
+
global_batch_size: Global batch size across all GPUs.
|
|
586
|
+
dtype: Model dtype (e.g., "bfloat16").
|
|
587
|
+
seed: Random seed.
|
|
588
|
+
model_post_init: List of post-initialization operations.
|
|
589
|
+
source_checkpoint_info: Optional checkpoint to resume from at
|
|
590
|
+
creation time (use ``{"checkpoint_id": "...", "source_job_id": "..."}`).
|
|
591
|
+
"""
|
|
592
|
+
return cls(
|
|
593
|
+
job_type=JobType.TRAINING,
|
|
594
|
+
model_name=model_name,
|
|
595
|
+
training=TrainingConfig(
|
|
596
|
+
optimizer=optimizer,
|
|
597
|
+
max_seq_len=max_seq_len,
|
|
598
|
+
train_batch_size=train_batch_size,
|
|
599
|
+
n_gpus=n_gpus,
|
|
600
|
+
gradient_clipping=gradient_clipping,
|
|
601
|
+
multiplex_job_id=multiplex_job_id,
|
|
602
|
+
load_optimizer_states=load_optimizer_states,
|
|
603
|
+
extra=dict(extra_training) if extra_training else {},
|
|
604
|
+
),
|
|
605
|
+
global_batch_size=global_batch_size,
|
|
606
|
+
dtype=dtype,
|
|
607
|
+
seed=seed,
|
|
608
|
+
model_post_init=model_post_init,
|
|
609
|
+
source_checkpoint_info=source_checkpoint_info,
|
|
610
|
+
)
|
|
611
|
+
|
|
612
|
+
@classmethod
|
|
613
|
+
def sampling_job(
|
|
614
|
+
cls,
|
|
615
|
+
model_name: str,
|
|
616
|
+
*,
|
|
617
|
+
max_seq_len: int,
|
|
618
|
+
n_gpus: int,
|
|
619
|
+
multiplex_job_id: str | None = None,
|
|
620
|
+
extra_sampling: dict | None = None,
|
|
621
|
+
job_type: JobType = JobType.SAMPLING,
|
|
622
|
+
global_batch_size: int | None = None,
|
|
623
|
+
dtype: str | None = None,
|
|
624
|
+
seed: int | None = None,
|
|
625
|
+
model_post_init: list[str] | None = None,
|
|
626
|
+
source_checkpoint_info: dict | None = None,
|
|
627
|
+
) -> "SubJobConfig":
|
|
628
|
+
"""Build a sampling/log-probability :class:`SubJobConfig`."""
|
|
629
|
+
if job_type not in (JobType.SAMPLING, JobType.LOG_PROBABILITY):
|
|
630
|
+
raise ValueError(f"sampling_job() only accepts SAMPLING or LOG_PROBABILITY, got {job_type!r}")
|
|
631
|
+
return cls(
|
|
632
|
+
job_type=job_type,
|
|
633
|
+
model_name=model_name,
|
|
634
|
+
sampling=InferenceConfig(
|
|
635
|
+
max_seq_len=max_seq_len,
|
|
636
|
+
n_gpus=n_gpus,
|
|
637
|
+
multiplex_job_id=multiplex_job_id,
|
|
638
|
+
extra=dict(extra_sampling) if extra_sampling else {},
|
|
639
|
+
),
|
|
640
|
+
global_batch_size=global_batch_size,
|
|
641
|
+
dtype=dtype,
|
|
642
|
+
seed=seed,
|
|
643
|
+
model_post_init=model_post_init,
|
|
644
|
+
source_checkpoint_info=source_checkpoint_info,
|
|
645
|
+
)
|
|
646
|
+
|
|
647
|
+
def validate(self) -> None:
|
|
648
|
+
# Mirrors the server-side CreateJob validation.
|
|
649
|
+
if not self.model_name:
|
|
650
|
+
raise ValueError("sub_job.model_name is required")
|
|
651
|
+
if self.training is not None and self.sampling is not None:
|
|
652
|
+
raise ValueError("sub_job.training and sub_job.sampling are mutually exclusive")
|
|
653
|
+
if self.job_type == JobType.TRAINING:
|
|
654
|
+
if self.training is None:
|
|
655
|
+
raise ValueError("training sub-job requires a `training` block")
|
|
656
|
+
self.training.validate()
|
|
657
|
+
elif self.job_type in (JobType.SAMPLING, JobType.LOG_PROBABILITY):
|
|
658
|
+
if self.sampling is None:
|
|
659
|
+
raise ValueError(f"{self.job_type.value} sub-job requires a `sampling` block")
|
|
660
|
+
self.sampling.validate()
|
|
661
|
+
else: # pragma: no cover - JobType enum is closed
|
|
662
|
+
raise ValueError(f"unknown job_type: {self.job_type!r}")
|
|
663
|
+
|
|
664
|
+
def to_wire(self) -> dict:
|
|
665
|
+
wire: dict = {
|
|
666
|
+
"job_type": self.job_type.value,
|
|
667
|
+
"model_name": self.model_name,
|
|
668
|
+
}
|
|
669
|
+
if self.global_batch_size is not None:
|
|
670
|
+
wire["global_batch_size"] = self.global_batch_size
|
|
671
|
+
if self.dtype is not None:
|
|
672
|
+
wire["dtype"] = self.dtype
|
|
673
|
+
if self.seed is not None:
|
|
674
|
+
wire["seed"] = self.seed
|
|
675
|
+
if self.model_post_init is not None:
|
|
676
|
+
wire["model_post_init"] = list(self.model_post_init)
|
|
677
|
+
if self.training is not None:
|
|
678
|
+
wire["training_config"] = self.training.to_wire()
|
|
679
|
+
if self.sampling is not None:
|
|
680
|
+
wire["inference_config"] = self.sampling.to_wire()
|
|
681
|
+
if self.source_checkpoint_info is not None:
|
|
682
|
+
wire["source_checkpoint_info"] = self.source_checkpoint_info
|
|
683
|
+
return wire
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
# ─── Forward-backward payload helpers ────────────────────────────────────
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _load_torch():
|
|
690
|
+
import torch
|
|
691
|
+
|
|
692
|
+
return torch
|
|
693
|
+
|
|
694
|
+
|
|
695
|
+
def _tensor_dtype(torch, dtype_name: str | None):
|
|
696
|
+
if dtype_name is None:
|
|
697
|
+
return torch.long
|
|
698
|
+
aliases = {
|
|
699
|
+
"bool": torch.bool,
|
|
700
|
+
"boolean": torch.bool,
|
|
701
|
+
"float": torch.float32,
|
|
702
|
+
"float32": torch.float32,
|
|
703
|
+
"float64": torch.float64,
|
|
704
|
+
"double": torch.float64,
|
|
705
|
+
"bfloat16": torch.bfloat16,
|
|
706
|
+
"bf16": torch.bfloat16,
|
|
707
|
+
"float16": torch.float16,
|
|
708
|
+
"fp16": torch.float16,
|
|
709
|
+
"int": torch.int32,
|
|
710
|
+
"int32": torch.int32,
|
|
711
|
+
"int64": torch.int64,
|
|
712
|
+
"long": torch.long,
|
|
713
|
+
}
|
|
714
|
+
try:
|
|
715
|
+
return aliases[dtype_name]
|
|
716
|
+
except KeyError as exc:
|
|
717
|
+
raise ValueError(f"unsupported tensor dtype: {dtype_name}") from exc
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _json_tensor(value: Any, *, default_dtype: str | None = "long"):
|
|
721
|
+
torch = _load_torch()
|
|
722
|
+
if isinstance(value, dict):
|
|
723
|
+
if "data" not in value:
|
|
724
|
+
raise ValueError("tensor object must contain a data field")
|
|
725
|
+
dtype_name = value.get("dtype", default_dtype)
|
|
726
|
+
value = value["data"]
|
|
727
|
+
else:
|
|
728
|
+
dtype_name = default_dtype
|
|
729
|
+
return torch.tensor(value, dtype=_tensor_dtype(torch, dtype_name))
|
|
730
|
+
|
|
731
|
+
|
|
732
|
+
def _read_fwd_bwd_args(payload: dict[str, Any]) -> tuple:
|
|
733
|
+
args = payload.get("args", [])
|
|
734
|
+
if args is None:
|
|
735
|
+
return ()
|
|
736
|
+
if not isinstance(args, list):
|
|
737
|
+
raise ValueError("fwd-bwd payload args must be a JSON list")
|
|
738
|
+
return tuple(args)
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def _tensorize_kwargs(kwargs: dict[str, Any]) -> dict[str, Any]:
|
|
742
|
+
out = {}
|
|
743
|
+
for key, value in kwargs.items():
|
|
744
|
+
if isinstance(value, dict) and "data" in value:
|
|
745
|
+
out[key] = _json_tensor(value)
|
|
746
|
+
elif isinstance(value, list):
|
|
747
|
+
out[key] = _json_tensor(value)
|
|
748
|
+
else:
|
|
749
|
+
out[key] = value
|
|
750
|
+
return out
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def _require_bool(value: Any, name: str) -> bool:
|
|
754
|
+
if not isinstance(value, bool):
|
|
755
|
+
raise ValueError(f"{name} must be a boolean")
|
|
756
|
+
return value
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def _positive_int(value: Any, name: str) -> int:
|
|
760
|
+
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
|
|
761
|
+
raise ValueError(f"{name} must be a positive integer")
|
|
762
|
+
return value
|
|
763
|
+
|
|
764
|
+
|
|
765
|
+
def _repeat_texts(texts: Any, batch_size: Any) -> list[str]:
|
|
766
|
+
if isinstance(texts, str):
|
|
767
|
+
text_list = [texts]
|
|
768
|
+
elif isinstance(texts, list) and texts and all(isinstance(x, str) for x in texts):
|
|
769
|
+
text_list = texts
|
|
770
|
+
else:
|
|
771
|
+
raise ValueError("fwd-bwd payload texts must be a non-empty string list")
|
|
772
|
+
|
|
773
|
+
if batch_size is None:
|
|
774
|
+
return text_list
|
|
775
|
+
batch = _positive_int(batch_size, "fwd-bwd payload batch_size")
|
|
776
|
+
return [text_list[i % len(text_list)] for i in range(batch)]
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def _load_tokenizer(tokenizer_spec: Any):
|
|
780
|
+
try:
|
|
781
|
+
from transformers import AutoTokenizer
|
|
782
|
+
except ModuleNotFoundError as exc:
|
|
783
|
+
raise ModuleNotFoundError(
|
|
784
|
+
"No module named 'transformers'",
|
|
785
|
+
name="transformers",
|
|
786
|
+
) from exc
|
|
787
|
+
|
|
788
|
+
if isinstance(tokenizer_spec, str):
|
|
789
|
+
model_name = tokenizer_spec
|
|
790
|
+
kwargs: dict[str, Any] = {}
|
|
791
|
+
elif isinstance(tokenizer_spec, dict):
|
|
792
|
+
model_name = tokenizer_spec.get("model_name") or tokenizer_spec.get("name") or tokenizer_spec.get("path")
|
|
793
|
+
if not isinstance(model_name, str) or not model_name:
|
|
794
|
+
raise ValueError("tokenizer.model_name is required")
|
|
795
|
+
kwargs = {}
|
|
796
|
+
for key in ("trust_remote_code", "use_fast", "revision"):
|
|
797
|
+
if key in tokenizer_spec:
|
|
798
|
+
kwargs[key] = tokenizer_spec[key]
|
|
799
|
+
else:
|
|
800
|
+
raise ValueError("fwd-bwd payload tokenizer must be a string or object")
|
|
801
|
+
|
|
802
|
+
tokenizer = AutoTokenizer.from_pretrained(model_name, **kwargs)
|
|
803
|
+
if getattr(tokenizer, "pad_token_id", None) is None:
|
|
804
|
+
eos_token = getattr(tokenizer, "eos_token", None)
|
|
805
|
+
if eos_token is not None:
|
|
806
|
+
tokenizer.pad_token = eos_token
|
|
807
|
+
return tokenizer
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def _tokenize_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
811
|
+
tokenizer_spec = payload.get("tokenizer", payload.get("model_name"))
|
|
812
|
+
if tokenizer_spec is None:
|
|
813
|
+
raise ValueError("fwd-bwd payload requires tokenizer or input_ids")
|
|
814
|
+
|
|
815
|
+
tokenizer = _load_tokenizer(tokenizer_spec)
|
|
816
|
+
texts = _repeat_texts(payload.get("texts"), payload.get("batch_size"))
|
|
817
|
+
padding = payload.get("padding", "max_length")
|
|
818
|
+
truncation = payload.get("truncation", True)
|
|
819
|
+
add_special_tokens = payload.get("add_special_tokens", True)
|
|
820
|
+
max_length = payload.get("max_length")
|
|
821
|
+
if padding == "max_length":
|
|
822
|
+
max_length = _positive_int(max_length, "fwd-bwd payload max_length")
|
|
823
|
+
elif max_length is not None:
|
|
824
|
+
max_length = _positive_int(max_length, "fwd-bwd payload max_length")
|
|
825
|
+
_require_bool(truncation, "fwd-bwd payload truncation")
|
|
826
|
+
_require_bool(add_special_tokens, "fwd-bwd payload add_special_tokens")
|
|
827
|
+
|
|
828
|
+
encode_kwargs = {
|
|
829
|
+
"return_tensors": "pt",
|
|
830
|
+
"padding": padding,
|
|
831
|
+
"truncation": truncation,
|
|
832
|
+
"add_special_tokens": add_special_tokens,
|
|
833
|
+
}
|
|
834
|
+
if max_length is not None:
|
|
835
|
+
encode_kwargs["max_length"] = max_length
|
|
836
|
+
encoded = tokenizer(texts, **encode_kwargs)
|
|
837
|
+
if "input_ids" not in encoded:
|
|
838
|
+
raise ValueError("tokenizer output did not include input_ids")
|
|
839
|
+
return encoded
|
|
840
|
+
|
|
841
|
+
|
|
842
|
+
def _ensure_rank2(tensor, name: str) -> None:
|
|
843
|
+
if len(tensor.shape) != 2:
|
|
844
|
+
raise ValueError(f"{name} must have shape [batch, seq_len]")
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
def _build_position_ids(input_ids, position_spec: Any):
|
|
848
|
+
torch = _load_torch()
|
|
849
|
+
if position_spec is None or position_spec is False:
|
|
850
|
+
return None
|
|
851
|
+
if position_spec is True or position_spec == "arange":
|
|
852
|
+
batch_size, seq_len = input_ids.shape
|
|
853
|
+
return torch.arange(seq_len, dtype=torch.long).unsqueeze(0).expand(batch_size, -1).contiguous()
|
|
854
|
+
if isinstance(position_spec, (dict, list)):
|
|
855
|
+
return _json_tensor(position_spec)
|
|
856
|
+
raise ValueError("fwd-bwd payload position_ids must be true, false, arange, or tensor data")
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def _label_config(payload: dict[str, Any]) -> tuple[Any, int, bool]:
|
|
860
|
+
labels = payload.get("labels", payload.get("label_strategy", "next_token"))
|
|
861
|
+
if isinstance(labels, dict) and "data" in labels:
|
|
862
|
+
return labels, int(payload.get("ignore_index", -100)), True
|
|
863
|
+
if isinstance(labels, dict):
|
|
864
|
+
strategy = labels.get("strategy", "next_token")
|
|
865
|
+
ignore_index = int(labels.get("ignore_index", payload.get("ignore_index", -100)))
|
|
866
|
+
mask_padding = labels.get("mask_padding", payload.get("mask_padding", True))
|
|
867
|
+
return strategy, ignore_index, _require_bool(mask_padding, "labels.mask_padding")
|
|
868
|
+
return (
|
|
869
|
+
labels,
|
|
870
|
+
int(payload.get("ignore_index", -100)),
|
|
871
|
+
_require_bool(
|
|
872
|
+
payload.get("mask_padding", True),
|
|
873
|
+
"fwd-bwd payload mask_padding",
|
|
874
|
+
),
|
|
875
|
+
)
|
|
876
|
+
|
|
877
|
+
|
|
878
|
+
def _build_labels(input_ids, attention_mask, payload: dict[str, Any]):
|
|
879
|
+
torch = _load_torch()
|
|
880
|
+
labels, ignore_index, mask_padding = _label_config(payload)
|
|
881
|
+
if labels is None or labels == "none":
|
|
882
|
+
return None
|
|
883
|
+
if isinstance(labels, (dict, list)):
|
|
884
|
+
return _json_tensor(labels)
|
|
885
|
+
if labels in ("next_token", "shifted_input_ids"):
|
|
886
|
+
out = torch.roll(input_ids, shifts=-1, dims=1)
|
|
887
|
+
out[:, -1] = ignore_index
|
|
888
|
+
if mask_padding and attention_mask is not None:
|
|
889
|
+
target_mask = torch.roll(attention_mask, shifts=-1, dims=1)
|
|
890
|
+
target_mask[:, -1] = 0
|
|
891
|
+
out = out.masked_fill(target_mask == 0, ignore_index)
|
|
892
|
+
return out
|
|
893
|
+
if labels in ("input_ids", "self"):
|
|
894
|
+
out = input_ids.clone()
|
|
895
|
+
if mask_padding and attention_mask is not None:
|
|
896
|
+
out = out.masked_fill(attention_mask == 0, ignore_index)
|
|
897
|
+
return out
|
|
898
|
+
raise ValueError("fwd-bwd labels strategy must be next_token, input_ids, none, or tensor data")
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def build_forward_backward_kwargs(payload: dict[str, Any]) -> dict[str, Any]:
|
|
902
|
+
"""Build tensor kwargs for a forward-backward training batch.
|
|
903
|
+
|
|
904
|
+
``payload`` is intentionally JSON-shaped so CLI examples can stay readable:
|
|
905
|
+
callers may provide either tokenizable ``texts`` plus a ``tokenizer`` or
|
|
906
|
+
direct tensor data under ``input_ids`` / ``labels`` / ``position_ids``.
|
|
907
|
+
"""
|
|
908
|
+
if not isinstance(payload, dict):
|
|
909
|
+
raise ValueError("fwd-bwd payload must be a JSON object")
|
|
910
|
+
|
|
911
|
+
if "kwargs" in payload:
|
|
912
|
+
kwargs = payload["kwargs"]
|
|
913
|
+
if not isinstance(kwargs, dict):
|
|
914
|
+
raise ValueError("fwd-bwd payload kwargs must be an object")
|
|
915
|
+
return _tensorize_kwargs(kwargs)
|
|
916
|
+
|
|
917
|
+
torch = _load_torch()
|
|
918
|
+
encoded = None
|
|
919
|
+
if "input_ids" in payload:
|
|
920
|
+
input_ids = _json_tensor(payload["input_ids"])
|
|
921
|
+
attention_mask = (
|
|
922
|
+
_json_tensor(payload["attention_mask"])
|
|
923
|
+
if isinstance(payload.get("attention_mask"), (dict, list))
|
|
924
|
+
else None
|
|
925
|
+
)
|
|
926
|
+
else:
|
|
927
|
+
encoded = _tokenize_payload(payload)
|
|
928
|
+
input_ids = encoded["input_ids"]
|
|
929
|
+
attention_mask = encoded.get("attention_mask")
|
|
930
|
+
|
|
931
|
+
_ensure_rank2(input_ids, "input_ids")
|
|
932
|
+
if attention_mask is not None:
|
|
933
|
+
_ensure_rank2(attention_mask, "attention_mask")
|
|
934
|
+
if attention_mask.shape != input_ids.shape:
|
|
935
|
+
raise ValueError("attention_mask must have the same shape as input_ids")
|
|
936
|
+
|
|
937
|
+
kwargs: dict[str, Any] = {"input_ids": input_ids.contiguous()}
|
|
938
|
+
|
|
939
|
+
include_attention = payload.get("include_attention_mask", False)
|
|
940
|
+
if "attention_mask" in payload and isinstance(payload["attention_mask"], bool):
|
|
941
|
+
include_attention = payload["attention_mask"]
|
|
942
|
+
include_attention = _require_bool(
|
|
943
|
+
include_attention,
|
|
944
|
+
"fwd-bwd payload include_attention_mask",
|
|
945
|
+
)
|
|
946
|
+
if include_attention:
|
|
947
|
+
if attention_mask is None:
|
|
948
|
+
attention_mask = torch.ones_like(input_ids, dtype=torch.long)
|
|
949
|
+
kwargs["attention_mask"] = attention_mask.contiguous()
|
|
950
|
+
|
|
951
|
+
position_spec = payload.get("position_ids")
|
|
952
|
+
if position_spec is None and payload.get("include_position_ids", False):
|
|
953
|
+
position_spec = "arange"
|
|
954
|
+
position_ids = _build_position_ids(input_ids, position_spec)
|
|
955
|
+
if position_ids is not None:
|
|
956
|
+
_ensure_rank2(position_ids, "position_ids")
|
|
957
|
+
if position_ids.shape != input_ids.shape:
|
|
958
|
+
raise ValueError("position_ids must have the same shape as input_ids")
|
|
959
|
+
kwargs["position_ids"] = position_ids.contiguous()
|
|
960
|
+
|
|
961
|
+
labels = _build_labels(input_ids, attention_mask, payload)
|
|
962
|
+
if labels is not None:
|
|
963
|
+
_ensure_rank2(labels, "labels")
|
|
964
|
+
if labels.shape != input_ids.shape:
|
|
965
|
+
raise ValueError("labels must have the same shape as input_ids")
|
|
966
|
+
kwargs["labels"] = labels.contiguous()
|
|
967
|
+
|
|
968
|
+
return kwargs
|
|
969
|
+
|
|
970
|
+
|
|
971
|
+
def serialize_forward_backward_args(args: tuple = (), kwargs: dict[str, Any] | None = None) -> bytes:
|
|
972
|
+
"""Serialize ``{"args": args, "kwargs": kwargs}`` for Cortex Training fwd-bwd."""
|
|
973
|
+
if not isinstance(args, tuple):
|
|
974
|
+
raise ValueError("forward-backward args must be a tuple")
|
|
975
|
+
if kwargs is None:
|
|
976
|
+
kwargs = {}
|
|
977
|
+
if not isinstance(kwargs, dict):
|
|
978
|
+
raise ValueError("forward-backward kwargs must be a dict")
|
|
979
|
+
|
|
980
|
+
return wire.dumps(
|
|
981
|
+
{"args": args, "kwargs": kwargs},
|
|
982
|
+
metadata={"response_options": {"format": "dssst1", "delivery": "chunked"}},
|
|
983
|
+
)
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def build_forward_backward_payload(spec: dict[str, Any]) -> bytes:
|
|
987
|
+
"""Build a serialized fwd-bwd byte payload from readable JSON."""
|
|
988
|
+
if not isinstance(spec, dict):
|
|
989
|
+
raise ValueError("fwd-bwd JSON must be an object")
|
|
990
|
+
payload = spec.get("payload", spec)
|
|
991
|
+
if not isinstance(payload, dict):
|
|
992
|
+
raise ValueError("fwd-bwd payload must be an object")
|
|
993
|
+
args = _read_fwd_bwd_args(payload)
|
|
994
|
+
kwargs = build_forward_backward_kwargs(payload)
|
|
995
|
+
return serialize_forward_backward_args(args, kwargs)
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def _sql_string_literal(value: str) -> str:
|
|
999
|
+
"""Return ``value`` as a single-quoted Snowflake SQL string."""
|
|
1000
|
+
return "'" + value.replace("'", "''") + "'"
|
|
1001
|
+
|
|
1002
|
+
|
|
1003
|
+
def _validate_artifact_relative_path(path: str) -> str:
|
|
1004
|
+
"""Reject paths that could escape or ambiguously address the run stage."""
|
|
1005
|
+
candidate = PurePosixPath(path)
|
|
1006
|
+
if (
|
|
1007
|
+
not path
|
|
1008
|
+
or path.startswith("/")
|
|
1009
|
+
or "\\" in path
|
|
1010
|
+
or any(part in {"", ".", ".."} for part in path.split("/"))
|
|
1011
|
+
or candidate.name in {"", ".", ".."}
|
|
1012
|
+
):
|
|
1013
|
+
raise ValueError(f"unsafe experiment artifact path: {path!r}")
|
|
1014
|
+
return path
|
|
1015
|
+
|
|
1016
|
+
|
|
1017
|
+
# ─── HTTP client ─────────────────────────────────────────────────────────
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
class CortexTrainingClient:
|
|
1021
|
+
"""HTTP client for the Cortex Training REST API (``cortex-training``).
|
|
1022
|
+
|
|
1023
|
+
Construct with :meth:`from_pat`::
|
|
1024
|
+
|
|
1025
|
+
client = CortexTrainingClient.from_pat(
|
|
1026
|
+
host="ACCOUNT.snowflakecomputing.com",
|
|
1027
|
+
pat="...",
|
|
1028
|
+
database="CORTEX_TRAINING_DB",
|
|
1029
|
+
schema="PUBLIC",
|
|
1030
|
+
)
|
|
1031
|
+
|
|
1032
|
+
Against a local or otherwise compatible server, construct the client
|
|
1033
|
+
directly with an explicit ``base_url``; this skips PAT auth::
|
|
1034
|
+
|
|
1035
|
+
client = CortexTrainingClient(
|
|
1036
|
+
base_url="http://localhost:8084",
|
|
1037
|
+
database="MY_DB",
|
|
1038
|
+
schema="PUBLIC",
|
|
1039
|
+
)
|
|
1040
|
+
|
|
1041
|
+
Then build one or more sub-jobs and submit::
|
|
1042
|
+
|
|
1043
|
+
sub = SubJobConfig.training_job(
|
|
1044
|
+
model_name="Qwen/Qwen3-1.7B",
|
|
1045
|
+
optimizer={"name": "AdamW", "lr": 1e-5},
|
|
1046
|
+
max_seq_len=2048,
|
|
1047
|
+
train_batch_size=1,
|
|
1048
|
+
n_gpus=1,
|
|
1049
|
+
)
|
|
1050
|
+
job_id = client.create_job(sub_jobs=[sub])
|
|
1051
|
+
"""
|
|
1052
|
+
|
|
1053
|
+
def __init__(
|
|
1054
|
+
self,
|
|
1055
|
+
base_url: str,
|
|
1056
|
+
database: str,
|
|
1057
|
+
schema: str,
|
|
1058
|
+
endpoint: str = "cortex-training",
|
|
1059
|
+
poll_interval: float = 0.5,
|
|
1060
|
+
poll_timeout: float = 1800.0,
|
|
1061
|
+
poll_backoff_multiplier: float = 1.25,
|
|
1062
|
+
poll_max_interval: float = 6.0,
|
|
1063
|
+
pool_maxsize: int = 1024,
|
|
1064
|
+
max_retries: int = 10,
|
|
1065
|
+
):
|
|
1066
|
+
if poll_interval <= 0:
|
|
1067
|
+
raise ValueError("poll_interval must be > 0")
|
|
1068
|
+
if poll_timeout <= 0:
|
|
1069
|
+
raise ValueError("poll_timeout must be > 0")
|
|
1070
|
+
if poll_backoff_multiplier < 1.0:
|
|
1071
|
+
raise ValueError("poll_backoff_multiplier must be >= 1.0")
|
|
1072
|
+
if poll_max_interval < poll_interval:
|
|
1073
|
+
raise ValueError("poll_max_interval must be >= poll_interval")
|
|
1074
|
+
if pool_maxsize <= 0:
|
|
1075
|
+
raise ValueError("pool_maxsize must be > 0")
|
|
1076
|
+
if max_retries < 0:
|
|
1077
|
+
raise ValueError("max_retries must be >= 0")
|
|
1078
|
+
self.base_url = base_url.rstrip("/")
|
|
1079
|
+
self.database = database
|
|
1080
|
+
self.schema = schema
|
|
1081
|
+
self.endpoint = endpoint
|
|
1082
|
+
self.poll_interval = poll_interval
|
|
1083
|
+
self.poll_timeout = poll_timeout
|
|
1084
|
+
self.poll_backoff_multiplier = poll_backoff_multiplier
|
|
1085
|
+
self.poll_max_interval = poll_max_interval
|
|
1086
|
+
self.max_retries = max_retries
|
|
1087
|
+
self._fwd_bwd_send_count = 0
|
|
1088
|
+
self._fwd_bwd_request_debug: dict[str, dict[str, Any]] = {}
|
|
1089
|
+
self._generate_request_ids: set[str] = set()
|
|
1090
|
+
# Per-job cache of the sampling sub-job's ``inference_config.max_seq_len``
|
|
1091
|
+
# (the value the backend launches vLLM with as ``max_model_len``). Used
|
|
1092
|
+
# to validate generate prompt lengths client-side. A cached ``None``
|
|
1093
|
+
# means the job was fetched but exposes no sampling max_seq_len (skip
|
|
1094
|
+
# validation); a missing key means it has not been resolved yet (e.g. a
|
|
1095
|
+
# transient get_job failure), so a later call will retry.
|
|
1096
|
+
self._sampling_max_seq_len: dict[str, int | None] = {}
|
|
1097
|
+
self._artifact_connection_config: dict[str, str] | None = None
|
|
1098
|
+
self._metric_emitter: OtlpMetricEmitter | None = None
|
|
1099
|
+
self._operation_metric_state = threading.local()
|
|
1100
|
+
self._session = requests.Session()
|
|
1101
|
+
adapter = HTTPAdapter(pool_connections=pool_maxsize, pool_maxsize=pool_maxsize)
|
|
1102
|
+
self._session.mount("https://", adapter)
|
|
1103
|
+
self._session.mount("http://", adapter)
|
|
1104
|
+
|
|
1105
|
+
@classmethod
|
|
1106
|
+
def from_pat(
|
|
1107
|
+
cls,
|
|
1108
|
+
host: str,
|
|
1109
|
+
pat: str,
|
|
1110
|
+
database: str,
|
|
1111
|
+
schema: str,
|
|
1112
|
+
endpoint: str = "cortex-training",
|
|
1113
|
+
verify_ssl: bool = True,
|
|
1114
|
+
*,
|
|
1115
|
+
telemetry_timeout: float = 3.0,
|
|
1116
|
+
**kwargs: Any,
|
|
1117
|
+
) -> "CortexTrainingClient":
|
|
1118
|
+
"""Authenticate with a Programmatic Access Token (PAT).
|
|
1119
|
+
|
|
1120
|
+
Sends ``Authorization: Bearer <pat>`` plus the
|
|
1121
|
+
``X-Snowflake-Authorization-Token-Type: PROGRAMMATIC_ACCESS_TOKEN`` header
|
|
1122
|
+
on every request. No login round-trip.
|
|
1123
|
+
"""
|
|
1124
|
+
client = cls(
|
|
1125
|
+
base_url=f"https://{host}",
|
|
1126
|
+
database=database,
|
|
1127
|
+
schema=schema,
|
|
1128
|
+
endpoint=endpoint,
|
|
1129
|
+
**kwargs,
|
|
1130
|
+
)
|
|
1131
|
+
client._session.headers["Authorization"] = f"Bearer {pat}"
|
|
1132
|
+
client._session.headers["X-Snowflake-Authorization-Token-Type"] = "PROGRAMMATIC_ACCESS_TOKEN"
|
|
1133
|
+
client._session.verify = verify_ssl
|
|
1134
|
+
client._artifact_connection_config = {"host": host, "pat": pat}
|
|
1135
|
+
if not _telemetry_disabled():
|
|
1136
|
+
token_provider = CachedSessionTokenProvider(
|
|
1137
|
+
client.base_url,
|
|
1138
|
+
pat,
|
|
1139
|
+
verify_ssl=verify_ssl,
|
|
1140
|
+
timeout=telemetry_timeout,
|
|
1141
|
+
)
|
|
1142
|
+
client._metric_emitter = OtlpMetricEmitter(
|
|
1143
|
+
client.base_url,
|
|
1144
|
+
token_provider,
|
|
1145
|
+
verify_ssl=verify_ssl,
|
|
1146
|
+
timeout=telemetry_timeout,
|
|
1147
|
+
)
|
|
1148
|
+
return client
|
|
1149
|
+
|
|
1150
|
+
@property
|
|
1151
|
+
def _prefix(self) -> str:
|
|
1152
|
+
return f"{self.base_url}/api/v2/databases/{self.database}/schemas/{self.schema}/{self.endpoint}"
|
|
1153
|
+
|
|
1154
|
+
def emit_metric(
|
|
1155
|
+
self,
|
|
1156
|
+
operation: str,
|
|
1157
|
+
value: Any = 1,
|
|
1158
|
+
*,
|
|
1159
|
+
attributes: dict[str, Any] | None = None,
|
|
1160
|
+
) -> None:
|
|
1161
|
+
"""Emit a best-effort OTLP log record used as a client metric.
|
|
1162
|
+
|
|
1163
|
+
PAT clients lazily exchange the PAT for a cached session token on the
|
|
1164
|
+
first call. Local/mock clients, or clients constructed with
|
|
1165
|
+
``CORTEX_TRAINING_DISABLE_TELEMETRY`` set, treat this method as a no-op.
|
|
1166
|
+
All authentication, discovery, and export errors are ignored.
|
|
1167
|
+
"""
|
|
1168
|
+
try:
|
|
1169
|
+
if self._metric_emitter is not None:
|
|
1170
|
+
self._metric_emitter.emit(operation, value, attributes=attributes)
|
|
1171
|
+
except Exception:
|
|
1172
|
+
return
|
|
1173
|
+
|
|
1174
|
+
def close(self) -> None:
|
|
1175
|
+
"""Flush telemetry briefly and close this client's HTTP sessions."""
|
|
1176
|
+
try:
|
|
1177
|
+
if self._metric_emitter is not None:
|
|
1178
|
+
self._metric_emitter.close()
|
|
1179
|
+
except Exception:
|
|
1180
|
+
logger.debug("client telemetry close failed", exc_info=True)
|
|
1181
|
+
finally:
|
|
1182
|
+
self._session.close()
|
|
1183
|
+
|
|
1184
|
+
def __enter__(self) -> "CortexTrainingClient":
|
|
1185
|
+
return self
|
|
1186
|
+
|
|
1187
|
+
def __exit__(self, exc_type, exc_value, traceback) -> None:
|
|
1188
|
+
self.close()
|
|
1189
|
+
|
|
1190
|
+
def _emit_operation_outcome(
|
|
1191
|
+
self,
|
|
1192
|
+
operation: str,
|
|
1193
|
+
started: float,
|
|
1194
|
+
arguments: Mapping[str, Any],
|
|
1195
|
+
*,
|
|
1196
|
+
result: Any = None,
|
|
1197
|
+
error: BaseException | None = None,
|
|
1198
|
+
) -> None:
|
|
1199
|
+
"""Emit one structured outcome without changing the operation result."""
|
|
1200
|
+
if self._metric_emitter is None:
|
|
1201
|
+
return
|
|
1202
|
+
if error is None and not _success_telemetry_enabled():
|
|
1203
|
+
return
|
|
1204
|
+
try:
|
|
1205
|
+
attempt_count = int(
|
|
1206
|
+
getattr(self._operation_metric_state, "attempt_count", 0)
|
|
1207
|
+
)
|
|
1208
|
+
request_count = int(
|
|
1209
|
+
getattr(self._operation_metric_state, "request_count", 0)
|
|
1210
|
+
)
|
|
1211
|
+
value: dict[str, Any] = {
|
|
1212
|
+
"attempt_count": attempt_count,
|
|
1213
|
+
"duration_ms": round((time.monotonic() - started) * 1000, 3),
|
|
1214
|
+
"request_count": request_count,
|
|
1215
|
+
"retry_count": max(attempt_count - request_count, 0),
|
|
1216
|
+
"success": error is None,
|
|
1217
|
+
}
|
|
1218
|
+
attributes = _metric_identity(operation, arguments, result)
|
|
1219
|
+
if error is not None:
|
|
1220
|
+
value["error_message"] = _safe_metric_error_message(error)
|
|
1221
|
+
attributes.update(_metric_error_attributes(error))
|
|
1222
|
+
self.emit_metric(operation, value, attributes=attributes)
|
|
1223
|
+
except Exception:
|
|
1224
|
+
logger.debug("client operation metric construction failed", exc_info=True)
|
|
1225
|
+
|
|
1226
|
+
@staticmethod
|
|
1227
|
+
def _debug_json_summary(value: Any, *, limit: int = 2000) -> str:
|
|
1228
|
+
try:
|
|
1229
|
+
text = json.dumps(value, sort_keys=True, default=str)
|
|
1230
|
+
except TypeError:
|
|
1231
|
+
text = repr(value)
|
|
1232
|
+
if len(text) > limit:
|
|
1233
|
+
return f"{text[:limit]}...<truncated {len(text) - limit} chars>"
|
|
1234
|
+
return text
|
|
1235
|
+
|
|
1236
|
+
@classmethod
|
|
1237
|
+
def _debug_response_summary(cls, resp: requests.Response, *, limit: int = 2000) -> str:
|
|
1238
|
+
try:
|
|
1239
|
+
return cls._debug_json_summary(resp.json(), limit=limit)
|
|
1240
|
+
except ValueError:
|
|
1241
|
+
text = getattr(resp, "text", "")
|
|
1242
|
+
except Exception as exc:
|
|
1243
|
+
text = f"<failed to read response body: {type(exc).__name__}: {exc}>"
|
|
1244
|
+
text = str(text).replace("\n", "\\n")
|
|
1245
|
+
if len(text) > limit:
|
|
1246
|
+
return f"{text[:limit]}...<truncated {len(text) - limit} chars>"
|
|
1247
|
+
return text
|
|
1248
|
+
|
|
1249
|
+
@staticmethod
|
|
1250
|
+
def _debug_context_label(debug_context: dict[str, Any]) -> str:
|
|
1251
|
+
parts = ["[cortex-training fwd-bwd]"]
|
|
1252
|
+
for key in ("phase", "call", "job_id", "request_id", "payload_bytes"):
|
|
1253
|
+
value = debug_context.get(key)
|
|
1254
|
+
if value is not None:
|
|
1255
|
+
parts.append(f"{key}={value}")
|
|
1256
|
+
return " ".join(parts)
|
|
1257
|
+
|
|
1258
|
+
def _send(self, method: str, url: str, *, retry_on=_is_transient, **kwargs) -> requests.Response:
|
|
1259
|
+
fn = getattr(self._session, method.lower())
|
|
1260
|
+
debug_context = kwargs.pop("debug_context", None)
|
|
1261
|
+
debug_label = self._debug_context_label(debug_context) if debug_context is not None else None
|
|
1262
|
+
attempt_no = 0
|
|
1263
|
+
max_attempts = 1 + self.max_retries
|
|
1264
|
+
try:
|
|
1265
|
+
state = self._operation_metric_state
|
|
1266
|
+
if getattr(state, "active", set()):
|
|
1267
|
+
state.request_count = getattr(state, "request_count", 0) + 1
|
|
1268
|
+
except Exception:
|
|
1269
|
+
logger.debug("client operation request counting failed", exc_info=True)
|
|
1270
|
+
|
|
1271
|
+
def attempt() -> requests.Response:
|
|
1272
|
+
nonlocal attempt_no
|
|
1273
|
+
attempt_no += 1
|
|
1274
|
+
try:
|
|
1275
|
+
state = self._operation_metric_state
|
|
1276
|
+
if getattr(state, "active", set()):
|
|
1277
|
+
state.attempt_count = getattr(state, "attempt_count", 0) + 1
|
|
1278
|
+
except Exception:
|
|
1279
|
+
logger.debug("client operation attempt counting failed", exc_info=True)
|
|
1280
|
+
if debug_label is not None:
|
|
1281
|
+
logger.debug(
|
|
1282
|
+
"%s sending %s %s attempt=%d/%d",
|
|
1283
|
+
debug_label,
|
|
1284
|
+
method.upper(),
|
|
1285
|
+
url,
|
|
1286
|
+
attempt_no,
|
|
1287
|
+
max_attempts,
|
|
1288
|
+
)
|
|
1289
|
+
try:
|
|
1290
|
+
resp = fn(url, **kwargs)
|
|
1291
|
+
except Exception as exc:
|
|
1292
|
+
if debug_label is not None:
|
|
1293
|
+
logger.debug(
|
|
1294
|
+
"%s request exception %s %s attempt=%d/%d: %s: %s",
|
|
1295
|
+
debug_label,
|
|
1296
|
+
method.upper(),
|
|
1297
|
+
url,
|
|
1298
|
+
attempt_no,
|
|
1299
|
+
max_attempts,
|
|
1300
|
+
type(exc).__name__,
|
|
1301
|
+
exc,
|
|
1302
|
+
)
|
|
1303
|
+
raise
|
|
1304
|
+
status_code = getattr(resp, "status_code", None)
|
|
1305
|
+
headers = getattr(resp, "headers", {}) or {}
|
|
1306
|
+
sf_request_id = headers.get("x-snowflake-request-id")
|
|
1307
|
+
if not isinstance(sf_request_id, str) or not sf_request_id:
|
|
1308
|
+
sf_request_id = None
|
|
1309
|
+
if sf_request_id:
|
|
1310
|
+
req = getattr(resp, "request", None)
|
|
1311
|
+
logger.debug(
|
|
1312
|
+
"snowflake request_id=%s %s %s status=%d",
|
|
1313
|
+
sf_request_id,
|
|
1314
|
+
getattr(req, "method", method.upper()),
|
|
1315
|
+
getattr(req, "path_url", url),
|
|
1316
|
+
status_code,
|
|
1317
|
+
)
|
|
1318
|
+
if debug_label is not None:
|
|
1319
|
+
try:
|
|
1320
|
+
status_int = int(status_code)
|
|
1321
|
+
except (TypeError, ValueError):
|
|
1322
|
+
status_int = None
|
|
1323
|
+
outcome = (
|
|
1324
|
+
"successful response" if status_int is not None and 200 <= status_int < 400 else "failed response"
|
|
1325
|
+
)
|
|
1326
|
+
snowflake = f" snowflake_request_id={sf_request_id}" if sf_request_id else ""
|
|
1327
|
+
logger.debug(
|
|
1328
|
+
"%s %s %s %s attempt=%d/%d status=%s%s body=%s",
|
|
1329
|
+
debug_label,
|
|
1330
|
+
outcome,
|
|
1331
|
+
method.upper(),
|
|
1332
|
+
url,
|
|
1333
|
+
attempt_no,
|
|
1334
|
+
max_attempts,
|
|
1335
|
+
status_code,
|
|
1336
|
+
snowflake,
|
|
1337
|
+
self._debug_response_summary(resp),
|
|
1338
|
+
)
|
|
1339
|
+
resp.raise_for_status()
|
|
1340
|
+
return resp
|
|
1341
|
+
|
|
1342
|
+
retryer = Retrying(
|
|
1343
|
+
retry=retry_if_exception(retry_on),
|
|
1344
|
+
stop=stop_after_attempt(1 + self.max_retries),
|
|
1345
|
+
wait=wait_exponential_jitter(initial=0.5, max=10.0),
|
|
1346
|
+
reraise=True,
|
|
1347
|
+
)
|
|
1348
|
+
return retryer(attempt)
|
|
1349
|
+
|
|
1350
|
+
# ─── Job management ──────────────────────────────────────────────────
|
|
1351
|
+
|
|
1352
|
+
@_track_operation("create_job")
|
|
1353
|
+
def create_job(
|
|
1354
|
+
self,
|
|
1355
|
+
sub_jobs: list[SubJobConfig],
|
|
1356
|
+
job_id: str | None = None,
|
|
1357
|
+
experiment_name: str | None = None,
|
|
1358
|
+
hardware: Hardware | str | None = None,
|
|
1359
|
+
idle_timeout_seconds: int | None = None,
|
|
1360
|
+
pending_timeout_seconds: int | None = None,
|
|
1361
|
+
) -> str:
|
|
1362
|
+
"""Create a job from a list of sub-jobs and return its server job_id.
|
|
1363
|
+
|
|
1364
|
+
Each :class:`SubJobConfig` is validated client-side before the request
|
|
1365
|
+
is sent (see :meth:`SubJobConfig.validate`). A job supports zero or one
|
|
1366
|
+
``training`` sub-job and any number of ``sampling`` /
|
|
1367
|
+
``log_probability`` sub-jobs. ``job_id`` is optional;
|
|
1368
|
+
when omitted the server generates one. ``experiment_name`` is optional;
|
|
1369
|
+
when omitted the server auto-creates an experiment for the job.
|
|
1370
|
+
``hardware`` is optional (:class:`Hardware`.H200 / .B200 / .B300); when
|
|
1371
|
+
set, all sub-jobs in the job run on that GPU type. Omitted defaults to
|
|
1372
|
+
H200.
|
|
1373
|
+
``idle_timeout_seconds`` bounds how long the job may sit idle before the
|
|
1374
|
+
server reclaims it: ``0`` disables reclamation, any other value must be
|
|
1375
|
+
between 300 and 604,800. ``pending_timeout_seconds`` bounds how long the
|
|
1376
|
+
job may wait for capacity before failing with ``pending_timeout``: between
|
|
1377
|
+
300 and 604,800, with no disable value. Omit either to use the server
|
|
1378
|
+
default, currently 30 minutes for idle and 24 hours for pending.
|
|
1379
|
+
"""
|
|
1380
|
+
if not sub_jobs:
|
|
1381
|
+
raise ValueError("create_job requires a non-empty sub_jobs list")
|
|
1382
|
+
if idle_timeout_seconds is not None:
|
|
1383
|
+
if isinstance(idle_timeout_seconds, bool) or not isinstance(
|
|
1384
|
+
idle_timeout_seconds, int
|
|
1385
|
+
):
|
|
1386
|
+
raise ValueError("idle_timeout_seconds must be an integer")
|
|
1387
|
+
if idle_timeout_seconds != 0 and not (
|
|
1388
|
+
300 <= idle_timeout_seconds <= 604_800
|
|
1389
|
+
):
|
|
1390
|
+
raise ValueError(
|
|
1391
|
+
"idle_timeout_seconds must be 0 or between 300 and 604800"
|
|
1392
|
+
)
|
|
1393
|
+
if pending_timeout_seconds is not None:
|
|
1394
|
+
if isinstance(pending_timeout_seconds, bool) or not isinstance(
|
|
1395
|
+
pending_timeout_seconds, int
|
|
1396
|
+
):
|
|
1397
|
+
raise ValueError("pending_timeout_seconds must be an integer")
|
|
1398
|
+
if not (300 <= pending_timeout_seconds <= 604_800):
|
|
1399
|
+
raise ValueError(
|
|
1400
|
+
"pending_timeout_seconds must be between 300 and 604800"
|
|
1401
|
+
)
|
|
1402
|
+
for sj in sub_jobs:
|
|
1403
|
+
sj.validate()
|
|
1404
|
+
body: dict = {"sub_job_configs": [sj.to_wire() for sj in sub_jobs]}
|
|
1405
|
+
if job_id is not None:
|
|
1406
|
+
body["job_id"] = job_id
|
|
1407
|
+
if experiment_name is not None:
|
|
1408
|
+
body["experiment_name"] = experiment_name
|
|
1409
|
+
if hardware is not None:
|
|
1410
|
+
body["hardware"] = _hardware_wire(hardware)
|
|
1411
|
+
if idle_timeout_seconds is not None:
|
|
1412
|
+
body["idle_timeout_seconds"] = idle_timeout_seconds
|
|
1413
|
+
if pending_timeout_seconds is not None:
|
|
1414
|
+
body["pending_timeout_seconds"] = pending_timeout_seconds
|
|
1415
|
+
return self.create_job_from_body(body)["job_id"]
|
|
1416
|
+
|
|
1417
|
+
@_track_operation("create_job")
|
|
1418
|
+
def create_job_from_body(self, body: dict) -> dict:
|
|
1419
|
+
"""Create a job from a raw REST CreateJob request body.
|
|
1420
|
+
|
|
1421
|
+
This is useful for tooling that already has the REST JSON payload,
|
|
1422
|
+
while :meth:`create_job` remains the typed path for Python callers.
|
|
1423
|
+
|
|
1424
|
+
A job supports zero or one ``training`` sub-job and any number of
|
|
1425
|
+
``sampling`` / ``log_probability`` sub-jobs; a second training sub-job
|
|
1426
|
+
raises :class:`ValueError` before the request is sent.
|
|
1427
|
+
"""
|
|
1428
|
+
if not isinstance(body, dict):
|
|
1429
|
+
raise ValueError("create_job_from_body requires a JSON object")
|
|
1430
|
+
sub_job_configs = body.get("sub_job_configs")
|
|
1431
|
+
if not isinstance(sub_job_configs, list) or not sub_job_configs:
|
|
1432
|
+
raise ValueError("create_job_from_body requires a non-empty sub_job_configs list")
|
|
1433
|
+
training_sub_jobs = sum(
|
|
1434
|
+
1
|
|
1435
|
+
for cfg in sub_job_configs
|
|
1436
|
+
if isinstance(cfg, dict)
|
|
1437
|
+
and str(cfg.get("job_type") or "").strip().lower() == JobType.TRAINING.value
|
|
1438
|
+
)
|
|
1439
|
+
if training_sub_jobs > 1:
|
|
1440
|
+
raise ValueError("at most one training sub-job is supported per job")
|
|
1441
|
+
if body.get("debug") and not _debug_options_enabled():
|
|
1442
|
+
raise ValueError(
|
|
1443
|
+
"create-job debug options are an internal-only capability; set "
|
|
1444
|
+
f"{DEBUG_OPTIONS_ENV}=1 to send a request with a `debug` block"
|
|
1445
|
+
)
|
|
1446
|
+
resp = self._send("POST", self._prefix, json=body, retry_on=_is_connect_error)
|
|
1447
|
+
return resp.json()
|
|
1448
|
+
|
|
1449
|
+
@staticmethod
|
|
1450
|
+
def _normalize_job_status(status) -> str:
|
|
1451
|
+
"""Normalize full enum names (``JOB_STATE_RUNNING``) to short form (``running``).
|
|
1452
|
+
|
|
1453
|
+
Coerces to ``str`` first: a backend running ahead of the API layer can
|
|
1454
|
+
return a JobState enum value the API layer doesn't yet know (e.g. a
|
|
1455
|
+
newly added state), which is then rendered as a raw integer rather than
|
|
1456
|
+
a name. Tolerating it keeps :meth:`wait_for_job` polling — an unknown
|
|
1457
|
+
status is neither ``running`` nor terminal, so it stays in-progress —
|
|
1458
|
+
instead of crashing on ``int.lower()``.
|
|
1459
|
+
"""
|
|
1460
|
+
return str(status or "").lower().removeprefix("job_state_")
|
|
1461
|
+
|
|
1462
|
+
def _sleep_with_backoff(self, delay: float, deadline: float) -> float:
|
|
1463
|
+
remaining = deadline - time.monotonic()
|
|
1464
|
+
if remaining <= 0:
|
|
1465
|
+
return delay
|
|
1466
|
+
time.sleep(min(delay, remaining))
|
|
1467
|
+
return min(delay * self.poll_backoff_multiplier, self.poll_max_interval)
|
|
1468
|
+
|
|
1469
|
+
@_track_operation("wait_for_job")
|
|
1470
|
+
def wait_for_job(self, job_id: str) -> dict:
|
|
1471
|
+
"""Poll until the job reaches ``running``. Raises on terminal states or timeout."""
|
|
1472
|
+
deadline = time.monotonic() + self.poll_timeout
|
|
1473
|
+
delay = self.poll_interval
|
|
1474
|
+
while time.monotonic() < deadline:
|
|
1475
|
+
job = self.get_job(job_id)
|
|
1476
|
+
status = self._normalize_job_status(job.get("status", ""))
|
|
1477
|
+
if status == "running":
|
|
1478
|
+
return job
|
|
1479
|
+
if status in ("failed", "done", "cancelled", "canceled"):
|
|
1480
|
+
reason = job.get("reason", "")
|
|
1481
|
+
raise RuntimeError(f"Job {job_id} reached terminal state '{status}': {reason}")
|
|
1482
|
+
delay = self._sleep_with_backoff(delay, deadline)
|
|
1483
|
+
raise TimeoutError(f"Job {job_id} did not become running within {self.poll_timeout}s")
|
|
1484
|
+
|
|
1485
|
+
@_track_operation("get_job")
|
|
1486
|
+
def get_job(self, job_id: str) -> dict:
|
|
1487
|
+
resp = self._send("GET", f"{self._prefix}/{job_id}")
|
|
1488
|
+
return resp.json()
|
|
1489
|
+
|
|
1490
|
+
@_track_operation("list_jobs")
|
|
1491
|
+
def list_jobs(self, status: str | None = None) -> list:
|
|
1492
|
+
params = {}
|
|
1493
|
+
if status is not None:
|
|
1494
|
+
params["status"] = status
|
|
1495
|
+
resp = self._send("GET", self._prefix, params=params)
|
|
1496
|
+
return resp.json().get("jobs", [])
|
|
1497
|
+
|
|
1498
|
+
@_track_operation("cancel_job")
|
|
1499
|
+
def cancel_job(self, job_id: str) -> None:
|
|
1500
|
+
# The REST API uses colon-action syntax: /{jobId}:cancel
|
|
1501
|
+
self._send("POST", f"{self._prefix}/{job_id}:cancel")
|
|
1502
|
+
|
|
1503
|
+
@_track_operation("get_capacity")
|
|
1504
|
+
def get_capacity(self, hardware: Hardware | str | None = None) -> dict:
|
|
1505
|
+
"""Return the calling account's GPU ceiling and current usage.
|
|
1506
|
+
|
|
1507
|
+
Backed by the account-scoped endpoint ``/cortex-training/capacity``
|
|
1508
|
+
(not under ``/{job_id}``). The account is resolved server-side from the
|
|
1509
|
+
caller's session — never from a request field — so a caller can only
|
|
1510
|
+
ever read its own account's capacity. Optional ``hardware``
|
|
1511
|
+
(:class:`Hardware`.H200 / .B200 / .B300) is sent as a query parameter and
|
|
1512
|
+
scopes the numbers to that GPU type; omitted defaults to H200.
|
|
1513
|
+
|
|
1514
|
+
The returned dict always carries all six fields. The server emits
|
|
1515
|
+
proto3 JSON, which omits zero/false fields (an account holding nothing
|
|
1516
|
+
under a zero ceiling is literally ``{}``), so we fill in the documented
|
|
1517
|
+
defaults:
|
|
1518
|
+
|
|
1519
|
+
- ``has_reservation`` (bool): whether ``max_total_gpus`` is a
|
|
1520
|
+
*guaranteed* commitment rather than a best-effort per-account cap.
|
|
1521
|
+
When ``False`` the account draws on the shared pool, so
|
|
1522
|
+
``available_gpus`` is an upper bound.
|
|
1523
|
+
- ``max_total_gpus`` (int): the account's ceiling — its commitment when
|
|
1524
|
+
``has_reservation``, else its per-account cap. ``-1`` means no
|
|
1525
|
+
ceiling, ``0`` a real quota of zero, ``> 0`` the limit. This is the
|
|
1526
|
+
canonical ceiling; prefer it over ``reserved_gpus``.
|
|
1527
|
+
- ``reserved_gpus`` (int, **deprecated**): mirrors ``max_total_gpus``
|
|
1528
|
+
when ``has_reservation`` is ``True``, else 0.
|
|
1529
|
+
- ``in_use_gpus`` (int): GPUs the account holds — jobs in ``placing``,
|
|
1530
|
+
``initializing``, or ``running``. Queued work is *not* counted here;
|
|
1531
|
+
it is in ``pending_gpus``.
|
|
1532
|
+
- ``pending_gpus`` (int): GPUs requested by jobs still waiting for
|
|
1533
|
+
capacity (``pending``). It claims quota, so it reduces
|
|
1534
|
+
``available_gpus``.
|
|
1535
|
+
- ``available_gpus`` (int): what a new job could start with right now —
|
|
1536
|
+
ceiling headroom capped by what is schedulable. ``0`` does not mean
|
|
1537
|
+
blocked: a submit within the ceiling is still accepted and queued.
|
|
1538
|
+
|
|
1539
|
+
See ``docs/reference/rest-api.md`` section 5.4 for the authoritative
|
|
1540
|
+
field list.
|
|
1541
|
+
"""
|
|
1542
|
+
params = {}
|
|
1543
|
+
if hardware is not None:
|
|
1544
|
+
params["hardware"] = _hardware_wire(hardware)
|
|
1545
|
+
resp = self._send(
|
|
1546
|
+
"GET",
|
|
1547
|
+
f"{self._prefix}/capacity",
|
|
1548
|
+
**({"params": params} if params else {}),
|
|
1549
|
+
)
|
|
1550
|
+
body = resp.json()
|
|
1551
|
+
return {
|
|
1552
|
+
"has_reservation": bool(body.get("has_reservation", False)),
|
|
1553
|
+
"max_total_gpus": int(body.get("max_total_gpus", 0)),
|
|
1554
|
+
"reserved_gpus": int(body.get("reserved_gpus", 0)),
|
|
1555
|
+
"in_use_gpus": int(body.get("in_use_gpus", 0)),
|
|
1556
|
+
"pending_gpus": int(body.get("pending_gpus", 0)),
|
|
1557
|
+
"available_gpus": int(body.get("available_gpus", 0)),
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
@_track_operation("get_experiment_run")
|
|
1561
|
+
def get_experiment_run(self, job_id: str) -> dict[str, str]:
|
|
1562
|
+
"""Return ``{experiment_name, experiment_run_name}`` for ``job_id``.
|
|
1563
|
+
|
|
1564
|
+
Backed by the endpoint ``/cortex-training/{job_id}/experiment-run``.
|
|
1565
|
+
"""
|
|
1566
|
+
resp = self._send("GET", f"{self._prefix}/{job_id}/experiment-run")
|
|
1567
|
+
return resp.json()
|
|
1568
|
+
|
|
1569
|
+
def _query_sql_row(self, statement: str) -> list[Any]:
|
|
1570
|
+
"""Execute a synchronous SQL statement and return its first row."""
|
|
1571
|
+
resp = self._send(
|
|
1572
|
+
"POST",
|
|
1573
|
+
f"{self.base_url}/api/v2/statements",
|
|
1574
|
+
json={"statement": statement, "database": self.database, "schema": self.schema},
|
|
1575
|
+
)
|
|
1576
|
+
rows = resp.json().get("data")
|
|
1577
|
+
if not isinstance(rows, list) or not rows or not isinstance(rows[0], list) or not rows[0]:
|
|
1578
|
+
raise ValueError(f"SQL query returned no rows: {statement}")
|
|
1579
|
+
return rows[0]
|
|
1580
|
+
|
|
1581
|
+
def _experiment_run_uri(self, job_id: str) -> tuple[str, str]:
|
|
1582
|
+
"""Return the experiment run's ``snow://`` URI and run name."""
|
|
1583
|
+
run = self.get_experiment_run(job_id)
|
|
1584
|
+
try:
|
|
1585
|
+
experiment_name = run["experiment_name"]
|
|
1586
|
+
run_name = run["experiment_run_name"]
|
|
1587
|
+
except KeyError as exc:
|
|
1588
|
+
raise ValueError(f"experiment-run response missing field: {exc.args[0]}") from exc
|
|
1589
|
+
if not isinstance(experiment_name, str) or not experiment_name:
|
|
1590
|
+
raise ValueError("experiment-run response has invalid experiment_name")
|
|
1591
|
+
if not isinstance(run_name, str) or not run_name:
|
|
1592
|
+
raise ValueError("experiment-run response has invalid experiment_run_name")
|
|
1593
|
+
return (
|
|
1594
|
+
f"snow://experiment/{experiment_name}/versions/{run_name}/",
|
|
1595
|
+
run_name,
|
|
1596
|
+
)
|
|
1597
|
+
|
|
1598
|
+
def _open_experiment_artifact_connection(self) -> Any:
|
|
1599
|
+
"""Open a connector session for Snowflake experiment artifact LIST/GET."""
|
|
1600
|
+
config = self._artifact_connection_config
|
|
1601
|
+
if config is None:
|
|
1602
|
+
raise RuntimeError(
|
|
1603
|
+
"experiment artifact download requires a PAT-authenticated client"
|
|
1604
|
+
)
|
|
1605
|
+
user, account, role = self._query_sql_row(
|
|
1606
|
+
"SELECT CURRENT_USER(), CURRENT_ACCOUNT_NAME(), CURRENT_ROLE()"
|
|
1607
|
+
)
|
|
1608
|
+
if not isinstance(user, str) or not user:
|
|
1609
|
+
raise ValueError("SQL identity response missing current user")
|
|
1610
|
+
if not isinstance(account, str) or not account:
|
|
1611
|
+
raise ValueError("SQL identity response missing current account")
|
|
1612
|
+
|
|
1613
|
+
import snowflake.connector
|
|
1614
|
+
|
|
1615
|
+
kwargs: dict[str, Any] = {
|
|
1616
|
+
"host": config["host"],
|
|
1617
|
+
"account": account,
|
|
1618
|
+
"user": user,
|
|
1619
|
+
"authenticator": "PROGRAMMATIC_ACCESS_TOKEN",
|
|
1620
|
+
"token": config["pat"],
|
|
1621
|
+
"database": self.database,
|
|
1622
|
+
"schema": self.schema,
|
|
1623
|
+
}
|
|
1624
|
+
if isinstance(role, str) and role:
|
|
1625
|
+
kwargs["role"] = role
|
|
1626
|
+
return snowflake.connector.connect(**kwargs)
|
|
1627
|
+
|
|
1628
|
+
@staticmethod
|
|
1629
|
+
def _list_experiment_artifacts(
|
|
1630
|
+
connection: Any,
|
|
1631
|
+
run_uri: str,
|
|
1632
|
+
run_name: str,
|
|
1633
|
+
artifact_root: str,
|
|
1634
|
+
) -> list[str]:
|
|
1635
|
+
"""List safe run-relative file paths below ``artifact_root``."""
|
|
1636
|
+
root = artifact_root.strip("/") + "/"
|
|
1637
|
+
_validate_artifact_relative_path(root.removesuffix("/"))
|
|
1638
|
+
cursor = connection.cursor()
|
|
1639
|
+
try:
|
|
1640
|
+
rows = cursor.execute(
|
|
1641
|
+
f"LIST {_sql_string_literal(run_uri + root)}"
|
|
1642
|
+
).fetchall()
|
|
1643
|
+
finally:
|
|
1644
|
+
cursor.close()
|
|
1645
|
+
|
|
1646
|
+
run_prefix = f"/versions/{run_name}/"
|
|
1647
|
+
paths: list[str] = []
|
|
1648
|
+
for row in rows:
|
|
1649
|
+
name = row[0] if row else None
|
|
1650
|
+
if not isinstance(name, str) or not name.startswith(run_prefix):
|
|
1651
|
+
raise ValueError(
|
|
1652
|
+
f"experiment artifact LIST returned an unexpected path: {name!r}"
|
|
1653
|
+
)
|
|
1654
|
+
relative_path = _validate_artifact_relative_path(
|
|
1655
|
+
name.removeprefix(run_prefix)
|
|
1656
|
+
)
|
|
1657
|
+
if not relative_path.startswith(root):
|
|
1658
|
+
raise ValueError(
|
|
1659
|
+
"experiment artifact LIST returned a file outside the "
|
|
1660
|
+
f"requested root {root!r}: {relative_path!r}"
|
|
1661
|
+
)
|
|
1662
|
+
paths.append(relative_path)
|
|
1663
|
+
return sorted(set(paths))
|
|
1664
|
+
|
|
1665
|
+
@staticmethod
|
|
1666
|
+
def _download_experiment_artifact(
|
|
1667
|
+
connection: Any,
|
|
1668
|
+
run_uri: str,
|
|
1669
|
+
relative_path: str,
|
|
1670
|
+
target_dir: Path,
|
|
1671
|
+
) -> Path:
|
|
1672
|
+
"""Download one exact experiment artifact and return its local path."""
|
|
1673
|
+
relative_path = _validate_artifact_relative_path(relative_path)
|
|
1674
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
1675
|
+
target_uri = target_dir.resolve().as_uri() + "/"
|
|
1676
|
+
cursor = connection.cursor()
|
|
1677
|
+
try:
|
|
1678
|
+
cursor.execute(
|
|
1679
|
+
"GET "
|
|
1680
|
+
f"{_sql_string_literal(run_uri + relative_path)} "
|
|
1681
|
+
f"{_sql_string_literal(target_uri)}"
|
|
1682
|
+
).fetchall()
|
|
1683
|
+
finally:
|
|
1684
|
+
cursor.close()
|
|
1685
|
+
downloaded = target_dir / PurePosixPath(relative_path).name
|
|
1686
|
+
if not downloaded.is_file():
|
|
1687
|
+
raise RuntimeError(
|
|
1688
|
+
f"Snowflake GET did not create expected artifact {downloaded}"
|
|
1689
|
+
)
|
|
1690
|
+
return downloaded
|
|
1691
|
+
|
|
1692
|
+
@_track_operation("fetch_execution_logs")
|
|
1693
|
+
def fetch_execution_logs(self, job_id: str) -> list[dict[str, str]]:
|
|
1694
|
+
"""Download every log file under the job's experiment run stage.
|
|
1695
|
+
|
|
1696
|
+
Returns a list of
|
|
1697
|
+
``{"sub_job_id", "filename", "artifact_uri", "content"}``
|
|
1698
|
+
entries, one per object found under a ``_logs/<sub_job_id>/`` subtree
|
|
1699
|
+
in the run's stage. Filenames such as ``execution.jsonl`` and
|
|
1700
|
+
``server.log`` are both included; bodies are decoded as UTF-8.
|
|
1701
|
+
|
|
1702
|
+
``artifact_uri`` is the logical ``snow://`` URI. Files are retrieved
|
|
1703
|
+
through Snowflake's supported experiment artifact ``LIST``/``GET``
|
|
1704
|
+
interface; no object-store credentials are exposed to the caller.
|
|
1705
|
+
"""
|
|
1706
|
+
run_uri, run_name = self._experiment_run_uri(job_id)
|
|
1707
|
+
connection = self._open_experiment_artifact_connection()
|
|
1708
|
+
try:
|
|
1709
|
+
paths: list[str] = []
|
|
1710
|
+
for artifact_root in ("_logs", "checkpoints/_logs"):
|
|
1711
|
+
paths.extend(
|
|
1712
|
+
self._list_experiment_artifacts(
|
|
1713
|
+
connection, run_uri, run_name, artifact_root
|
|
1714
|
+
)
|
|
1715
|
+
)
|
|
1716
|
+
|
|
1717
|
+
results: list[dict[str, str]] = []
|
|
1718
|
+
with tempfile.TemporaryDirectory(prefix="cortex-training-execution-logs-") as temp:
|
|
1719
|
+
temp_root = Path(temp)
|
|
1720
|
+
for relative_path in sorted(set(paths)):
|
|
1721
|
+
if relative_path.startswith("_logs/"):
|
|
1722
|
+
after_logs = relative_path.removeprefix("_logs/")
|
|
1723
|
+
elif relative_path.startswith("checkpoints/_logs/"):
|
|
1724
|
+
after_logs = relative_path.removeprefix("checkpoints/_logs/")
|
|
1725
|
+
else:
|
|
1726
|
+
continue
|
|
1727
|
+
sub_job_id, separator, filename = after_logs.partition("/")
|
|
1728
|
+
if (
|
|
1729
|
+
not separator
|
|
1730
|
+
or not filename
|
|
1731
|
+
or not (
|
|
1732
|
+
sub_job_id == job_id
|
|
1733
|
+
or sub_job_id.startswith(f"{job_id}:")
|
|
1734
|
+
)
|
|
1735
|
+
or sub_job_id in {".", ".."}
|
|
1736
|
+
or "\\" in sub_job_id
|
|
1737
|
+
):
|
|
1738
|
+
continue
|
|
1739
|
+
downloaded = self._download_experiment_artifact(
|
|
1740
|
+
connection,
|
|
1741
|
+
run_uri,
|
|
1742
|
+
relative_path,
|
|
1743
|
+
temp_root,
|
|
1744
|
+
)
|
|
1745
|
+
artifact_uri = run_uri + relative_path
|
|
1746
|
+
try:
|
|
1747
|
+
content = downloaded.read_text(encoding="utf-8")
|
|
1748
|
+
finally:
|
|
1749
|
+
downloaded.unlink(missing_ok=True)
|
|
1750
|
+
results.append(
|
|
1751
|
+
{
|
|
1752
|
+
"sub_job_id": sub_job_id,
|
|
1753
|
+
"filename": filename,
|
|
1754
|
+
"artifact_uri": artifact_uri,
|
|
1755
|
+
"content": content,
|
|
1756
|
+
}
|
|
1757
|
+
)
|
|
1758
|
+
return results
|
|
1759
|
+
finally:
|
|
1760
|
+
connection.close()
|
|
1761
|
+
|
|
1762
|
+
def _download_gzip_artifacts(
|
|
1763
|
+
self,
|
|
1764
|
+
job_id: str,
|
|
1765
|
+
output_dir: str | os.PathLike[str],
|
|
1766
|
+
*,
|
|
1767
|
+
artifact_name: str,
|
|
1768
|
+
chunk_pattern: re.Pattern[str],
|
|
1769
|
+
destination_name: str,
|
|
1770
|
+
temporary_prefix: str,
|
|
1771
|
+
) -> list[dict[str, Any]]:
|
|
1772
|
+
"""Reconstruct staged gzip chunks into one atomic file per sub-job."""
|
|
1773
|
+
run_uri, run_name = self._experiment_run_uri(job_id)
|
|
1774
|
+
connection = self._open_experiment_artifact_connection()
|
|
1775
|
+
root = Path(output_dir).expanduser()
|
|
1776
|
+
downloads: dict[str, dict[str, Any]] = {}
|
|
1777
|
+
artifact_roots = (f"_{artifact_name}", f"checkpoints/_{artifact_name}")
|
|
1778
|
+
try:
|
|
1779
|
+
paths_by_root = {
|
|
1780
|
+
artifact_root: self._list_experiment_artifacts(
|
|
1781
|
+
connection, run_uri, run_name, artifact_root
|
|
1782
|
+
)
|
|
1783
|
+
for artifact_root in artifact_roots
|
|
1784
|
+
}
|
|
1785
|
+
chunks_by_sub_job: dict[str, dict[str, Any]] = {}
|
|
1786
|
+
for artifact_root, paths in paths_by_root.items():
|
|
1787
|
+
prefix = artifact_root + "/"
|
|
1788
|
+
for relative_path in paths:
|
|
1789
|
+
after_root = relative_path.removeprefix(prefix)
|
|
1790
|
+
sub_job_id, separator, filename = after_root.partition("/")
|
|
1791
|
+
if (
|
|
1792
|
+
not separator
|
|
1793
|
+
or sub_job_id in {"", ".", ".."}
|
|
1794
|
+
or not (
|
|
1795
|
+
sub_job_id == job_id
|
|
1796
|
+
or sub_job_id.startswith(f"{job_id}:")
|
|
1797
|
+
)
|
|
1798
|
+
or "/" in filename
|
|
1799
|
+
or "\\" in sub_job_id
|
|
1800
|
+
or chunk_pattern.fullmatch(filename) is None
|
|
1801
|
+
):
|
|
1802
|
+
continue
|
|
1803
|
+
chunks = chunks_by_sub_job.get(sub_job_id)
|
|
1804
|
+
if chunks is None:
|
|
1805
|
+
chunks = {"artifact_root": artifact_root, "paths": []}
|
|
1806
|
+
chunks_by_sub_job[sub_job_id] = chunks
|
|
1807
|
+
elif chunks["artifact_root"] != artifact_root:
|
|
1808
|
+
raise ValueError(
|
|
1809
|
+
f"{artifact_name} chunks for {sub_job_id!r} appear "
|
|
1810
|
+
"under multiple artifact roots"
|
|
1811
|
+
)
|
|
1812
|
+
chunks["paths"].append(relative_path)
|
|
1813
|
+
|
|
1814
|
+
with tempfile.TemporaryDirectory(
|
|
1815
|
+
prefix=f"cortex-training-{artifact_name}-artifacts-"
|
|
1816
|
+
) as temp:
|
|
1817
|
+
artifact_temp = Path(temp)
|
|
1818
|
+
for sub_job_id in sorted(chunks_by_sub_job):
|
|
1819
|
+
chunk_paths = sorted(chunks_by_sub_job[sub_job_id]["paths"])
|
|
1820
|
+
destination = root / sub_job_id / destination_name
|
|
1821
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
1822
|
+
output = tempfile.NamedTemporaryFile(
|
|
1823
|
+
mode="wb",
|
|
1824
|
+
dir=str(destination.parent),
|
|
1825
|
+
prefix=temporary_prefix,
|
|
1826
|
+
suffix=".tmp",
|
|
1827
|
+
delete=False,
|
|
1828
|
+
)
|
|
1829
|
+
state = {
|
|
1830
|
+
"destination": destination,
|
|
1831
|
+
"temporary": Path(output.name),
|
|
1832
|
+
"output": output,
|
|
1833
|
+
"chunk_count": 0,
|
|
1834
|
+
"first_path": chunk_paths[0],
|
|
1835
|
+
"last_path": chunk_paths[-1],
|
|
1836
|
+
}
|
|
1837
|
+
downloads[sub_job_id] = state
|
|
1838
|
+
for relative_path in chunk_paths:
|
|
1839
|
+
downloaded = self._download_experiment_artifact(
|
|
1840
|
+
connection,
|
|
1841
|
+
run_uri,
|
|
1842
|
+
relative_path,
|
|
1843
|
+
artifact_temp,
|
|
1844
|
+
)
|
|
1845
|
+
try:
|
|
1846
|
+
with downloaded.open("rb") as compressed:
|
|
1847
|
+
with gzip.GzipFile(fileobj=compressed, mode="rb") as source:
|
|
1848
|
+
shutil.copyfileobj(
|
|
1849
|
+
source,
|
|
1850
|
+
output,
|
|
1851
|
+
length=_STREAM_COPY_BUFFER_BYTES,
|
|
1852
|
+
)
|
|
1853
|
+
finally:
|
|
1854
|
+
downloaded.unlink(missing_ok=True)
|
|
1855
|
+
state["chunk_count"] += 1
|
|
1856
|
+
|
|
1857
|
+
results: list[dict[str, Any]] = []
|
|
1858
|
+
for sub_job_id in sorted(downloads):
|
|
1859
|
+
state = downloads[sub_job_id]
|
|
1860
|
+
output = state["output"]
|
|
1861
|
+
output.flush()
|
|
1862
|
+
os.fsync(output.fileno())
|
|
1863
|
+
output.close()
|
|
1864
|
+
os.replace(state["temporary"], state["destination"])
|
|
1865
|
+
results.append(
|
|
1866
|
+
{
|
|
1867
|
+
"sub_job_id": sub_job_id,
|
|
1868
|
+
"filename": state["destination"].name,
|
|
1869
|
+
"saved_path": str(state["destination"]),
|
|
1870
|
+
"chunk_count": state["chunk_count"],
|
|
1871
|
+
"first_artifact_uri": run_uri + state["first_path"],
|
|
1872
|
+
"last_artifact_uri": run_uri + state["last_path"],
|
|
1873
|
+
}
|
|
1874
|
+
)
|
|
1875
|
+
return results
|
|
1876
|
+
except BaseException:
|
|
1877
|
+
for state in downloads.values():
|
|
1878
|
+
output = state["output"]
|
|
1879
|
+
if not output.closed:
|
|
1880
|
+
output.close()
|
|
1881
|
+
state["temporary"].unlink(missing_ok=True)
|
|
1882
|
+
raise
|
|
1883
|
+
finally:
|
|
1884
|
+
connection.close()
|
|
1885
|
+
|
|
1886
|
+
def download_stdout_logs(
|
|
1887
|
+
self, job_id: str, output_dir: str | os.PathLike[str]
|
|
1888
|
+
) -> list[dict[str, Any]]:
|
|
1889
|
+
"""Reconstruct sealed console artifacts into one file per sub-job."""
|
|
1890
|
+
return self._download_gzip_artifacts(
|
|
1891
|
+
job_id,
|
|
1892
|
+
output_dir,
|
|
1893
|
+
artifact_name="stdout",
|
|
1894
|
+
chunk_pattern=_STDOUT_CHUNK_RE,
|
|
1895
|
+
destination_name="stdout.log",
|
|
1896
|
+
temporary_prefix=".stdout-",
|
|
1897
|
+
)
|
|
1898
|
+
|
|
1899
|
+
def download_metrics(
|
|
1900
|
+
self, job_id: str, output_dir: str | os.PathLike[str]
|
|
1901
|
+
) -> list[dict[str, Any]]:
|
|
1902
|
+
"""Reconstruct staged GPU metric chunks into one JSONL file per sub-job."""
|
|
1903
|
+
return self._download_gzip_artifacts(
|
|
1904
|
+
job_id,
|
|
1905
|
+
output_dir,
|
|
1906
|
+
artifact_name="metrics",
|
|
1907
|
+
chunk_pattern=_GPU_METRICS_CHUNK_RE,
|
|
1908
|
+
destination_name="gpu.jsonl",
|
|
1909
|
+
temporary_prefix=".gpu-metrics-",
|
|
1910
|
+
)
|
|
1911
|
+
|
|
1912
|
+
# ─── Data-plane async operations ─────────────────────────────────────
|
|
1913
|
+
|
|
1914
|
+
# Maximum payload size for forward-backward requests.
|
|
1915
|
+
# Payloads beyond this limit will fail fast with a message.
|
|
1916
|
+
_MAX_FWD_BWD_BYTES = 60 * 1024 * 1024 # 60 MB
|
|
1917
|
+
|
|
1918
|
+
# Maximum payload size for generate / generate_stream request bodies.
|
|
1919
|
+
# The encoded JSON body must stay under this cap; oversized requests fail
|
|
1920
|
+
# fast client-side with a clear message.
|
|
1921
|
+
_MAX_GENERATE_BYTES = 60 * 1024 * 1024 # 60 MB
|
|
1922
|
+
|
|
1923
|
+
def _check_generate_payload(self, op: str, body: bytes) -> None:
|
|
1924
|
+
if len(body) > self._MAX_GENERATE_BYTES:
|
|
1925
|
+
size_mb = len(body) / (1024 * 1024)
|
|
1926
|
+
raise ValueError(
|
|
1927
|
+
f"{op} payload is {size_mb:.1f} MB, which exceeds the "
|
|
1928
|
+
"maximum allowed size of "
|
|
1929
|
+
f"{self._MAX_GENERATE_BYTES / (1024 * 1024):.0f} MB. "
|
|
1930
|
+
"Reduce the number of prompts or shorten each prompt to "
|
|
1931
|
+
"bring the serialized request under the limit."
|
|
1932
|
+
)
|
|
1933
|
+
|
|
1934
|
+
def _resolve_sampling_max_seq_len(self, job_id: str) -> int | None:
|
|
1935
|
+
"""Return the sampling sub-job's ``max_seq_len``, or ``None`` if unknown.
|
|
1936
|
+
|
|
1937
|
+
The value is read from the job's sampling ``inference_config`` (the same
|
|
1938
|
+
number the backend launches vLLM with as ``max_model_len``) and cached
|
|
1939
|
+
per ``job_id``. A successful lookup — including a definitive "no sampling
|
|
1940
|
+
sub-job" miss — is cached; a transient ``get_job`` failure is not, so a
|
|
1941
|
+
later call retries rather than disabling validation permanently.
|
|
1942
|
+
"""
|
|
1943
|
+
if job_id in self._sampling_max_seq_len:
|
|
1944
|
+
return self._sampling_max_seq_len[job_id]
|
|
1945
|
+
try:
|
|
1946
|
+
job = self.get_job(job_id)
|
|
1947
|
+
except Exception:
|
|
1948
|
+
# Don't let a transient lookup failure break generate, and don't
|
|
1949
|
+
# cache the miss — the real request will surface any hard error.
|
|
1950
|
+
return None
|
|
1951
|
+
max_seq_len: int | None = None
|
|
1952
|
+
for sub in job.get("sub_jobs", []) or []:
|
|
1953
|
+
if not isinstance(sub, dict):
|
|
1954
|
+
continue
|
|
1955
|
+
cfg = sub.get("inference_config")
|
|
1956
|
+
if not isinstance(cfg, dict):
|
|
1957
|
+
continue
|
|
1958
|
+
raw = cfg.get("max_seq_len")
|
|
1959
|
+
# Proto numbers can arrive as floats (e.g. 2048.0); coerce to int.
|
|
1960
|
+
if isinstance(raw, (int, float)) and raw > 0:
|
|
1961
|
+
max_seq_len = int(raw)
|
|
1962
|
+
break
|
|
1963
|
+
self._sampling_max_seq_len[job_id] = max_seq_len
|
|
1964
|
+
return max_seq_len
|
|
1965
|
+
|
|
1966
|
+
def _check_prompt_lengths(self, op: str, job_id: str, prompts: list[str | list[int]]) -> None:
|
|
1967
|
+
"""Fail fast when a pre-tokenized prompt cannot fit the sampling window.
|
|
1968
|
+
|
|
1969
|
+
Mirrors vLLM's decoder-prompt check (``vllm/v1/engine/input_processor``):
|
|
1970
|
+
a generation prompt must be strictly shorter than ``max_model_len`` so
|
|
1971
|
+
at least one output token fits, i.e. it is rejected when
|
|
1972
|
+
``len(prompt_token_ids) >= max_seq_len``. Only pre-tokenized prompts
|
|
1973
|
+
(``list[int]``) are validated here, since their token count is exact;
|
|
1974
|
+
string prompts would require replicating the server tokenizer and are
|
|
1975
|
+
left to fail server-side.
|
|
1976
|
+
"""
|
|
1977
|
+
if not any(isinstance(p, list) for p in prompts):
|
|
1978
|
+
return
|
|
1979
|
+
max_seq_len = self._resolve_sampling_max_seq_len(job_id)
|
|
1980
|
+
if max_seq_len is None:
|
|
1981
|
+
return
|
|
1982
|
+
for i, prompt in enumerate(prompts):
|
|
1983
|
+
if not isinstance(prompt, list):
|
|
1984
|
+
continue
|
|
1985
|
+
n = len(prompt)
|
|
1986
|
+
if n >= max_seq_len:
|
|
1987
|
+
raise ValueError(
|
|
1988
|
+
f"{op} prompt at index {i} has {n} tokens, which does not "
|
|
1989
|
+
f"fit the sampling job's max_seq_len of {max_seq_len}: a "
|
|
1990
|
+
"prompt must be shorter than max_seq_len to leave room for "
|
|
1991
|
+
"at least one generated token (vLLM rejects "
|
|
1992
|
+
"len(prompt) >= max_model_len). Shorten the prompt to at "
|
|
1993
|
+
f"most {max_seq_len - 1} tokens, or recreate the job with a "
|
|
1994
|
+
"larger max_seq_len."
|
|
1995
|
+
)
|
|
1996
|
+
|
|
1997
|
+
def _post_octet_request_chunks(
|
|
1998
|
+
self,
|
|
1999
|
+
*,
|
|
2000
|
+
job_id: str,
|
|
2001
|
+
path_suffix: str,
|
|
2002
|
+
operation: str,
|
|
2003
|
+
frame: bytes,
|
|
2004
|
+
max_bytes: int,
|
|
2005
|
+
force_chunk: bool = False,
|
|
2006
|
+
allow_group_restart: bool = False,
|
|
2007
|
+
debug_context: dict[str, Any] | None = None,
|
|
2008
|
+
) -> dict:
|
|
2009
|
+
chunks = wire.encode_byte_chunks(
|
|
2010
|
+
frame,
|
|
2011
|
+
kind="request",
|
|
2012
|
+
operation=operation,
|
|
2013
|
+
max_bytes=max_bytes,
|
|
2014
|
+
force_chunk=force_chunk,
|
|
2015
|
+
)
|
|
2016
|
+
group_metadata = wire.read_byte_chunk_metadata(chunks[0])
|
|
2017
|
+
chunk_group_id = (
|
|
2018
|
+
str(group_metadata["chunk_group_id"])
|
|
2019
|
+
if group_metadata is not None and group_metadata.get("chunk_group_id")
|
|
2020
|
+
else None
|
|
2021
|
+
)
|
|
2022
|
+
group_restarts = 0
|
|
2023
|
+
idx = 0
|
|
2024
|
+
final_body: dict | None = None
|
|
2025
|
+
while idx < len(chunks):
|
|
2026
|
+
chunk = chunks[idx]
|
|
2027
|
+
try:
|
|
2028
|
+
resp = self._send(
|
|
2029
|
+
"POST",
|
|
2030
|
+
f"{self._prefix}/{job_id}/{path_suffix}",
|
|
2031
|
+
data=chunk,
|
|
2032
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
2033
|
+
retry_on=(_is_chunk_post_transient if allow_group_restart else _is_transient),
|
|
2034
|
+
debug_context=debug_context,
|
|
2035
|
+
)
|
|
2036
|
+
except requests.exceptions.HTTPError as exc:
|
|
2037
|
+
detail = _chunk_group_error_detail(exc.response)
|
|
2038
|
+
if detail is None:
|
|
2039
|
+
raise
|
|
2040
|
+
code = detail.get("code")
|
|
2041
|
+
if allow_group_restart and code == _CHUNK_GROUP_RESTART_REQUIRED and group_restarts < 1:
|
|
2042
|
+
group_restarts += 1
|
|
2043
|
+
idx = 0
|
|
2044
|
+
final_body = None
|
|
2045
|
+
logger.warning(
|
|
2046
|
+
"%s chunk group %s lost server state; replaying once from chunk 0",
|
|
2047
|
+
operation,
|
|
2048
|
+
chunk_group_id,
|
|
2049
|
+
)
|
|
2050
|
+
continue
|
|
2051
|
+
error_type = (
|
|
2052
|
+
ChunkGroupRestartError if code == _CHUNK_GROUP_RESTART_REQUIRED else ChunkGroupConflictError
|
|
2053
|
+
)
|
|
2054
|
+
raise error_type(
|
|
2055
|
+
str(detail.get("message") or code),
|
|
2056
|
+
response=exc.response,
|
|
2057
|
+
detail=detail,
|
|
2058
|
+
) from exc
|
|
2059
|
+
|
|
2060
|
+
body = resp.json()
|
|
2061
|
+
if idx < len(chunks) - 1:
|
|
2062
|
+
if isinstance(body, dict) and body.get("request_id"):
|
|
2063
|
+
raise RuntimeError(f"{operation} chunk {idx} unexpectedly returned request_id")
|
|
2064
|
+
else:
|
|
2065
|
+
final_body = body
|
|
2066
|
+
idx += 1
|
|
2067
|
+
if final_body is None:
|
|
2068
|
+
raise RuntimeError(f"{operation} produced no request body")
|
|
2069
|
+
return final_body
|
|
2070
|
+
|
|
2071
|
+
@_track_operation("forward_backward")
|
|
2072
|
+
def forward_backward(self, job_id: str, data: bytes) -> str:
|
|
2073
|
+
"""Submit a forward+backward pass. Returns request_id."""
|
|
2074
|
+
self._fwd_bwd_send_count += 1
|
|
2075
|
+
call = self._fwd_bwd_send_count
|
|
2076
|
+
debug_context = {
|
|
2077
|
+
"phase": "submit",
|
|
2078
|
+
"call": call,
|
|
2079
|
+
"job_id": job_id,
|
|
2080
|
+
"payload_bytes": len(data),
|
|
2081
|
+
}
|
|
2082
|
+
debug_label = self._debug_context_label(debug_context)
|
|
2083
|
+
logger.debug("%s payload total bytes: %d", debug_label, len(data))
|
|
2084
|
+
body = self._post_octet_request_chunks(
|
|
2085
|
+
job_id=job_id,
|
|
2086
|
+
path_suffix="forward-backward",
|
|
2087
|
+
operation="fwd-bwd",
|
|
2088
|
+
frame=data,
|
|
2089
|
+
max_bytes=self._MAX_FWD_BWD_BYTES,
|
|
2090
|
+
force_chunk=True,
|
|
2091
|
+
allow_group_restart=True,
|
|
2092
|
+
debug_context=debug_context,
|
|
2093
|
+
)
|
|
2094
|
+
request_id = body["request_id"]
|
|
2095
|
+
request_debug_context = {
|
|
2096
|
+
"phase": "poll",
|
|
2097
|
+
"call": call,
|
|
2098
|
+
"job_id": job_id,
|
|
2099
|
+
"request_id": request_id,
|
|
2100
|
+
"payload_bytes": len(data),
|
|
2101
|
+
}
|
|
2102
|
+
self._fwd_bwd_request_debug[request_id] = request_debug_context
|
|
2103
|
+
logger.debug(
|
|
2104
|
+
"%s submitted request_id=%s body=%s",
|
|
2105
|
+
debug_label,
|
|
2106
|
+
request_id,
|
|
2107
|
+
self._debug_json_summary(body),
|
|
2108
|
+
)
|
|
2109
|
+
return request_id
|
|
2110
|
+
|
|
2111
|
+
@_track_operation("generate")
|
|
2112
|
+
def generate(
|
|
2113
|
+
self,
|
|
2114
|
+
job_id: str,
|
|
2115
|
+
prompts: list[str | list[int]],
|
|
2116
|
+
sampling_params: dict | list[dict | None] | None = None,
|
|
2117
|
+
routing_key: str | list[str | None] | None = None,
|
|
2118
|
+
strict: bool | None = None,
|
|
2119
|
+
) -> str:
|
|
2120
|
+
"""Submit a generate request to a sampling sub-job. Returns request_id.
|
|
2121
|
+
|
|
2122
|
+
The typed fields are serialized as a DSSST1 safetensors frame and sent
|
|
2123
|
+
as ``application/octet-stream``. Oversized frames are byte-chunked
|
|
2124
|
+
across multiple POSTs; the final POST returns the pollable request id.
|
|
2125
|
+
Poll with :meth:`poll_request`.
|
|
2126
|
+
|
|
2127
|
+
Prompt-length validation is asymmetric by input type:
|
|
2128
|
+
|
|
2129
|
+
* **Pre-tokenized prompts** (``list[int]``) are checked client-side
|
|
2130
|
+
against the sampling sub-job's ``max_seq_len`` and fail fast with a
|
|
2131
|
+
``ValueError`` when ``len(prompt) >= max_seq_len`` — the token count
|
|
2132
|
+
is exact, so this never rejects a prompt vLLM would accept.
|
|
2133
|
+
* **String prompts** are *not* validated here. Counting their tokens
|
|
2134
|
+
would require replicating the server tokenizer (including special
|
|
2135
|
+
tokens), so an over-long string instead fails server-side in vLLM
|
|
2136
|
+
with a ``maximum model length`` error rather than client-side.
|
|
2137
|
+
"""
|
|
2138
|
+
payload: dict = {"prompts": prompts}
|
|
2139
|
+
if sampling_params is not None:
|
|
2140
|
+
payload["sampling_params"] = sampling_params
|
|
2141
|
+
if routing_key is not None:
|
|
2142
|
+
payload["routing_key"] = routing_key
|
|
2143
|
+
if strict is not None:
|
|
2144
|
+
payload["strict"] = strict
|
|
2145
|
+
self._check_prompt_lengths("generate", job_id, prompts)
|
|
2146
|
+
body = wire.dumps(
|
|
2147
|
+
payload,
|
|
2148
|
+
metadata={"response_options": {"format": "dssst1", "delivery": "chunked"}},
|
|
2149
|
+
)
|
|
2150
|
+
response = self._post_octet_request_chunks(
|
|
2151
|
+
job_id=job_id,
|
|
2152
|
+
path_suffix="generate",
|
|
2153
|
+
operation="generate",
|
|
2154
|
+
frame=body,
|
|
2155
|
+
max_bytes=self._MAX_GENERATE_BYTES,
|
|
2156
|
+
)
|
|
2157
|
+
request_id = response["request_id"]
|
|
2158
|
+
self._generate_request_ids.add(request_id)
|
|
2159
|
+
return request_id
|
|
2160
|
+
|
|
2161
|
+
@_track_operation("generate_stream")
|
|
2162
|
+
def generate_stream(
|
|
2163
|
+
self,
|
|
2164
|
+
job_id: str,
|
|
2165
|
+
prompts: list[str | list[int]],
|
|
2166
|
+
sampling_params: dict | list[dict | None] | None = None,
|
|
2167
|
+
routing_key: str | list[str | None] | None = None,
|
|
2168
|
+
strict: bool | None = None,
|
|
2169
|
+
) -> dict:
|
|
2170
|
+
"""Start a streaming generate request. Returns the response body.
|
|
2171
|
+
|
|
2172
|
+
Progress is read with :meth:`get_request_status` using the returned
|
|
2173
|
+
``request_id``. Cancellation uses :meth:`cancel_request`.
|
|
2174
|
+
|
|
2175
|
+
Prompt-length validation matches :meth:`generate`: pre-tokenized
|
|
2176
|
+
(``list[int]``) prompts are checked client-side against the sampling
|
|
2177
|
+
sub-job's ``max_seq_len`` and fail fast, while over-long string
|
|
2178
|
+
prompts are left to fail server-side in vLLM (see :meth:`generate`).
|
|
2179
|
+
"""
|
|
2180
|
+
payload: dict = {"prompts": prompts}
|
|
2181
|
+
if sampling_params is not None:
|
|
2182
|
+
payload["sampling_params"] = sampling_params
|
|
2183
|
+
if routing_key is not None:
|
|
2184
|
+
payload["routing_key"] = routing_key
|
|
2185
|
+
if strict is not None:
|
|
2186
|
+
payload["strict"] = strict
|
|
2187
|
+
self._check_prompt_lengths("generate_stream", job_id, prompts)
|
|
2188
|
+
body = json.dumps(payload).encode("utf-8")
|
|
2189
|
+
self._check_generate_payload("generate_stream", body)
|
|
2190
|
+
resp = self._send(
|
|
2191
|
+
"POST",
|
|
2192
|
+
f"{self._prefix}/{job_id}/generate-stream",
|
|
2193
|
+
data=body,
|
|
2194
|
+
headers={"Content-Type": "application/octet-stream"},
|
|
2195
|
+
)
|
|
2196
|
+
return resp.json()
|
|
2197
|
+
|
|
2198
|
+
@_track_operation("step")
|
|
2199
|
+
def step(self, job_id: str, learning_rate: float | None = None) -> str:
|
|
2200
|
+
"""Submit an optimizer step. Returns request_id."""
|
|
2201
|
+
body: dict = {}
|
|
2202
|
+
if learning_rate is not None:
|
|
2203
|
+
body["learning_rate"] = learning_rate
|
|
2204
|
+
resp = self._send("POST", f"{self._prefix}/{job_id}/step", json=body)
|
|
2205
|
+
return resp.json()["request_id"]
|
|
2206
|
+
|
|
2207
|
+
@_track_operation("save")
|
|
2208
|
+
def save(
|
|
2209
|
+
self,
|
|
2210
|
+
job_id: str,
|
|
2211
|
+
checkpoint_id: str | None = None,
|
|
2212
|
+
checkpoint_type: str | None = None,
|
|
2213
|
+
) -> str:
|
|
2214
|
+
"""Submit a checkpoint save. Returns request_id."""
|
|
2215
|
+
body: dict = {}
|
|
2216
|
+
if checkpoint_id is not None:
|
|
2217
|
+
body["checkpoint_id"] = checkpoint_id
|
|
2218
|
+
if checkpoint_type is not None:
|
|
2219
|
+
normalized_type = checkpoint_type.lower()
|
|
2220
|
+
if normalized_type not in ("resumable", "weights-only"):
|
|
2221
|
+
raise ValueError("checkpoint_type must be 'resumable' or 'weights-only'")
|
|
2222
|
+
body["checkpoint_type"] = normalized_type
|
|
2223
|
+
resp = self._send("POST", f"{self._prefix}/{job_id}/save", json=body)
|
|
2224
|
+
return resp.json()["request_id"]
|
|
2225
|
+
|
|
2226
|
+
@_track_operation("load")
|
|
2227
|
+
def load(
|
|
2228
|
+
self,
|
|
2229
|
+
job_id: str,
|
|
2230
|
+
checkpoint_id: str,
|
|
2231
|
+
source_job_id: str | None = None,
|
|
2232
|
+
*,
|
|
2233
|
+
target_sub_job_id: str | None = None,
|
|
2234
|
+
) -> str:
|
|
2235
|
+
"""Submit a checkpoint load into an existing job. Returns request_id.
|
|
2236
|
+
|
|
2237
|
+
Args:
|
|
2238
|
+
job_id: The job to load the checkpoint into.
|
|
2239
|
+
checkpoint_id: Checkpoint identifier (from a prior save).
|
|
2240
|
+
source_job_id: Optional. Load from another job's checkpoint store.
|
|
2241
|
+
target_sub_job_id: Optional. Route the load to a specific training
|
|
2242
|
+
sub-job. Format: ``"{job_id}:training:{index}"``. Omit to use
|
|
2243
|
+
the session's default training sub-job.
|
|
2244
|
+
|
|
2245
|
+
Returns:
|
|
2246
|
+
request_id for polling via ``poll_request()``.
|
|
2247
|
+
|
|
2248
|
+
**When to use target_sub_job_id:**
|
|
2249
|
+
|
|
2250
|
+
A job has at most one training sub-job, so omitting this routes to that
|
|
2251
|
+
sub-job. Pass it when you want explicit routing rather than relying on
|
|
2252
|
+
the server's default resolution.
|
|
2253
|
+
|
|
2254
|
+
**Discovering sub-job IDs:**
|
|
2255
|
+
|
|
2256
|
+
.. code-block:: python
|
|
2257
|
+
|
|
2258
|
+
job = client.get_job(job_id)
|
|
2259
|
+
for sj in job["sub_jobs"]:
|
|
2260
|
+
if sj["job_type"] == "training":
|
|
2261
|
+
sub_job_id = sj["sub_job_id"]
|
|
2262
|
+
n_gpus = sj["training_config"]["n_gpus"]
|
|
2263
|
+
print(f"Training sub-job {sub_job_id}: {n_gpus} GPUs")
|
|
2264
|
+
|
|
2265
|
+
**DP size compatibility:**
|
|
2266
|
+
|
|
2267
|
+
If the target sub-job has a different DP size (``n_gpus``) than the
|
|
2268
|
+
checkpoint was saved from, you **must** have created the job with
|
|
2269
|
+
``load_optimizer_states=False`` in the ``SubJobConfig``. The optimizer
|
|
2270
|
+
states are DP-sharded and cannot be resized. This setting is configured
|
|
2271
|
+
at job creation time and cannot be changed at runtime.
|
|
2272
|
+
|
|
2273
|
+
**Server validation:**
|
|
2274
|
+
|
|
2275
|
+
The server validates that ``target_sub_job_id`` belongs to this session
|
|
2276
|
+
(returns 400 if not) and is a training sub-job (returns 501 for
|
|
2277
|
+
non-training sub-jobs like sampling).
|
|
2278
|
+
"""
|
|
2279
|
+
body: dict = {"checkpoint_id": checkpoint_id}
|
|
2280
|
+
if source_job_id is not None:
|
|
2281
|
+
body["source_job_id"] = source_job_id
|
|
2282
|
+
if target_sub_job_id is not None:
|
|
2283
|
+
body["target_sub_job_id"] = target_sub_job_id
|
|
2284
|
+
resp = self._send("POST", f"{self._prefix}/{job_id}/load", json=body)
|
|
2285
|
+
return resp.json()["request_id"]
|
|
2286
|
+
|
|
2287
|
+
def _operation(
|
|
2288
|
+
self,
|
|
2289
|
+
job_id: str,
|
|
2290
|
+
operation_type: str,
|
|
2291
|
+
*,
|
|
2292
|
+
payload: dict | bytes | bytearray | memoryview | None = None,
|
|
2293
|
+
sub_job_id: str | None = None,
|
|
2294
|
+
sub_job_type: str | None = None,
|
|
2295
|
+
) -> dict:
|
|
2296
|
+
"""Submit a generic data-plane operation to the routed sub-job."""
|
|
2297
|
+
body: dict = {"operation_type": operation_type}
|
|
2298
|
+
if sub_job_id is not None:
|
|
2299
|
+
body["sub_job_id"] = sub_job_id
|
|
2300
|
+
if sub_job_type is not None:
|
|
2301
|
+
body["sub_job_type"] = sub_job_type
|
|
2302
|
+
if isinstance(payload, (bytes, bytearray, memoryview)):
|
|
2303
|
+
payload = {
|
|
2304
|
+
"payload_b64": base64.b64encode(bytes(payload)).decode("ascii"),
|
|
2305
|
+
"content_type": "application/octet-stream",
|
|
2306
|
+
}
|
|
2307
|
+
if payload is not None:
|
|
2308
|
+
body["payload"] = payload
|
|
2309
|
+
print(f"***** POST operation {self._prefix}/{job_id}/operation {body=}")
|
|
2310
|
+
resp = self._send("POST", f"{self._prefix}/{job_id}/operation", json=body)
|
|
2311
|
+
return resp.json()
|
|
2312
|
+
|
|
2313
|
+
@_track_operation("forward")
|
|
2314
|
+
def forward(
|
|
2315
|
+
self,
|
|
2316
|
+
job_id: str,
|
|
2317
|
+
payload: dict | bytes | bytearray | memoryview | None = None,
|
|
2318
|
+
*,
|
|
2319
|
+
sub_job_id: str | None = None,
|
|
2320
|
+
sub_job_type: str | None = None,
|
|
2321
|
+
) -> dict:
|
|
2322
|
+
"""Submit a generic forward operation. Returns the response body."""
|
|
2323
|
+
if isinstance(payload, (bytes, bytearray, memoryview)) and len(payload) > self._MAX_FWD_BWD_BYTES:
|
|
2324
|
+
size_mb = len(payload) / (1024 * 1024)
|
|
2325
|
+
raise ValueError(
|
|
2326
|
+
f"forward payload is {size_mb:.1f} MB, which exceeds the "
|
|
2327
|
+
"maximum allowed size of "
|
|
2328
|
+
f"{self._MAX_FWD_BWD_BYTES / (1024 * 1024):.0f} MB. "
|
|
2329
|
+
"Reduce the batch size or sequence length in your training "
|
|
2330
|
+
"configuration to bring the serialized input batch under "
|
|
2331
|
+
"the limit."
|
|
2332
|
+
)
|
|
2333
|
+
return self._operation(
|
|
2334
|
+
job_id,
|
|
2335
|
+
"forward",
|
|
2336
|
+
payload=payload,
|
|
2337
|
+
sub_job_id=sub_job_id,
|
|
2338
|
+
sub_job_type=sub_job_type,
|
|
2339
|
+
)
|
|
2340
|
+
|
|
2341
|
+
def fwd(
|
|
2342
|
+
self,
|
|
2343
|
+
job_id: str,
|
|
2344
|
+
payload: dict | bytes | bytearray | memoryview | None = None,
|
|
2345
|
+
*,
|
|
2346
|
+
sub_job_id: str | None = None,
|
|
2347
|
+
sub_job_type: str | None = None,
|
|
2348
|
+
) -> dict:
|
|
2349
|
+
"""Compatibility alias for :meth:`forward`."""
|
|
2350
|
+
return self.forward(
|
|
2351
|
+
job_id,
|
|
2352
|
+
payload=payload,
|
|
2353
|
+
sub_job_id=sub_job_id,
|
|
2354
|
+
sub_job_type=sub_job_type,
|
|
2355
|
+
)
|
|
2356
|
+
|
|
2357
|
+
def fwd_no_grad(
|
|
2358
|
+
self,
|
|
2359
|
+
job_id: str,
|
|
2360
|
+
payload: dict | bytes | bytearray | memoryview | None = None,
|
|
2361
|
+
*,
|
|
2362
|
+
sub_job_id: str | None = None,
|
|
2363
|
+
sub_job_type: str | None = None,
|
|
2364
|
+
) -> dict:
|
|
2365
|
+
"""Compatibility alias for :meth:`forward`."""
|
|
2366
|
+
return self.forward(
|
|
2367
|
+
job_id,
|
|
2368
|
+
payload=payload,
|
|
2369
|
+
sub_job_id=sub_job_id,
|
|
2370
|
+
sub_job_type=sub_job_type,
|
|
2371
|
+
)
|
|
2372
|
+
|
|
2373
|
+
@_track_operation("weight_sync")
|
|
2374
|
+
def weight_sync(
|
|
2375
|
+
self,
|
|
2376
|
+
job_id: str,
|
|
2377
|
+
source_sub_job_id: str,
|
|
2378
|
+
target_sub_job_ids: list[str],
|
|
2379
|
+
*,
|
|
2380
|
+
weight_format: str | None = None,
|
|
2381
|
+
sub_job_id: str | None = None,
|
|
2382
|
+
sub_job_type: str | None = None,
|
|
2383
|
+
) -> str:
|
|
2384
|
+
"""Sync model weights from a training sub-job into one or more sampling
|
|
2385
|
+
sub-jobs (used in RL training loops). Returns request_id.
|
|
2386
|
+
|
|
2387
|
+
Multi-sub-job sessions require the operation envelope to include a
|
|
2388
|
+
routing hint; by default the operation is routed through the source
|
|
2389
|
+
training sub-job.
|
|
2390
|
+
|
|
2391
|
+
Args:
|
|
2392
|
+
job_id: The job owning both sub-jobs.
|
|
2393
|
+
source_sub_job_id: Training sub-job to read weights from, e.g.
|
|
2394
|
+
``f"{job_id}:training:0"``.
|
|
2395
|
+
target_sub_job_ids: Sampling sub-jobs to update, e.g.
|
|
2396
|
+
``[f"{job_id}:sampling:0"]``.
|
|
2397
|
+
weight_format: ``"vllm"`` (server default) or ``"hf"`` for full
|
|
2398
|
+
weights, or ``"lora"`` to broadcast only the adapter tensors.
|
|
2399
|
+
|
|
2400
|
+
Example::
|
|
2401
|
+
|
|
2402
|
+
request_id = client.weight_sync(
|
|
2403
|
+
job_id,
|
|
2404
|
+
source_sub_job_id=f"{job_id}:training:0",
|
|
2405
|
+
target_sub_job_ids=[f"{job_id}:sampling:0"],
|
|
2406
|
+
weight_format="lora",
|
|
2407
|
+
)
|
|
2408
|
+
client.poll_request(job_id, request_id)
|
|
2409
|
+
"""
|
|
2410
|
+
body = {
|
|
2411
|
+
"source_sub_job_id": source_sub_job_id, # training
|
|
2412
|
+
"target_sub_job_ids": list(target_sub_job_ids), # sampling
|
|
2413
|
+
}
|
|
2414
|
+
if weight_format is not None:
|
|
2415
|
+
# "lora" broadcasts only the trained adapter tensors.
|
|
2416
|
+
body["weight_format"] = weight_format
|
|
2417
|
+
return self._operation(
|
|
2418
|
+
job_id,
|
|
2419
|
+
"weight-sync",
|
|
2420
|
+
payload=body,
|
|
2421
|
+
sub_job_id=sub_job_id or source_sub_job_id, # training
|
|
2422
|
+
sub_job_type=(sub_job_type if sub_job_type is not None else "training"),
|
|
2423
|
+
)["request_id"]
|
|
2424
|
+
|
|
2425
|
+
@_track_operation("bootstrap_router_replay")
|
|
2426
|
+
def bootstrap_router_replay(
|
|
2427
|
+
self,
|
|
2428
|
+
job_id: str,
|
|
2429
|
+
source_sub_job_id: str,
|
|
2430
|
+
target_sub_job_id: str,
|
|
2431
|
+
*,
|
|
2432
|
+
max_cache_bytes: int | None = None,
|
|
2433
|
+
sub_job_id: str | None = None,
|
|
2434
|
+
sub_job_type: str | None = None,
|
|
2435
|
+
) -> dict:
|
|
2436
|
+
"""Bootstrap router replay from one sub-job to another."""
|
|
2437
|
+
payload: dict = {
|
|
2438
|
+
"source_sub_job_id": source_sub_job_id,
|
|
2439
|
+
"target_sub_job_id": target_sub_job_id,
|
|
2440
|
+
}
|
|
2441
|
+
if max_cache_bytes is not None:
|
|
2442
|
+
payload["max_cache_bytes"] = max_cache_bytes
|
|
2443
|
+
return self._operation(
|
|
2444
|
+
job_id,
|
|
2445
|
+
"bootstrap-router-replay",
|
|
2446
|
+
payload=payload,
|
|
2447
|
+
sub_job_id=sub_job_id,
|
|
2448
|
+
sub_job_type=sub_job_type,
|
|
2449
|
+
)
|
|
2450
|
+
|
|
2451
|
+
@_track_operation("router_replay_discard")
|
|
2452
|
+
def router_replay_discard(
|
|
2453
|
+
self,
|
|
2454
|
+
job_id: str,
|
|
2455
|
+
sample_ids: list[str] | None = None,
|
|
2456
|
+
*,
|
|
2457
|
+
sub_job_id: str | None = None,
|
|
2458
|
+
sub_job_type: str | None = None,
|
|
2459
|
+
extra: dict | None = None,
|
|
2460
|
+
) -> dict:
|
|
2461
|
+
"""Discard router replay samples on a routed sub-job."""
|
|
2462
|
+
payload = dict(extra or {})
|
|
2463
|
+
payload.setdefault("sample_ids", list(sample_ids or []))
|
|
2464
|
+
return self._operation(
|
|
2465
|
+
job_id,
|
|
2466
|
+
"router-replay-discard",
|
|
2467
|
+
payload=payload,
|
|
2468
|
+
sub_job_id=sub_job_id,
|
|
2469
|
+
sub_job_type=sub_job_type,
|
|
2470
|
+
)
|
|
2471
|
+
|
|
2472
|
+
@_track_operation("reset_prefix_cache")
|
|
2473
|
+
def reset_prefix_cache(
|
|
2474
|
+
self,
|
|
2475
|
+
job_id: str,
|
|
2476
|
+
*,
|
|
2477
|
+
sub_job_id: str | None = None,
|
|
2478
|
+
sub_job_type: str | None = "sampling",
|
|
2479
|
+
drain: bool = True,
|
|
2480
|
+
timeout_s: float = 60.0,
|
|
2481
|
+
retry_interval_s: float = 0.1,
|
|
2482
|
+
extra: dict | None = None,
|
|
2483
|
+
) -> dict:
|
|
2484
|
+
"""Reset the sampling prefix cache for a Cortex Training job.
|
|
2485
|
+
|
|
2486
|
+
Operation-specific fields are carried inside the opaque operation
|
|
2487
|
+
payload. ``extra`` allows new reset options to flow through without a
|
|
2488
|
+
cortex-training release.
|
|
2489
|
+
"""
|
|
2490
|
+
payload = dict(extra or {})
|
|
2491
|
+
payload.setdefault("drain", drain)
|
|
2492
|
+
payload.setdefault("timeout_s", timeout_s)
|
|
2493
|
+
payload.setdefault("retry_interval_s", retry_interval_s)
|
|
2494
|
+
return self._operation(
|
|
2495
|
+
job_id,
|
|
2496
|
+
"reset-prefix-cache",
|
|
2497
|
+
payload=payload,
|
|
2498
|
+
sub_job_id=sub_job_id,
|
|
2499
|
+
sub_job_type=sub_job_type,
|
|
2500
|
+
)
|
|
2501
|
+
|
|
2502
|
+
# ─── Async request polling ────────────────────────────────────────────
|
|
2503
|
+
|
|
2504
|
+
@staticmethod
|
|
2505
|
+
def _normalize_request_status(status: str) -> str:
|
|
2506
|
+
"""Normalize full enum names (``REQUEST_STATE_DONE``) to short form (``done``)."""
|
|
2507
|
+
return status.lower().removeprefix("request_state_")
|
|
2508
|
+
|
|
2509
|
+
@staticmethod
|
|
2510
|
+
def _decode_result_payload(result: dict) -> dict | None:
|
|
2511
|
+
if not isinstance(result, dict):
|
|
2512
|
+
return None
|
|
2513
|
+
if result.get("wire_format") != wire.WIRE_FORMAT_VERSION:
|
|
2514
|
+
return None
|
|
2515
|
+
if result.get("encoding") != "base64":
|
|
2516
|
+
raise RuntimeError("DSSST1 result payload must use base64 encoding")
|
|
2517
|
+
raw = result.get("payload_b64")
|
|
2518
|
+
if not isinstance(raw, str) or not raw:
|
|
2519
|
+
raise RuntimeError("DSSST1 result payload missing payload_b64")
|
|
2520
|
+
payload = base64.b64decode(raw)
|
|
2521
|
+
decoded = wire.loads(payload)
|
|
2522
|
+
if not isinstance(decoded, dict):
|
|
2523
|
+
raise RuntimeError("DSSST1 result payload did not decode to a dict")
|
|
2524
|
+
return decoded
|
|
2525
|
+
|
|
2526
|
+
@staticmethod
|
|
2527
|
+
def _decode_result_chunk_event(event: dict) -> bytes | None:
|
|
2528
|
+
if not isinstance(event, dict) or event.get("type") != "result_chunk":
|
|
2529
|
+
return None
|
|
2530
|
+
raw = event.get("payload_b64")
|
|
2531
|
+
if not isinstance(raw, str) or not raw:
|
|
2532
|
+
raise RuntimeError("result_chunk event missing payload_b64")
|
|
2533
|
+
payload = base64.b64decode(raw)
|
|
2534
|
+
expected = event.get("payload_sha256")
|
|
2535
|
+
if expected is not None and hashlib.sha256(payload).hexdigest() != expected:
|
|
2536
|
+
raise RuntimeError("result_chunk payload_sha256 mismatch")
|
|
2537
|
+
return payload
|
|
2538
|
+
|
|
2539
|
+
@staticmethod
|
|
2540
|
+
def _restore_generate_result_lists(value: Any, torch_module: Any | None = None) -> Any:
|
|
2541
|
+
torch_module = _load_torch() if torch_module is None else torch_module
|
|
2542
|
+
if torch_module.is_tensor(value):
|
|
2543
|
+
return value.cpu().tolist()
|
|
2544
|
+
if isinstance(value, dict):
|
|
2545
|
+
return {
|
|
2546
|
+
key: CortexTrainingClient._restore_generate_result_lists(item, torch_module)
|
|
2547
|
+
for key, item in value.items()
|
|
2548
|
+
}
|
|
2549
|
+
if isinstance(value, list):
|
|
2550
|
+
return [CortexTrainingClient._restore_generate_result_lists(item, torch_module) for item in value]
|
|
2551
|
+
if isinstance(value, tuple):
|
|
2552
|
+
return tuple(CortexTrainingClient._restore_generate_result_lists(item, torch_module) for item in value)
|
|
2553
|
+
return value
|
|
2554
|
+
|
|
2555
|
+
def _normalize_generate_result_if_needed(self, request_id: str, result: dict) -> dict:
|
|
2556
|
+
if request_id not in self._generate_request_ids:
|
|
2557
|
+
return result
|
|
2558
|
+
self._generate_request_ids.discard(request_id)
|
|
2559
|
+
if isinstance(result, dict) and "results" in result:
|
|
2560
|
+
result = dict(result)
|
|
2561
|
+
result["results"] = self._restore_generate_result_lists(result["results"])
|
|
2562
|
+
return result
|
|
2563
|
+
|
|
2564
|
+
@staticmethod
|
|
2565
|
+
def _decode_stream_result_event(event: Any) -> Any:
|
|
2566
|
+
if not isinstance(event, dict) or event.get("type") != "result":
|
|
2567
|
+
return event
|
|
2568
|
+
result = event.get("result")
|
|
2569
|
+
if not isinstance(result, dict):
|
|
2570
|
+
return event
|
|
2571
|
+
decoded = CortexTrainingClient._decode_result_payload(result)
|
|
2572
|
+
if decoded is None:
|
|
2573
|
+
return event
|
|
2574
|
+
event = dict(event)
|
|
2575
|
+
event["result"] = CortexTrainingClient._restore_generate_result_lists(decoded)
|
|
2576
|
+
return event
|
|
2577
|
+
|
|
2578
|
+
@staticmethod
|
|
2579
|
+
def _decode_stream_result_events(status: dict) -> dict:
|
|
2580
|
+
events = status.get("events")
|
|
2581
|
+
if not isinstance(events, list):
|
|
2582
|
+
return status
|
|
2583
|
+
decoded = [CortexTrainingClient._decode_stream_result_event(event) for event in events]
|
|
2584
|
+
if decoded == events:
|
|
2585
|
+
return status
|
|
2586
|
+
status = dict(status)
|
|
2587
|
+
status["events"] = decoded
|
|
2588
|
+
return status
|
|
2589
|
+
|
|
2590
|
+
@_track_operation("poll_request")
|
|
2591
|
+
def poll_request(self, job_id: str, request_id: str) -> dict:
|
|
2592
|
+
"""Poll until the async request completes. Returns the result dict."""
|
|
2593
|
+
deadline = time.monotonic() + self.poll_timeout
|
|
2594
|
+
delay = self.poll_interval
|
|
2595
|
+
debug_context = self._fwd_bwd_request_debug.get(request_id)
|
|
2596
|
+
debug_label = self._debug_context_label(debug_context) if debug_context is not None else None
|
|
2597
|
+
result_chunks: list[bytes] = []
|
|
2598
|
+
cursor: str | None = None
|
|
2599
|
+
while time.monotonic() < deadline:
|
|
2600
|
+
status = self.get_request_status(job_id, request_id, cursor=cursor)
|
|
2601
|
+
state = self._normalize_request_status(status.get("status", ""))
|
|
2602
|
+
events = status.get("events") or []
|
|
2603
|
+
received_chunk = False
|
|
2604
|
+
for event in events:
|
|
2605
|
+
chunk = self._decode_result_chunk_event(event)
|
|
2606
|
+
if chunk is not None:
|
|
2607
|
+
result_chunks.append(chunk)
|
|
2608
|
+
received_chunk = True
|
|
2609
|
+
next_cursor = status.get("next_cursor")
|
|
2610
|
+
if isinstance(next_cursor, str) and next_cursor:
|
|
2611
|
+
cursor = next_cursor
|
|
2612
|
+
# Drain available result chunks without backing off.
|
|
2613
|
+
continue
|
|
2614
|
+
if state in ("completed", "done", "succeeded"):
|
|
2615
|
+
if result_chunks:
|
|
2616
|
+
result = wire.decode_result_chunks(result_chunks)
|
|
2617
|
+
else:
|
|
2618
|
+
result = status.get("result") or {}
|
|
2619
|
+
decoded = self._decode_result_payload(result)
|
|
2620
|
+
if decoded is not None:
|
|
2621
|
+
result = decoded
|
|
2622
|
+
result = self._normalize_generate_result_if_needed(request_id, result)
|
|
2623
|
+
if debug_label is not None:
|
|
2624
|
+
logger.debug(
|
|
2625
|
+
"%s completed state=%s result=%s",
|
|
2626
|
+
debug_label,
|
|
2627
|
+
state,
|
|
2628
|
+
self._debug_json_summary(result),
|
|
2629
|
+
)
|
|
2630
|
+
self._fwd_bwd_request_debug.pop(request_id, None)
|
|
2631
|
+
return result
|
|
2632
|
+
if state in ("failed", "cancelled", "canceled"):
|
|
2633
|
+
error = status.get("error", "")
|
|
2634
|
+
if debug_label is not None:
|
|
2635
|
+
logger.debug(
|
|
2636
|
+
"%s failed state=%s error=%s body=%s",
|
|
2637
|
+
debug_label,
|
|
2638
|
+
state,
|
|
2639
|
+
error,
|
|
2640
|
+
self._debug_json_summary(status),
|
|
2641
|
+
)
|
|
2642
|
+
self._fwd_bwd_request_debug.pop(request_id, None)
|
|
2643
|
+
self._generate_request_ids.discard(request_id)
|
|
2644
|
+
raise RuntimeError(f"Request {request_id} ended with state '{state}': {error}")
|
|
2645
|
+
if received_chunk:
|
|
2646
|
+
# Defensive: if the server returns a chunk without next_cursor
|
|
2647
|
+
# before terminal status, poll again immediately rather than
|
|
2648
|
+
# sleeping.
|
|
2649
|
+
continue
|
|
2650
|
+
delay = self._sleep_with_backoff(delay, deadline)
|
|
2651
|
+
if debug_label is not None:
|
|
2652
|
+
logger.debug("%s timed out after %ss", debug_label, self.poll_timeout)
|
|
2653
|
+
self._fwd_bwd_request_debug.pop(request_id, None)
|
|
2654
|
+
self._generate_request_ids.discard(request_id)
|
|
2655
|
+
raise TimeoutError(f"Request {request_id} did not complete within {self.poll_timeout}s")
|
|
2656
|
+
|
|
2657
|
+
@_track_operation("get_request_status")
|
|
2658
|
+
def get_request_status(
|
|
2659
|
+
self,
|
|
2660
|
+
job_id: str,
|
|
2661
|
+
request_id: str,
|
|
2662
|
+
*,
|
|
2663
|
+
max_events: int | None = None,
|
|
2664
|
+
cursor: str | None = None,
|
|
2665
|
+
) -> dict:
|
|
2666
|
+
params = {}
|
|
2667
|
+
if max_events is not None:
|
|
2668
|
+
params["max_events"] = max_events
|
|
2669
|
+
if cursor:
|
|
2670
|
+
params["cursor"] = cursor
|
|
2671
|
+
debug_context = self._fwd_bwd_request_debug.get(request_id)
|
|
2672
|
+
resp = self._send(
|
|
2673
|
+
"GET",
|
|
2674
|
+
f"{self._prefix}/{job_id}/requests/{request_id}",
|
|
2675
|
+
params=params or None,
|
|
2676
|
+
debug_context=debug_context,
|
|
2677
|
+
)
|
|
2678
|
+
return self._decode_stream_result_events(resp.json())
|
|
2679
|
+
|
|
2680
|
+
@_track_operation("cancel_request")
|
|
2681
|
+
def cancel_request(
|
|
2682
|
+
self,
|
|
2683
|
+
job_id: str,
|
|
2684
|
+
request_id: str,
|
|
2685
|
+
*,
|
|
2686
|
+
sub_job_id: str | None = None,
|
|
2687
|
+
sub_job_type: str | None = None,
|
|
2688
|
+
) -> dict:
|
|
2689
|
+
return self._operation(
|
|
2690
|
+
job_id,
|
|
2691
|
+
"cancel-request",
|
|
2692
|
+
payload={"request_id": request_id},
|
|
2693
|
+
sub_job_id=sub_job_id,
|
|
2694
|
+
sub_job_type=sub_job_type,
|
|
2695
|
+
)
|
|
2696
|
+
|
|
2697
|
+
# ─── Logs (read-only) ────────────────────────────────────────────────
|
|
2698
|
+
|
|
2699
|
+
def tail_logs(
|
|
2700
|
+
self,
|
|
2701
|
+
job_id: str,
|
|
2702
|
+
*,
|
|
2703
|
+
cursor: str | None = None,
|
|
2704
|
+
max_lines: int | None = None,
|
|
2705
|
+
sub_job_id: str | None = None,
|
|
2706
|
+
sub_job_type: str | None = None,
|
|
2707
|
+
) -> dict:
|
|
2708
|
+
"""Fetch one page of a sub-job's logs from ``cursor`` (read-only).
|
|
2709
|
+
|
|
2710
|
+
The target is the sub-job's zone-manager (Ray head) pod, whose stdout
|
|
2711
|
+
already includes worker output (Ray ``log_to_driver``); identify it via
|
|
2712
|
+
``sub_job_id`` (or ``sub_job_type`` when unambiguous). Returns
|
|
2713
|
+
``{"entries": [...], "next_cursor": str, "eof": bool}``. Re-call with
|
|
2714
|
+
``next_cursor`` to continue; an empty ``cursor`` reads from the start.
|
|
2715
|
+
"""
|
|
2716
|
+
payload: dict = {}
|
|
2717
|
+
if cursor is not None:
|
|
2718
|
+
payload["cursor"] = cursor
|
|
2719
|
+
if max_lines is not None:
|
|
2720
|
+
payload["max_lines"] = max_lines
|
|
2721
|
+
return self._operation(
|
|
2722
|
+
job_id,
|
|
2723
|
+
"tail-logs",
|
|
2724
|
+
payload=payload,
|
|
2725
|
+
sub_job_id=sub_job_id,
|
|
2726
|
+
sub_job_type=sub_job_type,
|
|
2727
|
+
)
|
|
2728
|
+
|
|
2729
|
+
def _stream_pages(
|
|
2730
|
+
self,
|
|
2731
|
+
fetch,
|
|
2732
|
+
entries_key: str,
|
|
2733
|
+
*,
|
|
2734
|
+
follow: bool,
|
|
2735
|
+
cursor: str | None = None,
|
|
2736
|
+
poll_interval: float | None = None,
|
|
2737
|
+
) -> Iterator[dict]:
|
|
2738
|
+
"""Drive a cursor-paged tail: call ``fetch(cursor)`` for a page, yield
|
|
2739
|
+
its entries (under ``entries_key``), advance the cursor, and poll with
|
|
2740
|
+
backoff. With ``follow`` False, stop once a page reports ``eof``.
|
|
2741
|
+
"""
|
|
2742
|
+
base_delay = poll_interval or self.poll_interval
|
|
2743
|
+
delay = base_delay
|
|
2744
|
+
while True:
|
|
2745
|
+
page = fetch(cursor)
|
|
2746
|
+
cursor = page.get("next_cursor", cursor)
|
|
2747
|
+
entries = page.get(entries_key) or []
|
|
2748
|
+
for entry in entries:
|
|
2749
|
+
yield entry
|
|
2750
|
+
if entries:
|
|
2751
|
+
delay = base_delay # made progress; poll again promptly
|
|
2752
|
+
continue
|
|
2753
|
+
if page.get("eof", False) and not follow:
|
|
2754
|
+
return
|
|
2755
|
+
time.sleep(delay)
|
|
2756
|
+
delay = min(delay * self.poll_backoff_multiplier, self.poll_max_interval)
|
|
2757
|
+
|
|
2758
|
+
def stream_logs(
|
|
2759
|
+
self,
|
|
2760
|
+
job_id: str,
|
|
2761
|
+
*,
|
|
2762
|
+
sub_job_id: str | None = None,
|
|
2763
|
+
sub_job_type: str | None = None,
|
|
2764
|
+
cursor: str | None = None,
|
|
2765
|
+
max_lines: int | None = None,
|
|
2766
|
+
poll_interval: float | None = None,
|
|
2767
|
+
follow: bool = True,
|
|
2768
|
+
) -> Iterator[dict]:
|
|
2769
|
+
"""Yield a sub-job's log entries, polling with backoff.
|
|
2770
|
+
|
|
2771
|
+
Drains the log then, with ``follow=True`` (the default), keeps polling
|
|
2772
|
+
for new entries until the caller stops iterating — suitable for a live
|
|
2773
|
+
tail. With ``follow=False`` it stops once the log reports ``eof``.
|
|
2774
|
+
"""
|
|
2775
|
+
return self._stream_pages(
|
|
2776
|
+
lambda cur: self.tail_logs(
|
|
2777
|
+
job_id,
|
|
2778
|
+
cursor=cur,
|
|
2779
|
+
max_lines=max_lines,
|
|
2780
|
+
sub_job_id=sub_job_id,
|
|
2781
|
+
sub_job_type=sub_job_type,
|
|
2782
|
+
),
|
|
2783
|
+
"entries",
|
|
2784
|
+
follow=follow,
|
|
2785
|
+
cursor=cursor,
|
|
2786
|
+
poll_interval=poll_interval,
|
|
2787
|
+
)
|
|
2788
|
+
|
|
2789
|
+
# ─── Zone scheduling events (read-only) ───────────────────────────────
|
|
2790
|
+
|
|
2791
|
+
def tail_events(
|
|
2792
|
+
self,
|
|
2793
|
+
job_id: str,
|
|
2794
|
+
*,
|
|
2795
|
+
cursor: str | None = None,
|
|
2796
|
+
max_events: int | None = None,
|
|
2797
|
+
sub_job_id: str | None = None,
|
|
2798
|
+
sub_job_type: str | None = None,
|
|
2799
|
+
) -> dict:
|
|
2800
|
+
"""Fetch one page of zone scheduling events for the session.
|
|
2801
|
+
|
|
2802
|
+
Returns ``{"events": [...], "next_cursor": str, "eof": bool}``. Served
|
|
2803
|
+
by the zone dispatcher itself, not routed to a Zone Manager.
|
|
2804
|
+
|
|
2805
|
+
Note: the server does not currently accept this operation; see
|
|
2806
|
+
``docs/reference/rest-api.md`` section 7.9.
|
|
2807
|
+
"""
|
|
2808
|
+
payload: dict = {}
|
|
2809
|
+
if cursor is not None:
|
|
2810
|
+
payload["cursor"] = cursor
|
|
2811
|
+
if max_events is not None:
|
|
2812
|
+
payload["max_events"] = max_events
|
|
2813
|
+
return self._operation(
|
|
2814
|
+
job_id,
|
|
2815
|
+
"zmd-events",
|
|
2816
|
+
payload=payload or None,
|
|
2817
|
+
sub_job_id=sub_job_id,
|
|
2818
|
+
sub_job_type=sub_job_type,
|
|
2819
|
+
)
|
|
2820
|
+
|
|
2821
|
+
def stream_events(
|
|
2822
|
+
self,
|
|
2823
|
+
job_id: str,
|
|
2824
|
+
*,
|
|
2825
|
+
cursor: str | None = None,
|
|
2826
|
+
max_events: int | None = None,
|
|
2827
|
+
sub_job_id: str | None = None,
|
|
2828
|
+
sub_job_type: str | None = None,
|
|
2829
|
+
poll_interval: float | None = None,
|
|
2830
|
+
follow: bool = True,
|
|
2831
|
+
) -> Iterator[dict]:
|
|
2832
|
+
"""Yield zone scheduling events for the session, polling with backoff."""
|
|
2833
|
+
return self._stream_pages(
|
|
2834
|
+
lambda cur: self.tail_events(
|
|
2835
|
+
job_id,
|
|
2836
|
+
cursor=cur,
|
|
2837
|
+
max_events=max_events,
|
|
2838
|
+
sub_job_id=sub_job_id,
|
|
2839
|
+
sub_job_type=sub_job_type,
|
|
2840
|
+
),
|
|
2841
|
+
"events",
|
|
2842
|
+
follow=follow,
|
|
2843
|
+
cursor=cursor,
|
|
2844
|
+
poll_interval=poll_interval,
|
|
2845
|
+
)
|
|
2846
|
+
|
|
2847
|
+
# ─── Checkpoints ─────────────────────────────────────────────────────
|
|
2848
|
+
|
|
2849
|
+
@_track_operation("list_checkpoints")
|
|
2850
|
+
def list_checkpoints(self, job_id: str) -> list:
|
|
2851
|
+
resp = self._send("GET", f"{self._prefix}/{job_id}/checkpoints")
|
|
2852
|
+
return resp.json().get("checkpoints", [])
|
|
2853
|
+
|
|
2854
|
+
@_track_operation("export_checkpoint")
|
|
2855
|
+
def export_checkpoint(self, job_id: str, checkpoint_id: str) -> dict:
|
|
2856
|
+
# The REST API uses colon-action syntax: /{jobId}/checkpoints/{cpId}:export
|
|
2857
|
+
resp = self._send(
|
|
2858
|
+
"POST",
|
|
2859
|
+
f"{self._prefix}/{job_id}/checkpoints/{checkpoint_id}:export",
|
|
2860
|
+
)
|
|
2861
|
+
return resp.json()
|
|
2862
|
+
|
|
2863
|
+
@_track_operation("delete_checkpoint")
|
|
2864
|
+
def delete_checkpoint(self, job_id: str, checkpoint_id: str) -> None:
|
|
2865
|
+
"""Delete a checkpoint. Returns None on success."""
|
|
2866
|
+
self._send("DELETE", f"{self._prefix}/{job_id}/checkpoints/{checkpoint_id}")
|