File size: 16,517 Bytes
f0975ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0052e99
f0975ca
 
0052e99
f0975ca
 
 
 
 
 
 
0052e99
f0975ca
 
 
 
 
0052e99
f0975ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0052e99
 
 
f0975ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0052e99
 
 
f0975ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Self-host Beam-1.0 (NVFP4) on Modal with SGLang to generate distillation traces.

  modal run modal_app/beam_selfhost.py::download            # one-time: weights -> Volume (CPU only)
  modal run modal_app/beam_selfhost.py::bench --ids FILE    # throughput test + parity on 2x B300

Every generation is appended to the `beam-dory-data` Volume under selfhost/, in the
same record format as scripts/generate.py, so build_dataset.py can verify and merge it.
"""

import json
import os
import subprocess
import time
from pathlib import Path

import modal

APP = modal.App("beam-dory-selfhost")
weights = modal.Volume.from_name("beam-weights", create_if_missing=True, version=2)
data = modal.Volume.from_name("beam-dory-data", create_if_missing=True, version=2)

BEAM_REPO = "reflection/Beam-1.0-501B-A23B-NVFP4"
FP8_REPO = "reflection/Beam-1.0-501B-A23B-FP8"
DRAFT_REPO = "reflection/Beam-1.0-501B-A23B-DFlash2"
MODELS = {"nvfp4": ("beam-nvfp4", 2), "fp8": ("beam-fp8", 4)}  # weights subdir, tensor-parallel GPUs
# GPU configs that fit each checkpoint, in order of preference (cost-effectiveness)
GPU_CHOICES = {"nvfp4": ["B300:2"], "fp8": ["B300:4", "H200:8", "B200:8"],
               "auto": ["B300:4", "H200:8", "B200:8", "B200:4", "B300:2"]}  # auto: FP8 if it fits, else NVFP4
GPU_MEM_GB = {"B300": 288, "B200": 180, "H200": 141, "H100": 80}
GPU_RATES = {"B300": 7.10, "B200": 6.25, "H200": 4.54, "H100": 3.95}  # $/GPU-hour on Modal
W = "/weights"
D = "/data"

dl_image = modal.Image.debian_slim(python_version="3.12").pip_install("huggingface_hub[hf_xet]>=0.34")
sglang_image = (
    modal.Image.from_registry("lmsysorg/sglang:beam")
    .pip_install("httpx")
    .env({"HF_HUB_OFFLINE": "1", "SGLANG_DISABLE_REQUEST_LOGGING": "1"})
)


@APP.function(image=dl_image, volumes={W: weights}, cpu=8, memory=16384, timeout=4 * 3600)
def download(which: str = "nvfp4"):
    from huggingface_hub import snapshot_download

    repos = {"nvfp4": [(BEAM_REPO, "beam-nvfp4"), (DRAFT_REPO, "dflash2")], "fp8": [(FP8_REPO, "beam-fp8")]}[which]
    for repo, sub in repos:
        t0 = time.time()
        path = snapshot_download(repo, local_dir=f"{W}/{sub}", max_workers=16)
        size = sum(f.stat().st_size for f in Path(path).rglob("*") if f.is_file())
        print(f"{repo}: {size / 1e9:.1f} GB in {time.time() - t0:.0f}s", flush=True)
        weights.commit()


def start_server(tp, spec, context_len, mem_frac, model_dir="beam-nvfp4", attn="trtllm_mha"):
    cmd = [
        "python", "-m", "sglang.launch_server",
        "--model-path", f"{W}/{model_dir}", "--served-model-name", "beam",
        "--tp", str(tp), "--attention-backend", attn, "--page-size", "64",
        "--context-length", str(context_len), "--mem-fraction-static", str(mem_frac),
        "--reasoning-parser", "beam", "--tool-call-parser", "beam",
        "--host", "127.0.0.1", "--port", "30000",
    ]
    if spec:
        cmd += ["--speculative-algorithm", "DFLASH", "--speculative-draft-model-path", f"{W}/dflash2",
                "--speculative-dflash-block-size", "8"]
    print("launch:", " ".join(cmd), flush=True)
    log = open("/tmp/sglang.log", "w")
    return subprocess.Popen(cmd, stdout=log, stderr=subprocess.STDOUT)


def wait_ready(proc, timeout_s):
    import httpx

    t0 = time.time()
    while time.time() - t0 < timeout_s:
        if proc.poll() is not None:
            raise RuntimeError("sglang exited:\n" + Path("/tmp/sglang.log").read_text()[-6000:])
        try:
            if httpx.get("http://127.0.0.1:30000/health", timeout=5).status_code == 200:
                return time.time() - t0
        except httpx.HTTPError:
            pass
        time.sleep(10)
    raise TimeoutError("sglang not ready:\n" + Path("/tmp/sglang.log").read_text()[-6000:])


async def run_phase(items, concurrency, seconds, out_f, label, max_tokens):
    """Pull prompts from items with `concurrency` workers; stop issuing after `seconds`."""
    import asyncio

    import httpx

    queue = asyncio.Queue()
    for it in items:
        queue.put_nowait(it)
    stats = {"n": 0, "tokens": 0, "errors": 0}
    t_start = time.time()
    deadline = t_start + seconds if seconds else None

    async def worker(client):
        while not queue.empty():
            if deadline and time.time() > deadline:
                return
            it = queue.get_nowait()
            body = {
                "model": "beam",
                "messages": [{"role": "user", "content": it["prompt"]}],
                "max_tokens": max_tokens,
                "temperature": 0.7,
                "top_p": 0.99,
                "chat_template_kwargs": {"reasoning_effort": "medium"},
                "separate_reasoning": True,
            }
            t0 = time.time()
            try:
                r = await client.post("http://127.0.0.1:30000/v1/chat/completions", json=body)
                r.raise_for_status()
                resp = r.json()
            except Exception as e:  # keep the phase going; record the failure
                stats["errors"] += 1
                out_f.write(json.dumps({"id": it["id"], "phase": label, "error": repr(e)[:300]}) + "\n")
                continue
            choice = resp["choices"][0]
            usage = resp.get("usage") or {}
            stats["n"] += 1
            stats["tokens"] += usage.get("completion_tokens", 0)
            rec = {k: it.get(k) for k in ("id", "domain", "source", "prompt", "difficulty", "topic", "answer_type")}
            rec.update({
                "effort": "medium",
                "gold": it["answer"],
                "teacher": "beam-nvfp4-selfhost-sglang",
                "phase": label,
                "reasoning": choice["message"].get("reasoning_content") or "",
                "content": choice["message"].get("content") or "",
                "finish_reason": choice.get("finish_reason"),
                "usage": usage,
                "latency_s": round(time.time() - t0, 2),
                "time": time.strftime("%Y-%m-%dT%H:%M:%S"),
            })
            out_f.write(json.dumps(rec, ensure_ascii=False) + "\n")
            out_f.flush()

    limits = httpx.Limits(max_connections=concurrency + 8)
    async with httpx.AsyncClient(timeout=httpx.Timeout(1800), limits=limits) as client:
        await asyncio.gather(*(worker(client) for _ in range(concurrency)))
    wall = time.time() - t_start
    stats.update({"label": label, "concurrency": concurrency, "wall_s": round(wall, 1),
                  "out_tok_per_s": round(stats["tokens"] / wall, 1)})
    print("PHASE", json.dumps(stats), flush=True)
    return stats


@APP.function(image=sglang_image, gpu="B300:2", volumes={W: weights, D: data}, timeout=60 * 60,
              memory=262144, cpu=16)
def bench(items: list, parity_ids: list, sweep: list, phase_seconds: int = 300, spec: bool = False,
          max_tokens: int = 16384, tag: str = "bench"):
    import asyncio

    t_boot = time.time()
    proc = start_server(tp=2, spec=spec, context_len=32768, mem_frac=0.88)
    ready_s = wait_ready(proc, timeout_s=30 * 60)
    print(f"server ready after {ready_s:.0f}s", flush=True)
    print(subprocess.run(["nvidia-smi", "--query-gpu=name,memory.used,memory.total", "--format=csv"],
                         capture_output=True, text=True).stdout, flush=True)

    out_dir = Path(D) / "selfhost"
    out_dir.mkdir(parents=True, exist_ok=True)
    out_path = out_dir / f"{tag}-{time.strftime('%Y%m%d-%H%M%S')}.jsonl"
    parity = [it for it in items if it["id"] in set(parity_ids)]
    fresh = [it for it in items if it["id"] not in set(parity_ids)]
    results = {"ready_s": round(ready_s, 1), "spec": spec, "phases": [], "out_file": str(out_path)}
    with open(out_path, "a") as f:
        if parity:
            results["phases"].append(asyncio.run(run_phase(parity, len(parity), 0, f, "parity", max_tokens)))
            data.commit()
        pos = 0
        for conc in sweep:
            chunk = fresh[pos:]
            st = asyncio.run(run_phase(chunk, conc, phase_seconds, f, f"c{conc}", max_tokens))
            pos += st["n"] + st["errors"]
            results["phases"].append(st)
            data.commit()
    proc.terminate()
    results["total_s"] = round(time.time() - t_boot, 1)
    print("RESULTS", json.dumps(results), flush=True)
    return results


@APP.local_entrypoint()
def main(ids: str = "data/selfhost_test_ids.txt", parity: str = "data/pilot_ids.txt",
         sweep: str = "64,128,256", phase_seconds: int = 300, spec: bool = False):
    root = Path(__file__).resolve().parent.parent
    pool = {}
    for name in ("pool.jsonl", "pool_ext.jsonl"):
        for line in open(root / "data" / name):
            r = json.loads(line)
            pool[r["id"]] = r
    wanted = [l.strip() for l in open(root / ids) if l.strip()]
    parity_ids = [l.strip() for l in open(root / parity) if l.strip()]
    items = [pool[i] for i in parity_ids + wanted]
    res = bench.remote(items, parity_ids, [int(x) for x in sweep.split(",")], phase_seconds, spec)
    print(json.dumps(res, indent=1))


TEACHER = "beam-selfhost-sglang"


async def produce(items, n, effort, concurrency, deadline, out_path, max_tokens, log_every=120):
    """Long-running generation: n samples per prompt, stop issuing at `deadline`, commit periodically."""
    import asyncio

    import httpx

    queue = asyncio.Queue()
    for it in items:
        queue.put_nowait(it)
    st = {"prompts": 0, "samples": 0, "tokens": 0, "errors": 0}
    t0 = time.time()
    out_f = open(out_path, "a")

    async def worker(client):
        while not queue.empty() and time.time() < deadline:
            it = queue.get_nowait()
            eff = it.get("_effort", effort)  # multi-effort runs tag each queue item with its effort
            body = {"model": "beam", "messages": [{"role": "user", "content": it["prompt"]}],
                    "max_tokens": max_tokens, "temperature": 0.7, "top_p": 0.99, "n": n,
                    "chat_template_kwargs": {"reasoning_effort": eff}, "separate_reasoning": True}
            t1 = time.time()
            try:
                r = await client.post("http://127.0.0.1:30000/v1/chat/completions", json=body)
                r.raise_for_status()
                resp = r.json()
            except Exception as e:
                st["errors"] += 1
                out_f.write(json.dumps({"id": it["id"], "effort": eff, "error": repr(e)[:300]}) + "\n")
                continue
            usage = resp.get("usage") or {}
            per = usage.get("completion_tokens", 0) / max(1, len(resp["choices"]))
            for k, choice in enumerate(resp["choices"]):
                rec = {k2: it.get(k2) for k2 in ("id", "domain", "source", "prompt", "difficulty", "topic", "answer_type")}
                rec.update({"effort": eff, "gold": it["answer"], "teacher": TEACHER,
                            "sample_idx": k, "reasoning": choice["message"].get("reasoning_content") or "",
                            "content": choice["message"].get("content") or "",
                            "finish_reason": choice.get("finish_reason"),
                            # SGLang reports usage for the whole request; split evenly as an estimate
                            "usage": {"completion_tokens": round(per), "request_completion_tokens": usage.get("completion_tokens")},
                            "latency_s": round(time.time() - t1, 2), "time": time.strftime("%Y-%m-%dT%H:%M:%S")})
                out_f.write(json.dumps(rec, ensure_ascii=False) + "\n")
                st["samples"] += 1
            st["prompts"] += 1
            st["tokens"] += usage.get("completion_tokens", 0)

    async def reporter():
        while True:
            await asyncio.sleep(log_every)
            out_f.flush()
            await asyncio.to_thread(data.commit)
            el = time.time() - t0
            print(f"PROGRESS {time.strftime('%H:%M:%S')} prompts={st['prompts']} samples={st['samples']} "
                  f"err={st['errors']} tok/s={st['tokens'] / el:.0f} left={queue.qsize()} "
                  f"stop_in={(deadline - time.time()) / 60:.0f}min", flush=True)

    limits = httpx.Limits(max_connections=concurrency + 8)
    async with httpx.AsyncClient(timeout=httpx.Timeout(3600), limits=limits) as client:
        rep = asyncio.create_task(reporter())
        await asyncio.gather(*(worker(client) for _ in range(concurrency)))
        rep.cancel()
    out_f.close()
    data.commit()
    st["wall_s"] = round(time.time() - t0)
    return st


def _run_impl(prompts_file: str, tag: str, n: int = 2, effort: str = "medium", concurrency: int = 320,
              max_hours: float = 9.0, max_tokens: int = 16384, model: str = "nvfp4", max_dollars: float = 0):
    global TEACHER
    """Production run over a prompt file on the data Volume. Resumable: (prompt, effort) pairs already in
    selfhost/run-<tag>-*.jsonl are skipped (survives preemption). `effort` may list several levels
    ("low,high"): every prompt is queued once per level, interleaved so all levels progress together."""
    import asyncio

    t_start = time.time()
    deadline = t_start + max_hours * 3600
    items = [json.loads(l) for l in open(f"{D}/{prompts_file}")]
    out_dir = Path(D) / "selfhost"
    out_dir.mkdir(parents=True, exist_ok=True)
    done = set()
    for f in out_dir.glob(f"run-{tag}-*.jsonl"):
        for line in open(f):
            try:
                r = json.loads(line)
            except json.JSONDecodeError:  # torn last line after preemption
                continue
            if "error" not in r:
                done.add((r["id"], r.get("effort", "medium")))
    efforts = effort.split(",")
    todo = [dict(it, _effort=e) for it in items for e in efforts if (it["id"], e) not in done]
    TEACHER = f"beam-{model}-selfhost-sglang"
    print(f"{len(items)} prompts, {len(done)} already done, {len(todo)} to go; n={n} effort={effort}", flush=True)

    gpus = subprocess.run(["nvidia-smi", "--query-gpu=name", "--format=csv,noheader"],
                          capture_output=True, text=True).stdout.split("\n")
    gpus = [g for g in gpus if g.strip()]
    kind = next(k for k in GPU_RATES if k in gpus[0])
    tp = len(gpus)
    if model == "auto":  # FP8 (503 GB) needs ~1 TB of GPU memory; otherwise use NVFP4 (294 GB)
        model = "fp8" if tp * GPU_MEM_GB[kind] >= 1000 else "nvfp4"
        TEACHER = f"beam-{model}-selfhost-sglang"
    model_dir, _ = MODELS[model]
    rate = tp * GPU_RATES[kind] + 2.8  # $/h incl. CPU and memory
    if max_dollars:
        deadline = min(deadline, t_start + max_dollars / rate * 3600)
    attn = "fa3" if kind in ("H200", "H100") else "trtllm_mha"
    print(f"got {tp}x {kind} (~${rate:.1f}/h); model={model}; attention={attn}; stop issuing in "
          f"{(deadline - time.time()) / 3600:.2f}h", flush=True)
    proc = start_server(tp=tp, spec=False, context_len=32768, mem_frac=0.88, model_dir=model_dir, attn=attn)
    print(f"server ready after {wait_ready(proc, timeout_s=40 * 60):.0f}s", flush=True)
    out_path = out_dir / f"run-{tag}-{time.strftime('%Y%m%d-%H%M%S')}.jsonl"
    st = asyncio.run(produce(todo, n, effort, concurrency, deadline - 300, out_path, max_tokens))
    proc.terminate()
    st["total_h"] = round((time.time() - t_start) / 3600, 2)
    print("RUN DONE", json.dumps(st), flush=True)
    return st


RUN_KW = dict(image=sglang_image, volumes={W: weights, D: data}, timeout=12 * 3600, memory=262144, cpu=16)


@APP.function(gpu=GPU_CHOICES["nvfp4"], **RUN_KW)
def run(*args, **kwargs):
    return _run_impl(*args, **kwargs)


@APP.function(gpu=GPU_CHOICES["fp8"], **RUN_KW)
def run_fp8(*args, **kwargs):
    return _run_impl(*args, **kwargs)


@APP.function(gpu=GPU_CHOICES["auto"], **RUN_KW)
def run_auto(*args, **kwargs):
    return _run_impl(*args, **kwargs)


@APP.local_entrypoint()
def launch(prompts: str, tag: str, model: str = "fp8", n: int = 1, effort: str = "medium",
           concurrency: int = 384, max_hours: float = 8.0, max_dollars: float = 0, gpu: str = ""):
    """prompts: path inside the beam-dory-data Volume (upload first with `modal volume put`).
    Takes the first available GPU config from GPU_CHOICES[model]; max_dollars caps the run."""
    fn = {"fp8": run_fp8, "auto": run_auto}.get(model, run)
    if gpu:  # pin one exact GPU config instead of the fallback list
        fn = fn.with_options(gpu=gpu)
    res = fn.remote(prompts, tag, n, effort, concurrency, max_hours, 16384, model, max_dollars)
    print(json.dumps(res, indent=1))