Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ All notable changes to LLooM will be documented in this file. The format follows

### Added

- `lloom pool plan`: a dry-run placement plan across every cluster node. It proposes which lanes run where, which residency they get and how each alias routes, and diffs that against what runs now. It covers every required alias first, then minimises demand-weighted latency and the load cost of changes, within per-domain memory headroom, direct-link tensor parallelism, compute architecture and pins. Nodes report a pool inventory through `/gateway/node?inventory=1`. Recipes may declare `requirements.computeArchs`, `requirements.tensorParallel` and `settings.maxSequences`. `/gateway/metrics` adds `requested` buckets, which give per-alias demand. See `docs/pool-planning.md`.

- LLooM Hear CPU audio analysis, with structured estimates, optional dashboard images, and opt-in upstream interpretation. File and URL inputs require operator configuration.

- Four standalone NVIDIA Spark audio recipes (Qwen3-TTS 1.7B CustomVoice, Base voice cloning and VoiceDesign, and Whisper large-v3-turbo). Each runs one pinned, integrity-checked checkpoint in its own container from a locally built `spark-audio` image that replaces a host-local prebuilt image.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ lloom route q38fn # inspe
lloom route q38fn --suspend-member qwen3.8-flash-next --apply --yes # keep stable traffic on cloud
lloom runtime-stop qwen38-flash-next-cluster # stop local TP-2 after suspension
lloom route q38fn --resume-member qwen3.8-flash-next --apply --yes # restore preferred-local recovery
lloom pool plan --cordon spark-node-02 # dry-run placement across the cluster
lloom integrate omp --apply --yes
lloom add-model mlx-community/Qwen3.6-27B-OptiQ-4bit --keep-warm --default --go # Mac: install, download, start
lloom remove-model mlx-community/Qwen3.6-27B-OptiQ-4bit # preview safe cleanup
Expand Down
24 changes: 24 additions & 0 deletions bin/lloom.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
selectedRecipeIdFromCommunityPlan
} from '../src/community-client.mjs';
import { loadConfig } from '../src/config.mjs';
import { POOL_USAGE, createPoolPlanReport, formatPoolPlanReport, poolPlanOptions } from '../src/pool-cli.mjs';
import { mutateConfigSource } from '../src/config-mutation.mjs';
import { runtimeControlTimeoutMs } from '../src/control-timeout.mjs';
import { createDoctorReport } from '../src/doctor.mjs';
Expand Down Expand Up @@ -136,6 +137,7 @@ const COMMAND_REGISTRY = [
{ name: 'runtimes', aliases: ['runtime-status'], tier: 'advanced', needsInstalledConfig: true },
{ name: 'service', aliases: [], tier: 'advanced', needsInstalledConfig: true },
{ name: 'cluster', aliases: ['cluster-status'], tier: 'advanced', needsInstalledConfig: true },
{ name: 'pool', aliases: [], tier: 'advanced', needsInstalledConfig: true },
{ name: 'deployment', aliases: ['rollout'], tier: 'advanced', needsInstalledConfig: false },
{ name: 'node-agent', aliases: [], tier: 'advanced', needsInstalledConfig: false },
{ name: 'runtime-plan', aliases: [], tier: 'advanced', needsInstalledConfig: true },
Expand Down Expand Up @@ -228,6 +230,7 @@ Serving and discovery:
Backends and runtimes:
lloom down
lloom cluster [status|doctor|discover] [--apply] [--id NAME] [--api-key-env NAME] [--json]
lloom pool plan [--cordon NODE[,NODE]] [--pin ALIAS=MODEL] [--profile NAME] [--consider-installs] [--headroom-gb N] [--churn-weight N] [--external fallback|allow|never] [--offline] [--json]
lloom deployment plan --plan FILE [--json]
lloom deployment apply --plan FILE --nodes FILE [--operation-id ID] --apply --yes [--journal FILE] [--json]
lloom deployment status --operation-id ID [--journal FILE] [--json]
Expand Down Expand Up @@ -370,6 +373,7 @@ function commandLine(command, parts = []) {

const INSTALLED_CONFIG_COMMANDS = new Set([
'add-model',
'pool',
'bootstrap',
'service',
'cluster',
Expand Down Expand Up @@ -404,6 +408,7 @@ const INSTALLED_CONFIG_COMMANDS = new Set([

const OPERATIONAL_CONFIG_COMMANDS = new Set([
'add-model',
'pool',
'service',
'cluster',
'cluster-status',
Expand Down Expand Up @@ -2495,6 +2500,25 @@ async function main() {
if (!result) throw new Error(`route switch failed through ${gatewayUrlFor(config)}`);
console.log(JSON.stringify({ ...result, applied: true }, null, 2));
},
pool: async ({ args, config }) => {
const action = positional(args)[1] ?? 'plan';
if (action !== 'plan') throw new Error(POOL_USAGE);
const options = poolPlanOptions({
cordon: argValues(args, '--cordon'),
pins: argValues(args, '--pin'),
profile: argValue(args, '--profile'),
considerInstalls: hasFlag(args, '--consider-installs'),
offline: hasFlag(args, '--offline'),
headroomGb: argValue(args, '--headroom-gb'),
churnWeight: argValue(args, '--churn-weight'),
external: argValue(args, '--external')
});
const report = await createPoolPlanReport(config, options, {
gatewayRequest: (target, pathname) => gatewayRequest(target, pathname, { args, timeoutMs: 30000 }),
gatewayUrl: gatewayUrlFor(config)
});
console.log(hasFlag(args, '--json') ? JSON.stringify(report, null, 2) : formatPoolPlanReport(report));
},
fleet: async ({ args, config }) => {
const action = positional(args)[1] ?? 'list';
const name = positional(args)[2];
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ The gateway expands `voice: "sample-speaker"` into the profile model, on-disk `r

`/gateway/doctor` wraps setup status into a product-readiness report. It separates hard `blockers` from non-fatal `warnings`, groups install state into registry, recipe, backend, models, client, runtime, and benchmark phases, and returns ordered next actions that a UI can show directly.

`/gateway/metrics` is the operational feed for dashboards, local tuning, and recipe evidence gathering. Aggregate totals plus 400 days of daily rollups are persisted by default in `~/.lloom/metrics-history.json` with atomic replacement and restored when the gateway starts, while active connections, rolling windows, and the bounded recent-request list remain process-local. `period=today|7d|30d|all` selects the durable window, and the topology HUD exposes the same choices. The persistence file contains aggregate counts and timing only—never prompts, responses, raw headers, callers, or client IPs. Before the first persistence-aware deployment, `node scripts/capture-live-metrics.mjs` can capture the old gateway's in-memory snapshot so its counters become the initial durable baseline.
`/gateway/metrics` is the operational feed for dashboards, local tuning, and recipe evidence gathering. Aggregate totals plus 400 days of daily rollups are persisted by default in `~/.lloom/metrics-history.json` with atomic replacement and restored when the gateway starts, while active connections, rolling windows, and the bounded recent-request list remain process-local. Buckets are kept per resolved model, per HTTP route, and per requested id (`requested`, usually an alias), which is the demand signal for [pool planning](pool-planning.md). `period=today|7d|30d|all` selects the durable window, and the topology HUD exposes the same choices. The persistence file contains aggregate counts and timing only—never prompts, responses, raw headers, callers, or client IPs. Before the first persistence-aware deployment, `node scripts/capture-live-metrics.mjs` can capture the old gateway's in-memory snapshot so its counters become the initial durable baseline.

Model-facing routes record requested model ID, resolved model ID, upstream model ID, kind, backend, runtime, status, stream flag, duration, first-content latency, last-content latency, response bytes, and normalized usage when the upstream reports it. Each completed streaming decode measurement follows the Spark Arena / `llama-benchy` timing convention: `(generated tokens - 1) / (last content token time - first content token time)`. The dashboard header shows `totals.activeDecodeTokensPerSecond`: completed decode tokens divided by the union of their first-to-last-content generation intervals within the selected period. Overlapping streams contribute tokens together but count elapsed generation time once; idle gaps and prefill are excluded. It updates as requests complete and remains visible during idle or buffered work. Daily windows select requests by completion date, as with token totals. Merged generation intervals are persisted but omitted from the HTTP metrics response. Legacy history without interval timing contributes its summed generation duration as an estimate; `activeDecodeRateEstimated` flags this or estimated token counts, and the HUD prefixes the rate with `~`. Model topology cards show instantaneous output rates; the model inspector shows `decodeTokensPerSecond`, the rolling mean of the latest ten measured streaming request rates in the selected period. LLooM uses reported output tokens when available and otherwise estimates them from observed output characters. Embedding responses have no generative token count, so their output contribution is estimated from observed response bytes for cumulative volume only. `estimatedDecodeSamples` makes estimated decode samples explicit. Non-streaming generative responses are excluded from decode speed because LLooM cannot observe their token-by-token decode interval; their tokens and end-to-end duration remain available in the other aggregate fields. Connection telemetry derives a short caller label from explicit client-name headers, recognized User-Agent families, or the SDK language header, but never stores raw headers or client IPs; unknown callers retain `conn_N`. Client disconnects before completion are recorded as status `499` so local timeouts do not look like backend crashes. The endpoint aggregates durable totals by model and route while retaining live-only rolling and recent windows.

Expand Down
2 changes: 2 additions & 0 deletions docs/clusters.md
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,8 @@ before contacting any node.

The live dashboard renders node cards between the main LLooM chassis and model cards. Each node card reports architecture, accelerator type, CPU, RAM, GPU compute, GPU memory, temperature, and power when the host can report them. Unsupported fields render as unavailable rather than inventing values; Apple Silicon still reports its profile and shared-memory pressure even when per-GPU utilization is unavailable. Select a node for its complete profile. Replicated models connect to every target node and distributed models connect to every member node.

`GET /gateway/node?inventory=1` and `GET /gateway/cluster?inventory=1` add each node's pool inventory (compute architectures, model-root disk, cached recipe weights, installed images and fabric links). `lloom pool plan` uses it to propose placements across the whole cluster; see [`pool-planning.md`](pool-planning.md).

Before sending model traffic, use `lloom cluster doctor --runtime ID` to verify the required nodes and serving endpoint. Whole-topology doctor reports optional offline nodes and missing memory telemetry as warnings; they do not make unrelated runtimes unavailable. Then start a small replicated lane, verify requests alternate under equal load, and only then canary the distributed engine.

## A note on example identifiers
Expand Down
113 changes: 113 additions & 0 deletions docs/pool-planning.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
# Pool planning

`lloom pool plan` treats every node in the cluster as one pool and proposes which model lanes run where, how each alias routes, and what would change from today. It is a dry run: it never starts, stops or reroutes anything, and there is no apply step yet. Act on a plan with `lloom fleet`, `lloom route`, `lloom keep-warm` and `lloom suspend`/`resume`.

```bash
lloom pool plan # live status + 7-day demand from the gateway
lloom pool plan --cordon spark-node-01,spark-node-02 # what if the pair is taken?
lloom pool plan --profile standalone # keep a fleet profile's routes fixed
lloom pool plan --pin omp=qwen38-27b-nvfp4 # keep one alias fixed
lloom pool plan --consider-installs # also place bundled recipes not installed yet
lloom pool plan --offline --json # config + this machine only
```

The planner sits above the per-node admission planner (`runtime-policy.mjs`), and does not replace it. Admission still decides at request time whether a start fits in live memory. The pool plan decides which lanes should be resident and where.

## Inputs

**Nodes.** Each node comes from `GET /gateway/cluster?inventory=1`. `GET /gateway/node?inventory=1` adds the node's inventory, which is collected on request and cached for five minutes. The inventory contains:

- structured compute architectures (`sm_121` for GB10, `apple-metal` for Apple Silicon);
- memory domains;
- free disk per model root;
- recipes whose weights are already complete on disk;
- installed commands and Docker images;
- direct fabric links to other nodes, from NVIDIA Sync discovery.

A gateway that predates inventory still reports its machine profile, and the planner works from that, without weights or images. A node that does not answer is planned around as unavailable. Without a running gateway, the plan uses the config's node resources plus this machine's own inventory.

**Variants (the catalog).** A variant is one way to serve a gateway model. Variants are grouped by underlying model, and come from four sources:

- **Managed runtimes:** each runtime is fixed to its configured `node` or `placement`. Its memory per node is the configured estimate, raised to the measured resident size when that is larger.
- **Federated targets:** a node's proxy model with a `remoteRuntime`.
- **Unmanaged lanes:** declared with a `pool` block on the model, for example a hand-run TP2 server on the pair:

```json
{ "id": "qwen3.8-flash-next-atlas", "backend": "atlas", "pool": { "nodes": ["spark-node-01", "spark-node-02"], "memoryGbPerNode": 110 } }
```

LLooM cannot start or stop these, so the plan lists them as manual actions.
- **External models:** cloud and other models with no node.

With `--consider-installs`, bundled recipes add variants that may go on any eligible node set.

Each variant has structured requirements: node count, tensor-parallel degree, compute architectures, platform, backend, context window and scheduler sequences. Recipes can state these with `requirements.computeArchs`, `requirements.tensorParallel` and `models[].settings.maxSequences`. When they are absent, they are inferred from launch settings (`CUTE_DSL_ARCH`, `TORCH_CUDA_ARCH_LIST`, `--tensor-parallel-size`, `MAX_NUM_SEQS`) or from the accelerators and cluster node count. Performance comes from live gateway metrics when a model has at least five requests in the window, otherwise from the best matching benchmark result.

**Workloads.** Every alias is a workload. Its candidates are its current members plus the members of all its route profiles, expanded through nested aliases, with suspended members left out. Demand is the alias's request rate over the last seven days, from the `requested` buckets in `/gateway/metrics`. A gateway that predates those buckets reports demand per resolved model only, so each model's demand is split evenly across the aliases routed to it.

Residency is the larger of two values:

- **Demand-based:** at least 1 request/hour means `always`; at least 0.125 means `preferred`; otherwise `auto`.
- **Current-target:** the residency of the runtime the alias routes to now.

Override any of this per alias in config:

```json
"pool": {
"cordon": ["spark-node-02"],
"pins": { "routes": { "omp": "qwen38-27b-nvfp4" } },
"workloads": {
"embeddings": { "residency": "always" },
"video": { "required": false, "slo": { "ttftMs": 2000, "contextWindow": 131072 } }
},
"policy": { "headroomGb": 4, "churnWeight": 10, "external": "fallback" }
}
```

**Pins are hard constraints.** A pin is any of the following:

- a runtime's `node` or `placement`;
- `keepWarm` (residency `always`);
- `pool.pins.routes` and `--pin`;
- the routes and `always` residency of a `--profile`.

The currently active routes are not pins; they are what the diff is measured against. To keep a hand-chosen route, pin it.

## Constraints and objective

- **Memory per domain.** Unified memory on Spark and Apple Silicon. Device memory, plus system memory for host-side state, on discrete GPUs.
- The budget follows admission: `memoryBudgetGb`, else capacity × `maxMemoryUtilization`, else capacity minus `reserveMemoryGb`. Pool planning then subtracts `headroomGb` (default 4 GB), because exhausting unified memory hangs a Spark.
- `always` and `preferred` lanes must fit together. Every on-demand lane must fit next to the `always` set.
- A `preferred` lane that does not fit becomes on demand, with a warning.
- **Tensor parallel only across directly linked nodes.** Every pair in a multi-node set must share a reported fabric link.
- **Compute architecture, platform and accelerators** must match the node.
- **Cordoned and unavailable nodes** host nothing.

Plans are compared in this order:

1. Every required workload and pin covered.
2. Fewest workloads on external models, which are a fallback by default. `--external allow` lets them compete on latency; `never` excludes them.
3. Fewest latency SLO misses.
4. Lowest cost. Cost is demand × latency over a 24-hour horizon, plus each change's load time × `churnWeight`.

Latency is time to first token plus decode time for 256 output tokens, or the measured request duration for media. Load time is estimated from the variant's memory, or taken from its `loadSeconds`. Missing weights add download time, a missing image adds pull time, and an unmanaged lane adds a fixed manual-action time.

The search places workloads greedily, pinned and hot ones first, sharing a placement wherever several aliases can use the same lane. Local search then retries each workload with each alternative candidate and keeps any change that makes the whole plan better. For example, local search can move one alias to a smaller model so that another required alias still fits, or move all cognition aliases to a faster lane together once one of them pays its load cost.

## Output

The text output lists:

- each node's memory budget and resident set;
- every placement by tier, with the aliases it serves;
- the changes from now:
- route changes;
- lanes to start;
- manual starts and stops for unmanaged lanes;
- lanes no workload needs any more (`release`);
- residency changes;
- uncovered workloads, with reasons.

`--json` returns the full report. It includes the plan's profile-shaped `routes` and `residency`, `placements`, per-node memory, the `diff`, and the `score`.

On a three-Spark fleet, the planner's fixture tests (`test/fixtures/pool/spark-fleet.json`) reproduce the hand-written fleet profiles. With every node present, it gives the `sparkqwen-pair-rc16` routes and residency. With the 01/02 pair cordoned or offline, it gives `standalone`: cognition moves to the 27B on spark-node-03, which becomes `always`, and the SparkQwen lane gets a manual stop.
Loading
Loading