HearthNet / docs /p2_p3 /M26-distributed-inference.md
Chris4K's picture
p2, p3
70650b7
|
Raw
History Blame Contribute Delete
15.8 kB
# M26 — Distributed Inference
**Spec version:** v3.0 — *experimental*
**Depends on:** [X08 Tensor Transport](../cross-cutting/X08-tensor-transport.md), [X06 WebSocket](../../phase-2/cross-cutting/X06-websocket.md), [M04 LLM](../../modules/M04-llm.md), [M16 Tokens](../../phase-2/modules/M16-tokens.md), [X03 Observability](../../cross-cutting/X03-observability.md)
**Depended on by:** Optional `experimental.distributed_llm.chat` backend in M04
---
## 1. Responsibility
Run a single LLM forward pass across multiple machines on the same LAN (or, in extremely careful setups, a single federation). Take a 7B model that doesn't fit on any one anchor's GPU and split it: anchor A holds layers 0–7, anchor B holds 8–15, anchor C holds 16–23, etc. A request orchestrator chains them and streams tokens back to the user.
This is a **research module**. It exists for two reasons:
1. **Resilience.** When a community's biggest GPU breaks, the next-biggest fleet of GPUs can still serve mid-sized models cooperatively.
2. **Reach.** A community of three households, each with consumer hardware, can collectively run a model none of them could run alone.
It is explicitly **not** for serving production user-facing LLM traffic at scale. The latency is worse than local inference (typically 2–4× per token), the orchestration is fragile (one shard offline = retry the whole pipeline), and the GPU memory savings come at significant complexity cost. Communities should default to local inference; this module exists for the cases where local isn't enough.
---
## 2. Non-goals (loud and clear)
- **Large models.** 70B-class models are out of scope. The math says you'd need ten 24 GB GPUs to host one, which is the wrong problem for a neighbourhood mesh to solve.
- **Cross-WAN sharding.** Inference across the public internet is uneconomical (latency, bandwidth). Limit to same LAN or same-VPN federation.
- **Heterogeneous shards across model versions.** All shards in a pipeline must serve the **exact same model and weights checksum**. No partial-model recovery.
- **Replacing local inference.** When `policy.research.enable = false`, this module is inert.
---
## 3. File layout
```
hearthnet/distributed_inference/
├── __init__.py
├── shard.py # Shard, ShardDescriptor, ShardServer
├── pipeline.py # Pipeline, PipelineOrchestrator
├── routing.py # Picks a set of shards that cover [0..N] layers
├── health.py # Heartbeats, failover detection
└── backends/
├── base.py
├── petals_like.py # uses bigscience/petals client/server primitives
└── small_model_layered.py # custom impl for small models (≤ 3B)
```
---
## 4. Public API
### 4.1 Dataclasses
```python
@dataclass(frozen=True)
class ShardDescriptor:
shard_id: ShardID # "<model_id>:<lo>-<hi>"
model_id: str # HF model id
weights_sha256: str # full model weights hash; shards must match
layer_range: tuple[int,int] # inclusive
vram_required_mb: int
max_concurrent_streams: int
host: NodeID
endpoint: Endpoint # ws://...
advertised_at: datetime
@dataclass
class Pipeline:
pipeline_id: str
model_id: str
weights_sha256: str
total_layers: int
ordered_shards: list[ShardDescriptor]
established_at: datetime
@dataclass
class ShardHealth:
shard_id: ShardID
online: bool
last_seen: datetime
p95_latency_ms: float
queue_depth: int
```
### 4.2 `ShardServer`
```python
class ShardServer:
"""Hosts one contiguous shard. Loaded on demand; lazy-evictable under memory pressure."""
def __init__(self, descriptor: ShardDescriptor, model_loader: ModelLoader, settings: ShardSettings): ...
async def start(self) -> None:
# Load weights for the layer range; register `experimental.distributed_llm.shard.serve` on the bus
...
async def forward(self, activations_in: TensorChunkStream) -> TensorChunkStream:
"""The hot path. Receives activations, runs layers, emits activations."""
...
async def health(self) -> ShardHealth: ...
async def evict(self) -> None:
"""Free VRAM; triggered by host memory manager."""
...
```
### 4.3 `PipelineOrchestrator`
```python
class PipelineOrchestrator:
"""
Chooses shards to cover the model's layers, opens streams to each, and
pumps activations through them in order. Handles failover.
"""
def __init__(
self,
bus: CapabilityBus,
router: ShardRouter,
health: ShardHealthTracker,
observability: Observability,
): ...
async def chat(
self,
request: LlmChatRequest,
params: DistributedChatParams,
) -> AsyncIterator[StreamFrame]:
# 1. Resolve a Pipeline covering all layers of the target model
# 2. Open WS streams to each shard via X08 tensor transport
# 3. For each token step:
# embedding → shard 0 → shard 1 → ... → shard N → token sample
# 4. Yield `token_delta` frames; emit `shard_status` and `shard_failover` diagnostics
# 5. On any shard failure, attempt re-routing once; if that fails and
# `params.fallback_to_local`, fall back to local inference and emit a
# `pipeline_aborted` frame
...
```
### 4.4 `ShardRouter`
```python
class ShardRouter:
"""
Given a model_id and an `experimental.shard.advertised` event stream,
pick a covering set of shards minimising:
- total network hops
- max per-shard queue depth
- chance of overlap with the caller's own GPU (avoid self-as-shard)
"""
def __init__(self, store: ShardStore, settings: RoutingSettings): ...
async def pick(self, model_id: str, weights_sha256: str) -> Pipeline: ...
async def repick(self, pipeline: Pipeline, exclude: set[ShardID]) -> Pipeline: ...
```
---
## 5. Behaviour
### 5.1 Shard advertisement and discovery
A node hosting a shard emits `experimental.shard.advertised` events into the community event log. The event carries `ShardDescriptor` fields plus a timestamp. Advertisements expire after `DISTRIBUTED_SHARD_HEALTH_TIMEOUT_S * 4` (default 120s); shard hosts must re-advertise via heartbeat.
When a node opts out (`policy.research.enable=false`), it does not emit advertisements. Existing advertisements expire normally.
The shard store is a local read model built from these events, indexed by `(model_id, weights_sha256, layer_range)`.
### 5.2 Pipeline construction
`ShardRouter.pick`:
1. Filter advertisements to those matching `model_id` and `weights_sha256`.
2. Greedy cover: starting from layer 0, pick the shard with the lowest queue depth that includes the next uncovered layer; advance the cursor; repeat. Returns failure if any layer is uncoverable.
3. Prefer shards on the same LAN if possible (LAN advertisements have a lower "hop weight" metric attached by Discovery).
4. Avoid sharding to **self** as the first shard — embedding + sampling should stay on the orchestrator.
Constructed pipelines are not persisted; they're per-call.
### 5.3 Forward pass
Per token:
```
[orchestrator] embedding → [shard 0] layers 0..7 → [shard 1] layers 8..15 → ... → [orchestrator] sample
```
Activations flow as **fp16 tensors** by default (configurable to fp32 for debugging). Each hop is a WebSocket binary frame stream (see [X08](../cross-cutting/X08-tensor-transport.md)). The orchestrator interleaves token-N and token-N+1: as soon as shard 0 finishes token N, the orchestrator pushes token N+1's embedding into shard 0 while shard 1 is still processing N. This pipeline parallelism approaches the latency of the longest-latency shard at steady state.
### 5.4 Failure handling
If a shard's stream errors or stalls past `DISTRIBUTED_SHARD_HEALTH_TIMEOUT_S`:
1. The orchestrator emits a `shard_status` frame with `status:"degraded"`.
2. Calls `router.repick(pipeline, exclude={failed_shard_id})`.
3. If repick succeeds, opens a fresh stream to the replacement and emits `shard_failover` frame. **In-flight tokens are restarted** (no mid-token recovery).
4. If repick fails and `params.fallback_to_local`, the orchestrator silently restarts the call as a local-only `llm.chat@2.0` against any local model that matches.
5. Else: emit `pipeline_aborted` frame and return `shard_unavailable`.
`DISTRIBUTED_FALLBACK_TO_LOCAL_AFTER_FAILURES` (default 2): if failover happens that many times in one call, give up and fall back to local.
### 5.5 Streaming and backpressure
Tensor-chunk streams use a window of `TENSOR_FLOW_CONTROL_WINDOW` chunks (default 16). Each chunk is at most `TENSOR_CHUNK_BYTES` (1 MB). If the downstream shard's send queue fills, the orchestrator pauses upstream until ACKs drain. See [X08 §4](../cross-cutting/X08-tensor-transport.md).
### 5.6 Concurrency
A shard's `max_concurrent_streams` is honoured strictly. If the orchestrator's call would exceed it, the orchestrator picks a different shard (via `router.repick`) rather than queuing.
A shard's GPU memory budget is enforced by the shard host's own resource manager; a shard exceeding its budget gets evicted and re-advertises with `vram_required_mb` updated next time it loads.
### 5.7 Models supported
Phase 3 launches with two backend choices:
| Backend | Models | Notes |
|---------|--------|-------|
| `small_model_layered` | Qwen2.5-{1.5B,3B,7B}, Llama-3.2-{1B,3B}, MiniCPM-3 | Custom HearthNet impl; PyTorch model surgery to expose per-layer forward |
| `petals_like` | (vendored from BigScience Petals) | Optional; only if user installs `hearthnet[petals]` extra |
The `small_model_layered` backend handles models up to roughly 7B parameters cleanly; beyond that the activation transport becomes the bottleneck.
### 5.8 Security boundary
A shard host receives activation tensors which **leak training data residue**. Treat activations as sensitive: do not log them, do not persist, do not retain past forward pass. Per-call signed authentication; the caller's identity is recorded in metrics but not in logs of tensor contents.
A malicious shard could degrade outputs subtly. Detection is hard in general; the orchestrator does **basic sanity checks** (norm bounds, NaN/Inf detection) but cannot detect adversarial corruption. Communities should only enable distributed inference among members they trust as much as they trust the LLM service operator.
### 5.9 Privacy threat surface
A shard sees the activations of every request routed through it. With effort, a shard host can reconstruct approximate input text (especially the prompt) from activations of intermediate layers. This is **a real concern, not a theoretical one**.
Mitigations (none perfect):
- Restrict participation to members at trust level `trusted` or higher.
- Mix activations with a small amount of noise at the orchestrator (research; not yet implemented).
- Use this module only for queries the requester would already trust the community with.
### 5.10 Observability
Per call, emit:
- `distributed_inference.pipeline_construct_ms`
- `distributed_inference.first_token_ms`
- `distributed_inference.tokens_per_second`
- `distributed_inference.shard_latency_ms{shard_id}` histograms
- `distributed_inference.failovers_total`
- `distributed_inference.fallback_to_local_total`
---
## 6. Errors
| Code | Cause |
|------|-------|
| `experimental_disabled` | `policy.research.enable=false` |
| `shard_unavailable` | No shard covers a required layer range, or all candidates are at max concurrency |
| `pipeline_stalled` | No progress within timeout |
| `weights_mismatch` | A shard's advertised `weights_sha256` differs from requested |
| `bad_request` | Unknown model, malformed pipeline params |
---
## 7. Configuration
```toml
[research.distributed_inference]
enabled = false
backend = "small_model_layered"
max_shards_per_request = 16
shard_health_timeout_seconds = 30
fallback_to_local = true
activation_dtype = "fp16" # "fp16" | "fp32"
allow_self_as_shard = false
max_concurrent_pipelines = 4
[research.distributed_inference.host]
serve_shards = false
shard_eviction_idle_seconds = 600
shard_max_vram_mb = 20000
```
---
## 8. Tests
### 8.1 Unit
- ShardRouter cover algorithm: 16-layer model + 3 advertised shards (0-7, 4-11, 8-15) → picks {0-7, 8-15}, ignores overlap shard
- Sanity bounds on activations: NaN injection triggers `pipeline_stalled` (via failed health check on subsequent chunk)
- Pipeline construction with weights mismatch → `weights_mismatch`
### 8.2 Integration (LAN)
- Two-node setup, 1.5B model split as 0-7 / 8-15; happy-path tokens/sec measured; baseline single-machine inference also measured; ratio reported (expect 0.4–0.6× local)
- Shard host kill mid-stream; failover to a third node; total call still succeeds; latency penalty bounded
- Concurrent two-pipeline test on three nodes; no deadlock; per-call latency degrades < 2×
### 8.3 Adversarial
- Malicious shard returns garbage activations: orchestrator's NaN/Inf detector catches the call; metric `distributed_inference.shard_corruption_detected_total` increments; pipeline aborts
- Slowloris shard (returns one chunk per second): `pipeline_stalled` after timeout; failover succeeds
### 8.4 Performance budget
- 3B model, 2-shard pipeline, RTX 5090 + RTX 4090: ≥ 8 tokens/sec sustained
- First-token latency ≤ 800ms
- Construction-to-first-byte ≤ 500ms
- Tensor-chunk overhead per hop ≤ 25ms p95
---
## 9. Cross-references
- Capability spec: [CAPABILITY_CONTRACT_v3 §4.1–4.3](../CAPABILITY_CONTRACT_v3.md)
- Tensor transport: [X08](../cross-cutting/X08-tensor-transport.md)
- Base LLM service: [M04](../../modules/M04-llm.md)
- Trust levels: [M01](../../modules/M01-identity.md)
---
## 10. Open research questions
1. **Activation privacy.** Can we add fast-to-compute noise that preserves inference accuracy but defeats activation-inversion attacks? Cite the Geiping et al. inversion paper as the threat baseline.
2. **Mid-token recovery.** Currently a shard failure restarts the in-flight token. Could we use micro-checkpointing (every K tokens) to recover without a restart? Latency cost?
3. **Heterogeneous shards.** Could a 4090 host the early layers (heavier compute per layer) and a 3060 the later ones, while remaining balanced? Probably yes — automated load assignment is the research question.
4. **Async pipeline.** Currently the orchestrator interleaves at the token level. Could it interleave at the layer level (one shard processes token N+2 while another processes N+1) for higher throughput? In theory yes; coordination protocol unclear.
5. **Mixed local + distributed.** When the orchestrator could host some layers itself (it has a GPU), should it? When? Currently `allow_self_as_shard=false`. A heuristic that considers compute headroom would be richer.
6. **Adversarial detection.** Beyond NaN/Inf, can we cheaply detect activation tampering by comparing to a small "shadow inference" on a tiny model? Cost vs. benefit unclear.
7. **Pricing / incentive.** A shard host pays in GPU time. A community-internal token-economy is explicitly out of scope (00-OVERVIEW §8). But a *reputational* signal — "this anchor served 4000 shard-tokens this week" — could be helpful. Should it be a metric?
8. **Backend strategy.** `petals_like` vs `small_model_layered`: which delivers better quality / latency / robustness for our target models? An honest A/B is the answer.