gridlinegpu 0.1.10__tar.gz → 0.2.0__tar.gz

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.
Files changed (73) hide show
  1. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/PKG-INFO +117 -7
  2. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/README.md +116 -6
  3. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/pyproject.toml +1 -1
  4. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/__init__.py +7 -3
  5. gridlinegpu-0.2.0/src/gridlinegpu/_app_entry.py +114 -0
  6. gridlinegpu-0.2.0/src/gridlinegpu/_app_remote.py +175 -0
  7. gridlinegpu-0.2.0/src/gridlinegpu/_finetune_job.py +121 -0
  8. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_contract.py +10 -1
  9. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_errors.py +72 -2
  10. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_interfaces.py +71 -5
  11. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_job.py +41 -10
  12. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_release.py +1 -1
  13. gridlinegpu-0.2.0/src/gridlinegpu/app.py +770 -0
  14. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/cli.py +7 -1
  15. gridlinegpu-0.2.0/src/gridlinegpu/cli_app.py +218 -0
  16. gridlinegpu-0.2.0/src/gridlinegpu/finetune.py +78 -0
  17. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu.egg-info/PKG-INFO +117 -7
  18. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu.egg-info/SOURCES.txt +10 -0
  19. gridlinegpu-0.2.0/tests/test_app.py +349 -0
  20. gridlinegpu-0.2.0/tests/test_app_map.py +360 -0
  21. gridlinegpu-0.2.0/tests/test_cli_app.py +167 -0
  22. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_client_source.py +1 -1
  23. gridlinegpu-0.2.0/tests/test_finetune_helper.py +101 -0
  24. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_contract.py +78 -3
  25. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_errors.py +6 -2
  26. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_job.py +22 -7
  27. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_job_results.py +2 -2
  28. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/setup.cfg +0 -0
  29. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/UNICODE-LICENSE.txt +0 -0
  30. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/__main__.py +0 -0
  31. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_batch.py +0 -0
  32. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_access.py +0 -0
  33. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_admission.py +0 -0
  34. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_capture.py +0 -0
  35. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_casefold.json +0 -0
  36. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_credits.py +0 -0
  37. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_history.py +0 -0
  38. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_outputs.py +0 -0
  39. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_pool.py +0 -0
  40. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_public.py +0 -0
  41. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_results.py +0 -0
  42. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_run.py +0 -0
  43. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_submission.py +0 -0
  44. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/_source_transfer.py +0 -0
  45. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/cli_auth.py +0 -0
  46. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/client.py +0 -0
  47. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/errors.py +0 -0
  48. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu/worker.py +0 -0
  49. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu.egg-info/dependency_links.txt +0 -0
  50. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu.egg-info/entry_points.txt +0 -0
  51. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu.egg-info/requires.txt +0 -0
  52. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/src/gridlinegpu.egg-info/top_level.txt +0 -0
  53. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_batch.py +0 -0
  54. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_cli.py +0 -0
  55. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_cli_auth.py +0 -0
  56. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_client.py +0 -0
  57. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_finetune_acceptance.py +0 -0
  58. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_access.py +0 -0
  59. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_admission.py +0 -0
  60. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_canary.py +0 -0
  61. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_capture.py +0 -0
  62. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_capture_gaps.py +0 -0
  63. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_credits.py +0 -0
  64. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_curated_run.py +0 -0
  65. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_history.py +0 -0
  66. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_o6c_acceptance.py +0 -0
  67. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_pool.py +0 -0
  68. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_release.py +0 -0
  69. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_results.py +0 -0
  70. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_run.py +0 -0
  71. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_submission.py +0 -0
  72. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_source_transfer.py +0 -0
  73. {gridlinegpu-0.1.10 → gridlinegpu-0.2.0}/tests/test_worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gridlinegpu
3
- Version: 0.1.10
3
+ Version: 0.2.0
4
4
  Summary: Python client for Gridline YAML workload routing
5
5
  Author: Gridline GPU
6
6
  License: Proprietary
@@ -28,13 +28,13 @@ Linux.
28
28
  1. **Sign up** at https://gridlinegpu.com/signup and verify your email.
29
29
  2. **Request access** at https://gridlinegpu.com/get-started: accept the partner beta terms and
30
30
  describe your use case in a few lines.
31
- 3. **Wait for approval.** Gridline approves each request by hand and grants your account beta
32
- credit; the get-started page shows the status. There is no payment step: jobs draw on that
33
- credit.
31
+ 3. **Wait for approval.** Gridline checks each request; approval grants your account beta
32
+ credit, and the get-started page shows the status. There is no payment step: jobs draw on
33
+ that credit.
34
34
  4. **Install** the SDK with the source extra (Python 3.10 or newer):
35
35
 
36
36
  ```sh
37
- python -m pip install 'gridlinegpu[source-contract]>=0.1.10,<0.2'
37
+ python -m pip install 'gridlinegpu[source-contract]>=0.2.0,<0.3'
38
38
  ```
39
39
 
40
40
  5. **Create a token** once. Browser approval saves a private, scoped token file; it is never
@@ -151,6 +151,116 @@ compiler. Without it, the YAML commands and `client.access()` still work and sou
151
151
  `source_dependencies_missing` with the install line. The "Source runs (beta)" section below has the
152
152
  limits, costs and failure codes.
153
153
 
154
+ ## Functions: `App`, `@app.function`, `.remote()` and `.spawn()` (preview)
155
+
156
+ Decorate a Python function instead of writing YAML. Each call becomes one ordinary source job,
157
+ with the same access, credit holds, limits, isolation and billing as `run()`:
158
+
159
+ ```python
160
+ import gridlinegpu as gl
161
+
162
+ app = gl.App("transcribe")
163
+ image = gl.Image.curated("gridline/hf-torch-2.5@r1")
164
+
165
+ @app.function(image=image, gpu="any:16GB", outputs={"transcript": "transcript.txt"})
166
+ def transcribe(audio: gl.File, language: str = "en") -> dict:
167
+ text = run_model(audio.path, language)
168
+ (gl.output_dir() / "transcript.txt").write_text(text)
169
+ return {"chars": len(text)}
170
+
171
+ print(transcribe.remote(gl.File("audio/a.flac"))) # waits; files land in gridline-outputs/
172
+ call = transcribe.spawn(gl.File("audio/b.flac")) # returns once the job is admitted
173
+ print(call.job_id, call.get())
174
+ ```
175
+
176
+ How the parts work:
177
+
178
+ - **Code.** The function's module and the local modules and packages it imports (read from the
179
+ source, never run) are sent with the job. Add data files with `include=["data/*.json"]`. Inside
180
+ the job, `gridlinegpu` is a small stand-in, so the module imports in any image; only the
181
+ function runs, never `local_entrypoint` or `if __name__ == "__main__"` code.
182
+ - **Arguments and return values** are JSON data plus `gl.File` arguments. There is no pickle.
183
+ Each `gl.File` is uploaded as an input file (at most 512 MiB). The return value is at most
184
+ 8 MiB. Larger results are files written under `gl.output_dir()` and declared in `outputs`
185
+ (at most 128 MiB each).
186
+ - **Options:**
187
+ - `gpu="any:<N>GB"` sets the minimum GPU memory (default 8 GB).
188
+ - `models={"base": gl.Model.hf(repo, revision)}` is fetched by the platform and found at
189
+ `gl.model_path("base")`.
190
+ - `kind="training"` marks a training job.
191
+ - `timeout=` is a local limit, enforced best-effort while the SDK waits; past it the job is
192
+ cancelled.
193
+ - **Not yet available:** named GPU types, `max_price`, `capacity` and built images. Each
194
+ raises when used.
195
+
196
+ **Many calls: `.map()` and `.starmap()`.** These return a `MapRun`; iterating it submits the calls
197
+ and yields their values. Nothing is submitted before that.
198
+
199
+ ```python
200
+ for value in transcribe.map([gl.File(path) for path in sorted(Path("audio").glob("*.flac"))],
201
+ kwargs={"language": "en"}):
202
+ print(value)
203
+ results = list(transcribe.starmap([(gl.File("a.flac"), "en"), (gl.File("b.flac"), "de")],
204
+ max_spend_usd=5.0, return_exceptions=True))
205
+ ```
206
+
207
+ A map runs one of two ways:
208
+
209
+ - **Warm pool.** With `max_spend_usd` (and `warm=None`, the default) or `warm=True`, a worker
210
+ pool of 1 to `max_workers` (at most 4) GPU workers runs the calls. The module loads once per
211
+ worker.
212
+ - Each call is one item, delivered at least once, with exactly one result committed.
213
+ - The function's `timeout` is the item timeout (60 to 3,600 seconds).
214
+ - A pooled function declares at most 7 outputs, each a file name directly in
215
+ `gl.output_dir()`.
216
+ - An account without pools falls back to chunks, unless `warm=True`.
217
+ - **Chunks of ordinary jobs.** `per_job` calls per job (default 16; 1 when the function
218
+ declares outputs), at most `max_running` jobs at once (default 2, the account's
219
+ running-jobs limit). A call that raises is recorded and the job runs its next call.
220
+
221
+ Common to both:
222
+
223
+ - Values come back in input order (`order="completed"` yields each as it finishes).
224
+ - `@app.function(retries=N)` (0 to 4) gives each failed call N more attempts.
225
+ - A call that still fails raises `gl.FunctionError` (`function_raised`, the job's failure code,
226
+ or `item_dead`). With `return_exceptions=True` it is yielded instead.
227
+ - Each job's or item's files land under `gridline-outputs/<function>/<job or pool id>/`.
228
+
229
+ Inside a job, a decorated function is the plain function: call other functions with
230
+ `.local()`. `.remote()` and `.map()` are refused there, because the job has no network.
231
+
232
+ **Fine-tuning in one call.** `gl.finetune.lora()` and `gl.finetune.qlora()` submit one training job on
233
+ `gridline/hf-finetune-2.5@r1`. The job runs Gridline's recipe, trl `SFTTrainer` with a peft LoRA:
234
+ bf16 for LoRA, 4-bit NF4 for QLoRA. The platform fetches the base model at its pinned revision;
235
+ the dataset is a JSON-lines file whose rows have a `text` field:
236
+
237
+ ```python
238
+ run = gl.finetune.qlora(gl.Model.hf("HuggingFaceTB/SmolLM2-135M", revision="93efa2f097d58c2a74874c7e644dbc9b0cee75a2"),
239
+ "data/train.jsonl", eval_dataset="data/eval.jsonl", epochs=2, rank=16)
240
+ metrics = run.get(outputs_dir="adapters/smol") # adapter_model.safetensors, adapter_config.json, metrics.json
241
+ print(metrics["eval_loss_before"], metrics["eval_loss_after"])
242
+ ```
243
+
244
+ Options: `epochs`, `learning_rate`, `rank`, `alpha`, `dropout`, `max_seq_length`,
245
+ `batch_size`, `max_steps`, `target_modules` and `gpu` (default `"any:16GB"`). The adapter is at
246
+ most 128 MiB. Full fine-tunes (all weights) are not offered yet.
247
+
248
+ **From the command line.** Running submits paid jobs, so it needs `--apply`:
249
+
250
+ ```sh
251
+ gridline app show transcribe_app.py # each function's generated YAML; nothing is sent
252
+ gridline run --apply transcribe_app.py --folder audio # the local_entrypoint, arguments typed from it
253
+ gridline run --apply transcribe_app.py::transcribe --audio a.flac --language en
254
+ gridline run --apply --detach transcribe_app.py::transcribe --audio a.flac # print the job id and exit
255
+ gridline pools status <pool id> # also: items, close, cancel --apply
256
+ ```
257
+
258
+ `gridline run` imports the file the way `python app.py` would. Each `--name value` is converted
259
+ by the parameter's annotation or default: `int`, `float`, `bool`, `str`, or `gl.File` for a path.
260
+ - **Failures:** a failed job raises `gl.FunctionError` with the job's failure code. An uncertain
261
+ submission raises `SubmissionOutcomeUnknown`, as `run()` does; continue it with
262
+ `transcribe.resume(error.submission_id)`.
263
+
154
264
  ## Many files: batches of ordinary jobs
155
265
 
156
266
  `client.run_batch()` (0.1.10 or newer) runs one workload over many files, at most `per_job` files
@@ -272,7 +382,7 @@ files referenced by YAML, returns a route for review, then launches only that ex
272
382
  fresh plan when you supply a cost ceiling and idempotency key.
273
383
 
274
384
  ```sh
275
- python -m pip install 'gridlinegpu>=0.1.10,<0.2'
385
+ python -m pip install 'gridlinegpu>=0.2.0,<0.3'
276
386
  ```
277
387
 
278
388
  Run browser setup once. It creates a 30-day scoped token in a private file, attempts to
@@ -386,7 +496,7 @@ step in the SDK. Request access first (see the quickstart above); `client.access
386
496
  `gridline access` show where your request stands.
387
497
 
388
498
  ```sh
389
- python -m pip install 'gridlinegpu[source-contract]>=0.1.10,<0.2'
499
+ python -m pip install 'gridlinegpu[source-contract]>=0.2.0,<0.3'
390
500
  gridline setup --apply # once: saves a private, scoped token file
391
501
  gridline access # "approved" before your first run
392
502
  ```
@@ -10,13 +10,13 @@ Linux.
10
10
  1. **Sign up** at https://gridlinegpu.com/signup and verify your email.
11
11
  2. **Request access** at https://gridlinegpu.com/get-started: accept the partner beta terms and
12
12
  describe your use case in a few lines.
13
- 3. **Wait for approval.** Gridline approves each request by hand and grants your account beta
14
- credit; the get-started page shows the status. There is no payment step: jobs draw on that
15
- credit.
13
+ 3. **Wait for approval.** Gridline checks each request; approval grants your account beta
14
+ credit, and the get-started page shows the status. There is no payment step: jobs draw on
15
+ that credit.
16
16
  4. **Install** the SDK with the source extra (Python 3.10 or newer):
17
17
 
18
18
  ```sh
19
- python -m pip install 'gridlinegpu[source-contract]>=0.1.10,<0.2'
19
+ python -m pip install 'gridlinegpu[source-contract]>=0.2.0,<0.3'
20
20
  ```
21
21
 
22
22
  5. **Create a token** once. Browser approval saves a private, scoped token file; it is never
@@ -133,6 +133,116 @@ compiler. Without it, the YAML commands and `client.access()` still work and sou
133
133
  `source_dependencies_missing` with the install line. The "Source runs (beta)" section below has the
134
134
  limits, costs and failure codes.
135
135
 
136
+ ## Functions: `App`, `@app.function`, `.remote()` and `.spawn()` (preview)
137
+
138
+ Decorate a Python function instead of writing YAML. Each call becomes one ordinary source job,
139
+ with the same access, credit holds, limits, isolation and billing as `run()`:
140
+
141
+ ```python
142
+ import gridlinegpu as gl
143
+
144
+ app = gl.App("transcribe")
145
+ image = gl.Image.curated("gridline/hf-torch-2.5@r1")
146
+
147
+ @app.function(image=image, gpu="any:16GB", outputs={"transcript": "transcript.txt"})
148
+ def transcribe(audio: gl.File, language: str = "en") -> dict:
149
+ text = run_model(audio.path, language)
150
+ (gl.output_dir() / "transcript.txt").write_text(text)
151
+ return {"chars": len(text)}
152
+
153
+ print(transcribe.remote(gl.File("audio/a.flac"))) # waits; files land in gridline-outputs/
154
+ call = transcribe.spawn(gl.File("audio/b.flac")) # returns once the job is admitted
155
+ print(call.job_id, call.get())
156
+ ```
157
+
158
+ How the parts work:
159
+
160
+ - **Code.** The function's module and the local modules and packages it imports (read from the
161
+ source, never run) are sent with the job. Add data files with `include=["data/*.json"]`. Inside
162
+ the job, `gridlinegpu` is a small stand-in, so the module imports in any image; only the
163
+ function runs, never `local_entrypoint` or `if __name__ == "__main__"` code.
164
+ - **Arguments and return values** are JSON data plus `gl.File` arguments. There is no pickle.
165
+ Each `gl.File` is uploaded as an input file (at most 512 MiB). The return value is at most
166
+ 8 MiB. Larger results are files written under `gl.output_dir()` and declared in `outputs`
167
+ (at most 128 MiB each).
168
+ - **Options:**
169
+ - `gpu="any:<N>GB"` sets the minimum GPU memory (default 8 GB).
170
+ - `models={"base": gl.Model.hf(repo, revision)}` is fetched by the platform and found at
171
+ `gl.model_path("base")`.
172
+ - `kind="training"` marks a training job.
173
+ - `timeout=` is a local limit, enforced best-effort while the SDK waits; past it the job is
174
+ cancelled.
175
+ - **Not yet available:** named GPU types, `max_price`, `capacity` and built images. Each
176
+ raises when used.
177
+
178
+ **Many calls: `.map()` and `.starmap()`.** These return a `MapRun`; iterating it submits the calls
179
+ and yields their values. Nothing is submitted before that.
180
+
181
+ ```python
182
+ for value in transcribe.map([gl.File(path) for path in sorted(Path("audio").glob("*.flac"))],
183
+ kwargs={"language": "en"}):
184
+ print(value)
185
+ results = list(transcribe.starmap([(gl.File("a.flac"), "en"), (gl.File("b.flac"), "de")],
186
+ max_spend_usd=5.0, return_exceptions=True))
187
+ ```
188
+
189
+ A map runs one of two ways:
190
+
191
+ - **Warm pool.** With `max_spend_usd` (and `warm=None`, the default) or `warm=True`, a worker
192
+ pool of 1 to `max_workers` (at most 4) GPU workers runs the calls. The module loads once per
193
+ worker.
194
+ - Each call is one item, delivered at least once, with exactly one result committed.
195
+ - The function's `timeout` is the item timeout (60 to 3,600 seconds).
196
+ - A pooled function declares at most 7 outputs, each a file name directly in
197
+ `gl.output_dir()`.
198
+ - An account without pools falls back to chunks, unless `warm=True`.
199
+ - **Chunks of ordinary jobs.** `per_job` calls per job (default 16; 1 when the function
200
+ declares outputs), at most `max_running` jobs at once (default 2, the account's
201
+ running-jobs limit). A call that raises is recorded and the job runs its next call.
202
+
203
+ Common to both:
204
+
205
+ - Values come back in input order (`order="completed"` yields each as it finishes).
206
+ - `@app.function(retries=N)` (0 to 4) gives each failed call N more attempts.
207
+ - A call that still fails raises `gl.FunctionError` (`function_raised`, the job's failure code,
208
+ or `item_dead`). With `return_exceptions=True` it is yielded instead.
209
+ - Each job's or item's files land under `gridline-outputs/<function>/<job or pool id>/`.
210
+
211
+ Inside a job, a decorated function is the plain function: call other functions with
212
+ `.local()`. `.remote()` and `.map()` are refused there, because the job has no network.
213
+
214
+ **Fine-tuning in one call.** `gl.finetune.lora()` and `gl.finetune.qlora()` submit one training job on
215
+ `gridline/hf-finetune-2.5@r1`. The job runs Gridline's recipe, trl `SFTTrainer` with a peft LoRA:
216
+ bf16 for LoRA, 4-bit NF4 for QLoRA. The platform fetches the base model at its pinned revision;
217
+ the dataset is a JSON-lines file whose rows have a `text` field:
218
+
219
+ ```python
220
+ run = gl.finetune.qlora(gl.Model.hf("HuggingFaceTB/SmolLM2-135M", revision="93efa2f097d58c2a74874c7e644dbc9b0cee75a2"),
221
+ "data/train.jsonl", eval_dataset="data/eval.jsonl", epochs=2, rank=16)
222
+ metrics = run.get(outputs_dir="adapters/smol") # adapter_model.safetensors, adapter_config.json, metrics.json
223
+ print(metrics["eval_loss_before"], metrics["eval_loss_after"])
224
+ ```
225
+
226
+ Options: `epochs`, `learning_rate`, `rank`, `alpha`, `dropout`, `max_seq_length`,
227
+ `batch_size`, `max_steps`, `target_modules` and `gpu` (default `"any:16GB"`). The adapter is at
228
+ most 128 MiB. Full fine-tunes (all weights) are not offered yet.
229
+
230
+ **From the command line.** Running submits paid jobs, so it needs `--apply`:
231
+
232
+ ```sh
233
+ gridline app show transcribe_app.py # each function's generated YAML; nothing is sent
234
+ gridline run --apply transcribe_app.py --folder audio # the local_entrypoint, arguments typed from it
235
+ gridline run --apply transcribe_app.py::transcribe --audio a.flac --language en
236
+ gridline run --apply --detach transcribe_app.py::transcribe --audio a.flac # print the job id and exit
237
+ gridline pools status <pool id> # also: items, close, cancel --apply
238
+ ```
239
+
240
+ `gridline run` imports the file the way `python app.py` would. Each `--name value` is converted
241
+ by the parameter's annotation or default: `int`, `float`, `bool`, `str`, or `gl.File` for a path.
242
+ - **Failures:** a failed job raises `gl.FunctionError` with the job's failure code. An uncertain
243
+ submission raises `SubmissionOutcomeUnknown`, as `run()` does; continue it with
244
+ `transcribe.resume(error.submission_id)`.
245
+
136
246
  ## Many files: batches of ordinary jobs
137
247
 
138
248
  `client.run_batch()` (0.1.10 or newer) runs one workload over many files, at most `per_job` files
@@ -254,7 +364,7 @@ files referenced by YAML, returns a route for review, then launches only that ex
254
364
  fresh plan when you supply a cost ceiling and idempotency key.
255
365
 
256
366
  ```sh
257
- python -m pip install 'gridlinegpu>=0.1.10,<0.2'
367
+ python -m pip install 'gridlinegpu>=0.2.0,<0.3'
258
368
  ```
259
369
 
260
370
  Run browser setup once. It creates a 30-day scoped token in a private file, attempts to
@@ -368,7 +478,7 @@ step in the SDK. Request access first (see the quickstart above); `client.access
368
478
  `gridline access` show where your request stands.
369
479
 
370
480
  ```sh
371
- python -m pip install 'gridlinegpu[source-contract]>=0.1.10,<0.2'
481
+ python -m pip install 'gridlinegpu[source-contract]>=0.2.0,<0.3'
372
482
  gridline setup --apply # once: saves a private, scoped token file
373
483
  gridline access # "approved" before your first run
374
484
  ```
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "gridlinegpu"
7
- version = "0.1.10"
7
+ version = "0.2.0"
8
8
  description = "Python client for Gridline YAML workload routing"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -2,16 +2,20 @@
2
2
 
3
3
  from ._batch import BatchResult, BatchRun, batch
4
4
  from ._batch import slot_names as batch_slots
5
+ from .app import (App, File, Function, FunctionCall, FunctionError, Image, MapRun, Model, Output, model_path,
6
+ output_dir)
5
7
  from .client import GridlineClient, PlannedWorkload
6
8
  from .errors import (
7
9
  GridlineError, LaunchOutcomeUnknown, OutputsIncomplete, ReceiptPersistenceError, SubmissionOutcomeUnknown,
8
10
  WaitTimeout, WorkloadValidationError,
9
11
  )
12
+ from . import finetune # noqa: E402 - after app's names, which its job module imports
10
13
 
11
14
  __all__ = [
12
- "BatchResult", "BatchRun", "GridlineClient", "GridlineError", "LaunchOutcomeUnknown", "OutputsIncomplete",
13
- "PlannedWorkload", "ReceiptPersistenceError", "SubmissionOutcomeUnknown", "WaitTimeout", "WorkloadValidationError",
14
- "batch", "batch_slots",
15
+ "App", "BatchResult", "BatchRun", "File", "Function", "FunctionCall", "FunctionError", "GridlineClient",
16
+ "GridlineError", "Image", "LaunchOutcomeUnknown", "MapRun", "Model", "Output", "OutputsIncomplete", "PlannedWorkload",
17
+ "ReceiptPersistenceError", "SubmissionOutcomeUnknown", "WaitTimeout", "WorkloadValidationError", "batch",
18
+ "batch_slots", "finetune", "model_path", "output_dir",
15
19
  ]
16
20
 
17
21
 
@@ -0,0 +1,114 @@
1
+ """The job's entry for ``gridlinegpu.App`` functions (stdlib only; staged as _gridline_entry.py).
2
+
3
+ Reads the call file, imports the app module (its ``gridlinegpu`` is the staged stub) and calls
4
+ the plain function once per call, writing the JSON results as the declared return output. With
5
+ ``strict`` (one ``spawn()`` call) an exception propagates: the traceback goes to the job's log
6
+ and the job fails as any job whose code fails. Otherwise (a chunk of ``map()`` calls) each
7
+ exception is recorded for its call and the next call runs.
8
+
9
+ In a pool worker (``mode: pool``) the module is imported once and each item is one call: its
10
+ input is a zip of ``call.json`` and the call's files, and its outputs are the item's output
11
+ directory (the JSON return value plus the files the function wrote there). An exception hands
12
+ the item back failed with its type and message; the pool retries it up to its attempts.
13
+ """
14
+ import importlib
15
+ import json
16
+ import os
17
+ import shutil
18
+ import sys
19
+ import tempfile
20
+ import traceback
21
+ import zipfile
22
+
23
+ ROOT = os.path.dirname(os.path.abspath(__file__))
24
+ ERROR_TEXT = 2_000
25
+
26
+
27
+ def error_of(exception):
28
+ """A call's exception as JSON: its type and last line, bounded (the log has the traceback)."""
29
+ return {"type": type(exception).__name__, "message": str(exception)[:ERROR_TEXT]}
30
+
31
+
32
+ def run_call(gridlinegpu, function, call, paths):
33
+ args = gridlinegpu.decode_value(call["args"], paths)
34
+ kwargs = gridlinegpu.decode_value(call["kwargs"], paths)
35
+ return gridlinegpu.encode_value(function(*args, **kwargs), where="return value")
36
+
37
+
38
+ def unpack(gridlinegpu, archive_path, scratch):
39
+ """(call, local paths of its files) from one item's zip, its files written under scratch."""
40
+ with zipfile.ZipFile(archive_path) as archive:
41
+ call = json.loads(archive.read(gridlinegpu.ITEM_CALL))
42
+ paths = []
43
+ for index, name in enumerate(call["files"]):
44
+ target = os.path.join(scratch, os.path.basename(gridlinegpu.input_path(index, name)))
45
+ with archive.open(f"files/{index}") as source, open(target, "wb") as sink:
46
+ shutil.copyfileobj(source, sink, 1_048_576)
47
+ paths.append(target)
48
+ return call, paths
49
+
50
+
51
+ def serve_pool(gridlinegpu, function):
52
+ from gridlinegpu import worker
53
+
54
+ for item in worker.items():
55
+ scratch = tempfile.mkdtemp(prefix="gridline-item-")
56
+ try:
57
+ call, paths = unpack(gridlinegpu, item.input_path, scratch)
58
+ gridlinegpu._current_output = str(item.output_dir)
59
+ value = run_call(gridlinegpu, function, call, paths)
60
+ data = json.dumps({"results": [{"value": value}]}, allow_nan=False, separators=(",", ":"))
61
+ if len(data) > gridlinegpu.RETURN_MAX_BYTES:
62
+ raise ValueError("the return value is larger than 8 MiB; write it under output_dir() instead")
63
+ with open(os.path.join(item.output_dir, os.path.basename(gridlinegpu.RETURN_PATH)), "w",
64
+ encoding="utf-8") as handle:
65
+ handle.write(data)
66
+ except Exception as exception: # noqa: BLE001 - the item goes back failed; the log has the traceback
67
+ traceback.print_exc()
68
+ error = error_of(exception)
69
+ try:
70
+ item.fail(f"{error['type']}: {error['message']}")
71
+ except worker.WorkerError:
72
+ traceback.print_exc()
73
+ continue
74
+ finally:
75
+ gridlinegpu._current_output = None
76
+ shutil.rmtree(scratch, ignore_errors=True)
77
+ try:
78
+ item.done()
79
+ except worker.WorkerError:
80
+ traceback.print_exc() # handed back (outputs invalid) or retried elsewhere
81
+
82
+
83
+ def main():
84
+ os.environ.setdefault("GRIDLINE_APP_ROOT", ROOT)
85
+ sys.path.insert(0, ROOT)
86
+ import gridlinegpu # the staged stub, which owns the call codec
87
+
88
+ with open(os.path.join(ROOT, gridlinegpu.CALL_FILE), encoding="utf-8") as handle:
89
+ document = json.load(handle)
90
+ function = getattr(importlib.import_module(document["module"]), document["function"])
91
+ if document.get("mode") == "pool":
92
+ serve_pool(gridlinegpu, function)
93
+ return
94
+ paths = [os.path.join(ROOT, path) for path in document["files"]]
95
+ results = []
96
+ for call in document["calls"]:
97
+ if document["strict"]:
98
+ results.append({"value": run_call(gridlinegpu, function, call, paths)})
99
+ continue
100
+ try:
101
+ results.append({"value": run_call(gridlinegpu, function, call, paths)})
102
+ except Exception as exception: # noqa: BLE001 - recorded per call; the log has the traceback
103
+ traceback.print_exc()
104
+ results.append({"error": error_of(exception)})
105
+ data = json.dumps({"results": results}, allow_nan=False, separators=(",", ":")).encode("utf-8")
106
+ if len(data) > gridlinegpu.RETURN_MAX_BYTES:
107
+ raise ValueError("the return values are larger than 8 MiB; write results under output_dir() instead")
108
+ gridlinegpu.output_dir()
109
+ with open(os.path.join(ROOT, gridlinegpu.RETURN_PATH), "wb") as handle:
110
+ handle.write(data)
111
+
112
+
113
+ if __name__ == "__main__":
114
+ main()
@@ -0,0 +1,175 @@
1
+ """The job side of ``gridlinegpu.App`` (stdlib only).
2
+
3
+ ``App`` stages this file's text as ``gridlinegpu/__init__.py`` beside the app's own modules, so
4
+ the app module imports inside the GPU job even when the image has no ``gridlinegpu`` (or an
5
+ older one): its ``App``, ``Image``, ``Model`` and ``File`` names exist and do nothing, and the
6
+ job's entry calls the plain function. The local package imports the call codec from here, so
7
+ both sides encode a call the same way. Nothing here touches the network or a credential.
8
+ """
9
+ import json
10
+ import math
11
+ import os
12
+ import pathlib
13
+
14
+ CALL_FILE = "_gridline_call.json"
15
+ ENTRY_FILE = "_gridline_entry.py"
16
+ RETURN_OUTPUT = "gl_return"
17
+ RETURN_PATH = "outputs/_gridline_return.json"
18
+ RETURN_MAX_BYTES = 8 * 1024 * 1024
19
+ _MARK = "$gridline"
20
+ _ROOT = pathlib.Path(os.environ.get("GRIDLINE_APP_ROOT") or os.getcwd())
21
+ # A pool worker sets this to the current item's output directory while the item runs.
22
+ _current_output = None
23
+ ITEM_CALL = "call.json"
24
+
25
+
26
+ class File:
27
+ """A file argument. Locally, a path to send; inside the job, the staged input (read-only)."""
28
+
29
+ def __init__(self, path):
30
+ self.path = pathlib.Path(path)
31
+
32
+ @property
33
+ def name(self):
34
+ return self.path.name
35
+
36
+ def __fspath__(self):
37
+ return os.fspath(self.path)
38
+
39
+ def __repr__(self):
40
+ return f"File({os.fspath(self.path)!r})"
41
+
42
+
43
+ def encode_value(value, files=None, *, where="argument"):
44
+ """JSON-safe form of ``value``. ``File`` objects become references and, when ``files`` is a
45
+ list, are appended to it (locally: the files to stage). Anything that is not JSON data or a
46
+ ``File`` raises TypeError: calls carry JSON data and files only (no pickle)."""
47
+ if value is None or type(value) in (bool, str, int):
48
+ return value
49
+ if type(value) is float:
50
+ if not math.isfinite(value):
51
+ raise TypeError(f"a function {where} must be finite (NaN and infinity are not JSON)")
52
+ return value
53
+ if isinstance(value, File):
54
+ if files is None:
55
+ raise TypeError(f"a function {where} cannot be a File; write it under output_dir() instead")
56
+ files.append(value)
57
+ return {_MARK: "file", "index": len(files) - 1, "name": value.name}
58
+ if type(value) in (list, tuple):
59
+ return [encode_value(item, files, where=where) for item in value]
60
+ if type(value) is dict:
61
+ encoded = {}
62
+ for key, item in value.items():
63
+ if type(key) is not str:
64
+ raise TypeError(f"a function {where}'s dictionary keys must be strings")
65
+ if key == _MARK:
66
+ raise TypeError(f"a function {where}'s dictionary cannot use the key {_MARK!r}")
67
+ encoded[key] = encode_value(item, files, where=where)
68
+ return encoded
69
+ if isinstance(value, os.PathLike):
70
+ raise TypeError(f"a function {where} that is a path must be wrapped: gridlinegpu.File({os.fspath(value)!r}) "
71
+ "sends the file; str(path) sends the text")
72
+ raise TypeError(f"a function {where} must be JSON data or gridlinegpu.File, not {type(value).__name__}")
73
+
74
+
75
+ def decode_value(value, file_paths=None):
76
+ """The inverse of ``encode_value``; ``file_paths[index]`` is where each file landed."""
77
+ if type(value) is list:
78
+ return [decode_value(item, file_paths) for item in value]
79
+ if type(value) is dict:
80
+ if value.get(_MARK) == "file":
81
+ if file_paths is None:
82
+ raise ValueError("a file reference outside a call")
83
+ return File(file_paths[value["index"]])
84
+ return {key: decode_value(item, file_paths) for key, item in value.items()}
85
+ return value
86
+
87
+
88
+ def input_name(index):
89
+ return f"arg_{index}"
90
+
91
+
92
+ def input_path(index, name):
93
+ suffix = "".join(pathlib.PurePosixPath(name).suffixes)[-16:]
94
+ return f"inputs/{input_name(index)}{suffix}"
95
+
96
+
97
+ def output_dir():
98
+ """The directory whose declared files become the call's outputs (in a pool, the item's)."""
99
+ if _current_output is not None:
100
+ return pathlib.Path(_current_output)
101
+ directory = _ROOT / "outputs"
102
+ directory.mkdir(exist_ok=True)
103
+ return directory
104
+
105
+
106
+ def model_path(name):
107
+ """Where the platform staged the model declared as ``models={name: Model.hf(...)}``."""
108
+ return _ROOT / "models" / name
109
+
110
+
111
+ class _Inert:
112
+ """Accepts any construction or chained call; the job never builds or routes anything."""
113
+
114
+ def __init__(self, *args, **kwargs):
115
+ pass
116
+
117
+ def __getattr__(self, name):
118
+ if name.startswith("__"):
119
+ raise AttributeError(name)
120
+ return lambda *args, **kwargs: self
121
+
122
+ @classmethod
123
+ def _make(cls, *args, **kwargs):
124
+ return cls()
125
+
126
+
127
+ class Image(_Inert):
128
+ curated = from_base = from_dockerfile = from_registry = classmethod(_Inert._make.__func__)
129
+
130
+
131
+ class Model(_Inert):
132
+ hf = classmethod(_Inert._make.__func__)
133
+
134
+
135
+ class Output(_Inert):
136
+ pass
137
+
138
+
139
+ class Secret(_Inert):
140
+ from_name = classmethod(_Inert._make.__func__)
141
+
142
+
143
+ class FunctionError(Exception):
144
+ pass
145
+
146
+
147
+ def _in_job(name):
148
+ def refuse(*args, **kwargs):
149
+ raise RuntimeError(f".{name}() is not available inside a Gridline job (no network); call .local()")
150
+ return refuse
151
+
152
+
153
+ def _plain(fn):
154
+ """The plain function, with ``.local`` (itself) and the remote calls refused."""
155
+ fn.local = fn
156
+ for name in ("remote", "spawn", "map", "starmap"):
157
+ setattr(fn, name, _in_job(name))
158
+ return fn
159
+
160
+
161
+ class App:
162
+ """Inside the job: decorators return the plain function and the entrypoint never runs."""
163
+
164
+ def __init__(self, *args, **kwargs):
165
+ pass
166
+
167
+ def function(self, *args, **kwargs):
168
+ if args and callable(args[0]) and not kwargs:
169
+ return _plain(args[0])
170
+ return _plain
171
+
172
+ def local_entrypoint(self, *args, **kwargs):
173
+ if args and callable(args[0]) and not kwargs:
174
+ return args[0]
175
+ return lambda fn: fn