@tuned-tensor/cli 0.25.0 → 0.26.0
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.
- package/README.md +88 -13
- package/dist/{chunk-UCJCWJ6F.js → chunk-4BHDPRYD.js} +17 -13
- package/dist/chunk-4BHDPRYD.js.map +1 -0
- package/dist/{chunk-7RJGLRMO.js → chunk-FLRRDP3M.js} +1985 -576
- package/dist/chunk-FLRRDP3M.js.map +1 -0
- package/dist/index.js +2136 -1852
- package/dist/index.js.map +1 -1
- package/dist/local-runtime.js +47 -3
- package/dist/local-runtime.js.map +1 -1
- package/dist/{prefetch-GP3OPUUL.js → prefetch-DH365HNN.js} +2 -2
- package/docs/local-runtime/architecture.md +51 -0
- package/docs/local-runtime/aws-gpu.md +15 -0
- package/docs/local-runtime/background-runs.md +77 -0
- package/docs/local-runtime/decision.md +5 -0
- package/docs/local-runtime/serving.md +11 -2
- package/docs/spec-workflow.md +72 -2
- package/package.json +3 -2
- package/training/adapter/src/evaluate.py +127 -34
- package/training/adapter/src/model_contract.py +13 -0
- package/training/adapter/src/serve.py +30 -0
- package/training/adapter/src/train.py +5 -1
- package/dist/chunk-7RJGLRMO.js.map +0 -1
- package/dist/chunk-UCJCWJ6F.js.map +0 -1
- /package/dist/{prefetch-GP3OPUUL.js.map → prefetch-DH365HNN.js.map} +0 -0
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
safeName,
|
|
7
7
|
verifyLocalBaseModel,
|
|
8
8
|
verifyModelPrefetchCacheReport
|
|
9
|
-
} from "./chunk-
|
|
9
|
+
} from "./chunk-4BHDPRYD.js";
|
|
10
10
|
export {
|
|
11
11
|
buildModelPrefetchPayload,
|
|
12
12
|
buildModelSourcePrefetchPayload,
|
|
@@ -15,4 +15,4 @@ export {
|
|
|
15
15
|
verifyLocalBaseModel,
|
|
16
16
|
verifyModelPrefetchCacheReport
|
|
17
17
|
};
|
|
18
|
-
//# sourceMappingURL=prefetch-
|
|
18
|
+
//# sourceMappingURL=prefetch-DH365HNN.js.map
|
|
@@ -121,6 +121,57 @@ The Python evaluator receives only opaque IDs and prompts. Node joins its
|
|
|
121
121
|
predictions to trusted references before scoring. This avoids accidental label
|
|
122
122
|
leakage across the process boundary.
|
|
123
123
|
|
|
124
|
+
Generation uses left-padded batches of four prompts by default. Set
|
|
125
|
+
`evaluation.inference.batchSize` (1–128) in `tunedtensor.json` to tune memory
|
|
126
|
+
use. A CUDA allocation failure halves the batch and retries the unfinished
|
|
127
|
+
examples; failure at one example is reported instead of retrying forever.
|
|
128
|
+
Batch latency is the time each request waits for its whole batch, including
|
|
129
|
+
decoding; it is not latency divided by batch size. Batched greedy generation
|
|
130
|
+
can differ from single-prompt generation because of floating-point arithmetic.
|
|
131
|
+
|
|
132
|
+
On integrated CUDA devices such as GB10, training and evaluation first open
|
|
133
|
+
the weights on CPU, then transfer them to CUDA. This avoids retaining host
|
|
134
|
+
checkpoint copies alongside the full GPU allocation in shared system memory.
|
|
135
|
+
Generation and training still execute on CUDA with the same configured dtype.
|
|
136
|
+
|
|
137
|
+
Ordinary run output shows loading/generation, completed examples, throughput,
|
|
138
|
+
ETA and a heartbeat every 30 seconds. It warns if the estimated remaining work
|
|
139
|
+
exceeds `evaluation.timeoutMs`; that limit remains a hard wall-clock deadline
|
|
140
|
+
(30 minutes by default), including model loading. An ETA is an estimate, not
|
|
141
|
+
a deadline extension. `--verbose` additionally streams the raw process logs.
|
|
142
|
+
|
|
143
|
+
Each completed batch atomically saves a private prediction checkpoint. An
|
|
144
|
+
incomplete checkpoint is never scored as a completed evaluation. With the
|
|
145
|
+
default `evaluation.baselineCache: true`, rerunning the same baseline inputs
|
|
146
|
+
in a new run automatically continues the checkpoint in the local store's
|
|
147
|
+
`cache/baseline-evals/` directory. Changing only the timeout preserves reuse.
|
|
148
|
+
Changed prompts, model revision/fingerprint, generation settings, batch size
|
|
149
|
+
or package version invalidate it. Local base-model paths without a verified
|
|
150
|
+
fingerprint do not resume. Adapter contents are hashed before candidate
|
|
151
|
+
checkpoint reuse, so retraining at the same path cannot reuse old answers.
|
|
152
|
+
Per-run checkpoints live in `inference-checkpoints/` in the run directory;
|
|
153
|
+
retrying the same saved run request reuses them. A new training run has a new
|
|
154
|
+
candidate identity. Completed baseline reports retain their existing cache.
|
|
155
|
+
Checkpoints contain generated text; remove them when no longer needed.
|
|
156
|
+
|
|
157
|
+
For a smoke run, explicitly set a small deterministic sample, for example:
|
|
158
|
+
|
|
159
|
+
```json
|
|
160
|
+
"evaluation": {
|
|
161
|
+
"maxExamples": 16,
|
|
162
|
+
"sampleSeed": 42,
|
|
163
|
+
"timeoutMs": 3600000,
|
|
164
|
+
"inference": { "batchSize": 4 }
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
This limits evaluation, not training. Baseline and candidate use the same
|
|
169
|
+
sample. Remove `maxExamples` for full held-out evaluation and choose a timeout
|
|
170
|
+
appropriate to the measured throughput. TT never silently reduces the
|
|
171
|
+
evaluation set to meet a deadline. A smoke sample is pipeline validation,
|
|
172
|
+
not evidence of overall model quality. Exact text matching is also not a
|
|
173
|
+
coding correctness test; the scoring contract is unchanged here.
|
|
174
|
+
|
|
124
175
|
## Storage and integrity
|
|
125
176
|
|
|
126
177
|
Canonical JSON and JSONL files are the state store; there is no database or
|
|
@@ -169,6 +169,13 @@ evaluation (`evaluation.inference.device: "cpu"`) stays on the laptop.
|
|
|
169
169
|
Remove `gpu` to execute everything locally. `--dry-run` or `dryRun: true`
|
|
170
170
|
does not connect to AWS or copy files.
|
|
171
171
|
|
|
172
|
+
To keep using the terminal or the TT agent during a long run, start it in the
|
|
173
|
+
background instead: `tt pipeline start --spec tunedtensor.json --reason "..."`,
|
|
174
|
+
or approve a background run the agent proposes after the dry-run. Check it with
|
|
175
|
+
`tt pipeline status <id>` and stop it with `tt pipeline cancel <id> --reason "..."`.
|
|
176
|
+
The laptop still orchestrates the run, so keep it awake. See
|
|
177
|
+
[background runs](background-runs.md).
|
|
178
|
+
|
|
172
179
|
## 5. Inspect results and finish
|
|
173
180
|
|
|
174
181
|
Wait for a successful completion message. Adapter runs return a run ID and a
|
|
@@ -228,6 +235,14 @@ Foundation pretraining can resume from returned checkpoints using its existing
|
|
|
228
235
|
so checkpoint identities still match. Adapter training keeps its existing
|
|
229
236
|
restart behavior.
|
|
230
237
|
|
|
238
|
+
Adapter evaluation runs in configurable batches (`evaluation.inference.batchSize`,
|
|
239
|
+
default 4), reducing the batch after a CUDA out-of-memory error. Completed
|
|
240
|
+
prediction checkpoints return with outputs on success, cancellation or timeout
|
|
241
|
+
while SSH remains reachable. Matching baseline inputs reuse the returned
|
|
242
|
+
checkpoint on the next run when `evaluation.baselineCache` is enabled. As with
|
|
243
|
+
foundation checkpoints, nothing is continuously copied to the laptop during
|
|
244
|
+
generation. See [evaluation and smoke runs](architecture.md#evaluation).
|
|
245
|
+
|
|
231
246
|
You provide capacity and pay AWS directly. TT does not create, start, stop,
|
|
232
247
|
terminate or resize instances, request quotas, choose fallback instances or
|
|
233
248
|
fall back to another account. An AWS profile alone cannot create capacity.
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# Background runs
|
|
2
|
+
|
|
3
|
+
A background run is a real `tt pipeline run` that keeps going after the shell or
|
|
4
|
+
conversation that started it moves on. Start one from the TT agent (after an
|
|
5
|
+
approved dry-run) or directly:
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
tt pipeline run --spec tunedtensor.json --dry-run
|
|
9
|
+
tt pipeline start --spec tunedtensor.json --reason "Check the new negation examples"
|
|
10
|
+
tt pipeline list
|
|
11
|
+
tt pipeline status <id>
|
|
12
|
+
tt pipeline logs <id>
|
|
13
|
+
tt pipeline cancel <id> --reason "Started on the wrong dataset"
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
IDs accept a unique prefix. `--json` prints machine-readable output for every
|
|
17
|
+
command.
|
|
18
|
+
|
|
19
|
+
## From the agent
|
|
20
|
+
|
|
21
|
+
The agent can prepare, but never start, a run. The flow is:
|
|
22
|
+
|
|
23
|
+
1. `prepare_pipeline_run`: you approve a dry-run preview of the saved spec.
|
|
24
|
+
2. `prepare_background_run`: the agent proposes a real run of **the same spec
|
|
25
|
+
bytes** with a short reason. The proposal shows the plan, the target (laptop
|
|
26
|
+
or AWS instance), active runs, the parallel-run limit and current CPU, memory
|
|
27
|
+
and GPU usage. It is refused if no matching dry-run completed in the
|
|
28
|
+
conversation, if the spec changed, or if the parallel limit is reached.
|
|
29
|
+
3. On approval, tt revalidates the spec, writes the run record and starts a
|
|
30
|
+
detached supervisor. The conversation continues immediately.
|
|
31
|
+
|
|
32
|
+
While a run is active, the agent answers questions with read-only tools:
|
|
33
|
+
`list_background_runs`, `get_background_run` (status, metrics and the recent
|
|
34
|
+
log) and `get_system_usage` (CPU, memory, per-GPU load and active runs). It can
|
|
35
|
+
only stop a run through `prepare_cancel_background_run`, which you approve. When
|
|
36
|
+
a run started from a conversation finishes, the agent is told its outcome on your
|
|
37
|
+
next message; it cannot poll in the background.
|
|
38
|
+
|
|
39
|
+
## Isolation
|
|
40
|
+
|
|
41
|
+
The run executes the sealed spec captured at approval, not the live
|
|
42
|
+
`tunedtensor.json`, so later spec edits in the conversation cannot change it.
|
|
43
|
+
The snapshot sits beside the spec while the run is active (as
|
|
44
|
+
`.tunedtensor.json.tt-agent-<id>.json`), so relative paths, output directories
|
|
45
|
+
and `tunedtensor.lock.json` behave exactly as for a direct run. It is removed
|
|
46
|
+
when the run ends. The supervisor runs in its own process group, so closing tt
|
|
47
|
+
or the terminal does not stop the run; use `tt pipeline cancel`.
|
|
48
|
+
|
|
49
|
+
## Parallel runs
|
|
50
|
+
|
|
51
|
+
By default one background run may be active at a time. Set
|
|
52
|
+
`TT_MAX_PARALLEL_RUNS` (1–8) to allow more. Runs that share a target (the
|
|
53
|
+
laptop GPU, or the same AWS instance) share its GPU memory: check
|
|
54
|
+
`get_system_usage`, `nvidia-smi` or `tt doctor` first. The proposal warns when a
|
|
55
|
+
new run would share a target with an active one. Laptop usage figures do not
|
|
56
|
+
describe a remote AWS GPU.
|
|
57
|
+
|
|
58
|
+
## Audit record
|
|
59
|
+
|
|
60
|
+
Each run keeps a private directory under `~/.tuned-tensor/background-runs/<id>/`:
|
|
61
|
+
|
|
62
|
+
| File | Contents |
|
|
63
|
+
| --- | --- |
|
|
64
|
+
| `record.json` | Reason; requester (OS user, host, `agent` or `cli`, conversation, action and dry-run action IDs); approval time; choices (engine, spec path and SHA-256, config, steps, target, base model); resources at launch; process IDs; cancellation reason and requester; outcome (exit code, local run ID, model ID, report path, evaluation metrics, error). |
|
|
65
|
+
| `spec.json` | The sealed spec the run consumed. |
|
|
66
|
+
| `events.jsonl` | Hash-chained lifecycle events: requested, started, cancel requested, finished or lost. `tt pipeline status` verifies the chain. |
|
|
67
|
+
| `output.log` | Progress and process logs. |
|
|
68
|
+
| `result.json` | The pipeline's final JSON result. |
|
|
69
|
+
|
|
70
|
+
Adapter runs also keep their usual record in the local run store; use
|
|
71
|
+
`tt runs audit <run-id>`, `tt runs report <run-id>` and
|
|
72
|
+
`tt runs compare <a> <b>` with the run ID from the outcome. The hash chain makes
|
|
73
|
+
casual edits evident; it is not a signature.
|
|
74
|
+
|
|
75
|
+
If the supervisor disappears (crash, reboot), the run is reported as `lost` the
|
|
76
|
+
next time it is read. Inspect its log and the local run store before starting
|
|
77
|
+
again; a foundation run can be resumed with `tt pipeline run --resume`.
|
|
@@ -12,6 +12,11 @@ TT fine-tunes [Laya](https://huggingface.co/convaiinnovations/laya)
|
|
|
12
12
|
encoder with a typed decision head. Every stage uses Laya revision
|
|
13
13
|
`55cf4c4ebb4ebe31b2550e8bdf3bd21b99753851`, the reviewed commit that Laya 0.3.22
|
|
14
14
|
pins. Training and evaluation run on CPU, Apple MPS or CUDA; no GPU is required.
|
|
15
|
+
The startup panel and `tt hardware` show decision capabilities even without a
|
|
16
|
+
spec. A full hardware check probes the decision runtime separately from the
|
|
17
|
+
adapter runtime. Native Windows training is not yet end-to-end certified;
|
|
18
|
+
Intel macOS and Windows ARM64 have no compatible packaged PyTorch wheel.
|
|
19
|
+
See the [platform guidance](../../README.md) for setup and memory caveats.
|
|
15
20
|
|
|
16
21
|
## Spec
|
|
17
22
|
|
|
@@ -28,8 +28,12 @@ not mutate the training runtime.
|
|
|
28
28
|
Keep each model paired with its original training tokenizer. Context defaults to
|
|
29
29
|
the checkpoint limit. Text completions and chat are supported; foundation tool-call
|
|
30
30
|
parsing and image inputs are not supported.
|
|
31
|
-
- Qwen3.5-2B base and a
|
|
32
|
-
|
|
31
|
+
- Qwen3.5-2B base and a rank-16 `all-linear` adapter trained for one epoch on
|
|
32
|
+
10,458 banking-intent examples have been exercised on GB10. On a fixed
|
|
33
|
+
100-example test sample spanning all 77 labels, serving scored 53/100 for the
|
|
34
|
+
base and 93/100 for the adapter; all adapter predictions matched offline
|
|
35
|
+
evaluation. This checks adapter application, not general coding ability.
|
|
36
|
+
The registry's Nemotron and Muse Glimmer architectures have
|
|
33
37
|
upstream support, but their exact adapters/hardware combinations are **not
|
|
34
38
|
certified by that Qwen smoke test**. No 70B model was tested here.
|
|
35
39
|
|
|
@@ -48,6 +52,11 @@ never enables runtime adapter upload/loading. There is no merge/export step
|
|
|
48
52
|
and no modification of the verified weights. `tt serve base` starts base-only.
|
|
49
53
|
`active` still fails when no adapter is activated.
|
|
50
54
|
|
|
55
|
+
Qwen text-model adapters use a temporary copy with layer names mapped to vLLM's
|
|
56
|
+
conditional-model layout. Tensor values, adapter settings and the verified
|
|
57
|
+
artifact stay unchanged. This prevents vLLM from accepting an adapter whose
|
|
58
|
+
layer names do not match the loaded model and silently serving base behavior.
|
|
59
|
+
|
|
51
60
|
Artifact integrity, certified base revisions, and stored behavior-prompt
|
|
52
61
|
fingerprints retain their verification. A stored adapter's behavior prompt is
|
|
53
62
|
applied to both model IDs for comparison. `tt serve base` without explicit
|
package/docs/spec-workflow.md
CHANGED
|
@@ -24,11 +24,17 @@ Inside the TT shell, in your project directory:
|
|
|
24
24
|
|
|
25
25
|
`/spec` shows a readable overview: name, engine, base model and example count,
|
|
26
26
|
then highlighted sections for behavior (system prompt, guidelines, constraints),
|
|
27
|
-
examples, training settings, evaluation, runtime and pipeline, followed by
|
|
27
|
+
the `dataset_prebuilt` data source when set, examples, training settings, evaluation, runtime and pipeline, followed by
|
|
28
28
|
validation results. `/spec <section>` shows one section in full; sections are
|
|
29
|
-
`identity`, `behavior`, `examples`, `training`, `evaluation`, `runtime` and
|
|
29
|
+
`identity`, `behavior`, `dataset`, `examples`, `training`, `evaluation`, `runtime` and
|
|
30
30
|
`pipeline`. `/spec show` prints the complete file with JSON highlighting,
|
|
31
31
|
validation results and SHA-256.
|
|
32
|
+
|
|
33
|
+
For adapter specs, `dataset_prebuilt` replaces inline examples as training data;
|
|
34
|
+
the examples section labels this explicitly. Foundation and decision specs reject
|
|
35
|
+
`dataset_prebuilt`; remove it to validate the spec. Foundation corpus settings
|
|
36
|
+
are shown under `/spec training`.
|
|
37
|
+
|
|
32
38
|
`/spec diff` shows pending spec edits in the current conversation; otherwise it
|
|
33
39
|
shows the latest approved edit, or external changes since that revision.
|
|
34
40
|
`/spec history` lists up to 50 recent revision records. These commands require
|
|
@@ -163,3 +169,67 @@ reproducibility; these are outputs, not additional user-authored configuration.
|
|
|
163
169
|
Agent `/approve` for a pipeline still runs a preview only. Real training uses
|
|
164
170
|
the direct command above. The spec is the behavior recipe; approval of a spec
|
|
165
171
|
edit is not approval to launch training.
|
|
172
|
+
|
|
173
|
+
## Splitting an unsplit dataset
|
|
174
|
+
|
|
175
|
+
Keep the reusable master dataset as one chat JSONL file. For a downloaded
|
|
176
|
+
Hugging Face chat dataset, `inspect_local_dataset` returns a pinned `split_source`
|
|
177
|
+
(`hf://datasets/org/repo@<commit>/data.jsonl`). The inspection and split tools
|
|
178
|
+
accept that reference directly, verifying the recorded checksum without copying
|
|
179
|
+
or downloading the master again. Outputs still go into a new workspace directory.
|
|
180
|
+
The CLI accepts the same reference in place of `data.jsonl` below.
|
|
181
|
+
|
|
182
|
+
Ask the agent:
|
|
183
|
+
|
|
184
|
+
> Split data.jsonl into 80% training and 20% validation, using seed 123.
|
|
185
|
+
> Balance metadata.category, save to split-123, and use the files in my spec.
|
|
186
|
+
|
|
187
|
+
The agent validates the file, previews counts, and asks for approval to create
|
|
188
|
+
new split files. It then reads the spec and proposes a separate
|
|
189
|
+
`dataset_prebuilt` edit using the recorded output paths. Training remains a
|
|
190
|
+
separate step: the agent can preview a dry-run; actual training uses
|
|
191
|
+
`tt pipeline run --spec tunedtensor.json`.
|
|
192
|
+
|
|
193
|
+
The same operation is available without an agent or account:
|
|
194
|
+
|
|
195
|
+
```sh
|
|
196
|
+
tt datasets inspect data.jsonl
|
|
197
|
+
tt datasets split data.jsonl --output split-123 \
|
|
198
|
+
--train 0.8 --validation 0.2 --test 0 --seed 123 \
|
|
199
|
+
--stratify-by metadata.category --dry-run
|
|
200
|
+
# Remove --dry-run to create the reviewed files.
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Defaults are 80/10/10 with seed `42`. `--method ordered` assigns groups by source
|
|
204
|
+
order instead of seeded random membership. Records retain their source order
|
|
205
|
+
within each output; the seed controls membership, not training batch order.
|
|
206
|
+
`--group-by group_id` keeps related examples together; without it, normalized
|
|
207
|
+
user prompts define groups. Repeated prompts cannot cross splits, including
|
|
208
|
+
when using a custom grouping field. Groups containing repeated prompts stay in
|
|
209
|
+
training: TT requires unique prompts within each evaluation split. The preview
|
|
210
|
+
and manifest report these rows as `training_only_rows`. Ratios are approximate at group boundaries;
|
|
211
|
+
requested nonzero splits must contain examples. `--stratify-by` accepts a scalar
|
|
212
|
+
field (including dotted metadata paths), and each group must belong to one
|
|
213
|
+
stratum. This is not a near-duplicate detector or a time-series split engine.
|
|
214
|
+
|
|
215
|
+
Inputs must use an optional system message, one user message, and one assistant
|
|
216
|
+
answer per row. Metadata is preserved. During adapter training, rows without a
|
|
217
|
+
system message receive the current spec’s compiled system prompt, guidelines and
|
|
218
|
+
constraints in the run artifact. An explicit system message must match those
|
|
219
|
+
instructions; it is never silently replaced. Master and split files stay unchanged. Paths must be inside the current
|
|
220
|
+
workspace without symlinks; the output directory must be new, and its parent
|
|
221
|
+
must exist. The source and spec remain unchanged by the splitter. A manifest
|
|
222
|
+
records the source checksum, options, counts, and relative `dataset_prebuilt`
|
|
223
|
+
paths. A prepared agent action refuses a source changed since review.
|
|
224
|
+
|
|
225
|
+
Source split labels are metadata only. Random/ordered splitting does not enforce
|
|
226
|
+
original benchmark holdouts; preserve those holdouts separately when reporting
|
|
227
|
+
results against the original benchmark. Never shuffle a published test set into
|
|
228
|
+
training and still describe the result as that benchmark's evaluation.
|
|
229
|
+
|
|
230
|
+
Inspection and splitting stream source records, but the existing adapter training
|
|
231
|
+
loader still materializes complete split files and compiled JSONL in memory.
|
|
232
|
+
Splitting a multi-gigabyte master does not remove that training-loader limit.
|
|
233
|
+
Do not treat a successful split preview as proof that an arbitrarily large split
|
|
234
|
+
can train; streaming training ingestion or bounded dataset selection is separate
|
|
235
|
+
work. Use `tt validate` on the saved spec to validate the actual training inputs.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tuned-tensor/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Local TT agent and CUDA fine-tuning CLI for Tuned Tensor workflows",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
},
|
|
9
9
|
"type": "module",
|
|
10
10
|
"bin": {
|
|
11
|
-
"tt": "dist/index.js"
|
|
11
|
+
"tt": "dist/index.js",
|
|
12
|
+
"TT": "dist/index.js"
|
|
12
13
|
},
|
|
13
14
|
"scripts": {
|
|
14
15
|
"build": "tsup",
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
import argparse
|
|
4
|
+
import hashlib
|
|
4
5
|
import json
|
|
5
6
|
import os
|
|
6
7
|
import tarfile
|
|
7
8
|
import time
|
|
8
9
|
from pathlib import Path
|
|
9
|
-
from tempfile import TemporaryDirectory
|
|
10
|
+
from tempfile import NamedTemporaryFile, TemporaryDirectory
|
|
10
11
|
from typing import Any
|
|
11
12
|
|
|
12
13
|
from model_contract import (
|
|
@@ -15,6 +16,7 @@ from model_contract import (
|
|
|
15
16
|
assert_certified_base_model_revision,
|
|
16
17
|
assert_certified_model_config,
|
|
17
18
|
chat_template_kwargs,
|
|
19
|
+
cuda_model_load_device_map,
|
|
18
20
|
uncertified_load_kwargs,
|
|
19
21
|
)
|
|
20
22
|
|
|
@@ -186,6 +188,8 @@ def load_text_model(
|
|
|
186
188
|
raise ValueError(f"{base_model} tokenizer has no EOS token")
|
|
187
189
|
if tokenizer.pad_token_id is None:
|
|
188
190
|
tokenizer.pad_token = tokenizer.eos_token
|
|
191
|
+
# Decoder-only generation must end every padded prompt on a real token.
|
|
192
|
+
tokenizer.padding_side = "left"
|
|
189
193
|
|
|
190
194
|
dtype = None
|
|
191
195
|
if device == "cuda":
|
|
@@ -195,7 +199,7 @@ def load_text_model(
|
|
|
195
199
|
if dtype is not None:
|
|
196
200
|
model_kwargs["dtype"] = dtype
|
|
197
201
|
if device == "cuda":
|
|
198
|
-
model_kwargs["device_map"] =
|
|
202
|
+
model_kwargs["device_map"] = cuda_model_load_device_map(torch)
|
|
199
203
|
|
|
200
204
|
model_class = (
|
|
201
205
|
AutoModelForImageTextToText
|
|
@@ -206,7 +210,7 @@ def load_text_model(
|
|
|
206
210
|
assert_certified_model_config(model.config, expected_model_id=base_model)
|
|
207
211
|
if adapter_path:
|
|
208
212
|
model = PeftModel.from_pretrained(model, adapter_path)
|
|
209
|
-
if device != "cuda":
|
|
213
|
+
if device != "cuda" or model_kwargs["device_map"] is None:
|
|
210
214
|
model = model.to(device)
|
|
211
215
|
model.eval()
|
|
212
216
|
return model, tokenizer, device
|
|
@@ -223,19 +227,19 @@ def sampling_kwargs(generation: dict[str, Any]) -> dict[str, Any]:
|
|
|
223
227
|
}
|
|
224
228
|
|
|
225
229
|
|
|
226
|
-
def
|
|
230
|
+
def generate_text_batch(
|
|
227
231
|
model: Any,
|
|
228
232
|
tokenizer: Any,
|
|
229
233
|
system: str,
|
|
230
|
-
|
|
234
|
+
examples: list[dict[str, Any]],
|
|
231
235
|
generation: dict[str, Any],
|
|
232
236
|
base_model: str = CERTIFIED_BASE_MODEL,
|
|
233
|
-
) -> dict[str, Any]:
|
|
234
|
-
if example.get("input_assets"):
|
|
237
|
+
) -> list[dict[str, Any]]:
|
|
238
|
+
if any(example.get("input_assets") for example in examples):
|
|
235
239
|
raise ValueError("The bundled SFT evaluator is text-only and does not accept input_assets")
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
inputs = tokenizer(formatted, return_tensors="pt")
|
|
240
|
+
formatted = [format_prompt(tokenizer, system, str(example["input"]), base_model)
|
|
241
|
+
for example in examples]
|
|
242
|
+
inputs = tokenizer(formatted, return_tensors="pt", padding=True, add_special_tokens=False)
|
|
239
243
|
target_device = next(model.parameters()).device
|
|
240
244
|
inputs = {key: value.to(target_device) for key, value in inputs.items()}
|
|
241
245
|
started = time.perf_counter()
|
|
@@ -243,24 +247,126 @@ def generate_text_one(
|
|
|
243
247
|
generated = model.generate(
|
|
244
248
|
**inputs,
|
|
245
249
|
max_new_tokens=int(generation.get("max_new_tokens", 256)),
|
|
246
|
-
pad_token_id=tokenizer.
|
|
250
|
+
pad_token_id=tokenizer.pad_token_id,
|
|
247
251
|
**sampling_kwargs(generation),
|
|
248
252
|
)
|
|
249
|
-
latency_ms = max(0, round((time.perf_counter() - started) * 1000))
|
|
250
253
|
input_length = int(inputs["input_ids"].shape[-1])
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
+
# Slice at the padded width, not each prompt's unpadded length.
|
|
255
|
+
actuals = [tokenizer.decode(row[input_length:], skip_special_tokens=True).strip()
|
|
256
|
+
for row in generated]
|
|
257
|
+
# Decoding synchronizes CUDA before measuring wall latency.
|
|
258
|
+
latency_ms = max(0, round((time.perf_counter() - started) * 1000))
|
|
259
|
+
return [{
|
|
254
260
|
"id": str(example["id"]),
|
|
255
261
|
"actual": actual,
|
|
262
|
+
# Each request waits for the whole batch; this is batch wall latency.
|
|
256
263
|
"latency_ms": latency_ms,
|
|
257
|
-
}
|
|
264
|
+
} for example, actual in zip(examples, actuals, strict=True)]
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def write_json_atomic(path: Path, value: dict[str, Any]) -> None:
|
|
268
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
269
|
+
temporary: str | None = None
|
|
270
|
+
try:
|
|
271
|
+
with NamedTemporaryFile(mode="w", encoding="utf-8", dir=path.parent,
|
|
272
|
+
prefix=f".{path.name}.", delete=False) as handle:
|
|
273
|
+
temporary = handle.name
|
|
274
|
+
json.dump(value, handle)
|
|
275
|
+
handle.flush()
|
|
276
|
+
os.fsync(handle.fileno())
|
|
277
|
+
os.replace(temporary, path)
|
|
278
|
+
finally:
|
|
279
|
+
if temporary and os.path.exists(temporary):
|
|
280
|
+
os.unlink(temporary)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def checkpoint_identity(payload: dict[str, Any], adapter_path: str | None) -> str:
|
|
284
|
+
digest = hashlib.sha256(str(payload.get("checkpoint_key", "")).encode())
|
|
285
|
+
# A retrained/replaced adapter at the same path must never reuse predictions.
|
|
286
|
+
if adapter_path:
|
|
287
|
+
root = Path(adapter_path)
|
|
288
|
+
for path in sorted(root.rglob("*")):
|
|
289
|
+
if path.is_file():
|
|
290
|
+
digest.update(str(path.relative_to(root)).encode())
|
|
291
|
+
with path.open("rb") as handle:
|
|
292
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
293
|
+
digest.update(chunk)
|
|
294
|
+
return digest.hexdigest()
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def read_checkpoint(path: Path, identity: str, examples: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
298
|
+
try:
|
|
299
|
+
value = load_json(path)
|
|
300
|
+
results = value.get("results")
|
|
301
|
+
if value.get("identity") != identity or not isinstance(results, list) or len(results) > len(examples):
|
|
302
|
+
return []
|
|
303
|
+
for example, result in zip(examples, results):
|
|
304
|
+
if (not isinstance(result, dict) or result.get("id") != str(example["id"])
|
|
305
|
+
or not isinstance(result.get("actual"), str)
|
|
306
|
+
or type(result.get("latency_ms")) is not int or result["latency_ms"] < 0):
|
|
307
|
+
return []
|
|
308
|
+
return results
|
|
309
|
+
except (OSError, ValueError):
|
|
310
|
+
# A missing, interrupted or incompatible checkpoint is a fresh evaluation.
|
|
311
|
+
return []
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def emit_progress(phase: str, completed: int, total: int, batch_size: int,
|
|
315
|
+
elapsed: float = 0, eta: float | None = None) -> None:
|
|
316
|
+
print("@@tt-evaluation " + json.dumps({
|
|
317
|
+
"phase": phase, "completed": completed, "total": total,
|
|
318
|
+
"batch_size": batch_size, "elapsed_seconds": round(elapsed, 2),
|
|
319
|
+
"eta_seconds": round(eta, 2) if eta is not None else None,
|
|
320
|
+
}), flush=True)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def evaluate_batches(payload: dict[str, Any], adapter_path: str | None,
|
|
324
|
+
checkpoint: Path, model_loader: str) -> list[dict[str, Any]]:
|
|
325
|
+
examples = payload.get("examples", [])
|
|
326
|
+
batch_size = payload.get("batch_size", 4)
|
|
327
|
+
if type(batch_size) is not int or not 1 <= batch_size <= 128:
|
|
328
|
+
raise ValueError("batch_size must be an integer between 1 and 128")
|
|
329
|
+
generation = payload.get("generation", {})
|
|
330
|
+
if not isinstance(generation, dict):
|
|
331
|
+
raise ValueError("generation must be a JSON object")
|
|
332
|
+
identity = checkpoint_identity(payload, adapter_path)
|
|
333
|
+
results = read_checkpoint(checkpoint, identity, examples) if payload.get("checkpoint_key") else []
|
|
334
|
+
resumed = len(results)
|
|
335
|
+
emit_progress("resuming" if resumed else "loading", resumed, len(examples), batch_size)
|
|
336
|
+
if resumed == len(examples):
|
|
337
|
+
return results
|
|
338
|
+
model, tokenizer, device = load_text_model(payload, adapter_path, model_loader)
|
|
339
|
+
started = time.perf_counter()
|
|
340
|
+
emit_progress("generating", resumed, len(examples), batch_size)
|
|
341
|
+
while len(results) < len(examples):
|
|
342
|
+
batch = examples[len(results):len(results) + batch_size]
|
|
343
|
+
predictions = None
|
|
344
|
+
try:
|
|
345
|
+
predictions = generate_text_batch(model, tokenizer, str(payload.get("system", "")),
|
|
346
|
+
batch, generation, str(payload["base_model"]))
|
|
347
|
+
except torch.cuda.OutOfMemoryError:
|
|
348
|
+
if device != "cuda" or len(batch) == 1:
|
|
349
|
+
raise
|
|
350
|
+
# Retry only allocation failures; release the failed call's tensors first.
|
|
351
|
+
batch_size = max(1, len(batch) // 2)
|
|
352
|
+
if predictions is None:
|
|
353
|
+
# The exception's traceback is now released, including failed tensors.
|
|
354
|
+
torch.cuda.empty_cache()
|
|
355
|
+
emit_progress("reducing_batch", len(results), len(examples), batch_size)
|
|
356
|
+
continue
|
|
357
|
+
results.extend(predictions)
|
|
358
|
+
write_json_atomic(checkpoint, {"identity": identity, "results": results})
|
|
359
|
+
elapsed = time.perf_counter() - started
|
|
360
|
+
eta = elapsed / (len(results) - resumed) * (len(examples) - len(results))
|
|
361
|
+
emit_progress("generating", len(results), len(examples), batch_size, elapsed, eta)
|
|
362
|
+
return results
|
|
258
363
|
|
|
259
364
|
|
|
260
365
|
def main() -> None:
|
|
261
366
|
parser = argparse.ArgumentParser()
|
|
262
367
|
parser.add_argument("--input", required=True)
|
|
263
368
|
parser.add_argument("--output", required=True)
|
|
369
|
+
parser.add_argument("--checkpoint")
|
|
264
370
|
args = parser.parse_args()
|
|
265
371
|
|
|
266
372
|
payload = load_json(Path(args.input))
|
|
@@ -273,37 +379,24 @@ def main() -> None:
|
|
|
273
379
|
"expected causal_lm or image_text_to_text"
|
|
274
380
|
)
|
|
275
381
|
configure_hugging_face_cache(payload.get("model_cache"))
|
|
382
|
+
emit_progress("initializing", 0, len(payload.get("examples", [])), payload.get("batch_size", 4))
|
|
276
383
|
import_runtime_dependencies()
|
|
277
384
|
|
|
278
385
|
with TemporaryDirectory(prefix="tt-local-evaluate-") as tmp_dir:
|
|
279
386
|
adapter_path = resolve_adapter_path(payload.get("adapter_path"), Path(tmp_dir))
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
raise ValueError("generation must be a JSON object")
|
|
283
|
-
model, tokenizer, _device = load_text_model(payload, adapter_path, model_loader)
|
|
284
|
-
results = [
|
|
285
|
-
generate_text_one(
|
|
286
|
-
model,
|
|
287
|
-
tokenizer,
|
|
288
|
-
str(payload.get("system", "")),
|
|
289
|
-
example,
|
|
290
|
-
generation,
|
|
291
|
-
str(payload["base_model"]),
|
|
292
|
-
)
|
|
293
|
-
for example in payload.get("examples", [])
|
|
294
|
-
]
|
|
387
|
+
checkpoint = Path(args.checkpoint or f"{args.output}.checkpoint.json")
|
|
388
|
+
results = evaluate_batches(payload, adapter_path, checkpoint, model_loader)
|
|
295
389
|
|
|
296
390
|
output = {
|
|
297
391
|
"provider": "transformers",
|
|
298
392
|
"model_id": payload.get("model_id"),
|
|
299
393
|
"base_model": payload.get("base_model"),
|
|
300
394
|
"adapter_path": payload.get("adapter_path"),
|
|
301
|
-
"generation_config": generation,
|
|
395
|
+
"generation_config": payload.get("generation", {}),
|
|
302
396
|
"results": results,
|
|
303
397
|
}
|
|
304
398
|
output_path = Path(args.output)
|
|
305
|
-
output_path
|
|
306
|
-
output_path.write_text(json.dumps(output, indent=2), encoding="utf-8")
|
|
399
|
+
write_json_atomic(output_path, output)
|
|
307
400
|
|
|
308
401
|
|
|
309
402
|
if __name__ == "__main__":
|
|
@@ -20,6 +20,19 @@ CERTIFIED_BASE_MODELS = (CERTIFIED_BASE_MODEL, NEMOTRON_BASE_MODEL, MUSE_GLIMMER
|
|
|
20
20
|
# the run pins an immutable commit. Node enforces the same contract.
|
|
21
21
|
HUGGING_FACE_REPO_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,95}/[A-Za-z0-9][A-Za-z0-9._-]{0,95}")
|
|
22
22
|
COMMIT_SHA = re.compile(r"[0-9a-fA-F]{40}")
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def cuda_model_load_device_map(torch_runtime: Any) -> dict[str, int] | None:
|
|
26
|
+
"""Stage on CPU before CUDA transfer when host and GPU share memory.
|
|
27
|
+
|
|
28
|
+
Direct CUDA loading can retain a second host copy until checkpoint readers
|
|
29
|
+
close. CPU-first loading lets those readers close before the CUDA transfer.
|
|
30
|
+
"""
|
|
31
|
+
index = torch_runtime.cuda.current_device()
|
|
32
|
+
integrated = torch_runtime.cuda.get_device_properties(index).is_integrated
|
|
33
|
+
return None if integrated else {"": index}
|
|
34
|
+
|
|
35
|
+
|
|
23
36
|
CERTIFIED_QWEN_TEXT_CONFIG = {
|
|
24
37
|
"model_type": "qwen3_5_text",
|
|
25
38
|
"hidden_size": 2048,
|
|
@@ -63,6 +63,35 @@ def pin_native_content_format(args: list[str], model_source: str) -> None:
|
|
|
63
63
|
args += ["--chat-template-content-format", content_format]
|
|
64
64
|
|
|
65
65
|
|
|
66
|
+
def prepare_serving_adapter(adapter_path: str, temp_dir: Path) -> str:
|
|
67
|
+
# Transformers trains Qwen's text-only model, but vLLM loads the repository's
|
|
68
|
+
# conditional architecture, even with --language-model-only. Its LoRA mapper
|
|
69
|
+
# expects the language_model prefix; otherwise it silently misses our layers.
|
|
70
|
+
if os.environ.get("TT_BASE_MODEL") != "Qwen/Qwen3.5-2B":
|
|
71
|
+
return adapter_path
|
|
72
|
+
from safetensors import safe_open
|
|
73
|
+
from safetensors.torch import save_file
|
|
74
|
+
|
|
75
|
+
source = Path(adapter_path)
|
|
76
|
+
old = "base_model.model.model.layers."
|
|
77
|
+
new = "base_model.model.model.language_model.layers."
|
|
78
|
+
with safe_open(str(source / "adapter_model.safetensors"), framework="pt", device="cpu") as weights:
|
|
79
|
+
if not any(key.startswith(old) for key in weights.keys()):
|
|
80
|
+
return adapter_path
|
|
81
|
+
tensors = {}
|
|
82
|
+
for key in weights.keys():
|
|
83
|
+
mapped = new + key[len(old):] if key.startswith(old) else key
|
|
84
|
+
if mapped in tensors:
|
|
85
|
+
raise ValueError("Adapter contains conflicting text and conditional layer names.")
|
|
86
|
+
tensors[mapped] = weights.get_tensor(key)
|
|
87
|
+
destination = temp_dir / "serving-adapter"
|
|
88
|
+
destination.mkdir()
|
|
89
|
+
save_file(tensors, str(destination / "adapter_model.safetensors"), metadata=weights.metadata())
|
|
90
|
+
# Keep the verified archive and extracted training layout unchanged.
|
|
91
|
+
(destination / "adapter_config.json").write_bytes((source / "adapter_config.json").read_bytes())
|
|
92
|
+
return str(destination)
|
|
93
|
+
|
|
94
|
+
|
|
66
95
|
def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path) -> list[str]:
|
|
67
96
|
args = [
|
|
68
97
|
"serve", model_source,
|
|
@@ -101,6 +130,7 @@ def build_vllm_args(model_source: str, adapter_path: str | None, temp_dir: Path)
|
|
|
101
130
|
adapter_base = config.get("base_model_name_or_path")
|
|
102
131
|
if adapter_base not in {os.environ["TT_BASE_MODEL"], model_source}:
|
|
103
132
|
raise ValueError("The adapter base does not match the verified serving model.")
|
|
133
|
+
adapter_path = prepare_serving_adapter(adapter_path, temp_dir)
|
|
104
134
|
base_name = "base:" + os.environ["TT_BASE_MODEL"]
|
|
105
135
|
args[args.index("--served-model-name") + 1] = base_name
|
|
106
136
|
args += ["--enable-lora", "--max-lora-rank", str(max(8, int(config["r"]))),
|