heulistic 0.1.0__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.
- heulistic/__init__.py +0 -0
- heulistic/__main__.py +11 -0
- heulistic/client.py +161 -0
- heulistic/download_cmd.py +92 -0
- heulistic/gpu.py +90 -0
- heulistic/init_cmd.py +152 -0
- heulistic/local_compose.yml +101 -0
- heulistic/local_runner.py +124 -0
- heulistic/main.py +72 -0
- heulistic/preflight.py +86 -0
- heulistic/stack.py +176 -0
- heulistic/status_cmd.py +99 -0
- heulistic/train_cmd.py +137 -0
- heulistic/validate_cmd.py +148 -0
- heulistic-0.1.0.dist-info/METADATA +169 -0
- heulistic-0.1.0.dist-info/RECORD +19 -0
- heulistic-0.1.0.dist-info/WHEEL +4 -0
- heulistic-0.1.0.dist-info/entry_points.txt +2 -0
- heulistic-0.1.0.dist-info/licenses/LICENSE +21 -0
heulistic/__init__.py
ADDED
|
File without changes
|
heulistic/__main__.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Entry point for `python -m heulistic`.
|
|
2
|
+
|
|
3
|
+
The `heulistic` console script is the normal way in; this makes the module form
|
|
4
|
+
work too, which is how the CLI gets run inside a virtualenv that has not been
|
|
5
|
+
activated.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .main import main
|
|
9
|
+
|
|
10
|
+
if __name__ == "__main__":
|
|
11
|
+
main()
|
heulistic/client.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""Talk to the local Heulistic API.
|
|
2
|
+
|
|
3
|
+
Standard library only, so `pip install heulistic` stays light — which is why
|
|
4
|
+
multipart bodies are assembled here rather than handed to a library.
|
|
5
|
+
|
|
6
|
+
Local mode accepts a fixed bearer token in place of a Cognito JWT. When cloud
|
|
7
|
+
support lands this is the seam where a real credential goes.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import mimetypes
|
|
14
|
+
import secrets
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Iterator
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"ApiError",
|
|
22
|
+
"encode_multipart",
|
|
23
|
+
"submit",
|
|
24
|
+
"get_job",
|
|
25
|
+
"download_url",
|
|
26
|
+
"stream_logs",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
DEV_TOKEN = "dev-localuser"
|
|
30
|
+
_TIMEOUT = 30
|
|
31
|
+
# Training runs are long; the log stream must not time out mid-job.
|
|
32
|
+
_STREAM_TIMEOUT = 60 * 60 * 24
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ApiError(Exception):
|
|
36
|
+
"""The API refused the request, carrying its `detail` where there is one."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _headers(content_type: str | None = None) -> dict[str, str]:
|
|
40
|
+
headers = {"Authorization": f"Bearer {DEV_TOKEN}"}
|
|
41
|
+
if content_type:
|
|
42
|
+
headers["Content-Type"] = content_type
|
|
43
|
+
return headers
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _raise_for_error(exc: urllib.error.HTTPError) -> None:
|
|
47
|
+
try:
|
|
48
|
+
detail = json.loads(exc.read()).get("detail", "")
|
|
49
|
+
except Exception:
|
|
50
|
+
detail = ""
|
|
51
|
+
raise ApiError(detail or f"HTTP {exc.code}") from exc
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _request(
|
|
55
|
+
method: str,
|
|
56
|
+
url: str,
|
|
57
|
+
*,
|
|
58
|
+
body: bytes | None = None,
|
|
59
|
+
content_type: str | None = None,
|
|
60
|
+
stream: bool = False,
|
|
61
|
+
):
|
|
62
|
+
"""Perform one HTTP request. Tests replace this wholesale."""
|
|
63
|
+
request = urllib.request.Request(
|
|
64
|
+
url, data=body, method=method, headers=_headers(content_type)
|
|
65
|
+
)
|
|
66
|
+
try:
|
|
67
|
+
response = urllib.request.urlopen(
|
|
68
|
+
request, timeout=_STREAM_TIMEOUT if stream else _TIMEOUT
|
|
69
|
+
)
|
|
70
|
+
except urllib.error.HTTPError as exc:
|
|
71
|
+
_raise_for_error(exc)
|
|
72
|
+
except urllib.error.URLError as exc:
|
|
73
|
+
raise ApiError(
|
|
74
|
+
f"Could not reach the Heulistic API at {url} — is it running? "
|
|
75
|
+
"Start it with: heulistic start"
|
|
76
|
+
) from exc
|
|
77
|
+
return response if stream else response.read()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def encode_multipart(
|
|
81
|
+
fields: dict[str, str | None], files: dict[str, tuple[str, bytes]]
|
|
82
|
+
) -> tuple[str, bytes]:
|
|
83
|
+
"""Build a multipart/form-data body. Returns (content_type, body)."""
|
|
84
|
+
boundary = secrets.token_hex(16)
|
|
85
|
+
parts: list[bytes] = []
|
|
86
|
+
|
|
87
|
+
for name, value in fields.items():
|
|
88
|
+
if value in (None, ""):
|
|
89
|
+
continue # an empty optional field must not be sent at all
|
|
90
|
+
parts.append(
|
|
91
|
+
f"--{boundary}\r\n"
|
|
92
|
+
f'Content-Disposition: form-data; name="{name}"\r\n\r\n'
|
|
93
|
+
f"{value}\r\n".encode()
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
for name, (filename, content) in files.items():
|
|
97
|
+
mime = mimetypes.guess_type(filename)[0] or "application/octet-stream"
|
|
98
|
+
parts.append(
|
|
99
|
+
f"--{boundary}\r\n"
|
|
100
|
+
f'Content-Disposition: form-data; name="{name}"; filename="{filename}"\r\n'
|
|
101
|
+
f"Content-Type: {mime}\r\n\r\n".encode()
|
|
102
|
+
)
|
|
103
|
+
parts.append(content)
|
|
104
|
+
parts.append(b"\r\n")
|
|
105
|
+
|
|
106
|
+
parts.append(f"--{boundary}--\r\n".encode())
|
|
107
|
+
return f"multipart/form-data; boundary={boundary}", b"".join(parts)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def submit(
|
|
111
|
+
api_url: str,
|
|
112
|
+
config: Path,
|
|
113
|
+
dataset: Path | None = None,
|
|
114
|
+
hf_token: str | None = None,
|
|
115
|
+
) -> dict:
|
|
116
|
+
"""Submit a training job. Returns the API's job record."""
|
|
117
|
+
files = {"config": (config.name, config.read_bytes())}
|
|
118
|
+
if dataset is not None:
|
|
119
|
+
files["dataset"] = (dataset.name, dataset.read_bytes())
|
|
120
|
+
|
|
121
|
+
content_type, body = encode_multipart({"hf_token": hf_token}, files)
|
|
122
|
+
raw = _request(
|
|
123
|
+
"POST", f"{api_url}/api/jobs", body=body, content_type=content_type
|
|
124
|
+
)
|
|
125
|
+
return json.loads(raw)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def get_job(api_url: str, job_id: str) -> dict:
|
|
129
|
+
return json.loads(_request("GET", f"{api_url}/api/jobs/{job_id}"))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def download_url(api_url: str, job_id: str) -> str:
|
|
133
|
+
raw = _request("GET", f"{api_url}/api/jobs/{job_id}/download")
|
|
134
|
+
return json.loads(raw)["url"]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _stream_lines(url: str) -> Iterator[bytes]:
|
|
138
|
+
response = _request("GET", url, stream=True)
|
|
139
|
+
for raw_line in response:
|
|
140
|
+
yield raw_line.rstrip(b"\n")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def stream_logs(api_url: str, job_id: str) -> Iterator[tuple[str, str]]:
|
|
144
|
+
"""Yield ("line", text) for each log line, then ("done", status) at the end.
|
|
145
|
+
|
|
146
|
+
Server-sent events; anything that is not a well-formed `data:` frame — a
|
|
147
|
+
keepalive, a blank separator — is skipped rather than surfaced.
|
|
148
|
+
"""
|
|
149
|
+
url = f"{api_url}/api/jobs/{job_id}/logs"
|
|
150
|
+
for raw_line in _stream_lines(url):
|
|
151
|
+
if not raw_line.startswith(b"data: "):
|
|
152
|
+
continue
|
|
153
|
+
try:
|
|
154
|
+
payload = json.loads(raw_line[len(b"data: "):])
|
|
155
|
+
except json.JSONDecodeError:
|
|
156
|
+
continue
|
|
157
|
+
if payload.get("event") == "done":
|
|
158
|
+
yield "done", payload.get("status", "")
|
|
159
|
+
return
|
|
160
|
+
if "line" in payload:
|
|
161
|
+
yield "line", payload["line"]
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""`heulistic download` — fetch a finished model.
|
|
2
|
+
|
|
3
|
+
Locally the artifact sits in MinIO inside Docker, so without this the output of
|
|
4
|
+
a run is hard to reach. The API hands back a URL; this follows it.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
import urllib.error
|
|
11
|
+
import urllib.request
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from . import client, stack
|
|
18
|
+
|
|
19
|
+
_SUFFIX = "-model.tar.gz"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _fetch(url: str, dest: Path) -> Path:
|
|
23
|
+
"""Stream the artifact to disk. Replaced in tests."""
|
|
24
|
+
with urllib.request.urlopen(url, timeout=300) as response, open(dest, "wb") as out:
|
|
25
|
+
shutil.copyfileobj(response, out)
|
|
26
|
+
return dest
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _resolve_destination(output: Optional[Path], job_id: str) -> Path:
|
|
30
|
+
default_name = f"{job_id}{_SUFFIX}"
|
|
31
|
+
if output is None:
|
|
32
|
+
return Path.cwd() / default_name
|
|
33
|
+
if output.is_dir():
|
|
34
|
+
return output / default_name
|
|
35
|
+
return output
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def download(
|
|
39
|
+
job_id: str = typer.Argument(..., help="Job ID to download the model for."),
|
|
40
|
+
output: Optional[Path] = typer.Option(
|
|
41
|
+
None, "--output", "-o", help="File or directory to write to."
|
|
42
|
+
),
|
|
43
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite an existing file."),
|
|
44
|
+
url_only: bool = typer.Option(
|
|
45
|
+
False, "--url-only", help="Print the download URL instead of fetching it."
|
|
46
|
+
),
|
|
47
|
+
) -> None:
|
|
48
|
+
"""Download the trained model for a completed job."""
|
|
49
|
+
api_url = stack.api_url()
|
|
50
|
+
if not api_url:
|
|
51
|
+
typer.secho("Heulistic is not running.", fg=typer.colors.RED)
|
|
52
|
+
typer.echo("Start it with: heulistic start")
|
|
53
|
+
raise typer.Exit(code=1)
|
|
54
|
+
|
|
55
|
+
try:
|
|
56
|
+
job = client.get_job(api_url, job_id)
|
|
57
|
+
except client.ApiError as exc:
|
|
58
|
+
typer.secho(str(exc), fg=typer.colors.RED)
|
|
59
|
+
raise typer.Exit(code=1)
|
|
60
|
+
|
|
61
|
+
status = job.get("status", "unknown")
|
|
62
|
+
if status != "completed":
|
|
63
|
+
typer.secho(f"Job {job_id} is '{status}' — a model is only available once "
|
|
64
|
+
"the job has completed.", fg=typer.colors.RED)
|
|
65
|
+
raise typer.Exit(code=1)
|
|
66
|
+
|
|
67
|
+
try:
|
|
68
|
+
url = client.download_url(api_url, job_id)
|
|
69
|
+
except client.ApiError as exc:
|
|
70
|
+
typer.secho(str(exc), fg=typer.colors.RED)
|
|
71
|
+
raise typer.Exit(code=1)
|
|
72
|
+
|
|
73
|
+
if url_only:
|
|
74
|
+
typer.echo(url)
|
|
75
|
+
raise typer.Exit(code=0)
|
|
76
|
+
|
|
77
|
+
dest = _resolve_destination(output, job_id)
|
|
78
|
+
if dest.exists() and not force:
|
|
79
|
+
typer.secho(f"{dest} already exists — pass --force to overwrite.",
|
|
80
|
+
fg=typer.colors.RED)
|
|
81
|
+
raise typer.Exit(code=1)
|
|
82
|
+
|
|
83
|
+
typer.echo(f"Downloading {job_id} → {dest}")
|
|
84
|
+
try:
|
|
85
|
+
_fetch(url, dest)
|
|
86
|
+
except (urllib.error.URLError, OSError) as exc:
|
|
87
|
+
typer.secho(f"Download failed: {exc}", fg=typer.colors.RED)
|
|
88
|
+
raise typer.Exit(code=1)
|
|
89
|
+
|
|
90
|
+
size_mb = dest.stat().st_size / (1024 * 1024)
|
|
91
|
+
typer.secho(f"Saved {dest} ({size_mb:.1f} MB)", fg=typer.colors.GREEN)
|
|
92
|
+
typer.echo(f" tar xzf {dest.name}")
|
heulistic/gpu.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""Local GPU detection via nvidia-smi.
|
|
2
|
+
|
|
3
|
+
Used to check whether a config fits the machine it is about to run on. Every
|
|
4
|
+
path here degrades to "no GPU found" rather than raising — running
|
|
5
|
+
`validate-config` on a laptop without an NVIDIA card is ordinary, not an error.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import shutil
|
|
11
|
+
import subprocess
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
|
|
14
|
+
from heulistic_axolotl import Hardware
|
|
15
|
+
|
|
16
|
+
__all__ = ["DetectedGpu", "detect_gpus", "detect_hardware"]
|
|
17
|
+
|
|
18
|
+
_MB_PER_GB = 1024
|
|
19
|
+
_QUERY = ["--query-gpu=name,memory.total", "--format=csv,noheader,nounits"]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True)
|
|
23
|
+
class DetectedGpu:
|
|
24
|
+
name: str
|
|
25
|
+
vram_mb: int
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _nvidia_smi_output() -> str | None:
|
|
29
|
+
"""Raw `nvidia-smi` CSV, or None when unavailable.
|
|
30
|
+
|
|
31
|
+
Isolated so tests can supply canned output without mocking subprocess.
|
|
32
|
+
"""
|
|
33
|
+
if shutil.which("nvidia-smi") is None:
|
|
34
|
+
return None
|
|
35
|
+
try:
|
|
36
|
+
result = subprocess.run(
|
|
37
|
+
["nvidia-smi", *_QUERY],
|
|
38
|
+
capture_output=True,
|
|
39
|
+
text=True,
|
|
40
|
+
timeout=10,
|
|
41
|
+
)
|
|
42
|
+
except (OSError, subprocess.SubprocessError):
|
|
43
|
+
return None
|
|
44
|
+
return result.stdout if result.returncode == 0 else None
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def detect_gpus() -> list[DetectedGpu]:
|
|
48
|
+
"""Every NVIDIA GPU on this machine. Empty when none can be found."""
|
|
49
|
+
output = _nvidia_smi_output()
|
|
50
|
+
if not output:
|
|
51
|
+
return []
|
|
52
|
+
|
|
53
|
+
cards: list[DetectedGpu] = []
|
|
54
|
+
for line in output.splitlines():
|
|
55
|
+
line = line.strip()
|
|
56
|
+
if not line or "," not in line:
|
|
57
|
+
continue
|
|
58
|
+
name, _, memory = line.rpartition(",")
|
|
59
|
+
try:
|
|
60
|
+
vram_mb = int(memory.strip())
|
|
61
|
+
except ValueError:
|
|
62
|
+
continue # a driver error lands in this column instead of a number
|
|
63
|
+
cards.append(DetectedGpu(name=name.strip(), vram_mb=vram_mb))
|
|
64
|
+
return cards
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _label(cards: list[DetectedGpu]) -> str:
|
|
68
|
+
names = {c.name for c in cards}
|
|
69
|
+
if len(cards) == 1:
|
|
70
|
+
return cards[0].name
|
|
71
|
+
if len(names) == 1:
|
|
72
|
+
return f"{len(cards)}x {cards[0].name}"
|
|
73
|
+
return f"{len(cards)} GPUs"
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def detect_hardware() -> Hardware | None:
|
|
77
|
+
"""The local GPUs as a validation target, or None if there are none usable."""
|
|
78
|
+
cards = detect_gpus()
|
|
79
|
+
if not cards:
|
|
80
|
+
return None
|
|
81
|
+
total_gb = sum(c.vram_mb for c in cards) / _MB_PER_GB
|
|
82
|
+
try:
|
|
83
|
+
return Hardware(
|
|
84
|
+
total_vram_gb=round(total_gb, 2),
|
|
85
|
+
gpu_count=len(cards),
|
|
86
|
+
label=_label(cards),
|
|
87
|
+
)
|
|
88
|
+
except ValueError:
|
|
89
|
+
# A device reporting zero memory is not something we can size against.
|
|
90
|
+
return None
|
heulistic/init_cmd.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""`heulistic init` — write a starter config sized to this machine.
|
|
2
|
+
|
|
3
|
+
A generic sample config is a poor first experience: the usual failure is an
|
|
4
|
+
OOM twenty minutes in, because the example assumed a bigger card. Since the
|
|
5
|
+
CLI can already see the local GPU and estimate VRAM, the starter config picks
|
|
6
|
+
a model that actually fits.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Optional
|
|
13
|
+
|
|
14
|
+
import typer
|
|
15
|
+
|
|
16
|
+
from heulistic_axolotl import validate
|
|
17
|
+
from heulistic_axolotl.vram import vram_required_gb
|
|
18
|
+
|
|
19
|
+
from . import gpu
|
|
20
|
+
|
|
21
|
+
# QLoRA-friendly models, smallest first. The largest entry that fits the card
|
|
22
|
+
# wins.
|
|
23
|
+
#
|
|
24
|
+
# The ladder deliberately stops at 8B rather than climbing to 70B. This is a
|
|
25
|
+
# starter config: the goal is a run that finishes quickly and works, not one
|
|
26
|
+
# that fills the GPU. Someone with an 80 GB card can pass --model.
|
|
27
|
+
_LADDER = [
|
|
28
|
+
"Qwen/Qwen2.5-0.5B",
|
|
29
|
+
"Qwen/Qwen2.5-1.5B",
|
|
30
|
+
"Qwen/Qwen2.5-3B",
|
|
31
|
+
"Qwen/Qwen2.5-7B",
|
|
32
|
+
]
|
|
33
|
+
# Every entry must be ungated. The ladder previously led with Llama-3.2, which
|
|
34
|
+
# is gated on the Hub — so the first `heulistic train` after `init` was rejected
|
|
35
|
+
# for want of an approved HuggingFace token, which is precisely the wall `init`
|
|
36
|
+
# exists to avoid. Qwen2.5 is Apache-2.0 and needs no token.
|
|
37
|
+
_FALLBACK_MODEL = "Qwen/Qwen2.5-0.5B"
|
|
38
|
+
|
|
39
|
+
# Leave room for activations, fragmentation and the desktop's own usage.
|
|
40
|
+
_USABLE_FRACTION = 0.75
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _template(base_model: str) -> dict:
|
|
44
|
+
"""A complete, warning-free QLoRA config.
|
|
45
|
+
|
|
46
|
+
Every field the validator would otherwise flag as missing is set
|
|
47
|
+
explicitly, so `heulistic validate-config` on a freshly generated file is
|
|
48
|
+
silent — a starter config that immediately complains teaches the wrong
|
|
49
|
+
thing.
|
|
50
|
+
"""
|
|
51
|
+
return {
|
|
52
|
+
"base_model": base_model,
|
|
53
|
+
"adapter": "qlora",
|
|
54
|
+
"load_in_4bit": True,
|
|
55
|
+
"datasets": [{"path": "tatsu-lab/alpaca", "type": "alpaca"}],
|
|
56
|
+
"sequence_len": 1024,
|
|
57
|
+
"micro_batch_size": 1,
|
|
58
|
+
"gradient_accumulation_steps": 8,
|
|
59
|
+
"num_epochs": 1,
|
|
60
|
+
"learning_rate": 0.0002,
|
|
61
|
+
"lora_r": 16,
|
|
62
|
+
"lora_alpha": 32,
|
|
63
|
+
"lora_dropout": 0.05,
|
|
64
|
+
"lora_target_linear": True,
|
|
65
|
+
"bf16": "auto",
|
|
66
|
+
"gradient_checkpointing": True,
|
|
67
|
+
"save_steps": 100,
|
|
68
|
+
"val_set_size": 0.05,
|
|
69
|
+
"output_dir": "./outputs",
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _pick_model(budget_gb: float | None) -> str:
|
|
74
|
+
"""Largest model from the ladder that fits the budget."""
|
|
75
|
+
if budget_gb is None:
|
|
76
|
+
return _FALLBACK_MODEL
|
|
77
|
+
chosen = None
|
|
78
|
+
for model in _LADDER:
|
|
79
|
+
if vram_required_gb(_template(model)) <= budget_gb:
|
|
80
|
+
chosen = model
|
|
81
|
+
return chosen or _LADDER[0]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _render(config: dict) -> str:
|
|
85
|
+
"""Emit YAML by hand, so the file is commented and ordered for a human."""
|
|
86
|
+
import yaml
|
|
87
|
+
|
|
88
|
+
header = (
|
|
89
|
+
"# Generated by `heulistic init`.\n"
|
|
90
|
+
"# Check it before training: heulistic validate-config config.yaml\n"
|
|
91
|
+
"# Then run it: heulistic train config.yaml\n\n"
|
|
92
|
+
)
|
|
93
|
+
return header + yaml.safe_dump(config, sort_keys=False, default_flow_style=False)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def init(
|
|
97
|
+
output: Path = typer.Option(
|
|
98
|
+
Path("config.yaml"), "--output", "-o", help="Where to write the config."
|
|
99
|
+
),
|
|
100
|
+
model: Optional[str] = typer.Option(
|
|
101
|
+
None, "--model", "-m", help="Base model to use instead of the auto-picked one."
|
|
102
|
+
),
|
|
103
|
+
force: bool = typer.Option(False, "--force", "-f", help="Overwrite an existing file."),
|
|
104
|
+
) -> None:
|
|
105
|
+
"""Write a starter Axolotl config, sized to the local GPU."""
|
|
106
|
+
if output.exists() and not force:
|
|
107
|
+
typer.secho(f"{output} already exists — pass --force to overwrite.",
|
|
108
|
+
fg=typer.colors.RED)
|
|
109
|
+
raise typer.Exit(code=1)
|
|
110
|
+
|
|
111
|
+
hardware = gpu.detect_hardware()
|
|
112
|
+
if hardware is None:
|
|
113
|
+
typer.secho(
|
|
114
|
+
"No NVIDIA GPU detected — writing a small starter config. "
|
|
115
|
+
"Use --model to choose another.",
|
|
116
|
+
fg=typer.colors.YELLOW,
|
|
117
|
+
)
|
|
118
|
+
budget = None
|
|
119
|
+
else:
|
|
120
|
+
budget = hardware.total_vram_gb * _USABLE_FRACTION
|
|
121
|
+
typer.echo(
|
|
122
|
+
f"Detected {hardware.label} ({hardware.total_vram_gb:g} GB) — "
|
|
123
|
+
f"sizing for roughly {budget:.0f} GB of usable VRAM."
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
base_model = model or _pick_model(budget)
|
|
127
|
+
config = _template(base_model)
|
|
128
|
+
required = vram_required_gb(config)
|
|
129
|
+
|
|
130
|
+
if model and hardware is not None and required > hardware.total_vram_gb:
|
|
131
|
+
typer.secho(
|
|
132
|
+
f" Warning: {model} needs roughly {required:.0f} GB of VRAM but "
|
|
133
|
+
f"{hardware.label} has {hardware.total_vram_gb:g} GB.",
|
|
134
|
+
fg=typer.colors.YELLOW,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
output.write_text(_render(config))
|
|
138
|
+
|
|
139
|
+
typer.secho(f"Wrote {output}", fg=typer.colors.GREEN)
|
|
140
|
+
typer.echo(f" model: {base_model} (~{required:.1f} GB VRAM as QLoRA)")
|
|
141
|
+
typer.echo(f" data: tatsu-lab/alpaca — swap in your own under 'datasets'")
|
|
142
|
+
typer.echo()
|
|
143
|
+
typer.echo(f"Next: heulistic train {output}")
|
|
144
|
+
|
|
145
|
+
# Never ship a starter file our own validator would complain about.
|
|
146
|
+
problems = [d for d in validate(output.read_text()) if d.level in ("error", "warning")]
|
|
147
|
+
if problems: # pragma: no cover - guards against template drift
|
|
148
|
+
typer.secho(
|
|
149
|
+
f" ({len(problems)} validator finding(s) in the generated file — "
|
|
150
|
+
"please report this)",
|
|
151
|
+
fg=typer.colors.YELLOW,
|
|
152
|
+
)
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
name: heulistic
|
|
2
|
+
|
|
3
|
+
services:
|
|
4
|
+
db:
|
|
5
|
+
image: postgres:15
|
|
6
|
+
environment:
|
|
7
|
+
POSTGRES_DB: heulistic
|
|
8
|
+
POSTGRES_USER: heulistic_admin
|
|
9
|
+
POSTGRES_PASSWORD: password
|
|
10
|
+
volumes:
|
|
11
|
+
- postgres_data:/var/lib/postgresql/data
|
|
12
|
+
healthcheck:
|
|
13
|
+
test: ["CMD-SHELL", "pg_isready -U heulistic_admin -d heulistic"]
|
|
14
|
+
interval: 5s
|
|
15
|
+
timeout: 5s
|
|
16
|
+
retries: 5
|
|
17
|
+
|
|
18
|
+
minio:
|
|
19
|
+
image: minio/minio
|
|
20
|
+
command: server /data --console-address ":9001"
|
|
21
|
+
environment:
|
|
22
|
+
MINIO_ROOT_USER: minioadmin
|
|
23
|
+
MINIO_ROOT_PASSWORD: minioadmin
|
|
24
|
+
ports:
|
|
25
|
+
- "9000:9000"
|
|
26
|
+
- "9001:9001"
|
|
27
|
+
volumes:
|
|
28
|
+
- minio_data:/data
|
|
29
|
+
|
|
30
|
+
minio-init:
|
|
31
|
+
image: minio/mc
|
|
32
|
+
depends_on:
|
|
33
|
+
- minio
|
|
34
|
+
entrypoint: >
|
|
35
|
+
/bin/sh -c "
|
|
36
|
+
until mc alias set local http://minio:9000 minioadmin minioadmin; do sleep 1; done;
|
|
37
|
+
mc mb local/heulistic-local --ignore-existing;
|
|
38
|
+
exit 0"
|
|
39
|
+
|
|
40
|
+
api:
|
|
41
|
+
image: heulistic/api:latest
|
|
42
|
+
environment:
|
|
43
|
+
DATABASE_URL: postgresql+asyncpg://heulistic_admin:password@db:5432/heulistic
|
|
44
|
+
LOCAL_MODE: "true"
|
|
45
|
+
S3_BUCKET: heulistic-local
|
|
46
|
+
S3_ENDPOINT_URL: http://minio:9000
|
|
47
|
+
MINIO_ACCESS_KEY: minioadmin
|
|
48
|
+
MINIO_SECRET_KEY: minioadmin
|
|
49
|
+
FRONTEND_URL: http://localhost:${PORT:-3000}
|
|
50
|
+
# Download links are built from this, so it must match the published port.
|
|
51
|
+
API_PUBLIC_URL: http://localhost:${API_PORT:-8000}
|
|
52
|
+
AWS_REGION: us-east-1
|
|
53
|
+
ports:
|
|
54
|
+
- "${API_PORT:-8000}:8000"
|
|
55
|
+
depends_on:
|
|
56
|
+
db:
|
|
57
|
+
condition: service_healthy
|
|
58
|
+
minio-init:
|
|
59
|
+
condition: service_completed_successfully
|
|
60
|
+
|
|
61
|
+
docker-proxy:
|
|
62
|
+
image: tecnativa/docker-socket-proxy:edge
|
|
63
|
+
environment:
|
|
64
|
+
CONTAINERS: 1
|
|
65
|
+
IMAGES: 1
|
|
66
|
+
POST: 1
|
|
67
|
+
volumes:
|
|
68
|
+
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
69
|
+
restart: unless-stopped
|
|
70
|
+
|
|
71
|
+
scheduler:
|
|
72
|
+
image: heulistic/scheduler:latest
|
|
73
|
+
environment:
|
|
74
|
+
DATABASE_URL: postgresql://heulistic_admin:password@db:5432/heulistic
|
|
75
|
+
LOCAL_MODE: "true"
|
|
76
|
+
API_INTERNAL_URL: http://api:8000
|
|
77
|
+
LOCAL_TRAINING_IMAGE: heulistic/training:latest
|
|
78
|
+
DOCKER_NETWORK: heulistic_default
|
|
79
|
+
LOCAL_CACHE_VOLUME: heulistic-cache
|
|
80
|
+
LOCAL_GPU: "${LOCAL_GPU:-true}"
|
|
81
|
+
MAX_CONCURRENT_JOBS: "1"
|
|
82
|
+
HF_TOKEN: "${HF_TOKEN:-}"
|
|
83
|
+
AWS_REGION: us-east-1
|
|
84
|
+
DOCKER_SOCKET: tcp://docker-proxy:2375
|
|
85
|
+
depends_on:
|
|
86
|
+
- api
|
|
87
|
+
- docker-proxy
|
|
88
|
+
|
|
89
|
+
frontend:
|
|
90
|
+
image: heulistic/frontend-local:latest
|
|
91
|
+
environment:
|
|
92
|
+
API_URL: http://api:8000
|
|
93
|
+
ports:
|
|
94
|
+
- "${PORT:-3000}:3000"
|
|
95
|
+
depends_on:
|
|
96
|
+
- api
|
|
97
|
+
|
|
98
|
+
volumes:
|
|
99
|
+
postgres_data:
|
|
100
|
+
minio_data:
|
|
101
|
+
heulistic-cache:
|