forked from MakazhanAlpamys/Soup
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_autopilot.py
More file actions
399 lines (304 loc) · 13.8 KB
/
Copy pathtest_autopilot.py
File metadata and controls
399 lines (304 loc) · 13.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
"""Tests for Autopilot — zero-config fine-tuning (Part H of v0.25.0)."""
import json
import pytest
from typer.testing import CliRunner
from soup_cli.cli import app
runner = CliRunner()
# ---------------------------------------------------------------------------
# Dataset analyzer
# ---------------------------------------------------------------------------
class TestAnalyzeDataset:
def _write_alpaca(self, path, count=100):
rows = [
{"instruction": f"q{i} " * 20, "output": f"a{i} " * 10}
for i in range(count)
]
path.write_text(
"\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8"
)
return path
def test_analyze_alpaca_dataset(self, tmp_path):
from soup_cli.autopilot.analyzer import analyze_dataset
data_file = tmp_path / "train.jsonl"
self._write_alpaca(data_file, count=50)
profile = analyze_dataset(str(data_file))
assert profile.samples == 50
assert profile.format == "alpaca"
assert profile.avg_tokens > 0
assert profile.p95_tokens >= profile.avg_tokens
def test_analyze_empty_raises(self, tmp_path):
from soup_cli.autopilot.analyzer import analyze_dataset
data_file = tmp_path / "empty.jsonl"
data_file.write_text("", encoding="utf-8")
with pytest.raises(ValueError):
analyze_dataset(str(data_file))
# ---------------------------------------------------------------------------
# Model analyzer
# ---------------------------------------------------------------------------
class TestAnalyzeModel:
def test_analyze_llama3_8b(self):
from soup_cli.autopilot.analyzer import analyze_model
profile = analyze_model("meta-llama/Llama-3.1-8B-Instruct")
assert profile.params_b >= 7.0
assert profile.params_b <= 10.0
assert profile.context >= 2048
def test_analyze_tiny_model(self):
from soup_cli.autopilot.analyzer import analyze_model
profile = analyze_model("meta-llama/Llama-3.2-1B-Instruct")
assert profile.params_b <= 2.0
# ---------------------------------------------------------------------------
# Hardware analyzer
# ---------------------------------------------------------------------------
class TestAnalyzeHardware:
def test_returns_dict_like(self):
from soup_cli.autopilot.analyzer import analyze_hardware
profile = analyze_hardware()
assert hasattr(profile, "vram_gb")
assert profile.vram_gb >= 0
# ---------------------------------------------------------------------------
# Decision engine
# ---------------------------------------------------------------------------
class TestDecisionEngine:
def test_decide_task_chat(self):
from soup_cli.autopilot.decisions import decide_task
assert decide_task("chat", None) == "sft"
def test_decide_task_reasoning(self):
from soup_cli.autopilot.decisions import decide_task
assert decide_task("reasoning", None) == "grpo"
def test_decide_task_alignment(self):
from soup_cli.autopilot.decisions import decide_task
assert decide_task("alignment", None) == "dpo"
def test_decide_task_unknown(self):
from soup_cli.autopilot.decisions import decide_task
with pytest.raises(ValueError):
decide_task("evil-goal", None)
def test_decide_quantization_plenty(self):
from soup_cli.autopilot.decisions import decide_quantization
# 80GB VRAM, 7B model ≈ 14GB — plenty for full precision
assert decide_quantization(model_params_b=7.0, vram_gb=80.0) == "none"
def test_decide_quantization_4bit(self):
from soup_cli.autopilot.decisions import decide_quantization
# 24GB VRAM, 15B model ≈ 30GB full — needs 4bit to fit
result = decide_quantization(model_params_b=15.0, vram_gb=24.0)
assert result == "4bit"
def test_decide_quantization_too_small(self):
from soup_cli.autopilot.decisions import decide_quantization
with pytest.raises(ValueError):
decide_quantization(model_params_b=70.0, vram_gb=4.0)
def test_decide_quantization_8bit_tier(self):
from soup_cli.autopilot.decisions import decide_quantization
# 8B model in fp16 ≈ 16GB, 24GB / 16GB ≈ 1.5× → 8bit tier
result = decide_quantization(model_params_b=8.0, vram_gb=24.0)
assert result == "8bit"
def test_decide_peft_small_data(self):
from soup_cli.autopilot.decisions import decide_peft
peft = decide_peft(data_size=500, model_size_b=8.0, vram_gb=24.0)
assert peft["r"] == 8
def test_decide_peft_medium_data(self):
from soup_cli.autopilot.decisions import decide_peft
peft = decide_peft(data_size=5000, model_size_b=8.0, vram_gb=24.0)
assert peft["r"] == 16
def test_decide_peft_large_data(self):
from soup_cli.autopilot.decisions import decide_peft
peft = decide_peft(data_size=50_000, model_size_b=8.0, vram_gb=80.0)
assert peft["r"] == 32
def test_decide_peft_dora_only_when_headroom(self):
"""DoRA only enabled when data is huge AND VRAM is plentiful."""
from soup_cli.autopilot.decisions import decide_peft
# Big data + tight VRAM → LoRA, not DoRA (DoRA doubles the cost)
tight = decide_peft(data_size=200_000, model_size_b=8.0, vram_gb=12.0)
assert tight["use_dora"] is False
# Big data + plenty of VRAM → DoRA
spacious = decide_peft(data_size=200_000, model_size_b=8.0, vram_gb=80.0)
assert spacious["use_dora"] is True
def test_decide_lr_scales_with_rank(self):
from soup_cli.autopilot.decisions import decide_lr
assert decide_lr(rank=8, quantization="none") > decide_lr(rank=32, quantization="none")
def test_decide_epochs_small_data(self):
from soup_cli.autopilot.decisions import decide_epochs
assert decide_epochs(200) >= 3
assert decide_epochs(100_000) == 1
def test_decide_max_length(self):
from soup_cli.autopilot.decisions import decide_max_length
result = decide_max_length(p95_tokens=1800, model_context=8192)
# Rounded up with 10% margin
assert result >= 1800
assert result <= 8192
def test_decide_max_length_clamp(self):
from soup_cli.autopilot.decisions import decide_max_length
# p95 above model context — should clamp
result = decide_max_length(p95_tokens=20000, model_context=4096)
assert result == 4096
def test_decide_performance_flags_ampere(self):
from soup_cli.autopilot.decisions import decide_performance_flags
flags = decide_performance_flags(gpu_name="rtx4090", compute_capability=8.9)
assert flags["use_flash_attn"] is True
def test_decide_performance_flags_old_gpu(self):
from soup_cli.autopilot.decisions import decide_performance_flags
flags = decide_performance_flags(gpu_name="gtx1080", compute_capability=6.1)
assert flags["use_flash_attn"] is False
def test_decide_performance_flags_cpu(self):
"""CPU-only environment (compute_capability=0.0) must disable fast paths."""
from soup_cli.autopilot.decisions import decide_performance_flags
flags = decide_performance_flags(gpu_name="none", compute_capability=0.0)
assert flags["use_flash_attn"] is False
assert flags["use_liger"] is False
def test_gradient_checkpointing_long_sequence(self):
"""Long sequences (>8k) enable gradient checkpointing to avoid OOM."""
from soup_cli.autopilot.decisions import decide_performance_flags
flags = decide_performance_flags(
gpu_name="rtx4090",
compute_capability=8.9,
max_length=16384,
vram_headroom_gb=12.0,
)
assert flags["gradient_checkpointing"] is True
def test_gradient_checkpointing_tight_vram(self):
"""Tight VRAM headroom (<4GB) enables gradient checkpointing."""
from soup_cli.autopilot.decisions import decide_performance_flags
flags = decide_performance_flags(
gpu_name="rtx3050",
compute_capability=8.6,
max_length=2048,
vram_headroom_gb=2.0,
)
assert flags["gradient_checkpointing"] is True
def test_gradient_checkpointing_skipped_with_headroom(self):
from soup_cli.autopilot.decisions import decide_performance_flags
flags = decide_performance_flags(
gpu_name="a100",
compute_capability=8.0,
max_length=2048,
vram_headroom_gb=40.0,
)
assert flags["gradient_checkpointing"] is False
# ---------------------------------------------------------------------------
# Build config end-to-end
# ---------------------------------------------------------------------------
class TestBuildConfig:
def _write_data(self, tmp_path):
rows = [
{"instruction": f"q{i}", "output": f"a{i}"} for i in range(100)
]
path = tmp_path / "data.jsonl"
path.write_text(
"\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8"
)
return path
def test_build_soup_config(self, tmp_path):
from soup_cli.autopilot.generate_config import build_soup_config
from soup_cli.config.schema import SoupConfig
data_file = self._write_data(tmp_path)
cfg = build_soup_config(
model="meta-llama/Llama-3.1-8B-Instruct",
data_path=str(data_file),
goal="chat",
vram_gb=24.0,
)
assert isinstance(cfg, SoupConfig)
assert cfg.base == "meta-llama/Llama-3.1-8B-Instruct"
assert cfg.task == "sft"
assert cfg.training.quantization in ("4bit", "8bit", "none")
def test_write_yaml(self, tmp_path):
from soup_cli.autopilot.generate_config import build_soup_config, write_yaml
data_file = self._write_data(tmp_path)
cfg = build_soup_config(
model="meta-llama/Llama-3.1-8B-Instruct",
data_path=str(data_file),
goal="chat",
vram_gb=24.0,
)
output_path = tmp_path / "soup.yaml"
write_yaml(cfg, output_path)
assert output_path.exists()
content = output_path.read_text(encoding="utf-8")
assert "meta-llama/Llama-3.1-8B-Instruct" in content
# ---------------------------------------------------------------------------
# CLI command
# ---------------------------------------------------------------------------
class TestAutopilotCLI:
def _write_data(self, tmp_path):
rows = [
{"instruction": f"q{i}", "output": f"a{i}"} for i in range(50)
]
path = tmp_path / "data.jsonl"
path.write_text(
"\n".join(json.dumps(r) for r in rows) + "\n", encoding="utf-8"
)
return path
def test_help(self):
result = runner.invoke(app, ["autopilot", "--help"])
assert result.exit_code == 0
assert "autopilot" in result.output.lower()
def test_dry_run(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
data_file = self._write_data(tmp_path)
result = runner.invoke(app, [
"autopilot",
"--model", "meta-llama/Llama-3.1-8B-Instruct",
"--data", str(data_file.name),
"--goal", "chat",
"--gpu-budget", "24GB",
"--dry-run",
])
assert result.exit_code == 0
def test_writes_config(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
data_file = self._write_data(tmp_path)
result = runner.invoke(app, [
"autopilot",
"--model", "meta-llama/Llama-3.1-8B-Instruct",
"--data", str(data_file.name),
"--goal", "chat",
"--gpu-budget", "24GB",
"--output", "soup.yaml",
"--yes",
])
assert result.exit_code == 0, (
f"exit={result.exit_code}\n"
f"output={result.output}\n"
f"exception={result.exception!r}"
)
assert (tmp_path / "soup.yaml").exists()
def test_rejects_path_traversal_data(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
result = runner.invoke(app, [
"autopilot",
"--model", "meta-llama/Llama-3.1-8B-Instruct",
"--data", "../../etc/passwd",
"--goal", "chat",
"--gpu-budget", "24GB",
])
assert result.exit_code != 0
def test_rejects_bad_goal(self, tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
data_file = self._write_data(tmp_path)
result = runner.invoke(app, [
"autopilot",
"--model", "meta-llama/Llama-3.1-8B-Instruct",
"--data", str(data_file.name),
"--goal", "evil-goal",
"--gpu-budget", "24GB",
])
assert result.exit_code != 0
# ---------------------------------------------------------------------------
# GPU budget parsing
# ---------------------------------------------------------------------------
class TestGPUBudgetParsing:
def test_parse_gb(self):
from soup_cli.autopilot.decisions import parse_gpu_budget
assert parse_gpu_budget("24GB") == 24.0
assert parse_gpu_budget("80gb") == 80.0
def test_parse_numeric(self):
from soup_cli.autopilot.decisions import parse_gpu_budget
assert parse_gpu_budget("24") == 24.0
def test_parse_invalid_raises(self):
from soup_cli.autopilot.decisions import parse_gpu_budget
with pytest.raises(ValueError):
parse_gpu_budget("not-a-number")
def test_parse_out_of_bounds(self):
from soup_cli.autopilot.decisions import parse_gpu_budget
with pytest.raises(ValueError):
parse_gpu_budget("2000GB") # > 1TB
if __name__ == "__main__":
pytest.main([__file__, "-v"])