starforge-cli 0.1.6__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.
- starforge_cli/__init__.py +3 -0
- starforge_cli/api_client.py +589 -0
- starforge_cli/auth.py +349 -0
- starforge_cli/catalog.py +124 -0
- starforge_cli/cli.py +74 -0
- starforge_cli/cli_ui.py +469 -0
- starforge_cli/client_device.py +104 -0
- starforge_cli/commands/__init__.py +1 -0
- starforge_cli/commands/admin.py +140 -0
- starforge_cli/commands/bench.py +94 -0
- starforge_cli/commands/common.py +178 -0
- starforge_cli/commands/dataset.py +150 -0
- starforge_cli/commands/exp.py +213 -0
- starforge_cli/commands/init.py +52 -0
- starforge_cli/commands/jobs.py +223 -0
- starforge_cli/commands/login.py +54 -0
- starforge_cli/commands/plugin.py +243 -0
- starforge_cli/commands/recipe.py +163 -0
- starforge_cli/commands/serve.py +79 -0
- starforge_cli/commands/submit.py +467 -0
- starforge_cli/commands/sweep.py +154 -0
- starforge_cli/config_resolve.py +17 -0
- starforge_cli/data_prep.py +60 -0
- starforge_cli/new_experiment.py +195 -0
- starforge_cli/packing.py +179 -0
- starforge_cli/plugins_lock.py +73 -0
- starforge_cli/project.py +130 -0
- starforge_cli/recipe_lock.py +453 -0
- starforge_cli/scaffold/agent-run.py.tmpl +146 -0
- starforge_cli/scaffold/custom-framework/train.sh +56 -0
- starforge_cli/scaffold/experiment-template/.gitkeep +0 -0
- starforge_cli/scaffold/experiment-template/README.md +36 -0
- starforge_cli/scaffold/experiment-template/config.yaml +44 -0
- starforge_cli/scaffold/project/common/README.md +12 -0
- starforge_cli/scaffold/project/common/__init__.py +0 -0
- starforge_cli/scaffold/project/configs/README.md +103 -0
- starforge_cli/scaffold/project/configs/base/README.md +24 -0
- starforge_cli/scaffold/project/configs/base/distillation_math.yaml +284 -0
- starforge_cli/scaffold/project/configs/base/grpo_lora.yaml +30 -0
- starforge_cli/scaffold/project/configs/base/grpo_math_1B.yaml +470 -0
- starforge_cli/scaffold/project/configs/base/grpo_megatron.yaml +43 -0
- starforge_cli/scaffold/project/configs/base/grpo_noncolocated.yaml +18 -0
- starforge_cli/scaffold/project/configs/base/grpo_sliding_puzzle.yaml +81 -0
- starforge_cli/scaffold/project/configs/base/ppo_math_1B.yaml +454 -0
- starforge_cli/scaffold/project/configs/base/rm.yaml +224 -0
- starforge_cli/scaffold/project/configs/base/sft.yaml +294 -0
- starforge_cli/scaffold/project/configs/models/README.md +16 -0
- starforge_cli/scaffold/project/configs/models/qwen3.5-4b.yaml +12 -0
- starforge_cli/scaffold/project/configs/models/qwen3.5-9b.yaml +10 -0
- starforge_cli/scaffold/project/gitignore +11 -0
- starforge_cli/spec_builder.py +372 -0
- starforge_cli-0.1.6.dist-info/METADATA +40 -0
- starforge_cli-0.1.6.dist-info/RECORD +55 -0
- starforge_cli-0.1.6.dist-info/WHEEL +4 -0
- starforge_cli-0.1.6.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,467 @@
|
|
|
1
|
+
"""作业提交命令:submit / export / eval / clean(构建 JobSpec → 打包 → 经 Console 提交)。"""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import typer
|
|
7
|
+
|
|
8
|
+
from starforge_cli import api_client, cli_ui, packing
|
|
9
|
+
from starforge_cli.auth import gate
|
|
10
|
+
from starforge_cli.commands import common
|
|
11
|
+
from starforge_cli.commands.exp import _validate_exp
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _require_clean_tree(allow_dirty: bool, prov: dict) -> None:
|
|
15
|
+
"""工作区有未提交改动时必须显式 --allow-dirty,保证提交可追溯到确切 commit。"""
|
|
16
|
+
if prov["git_dirty"] and not allow_dirty:
|
|
17
|
+
cli_ui.fail(
|
|
18
|
+
"工作区有未提交改动,提交内容将无法追溯到确切 commit。",
|
|
19
|
+
hint="git commit 后重试;确要带脏改动提交,加 --allow-dirty 显式确认",
|
|
20
|
+
)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def submit(
|
|
24
|
+
exp: str = typer.Argument(..., autocompletion=common.complete_exp, help="实验名或路径"),
|
|
25
|
+
profile: list[str] = common.PROFILE_EXPR_OPT,
|
|
26
|
+
method: Optional[str] = typer.Option(
|
|
27
|
+
None, "--method", "-m", autocompletion=common.complete_method,
|
|
28
|
+
help="方法标识 <framework>/<method>(如 nemo-rl/grpo、verl/grpo);"
|
|
29
|
+
"不传则读实验目录 recipe.lock.json 的声明",
|
|
30
|
+
),
|
|
31
|
+
set_: list[str] = typer.Option(
|
|
32
|
+
[], "--set", "-s", metavar="KEY=VALUE",
|
|
33
|
+
help="覆盖超参,可重复。本地按方法声明校验类型与区间,拼错立刻报错",
|
|
34
|
+
),
|
|
35
|
+
init_from: Optional[str] = typer.Option(
|
|
36
|
+
None, "--init-from", metavar="run/<RUN_ID>/checkpoint[@step=N]",
|
|
37
|
+
help="从上一阶段的产物起训(SFT → DPO → GRPO 流水线)",
|
|
38
|
+
),
|
|
39
|
+
model: Optional[str] = typer.Option(
|
|
40
|
+
None, "--model", help="基座模型路径或 Hub id;verl/TRL 必填"
|
|
41
|
+
),
|
|
42
|
+
train_data: Optional[str] = typer.Option(
|
|
43
|
+
None, "--train-data",
|
|
44
|
+
help="训练数据路径;verl/TRL 必填。声明了平台数据集(config 或 --train-dataset)时,"
|
|
45
|
+
"写数据集内的相对文件名(如 train.parquet),作业侧自动落到缓存目录",
|
|
46
|
+
),
|
|
47
|
+
validation_data: Optional[str] = typer.Option(
|
|
48
|
+
None, "--validation-data", help="验证数据路径;verl/TRL 必填,用法同 --train-data"
|
|
49
|
+
),
|
|
50
|
+
train_dataset: Optional[str] = typer.Option(
|
|
51
|
+
None, "--train-dataset", metavar="<owner>/<name>[@version]",
|
|
52
|
+
help="平台数据集引用:作业启动时自动拉到共享缓存并注入 <NAME>_DATA_DIR。"
|
|
53
|
+
"推荐写在实验 config 的 data.train.dataset,此参数仅作临时覆盖",
|
|
54
|
+
),
|
|
55
|
+
validation_dataset: Optional[str] = typer.Option(
|
|
56
|
+
None, "--validation-dataset", metavar="<owner>/<name>[@version]",
|
|
57
|
+
help="验证集的平台数据集引用;对应 config 的 data.validation.dataset",
|
|
58
|
+
),
|
|
59
|
+
image: Optional[str] = typer.Option(
|
|
60
|
+
None,
|
|
61
|
+
"--image",
|
|
62
|
+
help="作业镜像(tag 即可,如 registry/nemo-rl-ext:v0.7.0)。"
|
|
63
|
+
"custom 必填;其它框架覆盖 console 为该框架配的默认镜像",
|
|
64
|
+
),
|
|
65
|
+
then: list[str] = typer.Option(
|
|
66
|
+
[], "--then", metavar="ACTION", help="训练成功后自动执行(export/eval),可重复",
|
|
67
|
+
),
|
|
68
|
+
observability_url: Optional[str] = typer.Option(
|
|
69
|
+
None,
|
|
70
|
+
"--observability-url",
|
|
71
|
+
help="仅 external observability recipe 使用;platform recipe 禁止设置",
|
|
72
|
+
),
|
|
73
|
+
framework_version: Optional[str] = typer.Option(
|
|
74
|
+
None,
|
|
75
|
+
"--framework-version",
|
|
76
|
+
help="仅配合 --upgrade-recipe 使用;单独指定不会改写锁文件",
|
|
77
|
+
),
|
|
78
|
+
upgrade_recipe: bool = typer.Option(
|
|
79
|
+
False,
|
|
80
|
+
"--upgrade-recipe",
|
|
81
|
+
help="提交前把本实验锁升级到当前 catalog,复用 sf recipe upgrade",
|
|
82
|
+
),
|
|
83
|
+
allow_dirty: bool = typer.Option(
|
|
84
|
+
False, "--allow-dirty", help="允许工作区有未提交改动(默认拒绝,保证可追溯)"
|
|
85
|
+
),
|
|
86
|
+
no_validate: bool = typer.Option(False, "--no-validate", help="跳过提交前校验"),
|
|
87
|
+
) -> None:
|
|
88
|
+
"""提交训练作业(提交前自动校验 config 与超参)。"""
|
|
89
|
+
gate()
|
|
90
|
+
exp_path = common.resolve_exp(exp)
|
|
91
|
+
# --profile 是唯一的资源入口:没传则回退实验目录遗留的 cluster 标注。
|
|
92
|
+
exprs = [e for e in (profile or []) if e.strip()]
|
|
93
|
+
if not exprs:
|
|
94
|
+
exprs = [common.resolve_profile(exp_path, None)]
|
|
95
|
+
resolved_profile, pools, roles = _materialize_profile_or_exit(exprs)
|
|
96
|
+
# --upgrade-recipe 必须发生在 provenance / clean-tree / 校验之前:
|
|
97
|
+
# ① 校验环节要求锁与当前 catalog 一致,锁过期时「先校验后升级」会让本参数永远执行不到;
|
|
98
|
+
# ② 升级会改写 recipe.lock.json,若发生在 clean-tree 检查与 provenance 计算之后,
|
|
99
|
+
# spec 记录 git_dirty=False 而上传 meta 重算出 git_dirty=True,溯源自相矛盾,
|
|
100
|
+
# 打包内容也与记录的 commit 不符。
|
|
101
|
+
if upgrade_recipe:
|
|
102
|
+
_upgrade_recipe_or_exit(exp_path, method=method, framework_version=framework_version)
|
|
103
|
+
prov = packing.git_provenance(common.ROOT, exp_path)
|
|
104
|
+
_require_clean_tree(allow_dirty, prov)
|
|
105
|
+
if not no_validate:
|
|
106
|
+
errors, _ = _validate_exp(exp_path, method or "")
|
|
107
|
+
if errors:
|
|
108
|
+
cli_ui.emit_error(
|
|
109
|
+
f"config 校验未通过({len(errors)} 处)",
|
|
110
|
+
items=errors,
|
|
111
|
+
hint="修复后重试,或加 --no-validate 跳过",
|
|
112
|
+
)
|
|
113
|
+
raise typer.Exit(1)
|
|
114
|
+
|
|
115
|
+
# 平台数据集引用:config 声明(data.{train,validation}.dataset)为默认,CLI 覆盖。
|
|
116
|
+
cfg_train_ds, cfg_val_ds = _dataset_refs_from_config(exp_path)
|
|
117
|
+
project = common.project_name()
|
|
118
|
+
spec = _build_spec_or_exit(
|
|
119
|
+
exp_path, method=method, project=project, sets=set_, pools=pools, roles=roles,
|
|
120
|
+
init_from=init_from, then=then, observability_url=observability_url,
|
|
121
|
+
model=model, train_data=train_data, validation_data=validation_data,
|
|
122
|
+
train_dataset=train_dataset or cfg_train_ds,
|
|
123
|
+
validation_dataset=validation_dataset or cfg_val_ds,
|
|
124
|
+
framework_version=framework_version,
|
|
125
|
+
image=image,
|
|
126
|
+
provenance=prov,
|
|
127
|
+
validate=not no_validate,
|
|
128
|
+
)
|
|
129
|
+
# 清单式打包 → 上传到中心化服务 → 服务端注入密钥/路径后代理提交(密钥/地址不外泄)。
|
|
130
|
+
with cli_ui.submit_progress() as reporter:
|
|
131
|
+
res = api_client.submit_via_server(
|
|
132
|
+
exp_path, resolved_profile, common.ROOT,
|
|
133
|
+
project=project, reporter=reporter, spec=spec,
|
|
134
|
+
)
|
|
135
|
+
_echo_submit_result(res)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _dataset_refs_from_config(exp_path: str) -> tuple[str, str]:
|
|
139
|
+
"""实验 config 里声明的平台数据集引用:data.{train,validation}.dataset。
|
|
140
|
+
|
|
141
|
+
数据集是实验的属性,声明跟着 config 走(含 defaults 继承与 _override_ 语义),
|
|
142
|
+
提交时 --train-dataset / --validation-dataset 仅作临时覆盖。
|
|
143
|
+
config 缺失、解析失败或未声明时返回空串——坏 config 由校验环节负责报错,
|
|
144
|
+
这里不重复拦。
|
|
145
|
+
"""
|
|
146
|
+
from starforge_core.config_resolve import resolve
|
|
147
|
+
|
|
148
|
+
cfg_path = common.ROOT / exp_path / "config.yaml"
|
|
149
|
+
if not cfg_path.is_file():
|
|
150
|
+
return "", ""
|
|
151
|
+
try:
|
|
152
|
+
data = resolve(cfg_path).get("data") or {}
|
|
153
|
+
except Exception: # noqa: BLE001
|
|
154
|
+
return "", ""
|
|
155
|
+
|
|
156
|
+
def _ref(section: str) -> str:
|
|
157
|
+
node = data.get(section)
|
|
158
|
+
v = node.get("dataset") if isinstance(node, dict) else None
|
|
159
|
+
return v.strip() if isinstance(v, str) else ""
|
|
160
|
+
|
|
161
|
+
return _ref("train"), _ref("validation")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _materialize_profile_or_exit(exprs: list[str]) -> tuple[str, list[str], list[str]]:
|
|
165
|
+
"""把 --profile 表达式物化成 (作业 profile, pools, roles);失败打印可读错误退出。
|
|
166
|
+
|
|
167
|
+
series 与默认形状查服务端注册表——用户只说「哪种卡、几张」,
|
|
168
|
+
拓扑细节由注册表补齐,两边不可能写出互相矛盾的资源声明。
|
|
169
|
+
"""
|
|
170
|
+
from starforge_core.contract import SpecError
|
|
171
|
+
|
|
172
|
+
from starforge_cli import spec_builder
|
|
173
|
+
|
|
174
|
+
try:
|
|
175
|
+
return spec_builder.materialize_pools(exprs, common.profile_registry())
|
|
176
|
+
except SpecError as e:
|
|
177
|
+
cli_ui.emit_error("--profile 解析未通过", items=[str(e)],
|
|
178
|
+
hint="格式:[role=]名称[:总卡数],如 h200、h200:4;`sf status` 查看可用 profile")
|
|
179
|
+
raise typer.Exit(1) from e
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _upgrade_recipe_or_exit(exp_path: str, *, method, framework_version) -> None:
|
|
183
|
+
"""执行 --upgrade-recipe(提交主流程的前置步骤,见 submit() 中的顺序说明)。
|
|
184
|
+
|
|
185
|
+
升级改写了锁文件时打印明确提示:随后的 clean-tree 检查会要求 commit
|
|
186
|
+
(或 --allow-dirty 显式确认),锁变更与代码变更一样纳入溯源。
|
|
187
|
+
"""
|
|
188
|
+
from starforge_cli import spec_builder
|
|
189
|
+
from starforge_cli.commands.exp import validate_exp_config
|
|
190
|
+
from starforge_cli.recipe_lock import RecipeLockManager
|
|
191
|
+
|
|
192
|
+
recipe = (method or "").strip() or spec_builder.infer_recipe(common.ROOT / exp_path)
|
|
193
|
+
if not recipe:
|
|
194
|
+
cli_ui.emit_error(
|
|
195
|
+
"实验没有声明 recipe",
|
|
196
|
+
hint="加 --method <framework>/<method>;`sf methods` 查看可用值",
|
|
197
|
+
)
|
|
198
|
+
raise typer.Exit(1)
|
|
199
|
+
try:
|
|
200
|
+
result = RecipeLockManager().upgrade(
|
|
201
|
+
common.ROOT / exp_path,
|
|
202
|
+
recipe_name=recipe,
|
|
203
|
+
framework_version=(framework_version or "").strip(),
|
|
204
|
+
validate_config=lambda path, rec: validate_exp_config(
|
|
205
|
+
path, rec, repo_root=common.ROOT
|
|
206
|
+
),
|
|
207
|
+
)
|
|
208
|
+
except ValueError as e: # 含 RecipeLockError
|
|
209
|
+
cli_ui.emit_error("recipe 锁升级失败", items=[str(e)])
|
|
210
|
+
raise typer.Exit(1) from e
|
|
211
|
+
if result.diffs:
|
|
212
|
+
typer.secho(
|
|
213
|
+
f"✓ 已升级 {result.path}({len(result.diffs)} 处变更)",
|
|
214
|
+
fg=typer.colors.GREEN,
|
|
215
|
+
)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def _build_spec_or_exit(exp_path: str, *, method, project, sets, pools, roles,
|
|
219
|
+
init_from, then, observability_url=None, model=None,
|
|
220
|
+
train_data=None, validation_data=None,
|
|
221
|
+
train_dataset=None, validation_dataset=None,
|
|
222
|
+
framework_version=None, image=None, provenance=None,
|
|
223
|
+
validate: bool):
|
|
224
|
+
"""构建强 JobSpec;缺少显式 recipe 时立即退出。"""
|
|
225
|
+
from starforge_core.contract import SpecError
|
|
226
|
+
|
|
227
|
+
from starforge_cli import spec_builder
|
|
228
|
+
|
|
229
|
+
recipe = (method or "").strip() or spec_builder.infer_recipe(common.ROOT / exp_path)
|
|
230
|
+
if not recipe:
|
|
231
|
+
cli_ui.emit_error(
|
|
232
|
+
"实验没有声明 recipe",
|
|
233
|
+
hint="加 --method <framework>/<method>,或用 `sf new` 生成 recipe.lock.json;`sf methods` 查看可用值",
|
|
234
|
+
)
|
|
235
|
+
raise typer.Exit(1)
|
|
236
|
+
try:
|
|
237
|
+
from starforge_cli.plugins_lock import read_plugin_lock
|
|
238
|
+
from starforge_cli.recipe_lock import RecipeLockManager
|
|
239
|
+
|
|
240
|
+
manager = RecipeLockManager()
|
|
241
|
+
exp_dir = common.ROOT / exp_path
|
|
242
|
+
selected = (framework_version or "").strip()
|
|
243
|
+
if selected:
|
|
244
|
+
inspection = manager.inspect(exp_dir, recipe)
|
|
245
|
+
if inspection.framework_version != selected:
|
|
246
|
+
raise ValueError(
|
|
247
|
+
f"锁内 framework version 是 {inspection.framework_version or '∅'},"
|
|
248
|
+
f"与 --framework-version {selected} 不一致;"
|
|
249
|
+
f"请执行 `sf recipe upgrade {exp_path} --framework-version {selected}`"
|
|
250
|
+
" 或提交时加 --upgrade-recipe"
|
|
251
|
+
)
|
|
252
|
+
selected_version = manager.require_current(exp_dir, recipe)
|
|
253
|
+
plugin_uses = read_plugin_lock(exp_dir)
|
|
254
|
+
return spec_builder.build_spec(
|
|
255
|
+
exp_path,
|
|
256
|
+
recipe=recipe,
|
|
257
|
+
framework_version=selected_version,
|
|
258
|
+
project=project or "",
|
|
259
|
+
sets=list(sets or []),
|
|
260
|
+
pools=list(pools or []),
|
|
261
|
+
roles=list(roles or []),
|
|
262
|
+
init_from=init_from or "",
|
|
263
|
+
on_success=list(then or []),
|
|
264
|
+
observability_url=observability_url or "",
|
|
265
|
+
base_model=model or "",
|
|
266
|
+
train_data=train_data or "",
|
|
267
|
+
validation_data=validation_data or "",
|
|
268
|
+
train_dataset=train_dataset or "",
|
|
269
|
+
validation_dataset=validation_dataset or "",
|
|
270
|
+
image=image or "",
|
|
271
|
+
provenance=provenance or packing.git_provenance(common.ROOT, exp_path),
|
|
272
|
+
validate=validate,
|
|
273
|
+
plugin_uses=plugin_uses,
|
|
274
|
+
)
|
|
275
|
+
except (SpecError, ValueError) as e:
|
|
276
|
+
from starforge_cli.recipe_lock import RecipeLockError
|
|
277
|
+
|
|
278
|
+
hint = (
|
|
279
|
+
f"执行 `sf recipe upgrade {exp_path}` 或提交时加 --upgrade-recipe"
|
|
280
|
+
if isinstance(e, RecipeLockError)
|
|
281
|
+
else "用 `sf methods` 查看可用方法与超参"
|
|
282
|
+
)
|
|
283
|
+
cli_ui.emit_error("作业规格校验未通过", items=[str(e)], hint=hint)
|
|
284
|
+
raise typer.Exit(1) from e
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _echo_submit_result(res: dict, label: str = "") -> None:
|
|
288
|
+
"""统一展示提交结果:排队(202,含卡型时段/容量原因)与直接提交两种形态。"""
|
|
289
|
+
gpus = res.get("requested_gpus")
|
|
290
|
+
if res.get("queued"):
|
|
291
|
+
msg = f"⏳ 已排队{label} run {res.get('run_id')}"
|
|
292
|
+
if gpus is not None:
|
|
293
|
+
msg += f" · {gpus} GPU"
|
|
294
|
+
typer.secho(msg, fg=typer.colors.YELLOW)
|
|
295
|
+
if res.get("message"):
|
|
296
|
+
typer.secho(f" {res['message']}", fg=typer.colors.BRIGHT_BLACK)
|
|
297
|
+
_echo_upload_summary(res)
|
|
298
|
+
typer.echo(" 满足条件后自动提交;查看状态:sf job ls")
|
|
299
|
+
_echo_submit_warnings(res)
|
|
300
|
+
return
|
|
301
|
+
msg = f"✓ 已提交{label} 作业 {res.get('job_id')}"
|
|
302
|
+
if gpus is not None:
|
|
303
|
+
msg += f" · {gpus} GPU"
|
|
304
|
+
if res.get("dry_run"):
|
|
305
|
+
msg += " · 预演"
|
|
306
|
+
typer.secho(msg, fg=typer.colors.GREEN)
|
|
307
|
+
_echo_upload_summary(res)
|
|
308
|
+
typer.echo(f" 查看日志:sf job logs {res.get('job_id')}")
|
|
309
|
+
_echo_submit_warnings(res)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _echo_submit_warnings(res: dict) -> None:
|
|
313
|
+
"""服务端下发的 profile 告警:提交受理了,但目标卡型/拓扑与集群实际情况对不上。
|
|
314
|
+
|
|
315
|
+
典型是集群里根本没有该卡型(作业会一直 PENDING)。走 stderr,便于在管道里也醒目。
|
|
316
|
+
"""
|
|
317
|
+
for w in res.get("warnings") or []:
|
|
318
|
+
cli_ui.emit_warning(str(w))
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def _echo_upload_summary(res: dict) -> None:
|
|
322
|
+
"""一行灰字汇报本次上传的文件数 / 体积 / 已略过的非负载文件。"""
|
|
323
|
+
files = res.get("upload_files")
|
|
324
|
+
if files is None:
|
|
325
|
+
return
|
|
326
|
+
parts = [f"上传 {files} 个文件", cli_ui.human_bytes(res.get("upload_bytes") or 0)]
|
|
327
|
+
skipped = res.get("upload_skipped") or 0
|
|
328
|
+
if skipped:
|
|
329
|
+
parts.append(f"清单外略过 {skipped} 个文件")
|
|
330
|
+
typer.secho(" " + " · ".join(parts), fg=typer.colors.BRIGHT_BLACK)
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
# ----------------------------- 训练后闭环(export / eval)-----------------------------
|
|
334
|
+
def _submit_post(action: str, exp_path: str, profile: Optional[str], flags: list[str],
|
|
335
|
+
dry_run: bool, allow_dirty: bool) -> int:
|
|
336
|
+
"""构建并预编译训练后强契约,再通过统一 launcher 提交。"""
|
|
337
|
+
from starforge_core.frameworks import CompileRequest, compile_launch_plan
|
|
338
|
+
from starforge_core.recipes import get_recipe
|
|
339
|
+
|
|
340
|
+
from starforge_cli import spec_builder
|
|
341
|
+
from starforge_cli.recipe_lock import validate_recipe_lock
|
|
342
|
+
|
|
343
|
+
recipe_name = spec_builder.infer_recipe(common.ROOT / exp_path)
|
|
344
|
+
if not recipe_name:
|
|
345
|
+
cli_ui.emit_error(
|
|
346
|
+
"实验没有声明 recipe",
|
|
347
|
+
hint="实验缺少 recipe.lock.json;用 `sf new` 重建或 `sf recipe upgrade` 生成",
|
|
348
|
+
)
|
|
349
|
+
return 1
|
|
350
|
+
recipe = get_recipe(recipe_name)
|
|
351
|
+
try:
|
|
352
|
+
validate_recipe_lock(common.ROOT / exp_path, recipe.name)
|
|
353
|
+
except ValueError as exc:
|
|
354
|
+
cli_ui.emit_error("实验 recipe 锁校验失败", items=[str(exc)])
|
|
355
|
+
return 1
|
|
356
|
+
if not recipe.supports(action):
|
|
357
|
+
cli_ui.emit_error(
|
|
358
|
+
f"recipe {recipe.name} 不支持 {action}",
|
|
359
|
+
hint=f"支持:{', '.join(recipe.lifecycle) or '(无)'}",
|
|
360
|
+
)
|
|
361
|
+
return 1
|
|
362
|
+
prof_name = common.resolve_profile(exp_path, profile)
|
|
363
|
+
# 池的 series 查注册表(h200-2g/b300 这类 profile 名 ≠ series id);
|
|
364
|
+
# dry-run 允许离线,此时退化为 profile 名(不上集群,无一致性风险)。
|
|
365
|
+
series = prof_name
|
|
366
|
+
if not dry_run:
|
|
367
|
+
entry = common.profile_registry().get(prof_name)
|
|
368
|
+
series = str((entry or {}).get("series") or prof_name)
|
|
369
|
+
prov = packing.git_provenance(common.ROOT, exp_path)
|
|
370
|
+
if not dry_run:
|
|
371
|
+
_require_clean_tree(allow_dirty, prov)
|
|
372
|
+
try:
|
|
373
|
+
spec = spec_builder.build_spec(
|
|
374
|
+
exp_path,
|
|
375
|
+
recipe=recipe.name,
|
|
376
|
+
project=common.project_name(),
|
|
377
|
+
pools=[f"lifecycle:{series}:1:{recipe.lifecycle_resources[action]}"],
|
|
378
|
+
provenance=prov,
|
|
379
|
+
operation=action,
|
|
380
|
+
)
|
|
381
|
+
plan = compile_launch_plan(CompileRequest(
|
|
382
|
+
operation=action,
|
|
383
|
+
spec=spec,
|
|
384
|
+
recipe=recipe,
|
|
385
|
+
work_dir=common.ROOT,
|
|
386
|
+
env={"STARFORGE_ENABLED": "0", "OUTPUT_ROOT": "/tmp/starforge-dry-run"},
|
|
387
|
+
action_args=tuple(flags),
|
|
388
|
+
))
|
|
389
|
+
except (ValueError, OSError) as exc:
|
|
390
|
+
cli_ui.emit_error("训练后作业规格校验未通过", items=[str(exc)])
|
|
391
|
+
return 1
|
|
392
|
+
if dry_run:
|
|
393
|
+
typer.echo(" ".join(plan.argv))
|
|
394
|
+
return 0
|
|
395
|
+
gate()
|
|
396
|
+
with cli_ui.submit_progress() as reporter:
|
|
397
|
+
res = api_client.submit_post_via_server(
|
|
398
|
+
action, exp_path, prof_name, flags, common.ROOT, reporter=reporter, spec=spec
|
|
399
|
+
)
|
|
400
|
+
_echo_submit_result(res, label="导出" if action == "export" else "评测")
|
|
401
|
+
return 0
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def export_ckpt(
|
|
405
|
+
exp: str = typer.Argument(..., autocompletion=common.complete_exp, help="实验名或路径"),
|
|
406
|
+
checkpoint: str = typer.Option(..., "--checkpoint", help="artifact registry 中记录的 checkpoint 路径"),
|
|
407
|
+
checkpoint_format: str = typer.Option(
|
|
408
|
+
..., "--checkpoint-format",
|
|
409
|
+
help="nemo-dcp | nemo-megatron | verl-fsdp | verl-megatron | huggingface",
|
|
410
|
+
),
|
|
411
|
+
push_repo: Optional[str] = typer.Option(None, "--push-repo", help="转换后上传到 HF Hub repo(user/name,需 HF_TOKEN)"),
|
|
412
|
+
profile: Optional[str] = common.PROF_OPT,
|
|
413
|
+
allow_dirty: bool = typer.Option(False, "--allow-dirty", help="允许工作区有未提交改动"),
|
|
414
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="只打印将提交的命令,不实际提交"),
|
|
415
|
+
) -> None:
|
|
416
|
+
"""将 checkpoint 转为 HuggingFace 格式(可推 Hub)。"""
|
|
417
|
+
flags = ["--checkpoint", checkpoint, "--checkpoint-format", checkpoint_format]
|
|
418
|
+
if push_repo:
|
|
419
|
+
flags += ["--push-repo", push_repo]
|
|
420
|
+
raise typer.Exit(_submit_post("export", common.resolve_exp(exp), profile, flags, dry_run, allow_dirty))
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def eval_ckpt(
|
|
424
|
+
ctx: typer.Context,
|
|
425
|
+
exp: str = typer.Argument(..., autocompletion=common.complete_exp, help="实验名或路径"),
|
|
426
|
+
run_id: Optional[str] = typer.Option(None, "--run-id", help="VeRL SFT:接收验证样本的训练 run id"),
|
|
427
|
+
model: Optional[str] = typer.Option(None, "--model", help="NeMo-RL 或 VeRL SFT:HF 模型路径/Hub id"),
|
|
428
|
+
eval_config: Optional[str] = typer.Option(None, "--eval-config", help="NeMo-RL:显式评测配置路径"),
|
|
429
|
+
data: Optional[str] = typer.Option(None, "--data", help="verl:显式评测数据路径"),
|
|
430
|
+
step: Optional[int] = typer.Option(None, "--step", min=0, help="VeRL SFT:导出 checkpoint 对应训练步"),
|
|
431
|
+
profile: Optional[str] = common.PROF_OPT,
|
|
432
|
+
allow_dirty: bool = typer.Option(False, "--allow-dirty", help="允许工作区有未提交改动"),
|
|
433
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="只打印将提交的命令,不实际提交"),
|
|
434
|
+
) -> None:
|
|
435
|
+
"""按 recipe 的原生评测入口执行;NeMo-RL 用 --model/--eval-config,verl 用 --data。"""
|
|
436
|
+
flags: list[str] = []
|
|
437
|
+
if run_id:
|
|
438
|
+
flags += ["--run-id", run_id]
|
|
439
|
+
if model:
|
|
440
|
+
flags += ["--model", model]
|
|
441
|
+
if eval_config:
|
|
442
|
+
flags += ["--eval-config", eval_config]
|
|
443
|
+
if data:
|
|
444
|
+
flags += ["--data", data]
|
|
445
|
+
if step is not None:
|
|
446
|
+
flags += ["--step", str(step)]
|
|
447
|
+
extra = list(ctx.args) # `--` 之后透传给 run_eval.py 的覆盖项
|
|
448
|
+
if extra:
|
|
449
|
+
flags += ["--", *extra]
|
|
450
|
+
raise typer.Exit(_submit_post("eval", common.resolve_exp(exp), profile, flags, dry_run, allow_dirty))
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def clean(
|
|
454
|
+
exp: str = typer.Argument(..., autocompletion=common.complete_exp, help="实验名或路径"),
|
|
455
|
+
yes: bool = typer.Option(False, "-y", "--yes", help="跳过确认"),
|
|
456
|
+
) -> None:
|
|
457
|
+
"""清理实验在集群上的 checkpoint 与日志(不可恢复)。"""
|
|
458
|
+
gate()
|
|
459
|
+
exp_path = common.resolve_exp(exp)
|
|
460
|
+
if not yes:
|
|
461
|
+
typer.confirm(
|
|
462
|
+
f"将删除 {exp_path} 在集群上的训练产物,不可恢复。继续?",
|
|
463
|
+
abort=True,
|
|
464
|
+
)
|
|
465
|
+
res = api_client.clean_via_server(exp_path)
|
|
466
|
+
typer.secho(f"✓ 已提交清理 作业 {res.get('job_id')}", fg=typer.colors.GREEN)
|
|
467
|
+
typer.echo(f" 查看进度:sf job logs {res.get('job_id')}")
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""超参 sweep 批量提交:网格展开 → N 个变体逐个走标准提交链路。
|
|
2
|
+
|
|
3
|
+
设计取舍:
|
|
4
|
+
- **不造第二条提交路径**:每个变体就是一次普通 `sf submit`(同一份 spec 构建、
|
|
5
|
+
校验、打包、配额准入、容量排队),服务端零特殊逻辑。sweep 只是「展开 + 循环 +
|
|
6
|
+
分组标识」。
|
|
7
|
+
- 分组复用现成机制:project 来自 starforge.yaml(web 实验分组页按项目聚合)+
|
|
8
|
+
client_meta.sweep_id / sweep_params(服务端落 jobs.sweep_id 索引列,
|
|
9
|
+
一键停止 / 徽标展示用)。
|
|
10
|
+
- 配额不足的变体自动入队(persist_and_enqueue),不会失败——批量提交天然
|
|
11
|
+
被配额与容量闸门限流。
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import itertools
|
|
16
|
+
import time
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
import typer
|
|
20
|
+
|
|
21
|
+
from starforge_cli import api_client, cli_ui, packing
|
|
22
|
+
from starforge_cli.auth import gate
|
|
23
|
+
from starforge_cli.commands import common
|
|
24
|
+
from starforge_cli.commands.exp import _validate_exp
|
|
25
|
+
from starforge_cli.commands.submit import (
|
|
26
|
+
_build_spec_or_exit,
|
|
27
|
+
_dataset_refs_from_config,
|
|
28
|
+
_echo_submit_result,
|
|
29
|
+
_materialize_profile_or_exit,
|
|
30
|
+
_require_clean_tree,
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
#: 变体数上限:防止一个手滑的网格把整个集群队列塞满。
|
|
34
|
+
MAX_VARIANTS = 64
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def parse_grid(exprs: list[str]) -> list[dict[str, str]]:
|
|
38
|
+
"""把 --set-grid "key=v1,v2" 列表展开为变体字典列表(笛卡尔积,顺序稳定)。"""
|
|
39
|
+
axes: list[tuple[str, list[str]]] = []
|
|
40
|
+
for raw in exprs:
|
|
41
|
+
expr = (raw or "").strip()
|
|
42
|
+
if "=" not in expr:
|
|
43
|
+
raise ValueError(f"--set-grid 格式应为 key=v1,v2:{raw!r}")
|
|
44
|
+
key, _, values_raw = expr.partition("=")
|
|
45
|
+
key = key.strip()
|
|
46
|
+
values = [v.strip() for v in values_raw.split(",") if v.strip()]
|
|
47
|
+
if not key or not values:
|
|
48
|
+
raise ValueError(f"--set-grid 缺少键或值:{raw!r}")
|
|
49
|
+
if key in (k for k, _ in axes):
|
|
50
|
+
raise ValueError(f"--set-grid 键重复:{key}")
|
|
51
|
+
axes.append((key, values))
|
|
52
|
+
if not axes:
|
|
53
|
+
return []
|
|
54
|
+
combos = itertools.product(*(values for _, values in axes))
|
|
55
|
+
return [
|
|
56
|
+
{key: value for (key, _), value in zip(axes, combo, strict=True)}
|
|
57
|
+
for combo in combos
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def sweep(
|
|
62
|
+
exp: str = typer.Argument(..., autocompletion=common.complete_exp, help="实验名或路径"),
|
|
63
|
+
set_grid: list[str] = typer.Option(
|
|
64
|
+
..., "--set-grid", "-g", metavar="KEY=V1,V2",
|
|
65
|
+
help="网格轴,可重复;多轴取笛卡尔积。如 -g policy.lr=1e-5,2e-5 -g grpo.kl=0.01,0.05",
|
|
66
|
+
),
|
|
67
|
+
set_: list[str] = typer.Option(
|
|
68
|
+
[], "--set", "-s", metavar="KEY=VALUE", help="所有变体共用的固定覆盖,可重复",
|
|
69
|
+
),
|
|
70
|
+
profile: list[str] = common.PROFILE_EXPR_OPT,
|
|
71
|
+
method: Optional[str] = typer.Option(
|
|
72
|
+
None, "--method", "-m", autocompletion=common.complete_method,
|
|
73
|
+
help="方法标识;不传则读实验 recipe.lock.json",
|
|
74
|
+
),
|
|
75
|
+
allow_dirty: bool = typer.Option(False, "--allow-dirty", help="允许工作区有未提交改动"),
|
|
76
|
+
no_validate: bool = typer.Option(False, "--no-validate", help="跳过提交前校验"),
|
|
77
|
+
dry_run: bool = typer.Option(False, "--dry-run", help="只打印变体列表,不提交"),
|
|
78
|
+
) -> None:
|
|
79
|
+
"""网格展开超参并批量提交(每个变体 = 一次标准提交,配额/排队照常生效)。"""
|
|
80
|
+
try:
|
|
81
|
+
variants = parse_grid(set_grid)
|
|
82
|
+
except ValueError as e:
|
|
83
|
+
cli_ui.fail(str(e))
|
|
84
|
+
if not variants:
|
|
85
|
+
cli_ui.fail("--set-grid 没有产生任何变体")
|
|
86
|
+
if len(variants) > MAX_VARIANTS:
|
|
87
|
+
cli_ui.fail(
|
|
88
|
+
f"网格展开出 {len(variants)} 个变体,超过上限 {MAX_VARIANTS}",
|
|
89
|
+
hint="缩小网格,或分多个 sweep 提交",
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
exp_path = common.resolve_exp(exp)
|
|
93
|
+
exp_short = exp_path.rstrip("/").split("/")[-1]
|
|
94
|
+
sweep_id = f"sweep-{exp_short}-{time.strftime('%Y%m%d-%H%M%S')}"
|
|
95
|
+
|
|
96
|
+
typer.secho(f"sweep {sweep_id}:{len(variants)} 个变体", fg=typer.colors.CYAN, bold=True)
|
|
97
|
+
for i, variant in enumerate(variants):
|
|
98
|
+
typer.echo(f" [{i + 1:>2}] " + " ".join(f"{k}={v}" for k, v in variant.items()))
|
|
99
|
+
if dry_run:
|
|
100
|
+
typer.echo("(--dry-run:未提交)")
|
|
101
|
+
return
|
|
102
|
+
|
|
103
|
+
project = common.project_name()
|
|
104
|
+
gate()
|
|
105
|
+
exprs = [e for e in (profile or []) if e.strip()]
|
|
106
|
+
if not exprs:
|
|
107
|
+
exprs = [common.resolve_profile(exp_path, None)]
|
|
108
|
+
resolved_profile, pools, roles = _materialize_profile_or_exit(exprs)
|
|
109
|
+
prov = packing.git_provenance(common.ROOT, exp_path)
|
|
110
|
+
_require_clean_tree(allow_dirty, prov)
|
|
111
|
+
if not no_validate:
|
|
112
|
+
errors, _ = _validate_exp(exp_path, method or "")
|
|
113
|
+
if errors:
|
|
114
|
+
cli_ui.emit_error(
|
|
115
|
+
f"config 校验未通过({len(errors)} 处)", items=errors,
|
|
116
|
+
hint="修复后重试,或加 --no-validate 跳过",
|
|
117
|
+
)
|
|
118
|
+
raise typer.Exit(1)
|
|
119
|
+
cfg_train_ds, cfg_val_ds = _dataset_refs_from_config(exp_path)
|
|
120
|
+
|
|
121
|
+
fixed = list(set_ or [])
|
|
122
|
+
submitted = 0
|
|
123
|
+
queued = 0
|
|
124
|
+
for i, variant in enumerate(variants):
|
|
125
|
+
variant_sets = fixed + [f"{k}={v}" for k, v in variant.items()]
|
|
126
|
+
typer.secho(
|
|
127
|
+
f"\n[{i + 1}/{len(variants)}] " + " ".join(f"{k}={v}" for k, v in variant.items()),
|
|
128
|
+
fg=typer.colors.CYAN,
|
|
129
|
+
)
|
|
130
|
+
spec = _build_spec_or_exit(
|
|
131
|
+
exp_path, method=method, project=project, sets=variant_sets,
|
|
132
|
+
pools=pools, roles=roles, init_from=None, then=[],
|
|
133
|
+
train_dataset=cfg_train_ds, validation_dataset=cfg_val_ds,
|
|
134
|
+
provenance=prov, validate=not no_validate,
|
|
135
|
+
)
|
|
136
|
+
with cli_ui.submit_progress() as reporter:
|
|
137
|
+
res = api_client.submit_via_server(
|
|
138
|
+
exp_path, resolved_profile, common.ROOT,
|
|
139
|
+
project=project, reporter=reporter, spec=spec,
|
|
140
|
+
extra_meta={"sweep_id": sweep_id, "sweep_params": variant},
|
|
141
|
+
)
|
|
142
|
+
_echo_submit_result(res)
|
|
143
|
+
if res.get("queued"):
|
|
144
|
+
queued += 1
|
|
145
|
+
else:
|
|
146
|
+
submitted += 1
|
|
147
|
+
|
|
148
|
+
typer.echo("")
|
|
149
|
+
typer.secho(
|
|
150
|
+
f"sweep {sweep_id} 完成:直接提交 {submitted} 个,入队 {queued} 个。",
|
|
151
|
+
fg=typer.colors.GREEN, bold=True,
|
|
152
|
+
)
|
|
153
|
+
typer.echo(f" 查看进度:sf job ls --all(项目 {project})")
|
|
154
|
+
typer.echo(f" 一键停止:sf job stop-sweep {sweep_id}")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""实验 config.yaml 解析与静态校验 —— 实现已迁至 starforge-core。
|
|
2
|
+
|
|
3
|
+
为什么迁走:`sf validate` 在本地校验、console 在提交时校验、集群侧 launcher 在
|
|
4
|
+
启动时解析,三方用的必须是同一套 defaults 继承与 `_override_` 语义。放在客户端
|
|
5
|
+
包里意味着 console 要依赖客户端才能校验配置 —— 方向是反的。
|
|
6
|
+
|
|
7
|
+
此处保留同名转发,既有 `from starforge_cli.config_resolve import ...` 无需改动。
|
|
8
|
+
新代码请直接用 `starforge_core.config_resolve`。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from starforge_core.config_resolve import * # noqa: F401,F403
|
|
13
|
+
from starforge_core.config_resolve import ( # noqa: F401
|
|
14
|
+
deep_merge,
|
|
15
|
+
load_yaml,
|
|
16
|
+
resolve,
|
|
17
|
+
)
|