blitzwing 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,32 +1,32 @@
1
- {
2
- "name": "blitzwing",
3
- "version": "0.2.1",
4
- "description": "Interactive setup wizard to join a Blitzwing Petals mother swarm as a compute contributor",
5
- "bin": {
6
- "blitzwing": "bin/blitzwing.js"
7
- },
8
- "type": "module",
9
- "engines": {
10
- "node": ">=18"
11
- },
12
- "files": [
13
- "bin",
14
- "src",
15
- "runtime",
16
- "README.md"
17
- ],
18
- "keywords": [
19
- "blitzwing",
20
- "petals",
21
- "llm",
22
- "distributed-inference"
23
- ],
24
- "license": "MIT",
25
- "config": {
26
- "discoveryUrl": "http://35.226.124.189:9000"
27
- },
28
- "dependencies": {
29
- "@clack/prompts": "^0.9.1",
30
- "picocolors": "^1.1.1"
31
- }
32
- }
1
+ {
2
+ "name": "blitzwing",
3
+ "version": "0.2.3",
4
+ "description": "Interactive setup wizard to join a Blitzwing Petals mother swarm as a compute contributor",
5
+ "bin": {
6
+ "blitzwing": "bin/blitzwing.js"
7
+ },
8
+ "type": "module",
9
+ "engines": {
10
+ "node": ">=18"
11
+ },
12
+ "files": [
13
+ "bin",
14
+ "src",
15
+ "runtime",
16
+ "README.md"
17
+ ],
18
+ "keywords": [
19
+ "blitzwing",
20
+ "petals",
21
+ "llm",
22
+ "distributed-inference"
23
+ ],
24
+ "license": "MIT",
25
+ "config": {
26
+ "discoveryUrl": "http://35.226.124.189:9000"
27
+ },
28
+ "dependencies": {
29
+ "@clack/prompts": "^0.9.1",
30
+ "picocolors": "^1.1.1"
31
+ }
32
+ }
package/runtime/app.py CHANGED
@@ -18,7 +18,7 @@ from pydantic import BaseModel, Field
18
18
 
19
19
  from http_chain import HttpChainInference
20
20
  from local_runner import LocalShardRunner
21
- from tensor_codec import tensor_to_payload
21
+ from tensor_codec import tensor_from_payload, tensor_to_payload
22
22
 
23
23
  logging.basicConfig(level=logging.INFO)
24
24
  logger = logging.getLogger(__name__)
@@ -65,6 +65,14 @@ class PrefixResponse(BaseModel):
65
65
  hidden: dict
66
66
 
67
67
 
68
+ class ContinueRequest(BaseModel):
69
+ hidden: dict
70
+
71
+
72
+ class ContinueResponse(BaseModel):
73
+ hidden: dict
74
+
75
+
68
76
  class ShardManager:
69
77
  def __init__(self) -> None:
70
78
  self.model = os.getenv("MODEL_NAME", "bigscience/bloom-560m")
@@ -293,6 +301,7 @@ def _prewarm_local_runner() -> None:
293
301
 
294
302
  @app.on_event("startup")
295
303
  def on_startup() -> None:
304
+ manager.last_exit_code = None
296
305
  if manager._auto_start:
297
306
  try:
298
307
  manager.start(bootstrap=manager.new_swarm)
@@ -364,6 +373,26 @@ async def chain_prefix(body: PrefixRequest) -> PrefixResponse:
364
373
  raise HTTPException(status_code=503, detail=str(exc)) from exc
365
374
 
366
375
 
376
+ @app.post("/v1/chain/continue", response_model=ContinueResponse)
377
+ async def chain_continue(body: ContinueRequest) -> ContinueResponse:
378
+ st = manager.status()
379
+ if not st.running:
380
+ raise HTTPException(status_code=503, detail="Petals server not running")
381
+
382
+ def _run() -> ContinueResponse:
383
+ runner = _get_local_runner()
384
+ hidden = tensor_from_payload(body.hidden)
385
+ hidden = runner.forward_tail(hidden)
386
+ return ContinueResponse(hidden=tensor_to_payload(hidden))
387
+
388
+ try:
389
+ loop = asyncio.get_running_loop()
390
+ return await loop.run_in_executor(_inference_executor, _run)
391
+ except Exception as exc: # noqa: BLE001
392
+ logger.exception("HTTP chain continue failed")
393
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
394
+
395
+
367
396
  @app.post("/v1/chat/completions", response_model=ChatInferenceResponse)
368
397
  async def chat_completions(body: ChatInferenceRequest) -> ChatInferenceResponse:
369
398
  st = manager.status()
@@ -1,11 +1,11 @@
1
- """HTTP-chained distributed inference — no libp2p between nodes."""
1
+ """HTTP-chained distributed inference — every online host runs its layer slice."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
5
  import logging
6
6
  import os
7
7
  from dataclasses import dataclass
8
- from typing import List, Optional, Sequence
8
+ from typing import List, Optional, Sequence, Tuple
9
9
 
10
10
  import httpx
11
11
  import torch
@@ -68,11 +68,39 @@ def resolve_mother_shard_url(manifest: dict) -> str:
68
68
  )
69
69
 
70
70
 
71
- def mother_prefix_blocks(manifest: dict) -> str:
72
- for host in manifest.get("hosts", []):
73
- if host.get("role") == "mother" and host.get("block_indices"):
74
- return str(host["block_indices"])
75
- return "0:18"
71
+ def ordered_hosts(manifest: dict) -> List[dict]:
72
+ hosts = [h for h in manifest.get("hosts", []) if h.get("block_indices")]
73
+ hosts.sort(key=lambda h: parse_range(str(h["block_indices"]))[0])
74
+ return hosts
75
+
76
+
77
+ def validate_chain_coverage(hosts: List[dict], total_layers: int) -> None:
78
+ if not hosts:
79
+ raise RuntimeError("swarm_manifest has no hosts")
80
+ expected = 0
81
+ for host in hosts:
82
+ start, end = parse_range(str(host["block_indices"]))
83
+ if start != expected:
84
+ raise RuntimeError(
85
+ f"layer gap before {host.get('host_id')}: expected start={expected}, got {start}"
86
+ )
87
+ expected = end
88
+ if total_layers and expected != int(total_layers):
89
+ raise RuntimeError(
90
+ f"layer map incomplete: covered 0:{expected}, need 0:{total_layers}"
91
+ )
92
+
93
+
94
+ def host_http_url(host: dict, manifest: dict) -> str:
95
+ if host.get("role") == "mother":
96
+ return resolve_mother_shard_url(manifest)
97
+ url = (host.get("shard_manager_url") or "").rstrip("/")
98
+ if not _is_public_http_url(url):
99
+ raise RuntimeError(
100
+ f"host {host.get('host_id')} has no public shard_manager_url "
101
+ f"(got {url or 'empty'}) — cannot include it in HTTP chain"
102
+ )
103
+ return url
76
104
 
77
105
 
78
106
  @dataclass
@@ -80,6 +108,22 @@ class HttpChainInference:
80
108
  runner: LocalShardRunner
81
109
  local_block_indices: str
82
110
 
111
+ def _remote_prefix(self, client: httpx.Client, url: str, input_ids: List[int]) -> torch.Tensor:
112
+ resp = client.post(
113
+ f"{url.rstrip('/')}/v1/chain/prefix",
114
+ json={"input_ids": input_ids},
115
+ )
116
+ resp.raise_for_status()
117
+ return tensor_from_payload(resp.json()["hidden"])
118
+
119
+ def _remote_continue(self, client: httpx.Client, url: str, hidden: torch.Tensor) -> torch.Tensor:
120
+ resp = client.post(
121
+ f"{url.rstrip('/')}/v1/chain/continue",
122
+ json={"hidden": tensor_to_payload(hidden)},
123
+ )
124
+ resp.raise_for_status()
125
+ return tensor_from_payload(resp.json()["hidden"])
126
+
83
127
  def generate(
84
128
  self,
85
129
  messages: Sequence[dict],
@@ -92,19 +136,22 @@ class HttpChainInference:
92
136
  if not swarm_manifest:
93
137
  raise RuntimeError("swarm_manifest required for HTTP-chained inference")
94
138
 
95
- mother_url = resolve_mother_shard_url(swarm_manifest)
96
- mother_blocks = mother_prefix_blocks(swarm_manifest)
97
- _, mother_end = parse_range(mother_blocks)
98
- local_start, _local_end = parse_range(self.local_block_indices)
99
- prefix_timeout = float(os.getenv("HTTP_CHAIN_PREFIX_TIMEOUT", "120"))
100
-
101
- if local_start != mother_end:
102
- logger.warning(
103
- "Layer gap between mother end=%s and local start=%s",
104
- mother_end,
105
- local_start,
139
+ hosts = ordered_hosts(swarm_manifest)
140
+ total_layers = int(swarm_manifest.get("total_layers") or 0)
141
+ validate_chain_coverage(hosts, total_layers)
142
+
143
+ local_start, local_end = parse_range(self.local_block_indices)
144
+ tail = hosts[-1]
145
+ tail_start, tail_end = parse_range(str(tail["block_indices"]))
146
+ if (tail_start, tail_end) != (local_start, local_end):
147
+ raise RuntimeError(
148
+ f"local blocks {self.local_block_indices} are not the tail "
149
+ f"(tail is {tail.get('host_id')} {tail['block_indices']})"
106
150
  )
107
151
 
152
+ upstream = hosts[:-1]
153
+ hop_timeout = float(os.getenv("HTTP_CHAIN_PREFIX_TIMEOUT", "120"))
154
+
108
155
  tokenizer = self.runner.tokenizer
109
156
  try:
110
157
  prompt = tokenizer.apply_chat_template(
@@ -124,17 +171,19 @@ class HttpChainInference:
124
171
  input_ids = tokenizer(prompt, return_tensors="pt")["input_ids"]
125
172
  prompt_tokens = int(input_ids.shape[-1])
126
173
  max_new_tokens = max(1, min(int(max_tokens), 512))
174
+ hop_desc = " -> ".join(
175
+ f"{h.get('host_id')}[{h.get('block_indices')}]" for h in hosts
176
+ )
127
177
  logger.info(
128
- "HTTP chain: mother=%s blocks=%s local=%s max_tokens=%s",
129
- mother_url,
130
- mother_blocks,
178
+ "HTTP chain hops=%s local=%s max_tokens=%s",
179
+ hop_desc,
131
180
  self.local_block_indices,
132
181
  max_new_tokens,
133
182
  )
134
183
  generated: List[int] = []
135
184
  finish_reason = "stop"
136
185
 
137
- with httpx.Client(timeout=prefix_timeout) as client:
186
+ with httpx.Client(timeout=hop_timeout) as client:
138
187
  for step in range(max_new_tokens):
139
188
  full_ids = (
140
189
  torch.cat(
@@ -144,16 +193,32 @@ class HttpChainInference:
144
193
  if generated
145
194
  else input_ids
146
195
  )
196
+ token_ids = full_ids[0].tolist()
147
197
 
148
- prefix_resp = client.post(
149
- f"{mother_url.rstrip('/')}/v1/chain/prefix",
150
- json={"input_ids": full_ids[0].tolist()},
151
- )
152
- prefix_resp.raise_for_status()
153
- hidden = tensor_from_payload(prefix_resp.json()["hidden"])
154
- logger.debug("HTTP chain step %s: mother prefix ok", step)
198
+ if not upstream:
199
+ # Mother-only / single-node: run everything locally.
200
+ hidden = self.runner.forward_prefix(
201
+ torch.tensor([token_ids], dtype=torch.long)
202
+ )
203
+ else:
204
+ first = upstream[0]
205
+ first_url = host_http_url(first, swarm_manifest)
206
+ hidden = self._remote_prefix(client, first_url, token_ids)
207
+ logger.debug(
208
+ "HTTP chain step %s: prefix via %s ok",
209
+ step,
210
+ first.get("host_id"),
211
+ )
212
+ for mid in upstream[1:]:
213
+ mid_url = host_http_url(mid, swarm_manifest)
214
+ hidden = self._remote_continue(client, mid_url, hidden)
215
+ logger.debug(
216
+ "HTTP chain step %s: continue via %s ok",
217
+ step,
218
+ mid.get("host_id"),
219
+ )
220
+ hidden = self.runner.forward_tail(hidden)
155
221
 
156
- hidden = self.runner.forward_tail(hidden)
157
222
  logits = self.runner.logits_from_hidden(hidden)
158
223
  next_logits = logits[0, -1, :]
159
224
 
package/src/install.js CHANGED
@@ -10,6 +10,72 @@ const PACKAGE_ROOT = path.join(__dirname, "..");
10
10
  const RUNTIME_SRC = path.join(PACKAGE_ROOT, "runtime");
11
11
  const RUNTIME_DEST = path.join(HOME_DIR, "runtime");
12
12
 
13
+ function sleep(ms) {
14
+ return new Promise((r) => setTimeout(r, ms));
15
+ }
16
+
17
+ /**
18
+ * Kill leftover contributor shard/Petals processes so a new join does not talk to a
19
+ * stale uvicorn that still reports last_exit_code=-15 (SIGTERM from a prior leave).
20
+ */
21
+ export function stopLocalContributorStack(extraPids = []) {
22
+ for (const pid of extraPids) {
23
+ if (!pid || !Number.isFinite(Number(pid))) continue;
24
+ try {
25
+ process.kill(Number(pid), "SIGTERM");
26
+ } catch {
27
+ /* already gone */
28
+ }
29
+ }
30
+
31
+ if (process.platform === "win32") {
32
+ for (const port of [SHARD_PORT, PETALS_PORT]) {
33
+ try {
34
+ const out = execFileSync("netstat", ["-ano"], { encoding: "utf8" });
35
+ const re = new RegExp(`:${port}\\s+.*LISTENING\\s+(\\d+)`, "i");
36
+ const m = out.match(re);
37
+ if (m) {
38
+ execFileSync("taskkill", ["/PID", m[1], "/T", "/F"], { stdio: "ignore" });
39
+ }
40
+ } catch {
41
+ /* ignore */
42
+ }
43
+ }
44
+ return;
45
+ }
46
+
47
+ try {
48
+ execFileSync(
49
+ "bash",
50
+ [
51
+ "-lc",
52
+ [
53
+ `fuser -k ${SHARD_PORT}/tcp ${PETALS_PORT}/tcp 2>/dev/null || true`,
54
+ `pkill -f 'uvicorn app:app --host 0.0.0.0 --port ${SHARD_PORT}' 2>/dev/null || true`,
55
+ `pkill -f 'petals.cli.run_server' 2>/dev/null || true`,
56
+ "sleep 1",
57
+ ].join("; "),
58
+ ],
59
+ { stdio: "ignore" }
60
+ );
61
+ } catch {
62
+ /* ignore */
63
+ }
64
+ }
65
+
66
+ function petalsLogShowsSessionStart(logPath) {
67
+ try {
68
+ if (!fs.existsSync(logPath)) return false;
69
+ const text = fs.readFileSync(logPath, "utf8");
70
+ return (
71
+ /Starting Petals:/.test(text) ||
72
+ /\[INFO\] Started\b/.test(text) ||
73
+ /Running a server on/.test(text)
74
+ );
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
13
79
  function venvPython() {
14
80
  if (process.platform === "win32") {
15
81
  return path.join(VENV_PATH, "Scripts", "python.exe");
@@ -226,7 +292,16 @@ export function writeLocalShardManager() {
226
292
  }
227
293
 
228
294
  export function startShardManagerProcess({ python, env, logPath }) {
295
+ // Always clear stale listeners before binding — leave() used to stop Petals only.
296
+ stopLocalContributorStack();
229
297
  const appPath = syncRuntimeFiles();
298
+ fs.mkdirSync(HOME_DIR, { recursive: true });
299
+ // Fresh logs so wait/error tails are from this session.
300
+ try {
301
+ fs.writeFileSync(path.join(HOME_DIR, "petals.log"), "");
302
+ } catch {
303
+ /* ignore */
304
+ }
230
305
  const out = fs.openSync(logPath, "a");
231
306
  const petalsPy = petalsImportable(venvPython()) ? venvPython() : python;
232
307
  const child = spawn(
@@ -253,28 +328,38 @@ export async function waitForShardRunning({ timeoutMs = 300000, statusHost } = {
253
328
  const host = statusHost || process.env.BLITZWING_STATUS_HOST || "127.0.0.1";
254
329
  const petalsLog = path.join(HOME_DIR, "petals.log");
255
330
  const start = Date.now();
331
+ let sawRunning = false;
332
+
256
333
  while (Date.now() - start < timeoutMs) {
257
334
  try {
258
335
  const res = await fetch(`http://${host}:${SHARD_PORT}/status`);
259
336
  if (res.ok) {
260
337
  const body = await res.json();
261
- if (body.running && body.pid) return body;
338
+ if (body.running && body.pid) {
339
+ sawRunning = true;
340
+ return body;
341
+ }
262
342
  if (body.last_exit_code != null) {
263
- const tail = fs.existsSync(petalsLog)
264
- ? fs.readFileSync(petalsLog, "utf8").trim().split("\n").slice(-5).join("\n")
265
- : "";
266
- throw new Error(
267
- `Petals exited (code ${body.last_exit_code}). ${tail || "See " + petalsLog}`
268
- );
343
+ // A prior leave/stop leaves uvicorn up with last_exit_code=-15. Ignore that
344
+ // until this session's Petals has actually started (or we already saw running).
345
+ const sessionStarted = petalsLogShowsSessionStart(petalsLog);
346
+ if (sawRunning || sessionStarted) {
347
+ const tail = fs.existsSync(petalsLog)
348
+ ? fs.readFileSync(petalsLog, "utf8").trim().split("\n").slice(-8).join("\n")
349
+ : "";
350
+ throw new Error(
351
+ `Petals exited (code ${body.last_exit_code}). ${tail || "See " + petalsLog}`
352
+ );
353
+ }
269
354
  }
270
355
  }
271
356
  } catch (err) {
272
357
  if (err instanceof Error && err.message.startsWith("Petals exited")) throw err;
273
358
  }
274
- await new Promise((r) => setTimeout(r, 2000));
359
+ await sleep(2000);
275
360
  }
276
361
  const tail = fs.existsSync(petalsLog)
277
- ? fs.readFileSync(petalsLog, "utf8").trim().split("\n").slice(-5).join("\n")
362
+ ? fs.readFileSync(petalsLog, "utf8").trim().split("\n").slice(-8).join("\n")
278
363
  : "";
279
364
  throw new Error(
280
365
  `Timed out waiting for Petals on http://${host}:${SHARD_PORT}/status. ${tail || "See " + petalsLog}`
package/src/wizard.js CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  ensureVenvAndPetals,
9
9
  startShardManagerProcess,
10
10
  waitForShardRunning,
11
+ stopLocalContributorStack,
11
12
  syncRuntimeFiles,
12
13
  venvPython,
13
14
  } from "./install.js";
@@ -370,6 +371,7 @@ async function doLeave() {
370
371
  } catch {
371
372
  /* ignore */
372
373
  }
374
+ stopLocalContributorStack([state.shard_pid, state.tunnel_pid].filter(Boolean));
373
375
  if (state.tunnel_pid) {
374
376
  stopTunnelProcess(state.tunnel_pid);
375
377
  }