You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Replace the current 3-server design (HTTP + Hydra control RPC + ggml-RPC) with a single merged Hydra RPC server in a new fork-isolated module tools/llama-engine/hydra_rpc/. The merged server dispatches by first byte (ggml-RPC 0x0E vs Hydra 0x30–0x46) and uses a bounded thread pool (size 2). COMBINE is layer-split only, with each engine mmap'ing the same model from disk and the head binding to the peer's mmap-resident tensors via the existing RPC_CMD_RESOLVE_TENSOR (PR #20) — no weight data on the wire. Per-process model loading: A tells B which layers to load via POST /control/load_model with a keep_layer_range field.
Phase 1 is MERGED via PR #37 (merged Hydra RPC server in tools/llama-engine/hydra_rpc/, bounded thread pool, MSG_PEEK dispatch). This issue is the implementation handoff for Phase 2 onward. Phase 2 is the Unified COMBINE refactor: collapse the Hydra-side COMBINED-OT and COMBINED-static abstractions in Hydra.Core into a single unified COMBINE concept, accept common_params at runtime via 0x40 EngineConfigure, and remove the Hydra-specific CLI flag names (--rpc-engine, --combined-ot-pattern, --combined-split-mode, --combined-tensor-split, --peer-only, --ggml-rpc-port). The dual-load machinery, the 0x44 SET_EXPERT_MODE runtime toggle, the RPC_CMD_RESOLVE_TENSOR no-weight-transfer path, and the no-model peer path all stay — they are the features, not the abstractions. Engines boot model-less in the lazy-load design; Hydra.Core tells each engine which model + mode (SOLO or COMBINE) to run via 0x40.
Why this matters
DENSE 27B at Q4 doesn't fit on a single 5060 Ti (16 GB) → layer-split COMBINE is the only path to serve it.
MoE 35B-A3B at Q3_K-mini fits on the 3060 alone → COMBINE is optional; SOLO on the 5060 Ti is faster (~200 vs ~48 tok/s for COMBINED layer-split). The MoE COMBINED win is resource-sharing (free the 5060 Ti), not throughput.
The target architecture lets the operator configure one port per engine (--rpc-port) instead of two (--rpc-port + --ggml-rpc-port).
Scheduler feasibility (CRITICAL — do this before Phase 2)
PR #34 documented that the upstream ggml scheduler assigns compute to the device that holds the inputs, not the device that holds the weights. This breaks layer-split for DENSE models: a layer's compute goes to the peer (where the previous layer's outputs live), but the peer's RPC server can't access the local CUDA's weights.
Before Phase 2 starts, run a 1B model (e.g. Qwen2-1.5B) layer-split 50/50 across CUDA0 + RPC0, decode 50 tokens, confirm no GET_TENSOR FAILED errors. If the test fails:
C1: minimal upstream diff. All new code in tools/llama-engine/hydra_rpc/ (fork-only) or one of four small upstream-touched functions: ggml_backend_rpc_handle_client (~20 LOC), ggml_backend_rpc_remove_server (~30 LOC), set_keepalive (~20 LOC), MSG_NOSIGNAL (1 LOC). Total upstream-touched: ~71 LOC.
C3: SOLO mode must not regress. No new global locks, no mandatory setup, no per-op overhead vs upstream.
C4: cross-host (P100) unchanged. All Hydra-specific behavior is same-host only. The P100 uses upstream rpc-server binary, not the merged engine.
C5: each engine can load any model from a known path on demand. No per-process model lock-in.
C6: fail-soft wrapping is allowed only in teardown paths (buffer_free during model unload, scheduler teardown). Normal ops keep RPC_STATUS_ASSERT / GGML_ABORT on failure. This is why PR fork: resolve #376 — make COMBINE mode RPC work (gate-then-degrade) #31's broad fail-soft replacement is rejected — it would mask data corruption in the inference path (peer dies mid-graph, head returns GGML_STATUS_FAILED, scheduler continues with uninitialized tensors, user gets garbage tokens).
C7: protocol dispatch uses one byte (ggml-RPC uses 0x00–0x11, Hydra uses 0x30–0x46; ranges don't overlap, dispatch is unambiguous). No magic bytes, no per-conn detach(), bounded thread pool.
Issue #29 was the original design doc for the unified server (now PR #31). Two clarifications to read carefully:
"No RPC_CMD_RESOLVE_TENSOR" in issue Unified RPC Server: one port, one binary, no extra flags #29 is wrong. The design wants "no weight transfer", but the only mechanism for "no weight transfer" with the standard upstream buffer model is RPC_CMD_RESOLVE_TENSOR. The v4 design uses RPC_CMD_RESOLVE_TENSOR explicitly. If you see "no RESOLVE_TENSOR" anywhere in issue Unified RPC Server: one port, one binary, no extra flags #29, treat it as a typo for "the RESOLVE_TENSOR path replaces the SET_TENSOR weight-transfer path".
The peer also supports RPC_CMD_RESOLVE_TENSOR for the no-weight-transfer path: the head calls RPC_CMD_RESOLVE_TENSOR("blk.21.attn_q.weight") on the peer, the peer looks up the tensor in its mmap, returns a remote_ptr. The head uses that remote_ptr in the rpc_tensor.buffer field of RPC_CMD_GRAPH_COMPUTE. No weight data crosses the wire.
For A→B partial load (Phase 3), a new HTTP route POST /control/load_model on B's HTTP server:
B unloads current model, loads with the range filter, returns {"loaded": true, "n_tensors": <int>, "size_mb": <int>}.
Trust-gated by --control-allow-from (default 127.0.0.1).
Phased implementation plan
Decisions made (resolved during Phase 2 design)
The following design questions were open at Phase 1 handoff and have been resolved as part of the Phase 2 (unified COMBINE) design. They are recorded here for traceability.
Every common_params field is classified into one of three tiers based on what it costs to change at runtime. The 0x40 payload diffs against the last-applied state and triggers the minimum rebuild needed.
Tier
Cost
What it means
T1 — no teardown
per-request
Field is applied per-request (sampling, n_predict, per-slot settings, server hot-swaps). No rebuild.
T2 — context teardown
llama_context_free + llama_new_context_with_model
Field is consumed at context init (n_ctx, kv cache types, n_threads, RoPE/YaRN, flash-attn, etc.).
T2 fields include: n_ctx, n_batch, n_ubatch, n_parallel, n_sequences, n_outputs_max, grp_attn_n, grp_attn_w, cpuparams, cpuparams_batch, cb_eval, cb_eval_user_data, numa, rope_freq_base/scale, yarn_*, rope_scaling_type, pooling_type, attention_type, flash_attn_type, cache_type_k, cache_type_v, kv_unified, swa_full, no_perf, no_kv_offload, no_op_offload, no_extra_bufts, no_host, speculative (lookup-ngram only — the model-draft field is T3).
T3 fields include: model, mmproj, n_gpu_layers, main_gpu, tensor_split, split_mode, devices, use_mmap, use_direct_io, use_mlock, check_tensors, no_alloc, kv_overrides, tensor_buft_overrides, lora_init_without_apply, lora_adapters, control_vectors, the rpc_servers list (stock --rpc-server), n_cpu_moe, speculative.model.
Field map reference: src/llama-cpp/common/common.h:426-706 is the source of truth. common_model_params_to_llama (common.h:884) consumes the T3 fields. common_context_params_to_llama (common.h:885) consumes the T2 fields. Sub-structs: common_params_sampling at common.h:210-289, common_params_speculative at common.h:347-370, common_params_model at common.h:292-301.
Q2 — 0x44 SET_EXPERT_MODE stays a separate opcode
0x44 does not fold into 0x40. The mode is cparams.hydra_expert_mode and is part of the graph-reuse key, so flipping it forces a graph rebuild but not a model reload. Keeping 0x44 separate from 0x40 means per-request SOLO↔COMBINED flips are cheap (graph rebuild only) when the dual-load is already set up at T3 load time. The cost is one extra opcode in the Hydra protocol; the win is no model reload on every mode flip.
Q3 — RPC_CMD_RESOLVE_TENSOR (PR #20) is a stable fork API
The fork commits to maintaining RPC_CMD_RESOLVE_TENSOR and RPC_CMD_RESOLVE_TENSES as extensions to ggml-RPC. Stock llama.cpp does not have an equivalent; this is what the dual-load zero-copy bind uses (ggml_backend_rpc_bind_remote_tensor resolves tensor handles by name from the peer's resident-tensor registry). Future work that needs the no-weight-transfer path (e.g. dense 27B layer-split with mmap-share) is built on top of PR #20.
Q4 — Lazy model load; no-model peer path is in scope; engines boot model-less
The default boot state is model-less. The engine binds the HTTP port, the Hydra RPC port, the local-CUDA-exposure port (if any), starts the servers, and waits for the first 0x40 EngineConfigure. The first 0x40 triggers a T3 model load.
This is the "3 GPU worker pool" pattern in production: each of the 5060 Ti, the 3060, and the P100 runs a llama-engine with no model loaded; Hydra.Core sends 0x40 to whichever engine should serve a given (model, mode) request. The benefit is fast startup (no model-load barrier at boot, <1 second to "engine ready" / waiting for 0x40) and operator simplicity (3 identical bare engines; Hydra.Core routes work).
The no-model peer path (the 3060 exposing its CUDA on --rpc-server-port without loading a model) is the no-weight-transfer support for the dense 27B layer-split. The 5060 Ti loads the full model and binds to the 3060's CUDA backend via stock --rpc-server localhost:9506.
Layer 1: common_params built-in defaults at common.h:426-706. Always present. Fork does not change these.
Layer 2: startup config. Two sources merged: CLI flags (the T4 items) and an optional YAML config file (the non-T4 defaults the operator wants to bake in: n_ctx, cache_type_k/v, n_gpu_layers, flash_attn, n_parallel, RoPE/YaRN, etc.). Without --config, only CLI flags are applied; the engine is a true bare compute slot.
Layer 3: 0x40 EngineConfigure payload, per-request. JSON shape mirrors common_params. Engine diffs against the last-applied state and triggers T1/T2/T3 rebuild as needed.
In production with YAML defaults, Hydra.Core can send a minimal 0x40 payload (e.g. { "model": "moe-35b", "mode": "combine", "peer": "localhost:9506" }) and the engine applies the layered defaults for everything else. In the bare-pool case (no YAML), Hydra.Core sends the full common_params-shaped payload and the engine applies it directly.
"Unified COMBINE" resolver (Hydra.Core C# side)
The C# MultiEngineRouter mode resolver replaces the OT-vs-static branch. Given (model_alias, combined: bool), it returns a 0x40 payload delta:
The resolver does not name "OT" or "static" — those words do not appear in the C# code. The four rows are just stock-params combinations, looked up by (model_arch, mode). The C# still emits 0x44 SET_EXPERT_MODE for MoE COMBINE plans (translator layer under the hood) so the live MoE dual-load path keeps working through the transition.
Phase 0: Scheduler feasibility (DO THIS BEFORE PHASE 1)
Goal: confirm the layer-split scheduler can place compute on the right device for our models.
Load Qwen2-1.5B-Instruct-Q4_K_M with tensor_split "20/40" (50/50) on CUDA0 + RPC0 (loopback, ggml-rpc-server upstream binary).
Decode 50 tokens. Log per-token device placement. Look for GET_TENSOR FAILED or RPC_CMD_* failed for ... on ... errors.
If the test passes: proceed to Phase 1. If it fails: file a parent issue documenting the scheduler limitation; coordinate with upstream llama.cpp; pick Fallback A (use -ts --rpc) or Fallback B (drop G1).
Acceptance: test runs cleanly, no errors. The output layer (output.weight or l_out-N) is placed on the device that holds its weights, not the device that holds its inputs.
Time: 1 day.
Commit message: infra: scheduler-feasibility check (1B layer-split sanity test) (in the parent hydra_vortex repo, not the fork).
Phase 1: Cherry-pick + new module (FIRST MERGE)
Goal: the merged Hydra RPC server runs in place of the current 3 servers. No new functionality, no behavior change, just the architecture swap.
Files to create (under src/llama-cpp/tools/llama-engine/hydra_rpc/):
File
LOC
Purpose
hydra_rpc.h
70
Public API: start(), stop(), is_running(), settings struct
Collapse two start_*_rpc_server calls into one hydra_rpc::start call. Delete start_shared_backend_rpc_server (~60 LOC) and start_backend_rpc_peer_server (~30 LOC). Update CLI to remove --ggml-rpc-port.
tools/server/server-context.cpp
−40
Delete server_context::start_rpc_server (replaced). Delete the per-conn std::thread::detach() at line 7187.
ggml/src/ggml-rpc/ggml-rpc.cpp
+50
Add ggml_backend_rpc_handle_client (public API for serving a pre-accepted fd; ~20 LOC) + ggml_backend_rpc_remove_server (cleanup; ~30 LOC). Register in ggml_backend_rpc_get_proc_address.
ggml/src/ggml-rpc/transport.cpp
+25
Add set_keepalive (~20 LOC) + MSG_NOSIGNAL on send (1 LOC).
ggml/include/ggml-rpc.h
+5
Export the two new functions.
ggml/src/ggml-rpc/ggml-rpc.cpp
−16
Revert PR #31's broad RPC_STATUS_ASSERT → fail-soft replacement. Restore upstream's GGML_ABORT behavior in normal ops. Only keep the null-guard (`if (!ctx
Total upstream-touched: ~75 LOC (4 functions, 2 files). Within G9 budget.
CLI flag changes:
Remove: --ggml-rpc-port (use --rpc-port only).
Add: --control-allow-from <ip> (default 127.0.0.1) for the new /control/load_model route in Phase 3. Default allows Phase 1 to work without changes.
Config file changes (in ddvnguyen/hydra_vortex parent repo):
infra/hydra-core/config/workers.json: remove ggml_rpc_port field from each worker.
socket_t::from_fd (transport.{h,cpp}, ~10 LOC) — needed for ggml_backend_rpc_handle_client to wrap the fd
NOT cherry-picked (rejected per C6 / C7 / G9 / dead-path cleanup):
The RPC_STATUS_ASSERT fail-soft replacement (16+ places)
The response = {} zero-inits (3 places)
The ggml-cuda/fattn.cuGGML_ABORT removal
The per-conn std::thread::detach() (replaced by bounded pool)
The start_rpc_accept_loop in tools/server/ (replaced by tools/llama-engine/hydra_rpc/)
The unconditional llama_hydra_enable_shared_backend_compute_lock() (the call site in start_shared_backend_rpc_server is being deleted)
llama_hydra_load_combined_experts, _rebind_*, s_hydra_combined_bindings, ffn_*_exps_rpc — KEPT, not cherry-picked from; these are the dual-load machinery that survives into Phase 2
llama_hydra_validate_quant_parity (Phase C) + SWAP_QUANT validation block (split to separate PR per hydra_vortex#394)
startup_stage and the staged init (we keep the simple is_ready for now; staged init can be added in Phase 2 if needed)
wait_for_peer_ready + --peer-health-url (added in Phase 3 when A→B load needs it)
Time: 1 week. PR title: fork: Phase 1 — merged Hydra RPC server in tools/llama-engine/hydra_rpc/. PR body references this issue, #35, and the existing hydra_vortex#392 epic.
Rollback plan: revert the PR. The previous 3-server design is preserved on hydra-fork. No production deploys are gated on this; the merged server is feature-equivalent to the 3-server design when --rpc-port=0 (no SOLO regression). Revert = git revert <merge-sha> and redeploy.
Goal: collapse the Hydra-side COMBINED-OT and COMBINED-static abstractions in Hydra.Core into a single unified COMBINE concept. The llama-engine accepts common_params at runtime via 0x40 EngineConfigure. The dual-load machinery, the 0x44 SET_EXPERT_MODE runtime toggle, the RPC_CMD_RESOLVE_TENSOR no-weight-transfer path, and the no-model peer path all stay — they are the features, not the abstractions. What dies is the Hydra-side labelling (Plan.OtSplit, Plan.run_type, OT-vs-static branches in MultiEngineRouter) and the Hydra-specific CLI flag names (--rpc-engine, --combined-ot-pattern, --combined-split-mode, --combined-tensor-split, --peer-only, --ggml-rpc-port).
Why this matters
The current state has two redundant "mode" abstractions:
The engine-side flags are shorthand for stock common_params (--rpc-server, --override-tensor, --split-mode, --tensor-split, and the stock arg for exposing local CUDA). The Hydra-side labels are shorthand for "set these stock params for this (model, combined) combination". Both shorthand layers hide what's actually happening and prevent the system from supporting any common_params field beyond the few the fork chose to expose. Removing them is a code-reduction win, a clarity win, and a "support all llama.cpp params" win.
The mechanisms that stay (per the corrected design):
src/llama-hydra.cpp — llama_hydra_load_combined_experts, llama_hydra_rebind_combined_experts, s_hydra_combined_bindings, llama_hydra_set_expert_mode, llama_hydra_register_local_tensors_for_rpc, llama_hydra_lock_compute / unlock_compute / llama_hydra_enable_shared_backend_compute_lock / llama_hydra_force_sync_if_shared, llama_hydra_clear_combined_bindings, llama_hydra_get_compute_backends, llama_hydra_preload_rpc_device. The dual-load is the feature: it provides the per-request SOLO↔COMBINED toggle (0x44) that stock llama.cpp's load-time-fixed --override-tensor cannot.
src/llama-model.h:320-322 — ffn_gate_exps_rpc, ffn_down_exps_rpc, ffn_up_exps_rpc fields on llama_layer. Stay.
src/llama-context.cpp:423-432 — llama_context::hydra_add_combined_rpc_backend. Stays (required for the scheduler to know about the peer's backend).
src/llama-context.cpp:2433-2441 — RAII compute lock. Stays (required for the same-host dual-role case: the 3060 serves its own SOLO and the head's RPC traffic on the same backend).
src/models/qwen35moe.cpp:507-513 — per-layer ternary selecting between ffn_*_exps and ffn_*_exps_rpc. Stays.
src/coordinator/MultiEngineRouter.cs — drop the OT-vs-static branch. The router emits a single Plan shape whose payload is a stock common_params JSON (or delta).
src/coordinator/WorkerSchedulerService.cs — model registry added. Keys by alias. Each entry holds { model_path, default_ctx_size, default_kv_quant, default_n_cpu_moe, default_overrides[] }.
src/coordinator/Plan.cs (wherever Plan is defined) — Plan.OtSplit and Plan.CombinedOtSplit removed. Replaced by Plan.CommonParams (the full stock-params JSON) or by fields Plan.OverrideTensors, Plan.TensorSplit, Plan.SplitMode, Plan.RpcServers.
src/core/Hydra.Shared/RpcClient.cs:499 — the ot_split JSON key replaced with a stock-params payload.
src/core/Hydra.Shared/Protocol.cs:55-58 — EnginePipelineAttach payload (ot_split field) updated to the new stock-params shape (or removed if 0x46 is also retired in favour of 0x40; see Q46 below).
infra/hydra-core/config/workers.json and workers-27b.json — run_type: "combined-static-peer" and combined_ot_split fields removed. Replaced with combined_peer: bool + stock args in the worker config.
infra/hydra-head/config/node-rtx.yaml, node-rtx-27b.yaml, node-rtx3060.yaml, node-rtx3060-27b.yaml — replace the Hydra-specific args with stock llama.cpp args passed through directly.
C# still emits 0x44 SET_EXPERT_MODE for MoE COMBINE plans (translator layer under the hood), so the live MoE dual-load path keeps working. This sub-phase ships in production alongside the current fork — no engine-side change, no production risk.
Sub-phase 2b — Fork runtime reconfigure + flag cleanup
Add a runtime reconfigure path in the main loop: receive 0x40 EngineConfigure with a full common_params JSON payload, diff against the last-applied snapshot, decide which lifecycle point is affected (model reload / context rebuild / per-request setting), tear down + rebuild as needed, return the actual state. The diff walks the common_params struct field by field; T3 fields trigger model teardown, T2 fields trigger context teardown, T1 fields apply per-request. T4 fields are rejected.
The 0x40 payload schema is common_params-shaped: { model, n_ctx, n_batch, n_ubatch, split_mode, tensor_split, override_tensors: [string], rpc_servers: [string], n_gpu_layers, n_cpu_moe, cache_type_k, cache_type_v, flash_attn, rope_*, yarn_*, ... }. The engine applies via a common_params_parse-equivalent on the new params.
The 0x44 SET_EXPERT_MODE opcode stays (separate from 0x40). The mode is cparams.hydra_expert_mode; flipping it forces a graph rebuild (the mode is in the graph-reuse key) but not a model reload.
The EngineConfigure (0x40) handler in tools/server/server-context.cpp is extended to apply the stock-params JSON (replacing the current per-mode branch).
Add (stock llama.cpp args, passed through unchanged): --rpc-server <host:port> (adds a remote device — replaces --rpc-engine), --rpc-server-port <port> (exposes local CUDA — replaces --ggml-rpc-port), --override-tensor <pat=backend> (replaces --combined-ot-pattern; the =CPU / =PEER / =RPC0 suffix is stock llama.cpp syntax already), --split-mode <none|layer|row>, --tensor-split <a,b,...>.
The --model flag stays (still required for SOLO + dense + MoE loads). For the no-model peer case, omit --model and pass --rpc-server-port <port> only.
The --config <yaml> flag is added (Layer 2 startup config; see "Three-layer startup config" in Decisions made).
src/llama-hydra.cpp:178-192 (llama_hydra_clear_combined_bindings): no change. Still used by the dual-load rebind path for the 0x44 handler's per-rebind cleanup.
Files that stay unchanged:
src/llama-hydra.h (full file, all 152 lines) — every API stays.
src/llama-hydra.cpp lines 1-435 — every function stays.
tools/server/server-context.cpp:3536-3666 (SET_EXPERT_MODE handler) — stays (the 0x44 opcode is still served; the new 0x40 semantics don't replace it).
Every common_params field Hydra.Core might emit (split_mode, tensor_split, override_tensors, n_cpu_moe, n_gpu_layers, ctx_size, kv cache types, etc.) is applied correctly
tests/test-hydra-combine-mode-toggle.cpp (NEW)
2b
0x44 SET_EXPERT_MODE still toggles between SOLO and COMBINED per request without a reload (G6 acceptance)
tests/test-hydra-no-model-peer.cpp (NEW)
2b
llama-engine boots with no --model and --rpc-server-port <port>, exposes local CUDA, accepts 0x40 EngineConfigure with a model field (loads it) or without (stays bare)
tests/test-hydra-tier-classification.cpp (NEW)
2b
Each common_params field is classified into the right tier; the reconfigure path applies the right rebuild for each tier transition
tests/test-hydra-lazy-load.cpp (NEW)
2b
Engine boots model-less, returns "no model" on first request without 0x40, loads on first 0x40, second 0x40 with same params is a no-op
tests/test-hydra-three-layer-config.cpp (NEW)
2b
Layer 1 defaults + Layer 2 YAML + Layer 3 0x40 payload compose correctly; precedence is right
The mode resolver returns the right stock-params for each (model, mode) combination — covers all 4 cells of the unified COMBINE table
tests/coordinator/test_plan_schema.py (NEW)
2a
The new Plan shape validates against a JSON schema; OtSplit field rejected if present (translator compatibility check)
tests/system/test_solo_rtx.py
2b regression
G3: SOLO 5060 Ti decode ≥ 190 tok/s (no regression)
tests/system/test_combine_moe.py
2b
G2: MoE 35B COMBINED decode ≥ 45 tok/s, dual-load still works end-to-end via 0x40 + 0x44
tests/system/test_combine_dense.py
2b
G1: DENSE 27B COMBINED decode via 0x40 with stock --tensor-split 21/44 --rpc-server works (≥ 24 tok/s — see G1 note)
tests/system/test_mode_switch.py
2b
G6: SOLO ↔ COMBINED < 100 ms round-trip via 0x44, no model reload
tests/system/test_lazy_load.py (NEW)
2b
Engine boot time < 1s; first 0x40 triggers model load; subsequent 0x40 with same params is a no-op
Acceptance:
All Phase 1 tests still pass (no regression from the flag cleanup).
The 5-flag deprecation shim is removed; the 6 flags themselves are removed from llama-engine.cpp argparse.
llama-engine accepts --rpc-server, --rpc-server-port, --override-tensor, --split-mode, --tensor-split (stock llama.cpp args) and applies them at runtime via 0x40.
The dual-load machinery (llama_hydra_load_combined_experts, *_rpc fields, 0x44 SET_EXPERT_MODE) still works; existing MoE dual-load tests pass.
The no-model peer path still works; the dense 27B profile loads via 0x40 with stock --tensor-split 21/44 --rpc-server localhost:9506.
The engine boots model-less by default; first 0x40 triggers a T3 model load; subsequent 0x40 with the same model + no T2/T3 changes is a no-op.
The three-layer config (defaults + YAML + 0x40) composes correctly; precedence is right; T4 fields are rejected with a clear error.
Hydra.Core emits a single Plan shape with stock-params payload; no OT-vs-static branching.
workers.json schema simplified: combined_peer: bool instead of run_type strings; no combined_ot_split field.
NOT in this phase (deferred to Phase 3 or as separate issues):
The tensor_buft_overrides extension for per-process layer ranges (Phase 3) — same as the existing plan; required for A→B partial load.
The A→B POST /control/load_model route (Phase 3) — same.
The startup_stage state machine and --peer-health-url (existing Q10) — same.
G1 number revision: the original G1 (DENSE 27B ≥ 50 tok/s on 5060 Ti + 3060 with 25/40 split) is aspirational; live Phase 1 measurement on this hardware is 24.70 tok/s (the 3060 is the bottleneck). Update G1 to "≥ 24 tok/s" or document the 3060 as the binding constraint. The 50 tok/s target was set before the v4 design's actual perf profile was measured; the v4 keeps the goal of layer-split working end-to-end, not the specific tok/s number. File a follow-up issue for the G1 number revision before Phase 2 starts; update the design doc (#35) accordingly.
hydra_vortex#392: parent epic in the parent repo. Phase 2 work in the parent repo is sub-phase 2a.
hydra_vortex#398: per-task tracker in the parent repo.
hydra_vortex#394: Phase C / quant-parity split, now separate.
Time: 1-2 weeks (2a is C# only, ~1 week; 2b is fork, ~1 week including the runtime reconfigure path).
Rollback plan: revert 2b PR if the reconfigure path has bugs. The 5-flag deprecation shim was a transitional aid; reverting the cleanup just leaves the shim back in place — Phase 1's behavior is preserved. 2a is independently rollbackable: the old Plan.OtSplit field can stay (deprecated) and the new stock-params path can be a parallel implementation until verified.
Open questions for this phase:
Q46: Does 0x46 PIPELINE_ATTACH stay, get retired in favour of 0x40 stock --tensor-split, or get renamed? The v4 design's PIPELINE_ATTACH was the prima.cpp-style attach — that's now expressible as 0x40 EngineConfigure with stock params. Recommend retire 0x46 and fold into 0x40.
Q47: When the 0x40 payload includes a model field that differs from the currently-loaded model, the engine must unload the current model first. Confirm this is acceptable (it is — llama_model_free is straightforward) and that the reconfigure path handles it.
Q48: When the 0x40 payload includes rpc_servers with a peer that's unreachable, should the reconfigure fail (refuse to apply) or fall back to SOLO (apply, log a warning, return partial success)? Recommend fail — operator should know the peer is gone, not silently degrade.
Q49: For the no-model peer path, when 0x40 EngineConfigure arrives with a model field, should the peer load the model (becoming a SOLO+COMBINE-peer dual-role engine, per CLAUDE.md:266) or refuse (peer is a pure RPC backend)? Recommend "load it" — the peer is dual-role by architecture principle, just initially bare-metal. The "peer (no model)" and "peer (model)" rows in the existing Mode-to-port mapping collapse into one row.
Phase 3: Per-engine model loading (A tells B)
Goal: A and B both have the model loaded from a known path on demand. A tells B which layers to load via HTTP. B's mmap covers only the kept layers. Memory savings: ~50% on B's VRAM + host mmap.
Files to modify:
File
LOC
Change
src/llama-model-loader.cpp
+30
Extend tensor_buft_overrides to match by layer range. Pattern layer:N-M matches tensors with tn.bid ∈ [N, M]. The existing pattern matching code at src/llama-model-loader.cpp:1154 is the hook point.
src/llama-hydra.cpp
+20
Add llama_hydra_set_keep_layer_range(ctx, lo, hi) setter. Called by the loader when the override matches a layer range.
tools/llama-engine/llama-engine.cpp
+80
Add POST /control/load_model route. Add extract_keep_layer_range_from_split(split, &lo, &hi) helper. A's start_combined_with_peer orchestrator parses the split, computes B's range, sends the load command via HTTP.
tools/llama-engine/llama-engine.cpp
+10
Add load_model_unload_old(ctx) helper: unload current model (if any), call llama_model_load_from_file with the new keep_layer_range.
Test plan:
Test
What it verifies
tests/test-hydra-partial-load.cpp
The tensor_buft_overrides parser matches layer:N-M correctly. The loader skips tensors outside the range. The n_tensors count is consistent with the range.
tests/test-hydra-keep-layer-range.cpp
llama_hydra_set_keep_layer_range correctly filters model loading. B's mmap is half the size of the full model.
tests/system/test_combine_partial_load.py
G4 acceptance: cold load (NVMe) ≤ 25 s for 27B Q4. G5 acceptance: warm load (page cache) ≤ 5 s. G6 acceptance: A→B parallel load → COMBINED ready in ≤ 25 s wall clock.
tests/system/test_mode_switch.py
G6 acceptance: SOLO → COMBINED → SOLO round trip < 100 ms (no model reload).
Rollback plan: revert Phase 3 PR. Phase 1 + 2 stay. The A→B orchestration falls back to the full-model load (B mmaps the full GGUF; A's RPC_CMD_RESOLVE_TENSOR returns nullptr for out-of-range tensors; A's scheduler falls back to local CUDA for those). Performance degrades but correctness holds.
Phase 4: Hydra.Core orchestration (parent repo)
Goal: Hydra.Core sends the right config to the right engine at the right time. Profile switch (DENSE ↔ MoE) is bounded by G7.
Files to modify (in ddvnguyen/hydra_vortex parent repo):
File
LOC
Change
src/coordinator/MultiEngineRouter.cs
+30
Extended Select to emit a per-engine config block: {mode, peer, model, split, keep_layer_range}.
src/coordinator/WorkerSchedulerService.cs
+50
New SendEngineConfig(worker, config) method. Called when the head engine is selected.
Rollback plan: revert Phase 4 PR. The new routes (/control/load_model, /control/set_expert_mode) are unused; engines continue to work as before. The profile switch UX is lost (manual restart required).
Phase 5: Profile + decide on the wire (deferred, opt-in)
Goal: measure per-token time on the 3060+5060 Ti pair in COMBINED layer-split mode. Decide whether to invest in AF_UNIX, SHM, or CUDA IPC.
Tasks:
Run nsys profile --stats=true ./llama-engine --model DENSE-27B-Q4 --port 8080 --rpc-port 9504 --combined-tensor-split 21/44 --rpc-engine localhost:9505 on a 200-token decode.
Capture kernel timeline, RPC dispatch time, scheduler time, lock wait time.
Identify the per-token time breakdown. If RPC_CMD_GRAPH_COMPUTE dispatch is > 20% of per-token time, proceed to Phase 6 (AF_UNIX). Otherwise, stop here.
Output: docs/perf-baseline-2026-07.md with timeline screenshots.
Time: 2 days. No code change.
Phase 6 (optional): AF_UNIX wrapper
Only if Phase 5 shows the wire is the bottleneck. Fork-isolated in tools/llama-engine/hydra_rpc/af_unix.{h,cpp}. Wraps ggml_backend_rpc_* to use AF_UNIX sockets (/var/run/hydra-<node>.sock) instead of TCP loopback. Wire format unchanged. ~2× lower dispatch latency on same-host. Cross-host stays on TCP.
Only if Phase 6 leaves the per-token path on the critical path. Skipped for the current hardware.
Non-goals (do NOT implement)
A new ggml-RPC opcode set. We use upstream's opcodes + the Hydra protocol unchanged. The new thing is the merged server, not a new wire.
COMBINED for MoE 35B as a throughput win over SOLO 5060 Ti (~48 vs 200 tok/s, by math). The MoE COMBINED win is resource-sharing (free the 5060 Ti), not throughput.
Simultaneous DENSE 27B + MoE 35B residency (28 GB VRAM, not enough for both at usable quants).
A custom CUDA shared-context multi-GPU (would require rewriting ggml's backend scheduler; out of scope).
"Seamless" < 100 ms model swap between DENSE and MoE. The model reload dominates (~25 s); we don't keep both resident.
Adds the stock llama.cpp flags --rpc-server <host:port> (replaces --rpc-engine), --rpc-server-port <port> (replaces --ggml-rpc-port for peer exposure), --override-tensor <pat=backend> (replaces --combined-ot-pattern; the =CPU / =PEER / =RPC0 suffix is stock llama.cpp syntax), --split-mode <none|layer|row>, --tensor-split <a,b,...>.
Adds the --config <yaml> flag for the Layer 2 startup config (non-T4 defaults).
Config files to update:
ddvnguyen/hydra_vortex/infra/hydra-core/config/workers.json — remove ggml_rpc_port, combined_ot_split, run_type from each worker. Add combined_peer: bool and the Layer 2 default defaults: block.
In Phase 3, add a peer_endpoint field per worker (e.g., peer_endpoint: "localhost:9505").
In Phase 4, add a default_model and default_split per worker (e.g., default_model: "/models/DENSE-27B-Q4.gguf", default_split: "21/44").
Communication plan
When each phase lands:
Phase 1: comment on this issue with the PR link + the build/test results. Update the parent hydra_vortex#392 epic with status. File a monitoring issue if any alert fires.
Phase 2 (unified COMBINE): open sub-phase 2a PR against ddvnguyen/hydra_vortex (parent repo) with the C# refactor; cross-link this issue. Open sub-phase 2b PR against ddvnguyen/llama.cpp (this repo) with the runtime reconfigure path + flag cleanup. Comment on this issue with both PR links + the test results. If G1 number is revised (50 → 24 tok/s), file a follow-up issue documenting the 3060 as the binding constraint. Update PR fix: COMBINED layer-split startup crash + performance improvements #34 with a "superseded by Phase 2" notice (the scheduler feasibility finding is still valid; the crash is addressed by the v4 layer-split design).
Phase 3: comment on this issue. Update the parent hydra_vortex#392 epic.
Phase 4: comment on this issue. Update hydra_vortex#392. The profile switch is now usable end-to-end.
When each phase breaks (rolls back):
Comment on this issue with the root cause.
File a review-finding issue per hydra_vortex's ## Coding Agent Rules with the [Llama-Engine-P1-...] label.
Do not merge the next phase until the rollback is understood.
Open questions
(Updated after Phase 2 design pass. Q1–Q4 are RESOLVED — see "Decisions made" at the top of the Phased implementation plan. Remaining open questions are Q5–Q10 from the v4 design doc plus Q46–Q49 from the Phase 2 refactor.)
Model storage location. Each engine mmap's the same GGUF. Same path on every engine? Shared mount? Different paths? (Affects G4 cold-load time.) — still open
Engine startup model config. RESOLVED in Phase 2: engines boot model-less by default; first 0x40 EngineConfigure triggers a T3 model load. The --config <yaml> flag is the optional Layer 2 startup defaults.
Per-request payload to engine A. RESOLVED in Phase 2: the payload is a common_params JSON delta. Anything in common_params is supported (subject to tier classification). Peer engines do not need per-request config; they're configured once at startup and accept inbound 0x40/0x44 from the head.
Where does the split ratio come from? RESOLVED in Phase 2: operator-configured in the YAML defaults: block and overridable per-request via the 0x40tensor_split field. The unified COMBINE resolver emits the right split per (model, mode) combination.
Per-request peer switch. If A is currently in COMBINED with B and a new request asks for COMBINED with C, do we tear down B and bring up C inline (blocks the request), or queue and tear down async? (Affects request latency tail.) — still open
tensor_buft_overrides extension syntax. Is the layer:N-M pattern sufficient, or do we need a more expressive syntax (e.g., layer:N-M.*.ffn_.*=CPU for subset of tensors within a layer range)? — still open (Phase 3 dependency)
B's /control/load_model auth. Per hydra_vortex#393, the per-request peer field needs a trust gate. The /control/load_model endpoint has the same surface. Default: only accept from loopback (--control-allow-from 127.0.0.1). Confirm the gate is sufficient. — still open (Phase 3 dependency)
Phase 2 — 0x46 PIPELINE_ATTACH retirement. Fold into 0x40 stock --tensor-split or keep as a separate opcode? Recommend retire.
Phase 2 — T3 model swap on 0x40. Confirm llama_model_free + llama_model_load_from_file is the right unload-then-reload path; the reconfigure path handles it.
Phase 2 — unreachable peer in rpc_servers. Fail the reconfigure (operator must know the peer is gone) or fall back to SOLO with a warning? Recommend fail.
Phase 2 — no-model peer receives 0x40 with a model field. Load the model (becoming a dual-role engine per CLAUDE.md:266) or refuse? Recommend "load it" — the peer is dual-role by architecture principle, just initially bare-metal.
Phase 2 (unified COMBINE refactor) is merged: sub-phase 2a (Hydra.Core C#) and sub-phase 2b (fork runtime reconfigure + flag cleanup).
Phases 3–4 are merged (per-process model loading + coordinator orchestration), or superseded with rationale.
G1–G9 are all met (or G1 is explicitly dropped with documented rationale — the G1 number is likely revised from 50 tok/s to ≥ 24 tok/s based on the 3060 bottleneck; the file-a-follow-up-issue step is in the Phase 2 acceptance).
The merged server is in production for the 5060 Ti + 3060 same-host pair.
The cross-host P100 path is unchanged (G8 met).
The dual-load machinery (llama_hydra_*, *_rpc fields, 0x44 SET_EXPERT_MODE) is verified working in the new runtime-reconfigure flow.
Implementation handoff — v4 design: per-GPU llama-engines + merged Hydra RPC + no-weight-transfer COMBINE
TL;DR
Replace the current 3-server design (HTTP + Hydra control RPC + ggml-RPC) with a single merged Hydra RPC server in a new fork-isolated module
tools/llama-engine/hydra_rpc/. The merged server dispatches by first byte (ggml-RPC0x0Evs Hydra0x30–0x46) and uses a bounded thread pool (size 2). COMBINE is layer-split only, with each engine mmap'ing the same model from disk and the head binding to the peer's mmap-resident tensors via the existingRPC_CMD_RESOLVE_TENSOR(PR #20) — no weight data on the wire. Per-process model loading: A tells B which layers to load viaPOST /control/load_modelwith akeep_layer_rangefield.Phase 1 is MERGED via PR #37 (merged Hydra RPC server in
tools/llama-engine/hydra_rpc/, bounded thread pool, MSG_PEEK dispatch). This issue is the implementation handoff for Phase 2 onward. Phase 2 is the Unified COMBINE refactor: collapse the Hydra-side COMBINED-OT and COMBINED-static abstractions in Hydra.Core into a single unified COMBINE concept, acceptcommon_paramsat runtime via0x40 EngineConfigure, and remove the Hydra-specific CLI flag names (--rpc-engine,--combined-ot-pattern,--combined-split-mode,--combined-tensor-split,--peer-only,--ggml-rpc-port). The dual-load machinery, the0x44 SET_EXPERT_MODEruntime toggle, theRPC_CMD_RESOLVE_TENSORno-weight-transfer path, and the no-model peer path all stay — they are the features, not the abstractions. Engines boot model-less in the lazy-load design; Hydra.Core tells each engine which model + mode (SOLO or COMBINE) to run via0x40.Why this matters
--rpc-port) instead of two (--rpc-port+--ggml-rpc-port).Scheduler feasibility (CRITICAL — do this before Phase 2)
PR #34 documented that the upstream ggml scheduler assigns compute to the device that holds the inputs, not the device that holds the weights. This breaks layer-split for DENSE models: a layer's compute goes to the peer (where the previous layer's outputs live), but the peer's RPC server can't access the local CUDA's weights.
Before Phase 2 starts, run a 1B model (e.g. Qwen2-1.5B) layer-split 50/50 across CUDA0 + RPC0, decode 50 tokens, confirm no
GET_TENSOR FAILEDerrors. If the test fails:-ts --rpcpath (option 2 in PR fix: COMBINED layer-split startup crash + performance improvements #34). The fork's per-process model loading (Phase 3) still works; it just drives the upstream CLI instead of--rpc-engine.The feasibility check is a precondition for G1 (DENSE 27B ≥ 50 tok/s). If G1 is dropped, update the design doc (#35) accordingly.
Goals & acceptance criteria
Each goal is measurable. The acceptance test for each is the listed condition.
tests/system/test_combine_dense.pypasses; the scheduler feasibility check above passes firsttests/system/test_combine_moe.pypassestests/system/test_solo_rtx.pymeasures ≥ 190 tok/s on the existing Q3_K-mini 35Btests/system/test_engine_load.py --model DENSE-27B-Q4 --coldmeasures ≤ 25 stests/system/test_engine_load.py --model DENSE-27B-Q4 --warmmeasures ≤ 5 stests/system/test_mode_switch.pymeasures < 100 ms between SOLO and COMBINED for the same modeltests/system/test_profile_switch.pymeasures end-to-end reload + RPC rebind ≤ 60 stests/system/test_p100_pd.pymeasures ≥ 28 tok/s on the existing P100 P/D pathgit diff upstream/master -- 'ggml/src/ggml-rpc/*' 'ggml/src/ggml-cuda/*' 'ggml/src/ggml-backend.cpp'shows ≤ 75 insertions across ≤ 4 functionsHard constraints
tools/llama-engine/hydra_rpc/(fork-only) or one of four small upstream-touched functions:ggml_backend_rpc_handle_client(~20 LOC),ggml_backend_rpc_remove_server(~30 LOC),set_keepalive(~20 LOC),MSG_NOSIGNAL(1 LOC). Total upstream-touched: ~71 LOC.RPC_CMD_RESOLVE_TENSOR(PR fork: zero-copy COMBINED expert tensors (name-based remote binding, replaces network copy) #20) sends the tensor name only. This is the explicit mechanism for "no weight transfer" — not "no RPC at all". Issue Unified RPC Server: one port, one binary, no extra flags #29's design doc had this wrong (it said "noRPC_CMD_RESOLVE_TENSOR" which is contradictory; see "Clarifications" below).rpc-serverbinary, not the merged engine.RPC_STATUS_ASSERT/GGML_ABORTon failure. This is why PR fork: resolve #376 — make COMBINE mode RPC work (gate-then-degrade) #31's broad fail-soft replacement is rejected — it would mask data corruption in the inference path (peer dies mid-graph, head returnsGGML_STATUS_FAILED, scheduler continues with uninitialized tensors, user gets garbage tokens).0x00–0x11, Hydra uses0x30–0x46; ranges don't overlap, dispatch is unambiguous). No magic bytes, no per-conndetach(), bounded thread pool.Clarifications (vs the issue #29 design doc)
Issue #29 was the original design doc for the unified server (now PR #31). Two clarifications to read carefully:
"No
RPC_CMD_RESOLVE_TENSOR" in issue Unified RPC Server: one port, one binary, no extra flags #29 is wrong. The design wants "no weight transfer", but the only mechanism for "no weight transfer" with the standard upstream buffer model isRPC_CMD_RESOLVE_TENSOR. The v4 design usesRPC_CMD_RESOLVE_TENSORexplicitly. If you see "no RESOLVE_TENSOR" anywhere in issue Unified RPC Server: one port, one binary, no extra flags #29, treat it as a typo for "the RESOLVE_TENSOR path replaces the SET_TENSOR weight-transfer path".PR fix: COMBINED layer-split startup crash + performance improvements #34's 7 fixes don't address the actual crash. The author's own analysis says "The GET_TENSOR crash persists" and the root cause is the upstream ggml scheduler. The 7 fixes are mitigations. Do not cherry-pick from PR fix: COMBINED layer-split startup crash + performance improvements #34. The crash is real, but the fix is upstream or a different split mechanism (Fallback A in the "Scheduler feasibility" section).
Architecture
Inside the merged Hydra RPC server
Mode-to-port mapping
--port--rpc-portWire protocol summary
The wire format is upstream ggml-RPC (
RPC_CMD_*) + the Hydra protocol (HYDRA_OP_*), served from the same listener. The dispatch is by first byte:0x0E(RPC_CMD_HELLO) →ggml_backend_rpc_handle_client(conn_fd, ...)servesRPC_CMD_GRAPH_COMPUTE,RPC_CMD_SET_TENSOR,RPC_CMD_INIT_TENSOR,RPC_CMD_RESOLVE_TENSOR, etc.0x30–0x46(HYDRA_OP_*) →hydra_handle_connection(conn_fd, ctx)servesHYDRA_OP_SET_EXPERT_MODE,HYDRA_OP_SWAP_QUANT,HYDRA_OP_STATE_GET,HYDRA_OP_STATE_PUT.The peer also supports
RPC_CMD_RESOLVE_TENSORfor the no-weight-transfer path: the head callsRPC_CMD_RESOLVE_TENSOR("blk.21.attn_q.weight")on the peer, the peer looks up the tensor in its mmap, returns a remote_ptr. The head uses that remote_ptr in therpc_tensor.bufferfield ofRPC_CMD_GRAPH_COMPUTE. No weight data crosses the wire.For A→B partial load (Phase 3), a new HTTP route
POST /control/load_modelon B's HTTP server:{"model": "<path>", "keep_layer_range": {"lo": <int>, "hi": <int>}}{"loaded": true, "n_tensors": <int>, "size_mb": <int>}.--control-allow-from(default127.0.0.1).Phased implementation plan
Decisions made (resolved during Phase 2 design)
The following design questions were open at Phase 1 handoff and have been resolved as part of the Phase 2 (unified COMBINE) design. They are recorded here for traceability.
Q1 — Runtime
EngineConfigure (0x40)reconfigure path: three-tier classificationEvery
common_paramsfield is classified into one of three tiers based on what it costs to change at runtime. The0x40payload diffs against the last-applied state and triggers the minimum rebuild needed.llama_context_free+llama_new_context_with_modelllama_model_free+llama_model_load_from_file+llama_new_context_with_modelcommon_params)common_params: bind ports (--port,--rpc-port,--rpc-server-port),--hostname,--n-threads-http, log config, TLS cert paths, CUDA device selection. Forbidden to change at runtime;0x40rejects these fields.T1 fields include:
n_predict,sampling.*,n_keep,n_chunks,n_print,antiprompt,prompt_cache_*,cache_prompt,cache_idle_slots,n_cache_reuse,n_ctx_checkpoints,checkpoint_min_step,cache_ram_mib,slot_prompt_similarity,single_turn,cont_batching,ctx_shift,input_prefix/suffix/bos,prompt,system_prompt,prompt_file,api_keys,chat_template,use_jinja,enable_chat_template,force_pure_content_parser,default_template_kwargs,reasoning_format,enable_reasoning,prefill_assistant,embedding,embd_normalize,embd_out,embd_sep,cls_sep,image,image_min_tokens,image_max_tokens,timeout_read,timeout_write,sse_ping_interval,sleep_idle_seconds, server TTY-related flags,logits_file,logits_output_dir,save_logits,tensor_filter,out_file,load_progress_callback[_user_data].T2 fields include:
n_ctx,n_batch,n_ubatch,n_parallel,n_sequences,n_outputs_max,grp_attn_n,grp_attn_w,cpuparams,cpuparams_batch,cb_eval,cb_eval_user_data,numa,rope_freq_base/scale,yarn_*,rope_scaling_type,pooling_type,attention_type,flash_attn_type,cache_type_k,cache_type_v,kv_unified,swa_full,no_perf,no_kv_offload,no_op_offload,no_extra_bufts,no_host,speculative(lookup-ngram only — themodel-draftfield is T3).T3 fields include:
model,mmproj,n_gpu_layers,main_gpu,tensor_split,split_mode,devices,use_mmap,use_direct_io,use_mlock,check_tensors,no_alloc,kv_overrides,tensor_buft_overrides,lora_init_without_apply,lora_adapters,control_vectors, therpc_serverslist (stock--rpc-server),n_cpu_moe,speculative.model.Field map reference:
src/llama-cpp/common/common.h:426-706is the source of truth.common_model_params_to_llama(common.h:884) consumes the T3 fields.common_context_params_to_llama(common.h:885) consumes the T2 fields. Sub-structs:common_params_samplingatcommon.h:210-289,common_params_speculativeatcommon.h:347-370,common_params_modelatcommon.h:292-301.Q2 —
0x44 SET_EXPERT_MODEstays a separate opcode0x44does not fold into0x40. The mode iscparams.hydra_expert_modeand is part of the graph-reuse key, so flipping it forces a graph rebuild but not a model reload. Keeping0x44separate from0x40means per-request SOLO↔COMBINED flips are cheap (graph rebuild only) when the dual-load is already set up at T3 load time. The cost is one extra opcode in the Hydra protocol; the win is no model reload on every mode flip.Q3 —
RPC_CMD_RESOLVE_TENSOR(PR #20) is a stable fork APIThe fork commits to maintaining
RPC_CMD_RESOLVE_TENSORandRPC_CMD_RESOLVE_TENSESas extensions to ggml-RPC. Stock llama.cpp does not have an equivalent; this is what the dual-load zero-copy bind uses (ggml_backend_rpc_bind_remote_tensorresolves tensor handles by name from the peer's resident-tensor registry). Future work that needs the no-weight-transfer path (e.g. dense 27B layer-split with mmap-share) is built on top of PR #20.Q4 — Lazy model load; no-model peer path is in scope; engines boot model-less
The default boot state is model-less. The engine binds the HTTP port, the Hydra RPC port, the local-CUDA-exposure port (if any), starts the servers, and waits for the first
0x40 EngineConfigure. The first0x40triggers a T3 model load.This is the "3 GPU worker pool" pattern in production: each of the 5060 Ti, the 3060, and the P100 runs a
llama-enginewith no model loaded; Hydra.Core sends0x40to whichever engine should serve a given (model, mode) request. The benefit is fast startup (no model-load barrier at boot, <1 second to "engine ready" / waiting for0x40) and operator simplicity (3 identical bare engines; Hydra.Core routes work).The no-model peer path (the 3060 exposing its CUDA on
--rpc-server-portwithout loading a model) is the no-weight-transfer support for the dense 27B layer-split. The 5060 Ti loads the full model and binds to the 3060's CUDA backend via stock--rpc-server localhost:9506.Three-layer startup config (CLI + optional YAML +
0x40payload)The three layers, in order of precedence:
common_paramsbuilt-in defaults atcommon.h:426-706. Always present. Fork does not change these.n_ctx,cache_type_k/v,n_gpu_layers,flash_attn,n_parallel, RoPE/YaRN, etc.). Without--config, only CLI flags are applied; the engine is a true bare compute slot.0x40 EngineConfigurepayload, per-request. JSON shape mirrorscommon_params. Engine diffs against the last-applied state and triggers T1/T2/T3 rebuild as needed.In production with YAML defaults, Hydra.Core can send a minimal
0x40payload (e.g.{ "model": "moe-35b", "mode": "combine", "peer": "localhost:9506" }) and the engine applies the layered defaults for everything else. In the bare-pool case (no YAML), Hydra.Core sends the fullcommon_params-shaped payload and the engine applies it directly."Unified COMBINE" resolver (Hydra.Core C# side)
The C#
MultiEngineRoutermode resolver replaces the OT-vs-static branch. Given(model_alias, combined: bool), it returns a0x40payload delta:0x40payload shape (delta from defaults)MoE + SOLO{ model, n_gpu_layers: 99, n_cpu_moe: 8, override_tensors: ["token_embd=CPU", "output=CPU", "output_norm=CPU"] }MoE + COMBINEoverride_tensors: [..., "blk.*.ffn_*_exps.weight=RPC0"]andrpc_servers: ["localhost:9506"](the peer)DENSE + SOLO{ model, n_gpu_layers: 99, split_mode: "none" }DENSE + COMBINE{ model, n_gpu_layers: 99, split_mode: "layer", tensor_split: [21, 44], rpc_servers: ["localhost:9506"] }The resolver does not name "OT" or "static" — those words do not appear in the C# code. The four rows are just stock-params combinations, looked up by
(model_arch, mode). The C# still emits0x44 SET_EXPERT_MODEfor MoE COMBINE plans (translator layer under the hood) so the live MoE dual-load path keeps working through the transition.Phase 0: Scheduler feasibility (DO THIS BEFORE PHASE 1)
Goal: confirm the layer-split scheduler can place compute on the right device for our models.
Tasks:
hydra-forkfat binary:cmake --build build_sm86_sm120 --target llama-enginetensor_split "20/40"(50/50) on CUDA0 + RPC0 (loopback,ggml-rpc-serverupstream binary).GET_TENSOR FAILEDorRPC_CMD_* failed for ... on ...errors.-ts --rpc) or Fallback B (drop G1).Acceptance: test runs cleanly, no errors. The output layer (
output.weightorl_out-N) is placed on the device that holds its weights, not the device that holds its inputs.Time: 1 day.
Commit message:
infra: scheduler-feasibility check (1B layer-split sanity test)(in the parenthydra_vortexrepo, not the fork).Phase 1: Cherry-pick + new module (FIRST MERGE)
Goal: the merged Hydra RPC server runs in place of the current 3 servers. No new functionality, no behavior change, just the architecture swap.
Files to create (under
src/llama-cpp/tools/llama-engine/hydra_rpc/):hydra_rpc.hstart(),stop(),is_running(),settingsstructhydra_rpc.cppbounded_thread_pool.hbounded_thread_pool<N>template +enqueue()+try_enqueue()bounded_thread_pool.cppTotal new fork-isolated: ~390 LOC.
Files to modify:
tools/llama-engine/llama-engine.cppstart_*_rpc_servercalls into onehydra_rpc::startcall. Deletestart_shared_backend_rpc_server(~60 LOC) andstart_backend_rpc_peer_server(~30 LOC). Update CLI to remove--ggml-rpc-port.tools/server/server-context.cppserver_context::start_rpc_server(replaced). Delete the per-connstd::thread::detach()at line 7187.ggml/src/ggml-rpc/ggml-rpc.cppggml_backend_rpc_handle_client(public API for serving a pre-accepted fd; ~20 LOC) +ggml_backend_rpc_remove_server(cleanup; ~30 LOC). Register inggml_backend_rpc_get_proc_address.ggml/src/ggml-rpc/transport.cppset_keepalive(~20 LOC) +MSG_NOSIGNALonsend(1 LOC).ggml/include/ggml-rpc.hggml/src/ggml-rpc/ggml-rpc.cppRPC_STATUS_ASSERT→ fail-soft replacement. Restore upstream'sGGML_ABORTbehavior in normal ops. Only keep the null-guard (`if (!ctxTotal upstream-touched: ~75 LOC (4 functions, 2 files). Within G9 budget.
CLI flag changes:
--ggml-rpc-port(use--rpc-portonly).--control-allow-from <ip>(default127.0.0.1) for the new/control/load_modelroute in Phase 3. Default allows Phase 1 to work without changes.Config file changes (in
ddvnguyen/hydra_vortexparent repo):infra/hydra-core/config/workers.json: removeggml_rpc_portfield from each worker.infra/hydra-head/config/node-rtx.yaml: removeggml_rpc_portfield.infra/hydra-head/config/node-rtx3060.yaml: removeggml_rpc_portfield.Test plan (added in this phase):
tests/test-hydra-rpc-accept-loop.cpp0x0Eto ggml-RPC handler and0x30to Hydra handler. Pool full → connection dropped. Shutdown unblocks accept thread.tests/test-hydra-bounded-thread-pool.cppenqueueblocks when full,try_enqueuereturns false, workers join onstop().tests/test-hydra-solo-regression.cpp--rpc-port) starts and serves /health on--port. No accept thread spawned. Same as before Phase 1.tests/system/test_solo_rtx.pyAcceptance:
cmake --build build_sm86_sm120 --target llama-engine.Cherry-pick source: from PR #31, only:
ggml_backend_rpc_handle_client(ggml-rpc.cpp, ~20 LOC)ggml_backend_rpc_remove_server(ggml-rpc.cpp, ~30 LOC)set_keepalive(transport.cpp, ~20 LOC)MSG_NOSIGNAL(transport.cpp, 1 LOC)socket_t::from_fd(transport.{h,cpp}, ~10 LOC) — needed forggml_backend_rpc_handle_clientto wrap the fdNOT cherry-picked (rejected per C6 / C7 / G9 / dead-path cleanup):
RPC_STATUS_ASSERTfail-soft replacement (16+ places)response = {}zero-inits (3 places)ggml-cuda/fattn.cuGGML_ABORTremovalstd::thread::detach()(replaced by bounded pool)start_rpc_accept_loopintools/server/(replaced bytools/llama-engine/hydra_rpc/)llama_hydra_enable_shared_backend_compute_lock()(the call site instart_shared_backend_rpc_serveris being deleted)llama_hydra_load_combined_experts,_rebind_*,s_hydra_combined_bindings,ffn_*_exps_rpc— KEPT, not cherry-picked from; these are the dual-load machinery that survives into Phase 2llama_hydra_validate_quant_parity(Phase C) +SWAP_QUANTvalidation block (split to separate PR perhydra_vortex#394)startup_stageand the staged init (we keep the simpleis_readyfor now; staged init can be added in Phase 2 if needed)wait_for_peer_ready+--peer-health-url(added in Phase 3 when A→B load needs it)Time: 1 week. PR title:
fork: Phase 1 — merged Hydra RPC server in tools/llama-engine/hydra_rpc/. PR body references this issue, #35, and the existinghydra_vortex#392epic.Rollback plan: revert the PR. The previous 3-server design is preserved on
hydra-fork. No production deploys are gated on this; the merged server is feature-equivalent to the 3-server design when--rpc-port=0(no SOLO regression). Revert =git revert <merge-sha>and redeploy.Phase 2: Unified COMBINE refactor — stock params + runtime reconfigure
Goal: collapse the Hydra-side COMBINED-OT and COMBINED-static abstractions in Hydra.Core into a single unified COMBINE concept. The
llama-engineacceptscommon_paramsat runtime via0x40 EngineConfigure. The dual-load machinery, the0x44 SET_EXPERT_MODEruntime toggle, theRPC_CMD_RESOLVE_TENSORno-weight-transfer path, and the no-model peer path all stay — they are the features, not the abstractions. What dies is the Hydra-side labelling (Plan.OtSplit,Plan.run_type, OT-vs-static branches inMultiEngineRouter) and the Hydra-specific CLI flag names (--rpc-engine,--combined-ot-pattern,--combined-split-mode,--combined-tensor-split,--peer-only,--ggml-rpc-port).Why this matters
The current state has two redundant "mode" abstractions:
Plan.OtSplit,Plan.CombinedOtSplit,run_type: "combined-static-peer",combined_ot_splitconfig field, OT-vs-static branching inMultiEngineRouter.cs.--rpc-engine,--combined-ot-pattern,--combined-split-mode,--combined-tensor-split,--peer-only,--ggml-rpc-port(the last five stripped by the PR fork: Phase 1 — merged Hydra RPC server in tools/llama-engine/hydra_rpc/ #37 deprecation shim).The engine-side flags are shorthand for stock
common_params(--rpc-server,--override-tensor,--split-mode,--tensor-split, and the stock arg for exposing local CUDA). The Hydra-side labels are shorthand for "set these stock params for this (model, combined) combination". Both shorthand layers hide what's actually happening and prevent the system from supporting anycommon_paramsfield beyond the few the fork chose to expose. Removing them is a code-reduction win, a clarity win, and a "support all llama.cpp params" win.The mechanisms that stay (per the corrected design):
src/llama-hydra.cpp—llama_hydra_load_combined_experts,llama_hydra_rebind_combined_experts,s_hydra_combined_bindings,llama_hydra_set_expert_mode,llama_hydra_register_local_tensors_for_rpc,llama_hydra_lock_compute/unlock_compute/llama_hydra_enable_shared_backend_compute_lock/llama_hydra_force_sync_if_shared,llama_hydra_clear_combined_bindings,llama_hydra_get_compute_backends,llama_hydra_preload_rpc_device. The dual-load is the feature: it provides the per-request SOLO↔COMBINED toggle (0x44) that stock llama.cpp's load-time-fixed--override-tensorcannot.src/llama-model.h:320-322—ffn_gate_exps_rpc,ffn_down_exps_rpc,ffn_up_exps_rpcfields onllama_layer. Stay.src/llama-context.cpp:423-432—llama_context::hydra_add_combined_rpc_backend. Stays (required for the scheduler to know about the peer's backend).src/llama-context.cpp:2433-2441— RAII compute lock. Stays (required for the same-host dual-role case: the 3060 serves its own SOLO and the head's RPC traffic on the same backend).src/models/qwen35moe.cpp:507-513— per-layer ternary selecting betweenffn_*_expsandffn_*_exps_rpc. Stays.tools/llama-engine/hydra_rpc/module (added by PR fork: Phase 1 — merged Hydra RPC server in tools/llama-engine/hydra_rpc/ #37). Stays.ggml/src/ggml-rpc/RPC_CMD_RESOLVE_TENSOR(PR fork: zero-copy COMBINED expert tensors (name-based remote binding, replaces network copy) #20). Stays — the dual-load zero-copy bind uses it.Sub-phase 2a — Hydra.Core C# refactor (parent repo only, no fork change)
Files (in
ddvnguyen/hydra_vortexparent):src/coordinator/MultiEngineRouter.cs— drop the OT-vs-static branch. The router emits a singlePlanshape whose payload is a stockcommon_paramsJSON (or delta).src/coordinator/WorkerSchedulerService.cs— model registry added. Keys by alias. Each entry holds{ model_path, default_ctx_size, default_kv_quant, default_n_cpu_moe, default_overrides[] }.src/coordinator/Plan.cs(whereverPlanis defined) —Plan.OtSplitandPlan.CombinedOtSplitremoved. Replaced byPlan.CommonParams(the full stock-params JSON) or by fieldsPlan.OverrideTensors,Plan.TensorSplit,Plan.SplitMode,Plan.RpcServers.src/core/Hydra.Shared/RpcClient.cs:499— theot_splitJSON key replaced with a stock-params payload.src/core/Hydra.Shared/Protocol.cs:55-58—EnginePipelineAttachpayload (ot_splitfield) updated to the new stock-params shape (or removed if0x46is also retired in favour of0x40; see Q46 below).infra/hydra-core/config/workers.jsonandworkers-27b.json—run_type: "combined-static-peer"andcombined_ot_splitfields removed. Replaced withcombined_peer: bool+ stock args in the worker config.infra/hydra-head/config/node-rtx.yaml,node-rtx-27b.yaml,node-rtx3060.yaml,node-rtx3060-27b.yaml— replace the Hydra-specific args with stock llama.cpp args passed through directly.C# still emits
0x44 SET_EXPERT_MODEfor MoE COMBINE plans (translator layer under the hood), so the live MoE dual-load path keeps working. This sub-phase ships in production alongside the current fork — no engine-side change, no production risk.Sub-phase 2b — Fork runtime reconfigure + flag cleanup
Files (in fork):
tools/llama-engine/llama-engine.cpp:tools/llama-engine/llama-engine.cpp:201-214(added by PR fork: Phase 1 — merged Hydra RPC server in tools/llama-engine/hydra_rpc/ #37, now obsolete). The flags themselves are also removed from argparse.start_shared_backend_rpc_serverandstart_backend_rpc_peer_serverpaths in the no-model-peer block. PR fork: Phase 1 — merged Hydra RPC server in tools/llama-engine/hydra_rpc/ #37 already replaced them withhydra_rpc::start; verify no callers remain and remove.0x40 EngineConfigurewith a fullcommon_paramsJSON payload, diff against the last-applied snapshot, decide which lifecycle point is affected (model reload / context rebuild / per-request setting), tear down + rebuild as needed, return the actual state. The diff walks thecommon_paramsstruct field by field; T3 fields trigger model teardown, T2 fields trigger context teardown, T1 fields apply per-request. T4 fields are rejected.0x40payload schema iscommon_params-shaped:{ model, n_ctx, n_batch, n_ubatch, split_mode, tensor_split, override_tensors: [string], rpc_servers: [string], n_gpu_layers, n_cpu_moe, cache_type_k, cache_type_v, flash_attn, rope_*, yarn_*, ... }. The engine applies via acommon_params_parse-equivalent on the new params.0x44 SET_EXPERT_MODEopcode stays (separate from0x40). The mode iscparams.hydra_expert_mode; flipping it forces a graph rebuild (the mode is in the graph-reuse key) but not a model reload.EngineConfigure (0x40)handler intools/server/server-context.cppis extended to apply the stock-params JSON (replacing the current per-mode branch).tools/llama-engine/llama-engine.cppCLI parser:--rpc-engine,--combined-ot-pattern,--combined-split-mode,--combined-tensor-split,--peer-only,--ggml-rpc-port.--rpc-server <host:port>(adds a remote device — replaces--rpc-engine),--rpc-server-port <port>(exposes local CUDA — replaces--ggml-rpc-port),--override-tensor <pat=backend>(replaces--combined-ot-pattern; the=CPU/=PEER/=RPC0suffix is stock llama.cpp syntax already),--split-mode <none|layer|row>,--tensor-split <a,b,...>.--modelflag stays (still required for SOLO + dense + MoE loads). For the no-model peer case, omit--modeland pass--rpc-server-port <port>only.--config <yaml>flag is added (Layer 2 startup config; see "Three-layer startup config" in Decisions made).src/llama-hydra.cpp:178-192(llama_hydra_clear_combined_bindings): no change. Still used by the dual-load rebind path for the0x44handler's per-rebind cleanup.Files that stay unchanged:
src/llama-hydra.h(full file, all 152 lines) — every API stays.src/llama-hydra.cpplines 1-435 — every function stays.src/llama-model.h:320-322—*_rpcfields stay.src/llama-context.cpp:418-432(hydra_add_combined_rpc_backend) — stays.src/llama-context.cpp:2433-2498(RAII compute lock) — stays.src/models/qwen35moe.cpp:507-513— per-layer ternary stays.tools/llama-engine/hydra_rpc/(PR fork: Phase 1 — merged Hydra RPC server in tools/llama-engine/hydra_rpc/ #37 module) — stays.ggml/src/ggml-rpc/RPC_CMD_RESOLVE_TENSOR(PR fork: zero-copy COMBINED expert tensors (name-based remote binding, replaces network copy) #20) — stays.tools/server/server-context.cpp:3536-3666(SET_EXPERT_MODEhandler) — stays (the0x44opcode is still served; the new0x40semantics don't replace it).specs/rpc-protocol.md:219-227(0x44 SET_EXPERT_MODEdescription) — stays.Test plan:
tests/test-hydra-rpc-accept-loop.cpp(from Phase 1)tests/test-hydra-bounded-thread-pool.cpp(from Phase 1)tests/test-hydra-solo-regression.cpp(from Phase 1)tests/test-hydra-runtime-reconfigure.cpp(NEW)common_paramspayloads, rebuilds model/context as needed, returns the new state. Covers: ctx-only changes, model-load changes, rpc-server list changes, override-tensor changes, no-op diffs.tests/test-hydra-common-params-passthrough.cpp(NEW)common_paramsfield Hydra.Core might emit (split_mode, tensor_split, override_tensors, n_cpu_moe, n_gpu_layers, ctx_size, kv cache types, etc.) is applied correctlytests/test-hydra-combine-mode-toggle.cpp(NEW)0x44 SET_EXPERT_MODEstill toggles between SOLO and COMBINED per request without a reload (G6 acceptance)tests/test-hydra-no-model-peer.cpp(NEW)--modeland--rpc-server-port <port>, exposes local CUDA, accepts0x40 EngineConfigurewith a model field (loads it) or without (stays bare)tests/test-hydra-tier-classification.cpp(NEW)common_paramsfield is classified into the right tier; the reconfigure path applies the right rebuild for each tier transitiontests/test-hydra-lazy-load.cpp(NEW)0x40, loads on first0x40, second0x40with same params is a no-optests/test-hydra-three-layer-config.cpp(NEW)0x40payload compose correctly; precedence is righttests/coordinator/test_multi_engine_router.py(extended)tests/coordinator/test_plan_schema.py(NEW)Planshape validates against a JSON schema;OtSplitfield rejected if present (translator compatibility check)tests/system/test_solo_rtx.pytests/system/test_combine_moe.py0x40+0x44tests/system/test_combine_dense.py0x40with stock--tensor-split 21/44 --rpc-serverworks (≥ 24 tok/s — see G1 note)tests/system/test_mode_switch.py0x44, no model reloadtests/system/test_lazy_load.py(NEW)0x40triggers model load; subsequent0x40with same params is a no-opAcceptance:
llama-engine.cppargparse.llama-engineaccepts--rpc-server,--rpc-server-port,--override-tensor,--split-mode,--tensor-split(stock llama.cpp args) and applies them at runtime via0x40.llama_hydra_load_combined_experts,*_rpcfields,0x44 SET_EXPERT_MODE) still works; existing MoE dual-load tests pass.0x40with stock--tensor-split 21/44 --rpc-server localhost:9506.0x40triggers a T3 model load; subsequent0x40with the same model + no T2/T3 changes is a no-op.0x40) composes correctly; precedence is right; T4 fields are rejected with a clear error.Planshape with stock-params payload; no OT-vs-static branching.workers.jsonschema simplified:combined_peer: boolinstead ofrun_typestrings; nocombined_ot_splitfield.NOT in this phase (deferred to Phase 3 or as separate issues):
tensor_buft_overridesextension for per-process layer ranges (Phase 3) — same as the existing plan; required for A→B partial load.POST /control/load_modelroute (Phase 3) — same.startup_stagestate machine and--peer-health-url(existing Q10) — same.G1 number revision: the original G1 (DENSE 27B ≥ 50 tok/s on 5060 Ti + 3060 with 25/40 split) is aspirational; live Phase 1 measurement on this hardware is 24.70 tok/s (the 3060 is the bottleneck). Update G1 to "≥ 24 tok/s" or document the 3060 as the binding constraint. The 50 tok/s target was set before the v4 design's actual perf profile was measured; the v4 keeps the goal of layer-split working end-to-end, not the specific tok/s number. File a follow-up issue for the G1 number revision before Phase 2 starts; update the design doc (#35) accordingly.
Cross-links:
RPC_CMD_RESOLVE_TENSOR. Used by the dual-load machinery; not modified in this phase.hydra_vortex#392: parent epic in the parent repo. Phase 2 work in the parent repo is sub-phase 2a.hydra_vortex#398: per-task tracker in the parent repo.hydra_vortex#394: Phase C / quant-parity split, now separate.Time: 1-2 weeks (2a is C# only, ~1 week; 2b is fork, ~1 week including the runtime reconfigure path).
Rollback plan: revert 2b PR if the reconfigure path has bugs. The 5-flag deprecation shim was a transitional aid; reverting the cleanup just leaves the shim back in place — Phase 1's behavior is preserved. 2a is independently rollbackable: the old
Plan.OtSplitfield can stay (deprecated) and the new stock-params path can be a parallel implementation until verified.Open questions for this phase:
0x46 PIPELINE_ATTACHstay, get retired in favour of0x40stock--tensor-split, or get renamed? The v4 design's PIPELINE_ATTACH was the prima.cpp-style attach — that's now expressible as0x40 EngineConfigurewith stock params. Recommend retire0x46and fold into0x40.0x40payload includes amodelfield that differs from the currently-loaded model, the engine must unload the current model first. Confirm this is acceptable (it is —llama_model_freeis straightforward) and that the reconfigure path handles it.0x40payload includesrpc_serverswith a peer that's unreachable, should the reconfigure fail (refuse to apply) or fall back to SOLO (apply, log a warning, return partial success)? Recommend fail — operator should know the peer is gone, not silently degrade.0x40 EngineConfigurearrives with amodelfield, should the peer load the model (becoming a SOLO+COMBINE-peer dual-role engine, perCLAUDE.md:266) or refuse (peer is a pure RPC backend)? Recommend "load it" — the peer is dual-role by architecture principle, just initially bare-metal. The "peer (no model)" and "peer (model)" rows in the existing Mode-to-port mapping collapse into one row.Phase 3: Per-engine model loading (A tells B)
Goal: A and B both have the model loaded from a known path on demand. A tells B which layers to load via HTTP. B's mmap covers only the kept layers. Memory savings: ~50% on B's VRAM + host mmap.
Files to modify:
src/llama-model-loader.cpptensor_buft_overridesto match by layer range. Patternlayer:N-Mmatches tensors withtn.bid ∈ [N, M]. The existing pattern matching code atsrc/llama-model-loader.cpp:1154is the hook point.src/llama-hydra.cppllama_hydra_set_keep_layer_range(ctx, lo, hi)setter. Called by the loader when the override matches a layer range.tools/llama-engine/llama-engine.cppPOST /control/load_modelroute. Addextract_keep_layer_range_from_split(split, &lo, &hi)helper. A'sstart_combined_with_peerorchestrator parses the split, computes B's range, sends the load command via HTTP.tools/llama-engine/llama-engine.cppload_model_unload_old(ctx)helper: unload current model (if any), callllama_model_load_from_filewith the newkeep_layer_range.Test plan:
tests/test-hydra-partial-load.cpptensor_buft_overridesparser matcheslayer:N-Mcorrectly. The loader skips tensors outside the range. Then_tensorscount is consistent with the range.tests/test-hydra-keep-layer-range.cppllama_hydra_set_keep_layer_rangecorrectly filters model loading. B's mmap is half the size of the full model.tests/system/test_combine_partial_load.pytests/system/test_mode_switch.pytests/system/test_profile_switch.pyAcceptance:
Time: 1 week. PR title:
fork: Phase 3 — A→B partial load via keep_layer_range.Rollback plan: revert Phase 3 PR. Phase 1 + 2 stay. The A→B orchestration falls back to the full-model load (B mmaps the full GGUF; A's
RPC_CMD_RESOLVE_TENSORreturns nullptr for out-of-range tensors; A's scheduler falls back to local CUDA for those). Performance degrades but correctness holds.Phase 4: Hydra.Core orchestration (parent repo)
Goal: Hydra.Core sends the right config to the right engine at the right time. Profile switch (DENSE ↔ MoE) is bounded by G7.
Files to modify (in
ddvnguyen/hydra_vortexparent repo):src/coordinator/MultiEngineRouter.csSelectto emit a per-engine config block:{mode, peer, model, split, keep_layer_range}.src/coordinator/WorkerSchedulerService.csSendEngineConfig(worker, config)method. Called when the head engine is selected.src/coordinator/WorkerInfo.csPeerEndpoint,CurrentModel,KeepLayerRangefields.src/coordinator/HydraEngineClient.csSetEngineConfigAsync(config)that POSTs to the engine's/control/set_expert_moderoute.infra/hydra-core/config/workers.jsonpeer_endpoint,default_model,default_splitper worker.src/coordinator/WorkerConfig.csReloadAsync(newModel)method that callsWorkerConfig.Reload()with the new env vars (for profile switch).src/coordinator/ProfileSwitcher.csTest plan:
tests/coordinator/test_multi_engine_router.pySelectemits the right config for each model + mode + GPU pair.tests/coordinator/test_worker_config_reload.pyWorkerConfig.Reloadupdates env vars and restarts the engine.tests/system/test_profile_switch.py(extended)tests/coordinator/test_hydra_engine_client.pySetEngineConfigAsyncPOSTs the right payload; engine acknowledges.Acceptance:
workers.jsonschema is documented and validated.Time: 1 week. PR title:
hydra: Phase 4 — coordinator orchestration + profile switch.Rollback plan: revert Phase 4 PR. The new routes (
/control/load_model,/control/set_expert_mode) are unused; engines continue to work as before. The profile switch UX is lost (manual restart required).Phase 5: Profile + decide on the wire (deferred, opt-in)
Goal: measure per-token time on the 3060+5060 Ti pair in COMBINED layer-split mode. Decide whether to invest in AF_UNIX, SHM, or CUDA IPC.
Tasks:
nsys profile --stats=true ./llama-engine --model DENSE-27B-Q4 --port 8080 --rpc-port 9504 --combined-tensor-split 21/44 --rpc-engine localhost:9505on a 200-token decode.RPC_CMD_GRAPH_COMPUTEdispatch is > 20% of per-token time, proceed to Phase 6 (AF_UNIX). Otherwise, stop here.Output:
docs/perf-baseline-2026-07.mdwith timeline screenshots.Time: 2 days. No code change.
Phase 6 (optional): AF_UNIX wrapper
Only if Phase 5 shows the wire is the bottleneck. Fork-isolated in
tools/llama-engine/hydra_rpc/af_unix.{h,cpp}. Wrapsggml_backend_rpc_*to useAF_UNIXsockets (/var/run/hydra-<node>.sock) instead of TCP loopback. Wire format unchanged. ~2× lower dispatch latency on same-host. Cross-host stays on TCP.Time: 1 week. PR title:
fork: Phase 6 — AF_UNIX wrapper for same-host dispatch.Phase 7+ (deferred): shared memory, CUDA IPC
Only if Phase 6 leaves the per-token path on the critical path. Skipped for the current hardware.
Non-goals (do NOT implement)
Test strategy
Unit tests (in
tests/)test-hydra-rpc-accept-loop.cpptest-hydra-bounded-thread-pool.cpptest-hydra-solo-regression.cpptest-hydra-runtime-reconfigure.cppcommon_params, rebuilds model/context as neededtest-hydra-common-params-passthrough.cppcommon_paramsfield Hydra.Core might emit is applied correctlytest-hydra-combine-mode-toggle.cpp0x44 SET_EXPERT_MODEstill toggles SOLO/COMBINED per request without reloadtest-hydra-no-model-peer.cpp--modeland--rpc-server-port, exposes local CUDAtest-hydra-tier-classification.cppcommon_paramsfield is classified into the right tiertest-hydra-lazy-load.cpp0x40triggers model loadtest-hydra-three-layer-config.cpp0x40compose correctly; T4 fields rejectedtest-hydra-resolve-mode.cppRPC_CMD_RESOLVE_TENSORround-trip + epoch check (still works after fork refactor)test-hydra-partial-load.cpptensor_buft_overrideslayer-range patterntest-hydra-keep-layer-range.cppllama_hydra_set_keep_layer_rangefiltertest-hydra-orchestrator.cppstart_combined_with_peerparses split correctlyE2E tests (in
tests/system/)test_solo_rtx.pytest_combine_dense.py--tensor-split 21/44 --rpc-servertest_combine_moe.py0x40+ dual-loadtest_p100_pd.pytest_lazy_load.py0x40triggers T3 model load; same-params0x40is no-optest_combine_partial_load.pytest_mode_switch.pytest_profile_switch.pyCI gates (per phase)
test-hydra-rpc-accept-loop,test-hydra-bounded-thread-pool,test-hydra-solo-regression,test_solo_rtx.pytest_multi_engine_router.py(extended),test_plan_schema.py. No fork change.test-hydra-runtime-reconfigure,test-hydra-common-params-passthrough,test-hydra-combine-mode-toggle,test-hydra-no-model-peer,test-hydra-tier-classification,test-hydra-lazy-load,test-hydra-three-layer-config,test-hydra-resolve-mode(regression),test_combine_moe.py,test_p100_pd.py,test_lazy_load.py.test_combine_dense.pyis gated on Phase 0 result.test-hydra-partial-load,test-hydra-keep-layer-range,test_combine_partial_load.py,test_mode_switch.pytest_profile_switch.pyConfiguration migration
Phase 1 (DONE via PR #37) removed the
--ggml-rpc-portflag and consolidated to--rpc-port. The Phase 2 (unified COMBINE) refactor additionally:--rpc-engine,--combined-ot-pattern,--combined-split-mode,--combined-tensor-split,--peer-only(the last five are no longer needed; PR fork: Phase 1 — merged Hydra RPC server in tools/llama-engine/hydra_rpc/ #37's deprecation shim is deleted in sub-phase 2b).--rpc-server <host:port>(replaces--rpc-engine),--rpc-server-port <port>(replaces--ggml-rpc-portfor peer exposure),--override-tensor <pat=backend>(replaces--combined-ot-pattern; the=CPU/=PEER/=RPC0suffix is stock llama.cpp syntax),--split-mode <none|layer|row>,--tensor-split <a,b,...>.--config <yaml>flag for the Layer 2 startup config (non-T4 defaults).Config files to update:
ddvnguyen/hydra_vortex/infra/hydra-core/config/workers.json— removeggml_rpc_port,combined_ot_split,run_typefrom each worker. Addcombined_peer: booland the Layer 2 defaultdefaults:block.ddvnguyen/hydra_vortex/infra/hydra-head/config/node-rtx.yaml— replace the--rpc-engine,--combined-ot-pattern,--combined-split-mode,--combined-tensor-split,--peer-only,--ggml-rpc-portlines with stock llama.cpp args (--rpc-server localhost:9506,--rpc-server-port <port>,--override-tensor <...>,--split-mode <...>,--tensor-split <...>).ddvnguyen/hydra_vortex/infra/hydra-head/config/node-rtx3060.yaml— same.ddvnguyen/hydra_vortex/infra/hydra-head/config/node-rtx-27b.yaml— same.ddvnguyen/hydra_vortex/infra/hydra-head/config/node-rtx3060-27b.yaml— same.In Phase 3, add a
peer_endpointfield per worker (e.g.,peer_endpoint: "localhost:9505").In Phase 4, add a
default_modelanddefault_splitper worker (e.g.,default_model: "/models/DENSE-27B-Q4.gguf",default_split: "21/44").Communication plan
When each phase lands:
hydra_vortex#392epic with status. File amonitoringissue if any alert fires.ddvnguyen/hydra_vortex(parent repo) with the C# refactor; cross-link this issue. Open sub-phase 2b PR againstddvnguyen/llama.cpp(this repo) with the runtime reconfigure path + flag cleanup. Comment on this issue with both PR links + the test results. If G1 number is revised (50 → 24 tok/s), file a follow-up issue documenting the 3060 as the binding constraint. Update PR fix: COMBINED layer-split startup crash + performance improvements #34 with a "superseded by Phase 2" notice (the scheduler feasibility finding is still valid; the crash is addressed by the v4 layer-split design).hydra_vortex#392epic.hydra_vortex#392. The profile switch is now usable end-to-end.When each phase breaks (rolls back):
review-findingissue perhydra_vortex's## Coding Agent Ruleswith the[Llama-Engine-P1-...]label.Open questions
(Updated after Phase 2 design pass. Q1–Q4 are RESOLVED — see "Decisions made" at the top of the Phased implementation plan. Remaining open questions are Q5–Q10 from the v4 design doc plus Q46–Q49 from the Phase 2 refactor.)
0x40 EngineConfiguretriggers a T3 model load. The--config <yaml>flag is the optional Layer 2 startup defaults.common_paramsJSON delta. Anything incommon_paramsis supported (subject to tier classification). Peer engines do not need per-request config; they're configured once at startup and accept inbound0x40/0x44from the head.defaults:block and overridable per-request via the0x40tensor_splitfield. The unified COMBINE resolver emits the right split per (model, mode) combination.tensor_buft_overridesextension syntax. Is thelayer:N-Mpattern sufficient, or do we need a more expressive syntax (e.g.,layer:N-M.*.ffn_.*=CPUfor subset of tensors within a layer range)? — still open (Phase 3 dependency)/control/load_modelauth. Perhydra_vortex#393, the per-requestpeerfield needs a trust gate. The/control/load_modelendpoint has the same surface. Default: only accept from loopback (--control-allow-from 127.0.0.1). Confirm the gate is sufficient. — still open (Phase 3 dependency)set_hydra_capabilitiescall site may have moved). — still open (post-Phase 2)startup_stagestate machine in PR fork: resolve #376 — make COMBINE mode RPC work (gate-then-degrade) #31 was useful for the staged/health. We deferred it from Phase 1. If a phase needs it (e.g., Phase 3's A→B readiness gate), it's a small Phase 1.5 add. — still open0x46 PIPELINE_ATTACHretirement. Fold into0x40stock--tensor-splitor keep as a separate opcode? Recommend retire.0x40. Confirmllama_model_free+llama_model_load_from_fileis the right unload-then-reload path; the reconfigure path handles it.rpc_servers. Fail the reconfigure (operator must know the peer is gone) or fall back to SOLO with a warning? Recommend fail.0x40with amodelfield. Load the model (becoming a dual-role engine perCLAUDE.md:266) or refuse? Recommend "load it" — the peer is dual-role by architecture principle, just initially bare-metal.References
docs/hydra-llama-engine-architecture.mdin that PR.hydra_vortex#376: startup crash (closed by Phase 2)hydra_vortex#392: Unified RPC Server epic (parent)hydra_vortex#394: split Phase C out of fork: resolve #376 — make COMBINE mode RPC work (gate-then-degrade) #31 (independent)hydra_vortex#393: per-request peer trust gate (Phase 3 dependency)hydra_vortex#353: COMBINED first-PREFILL crash (closed by Phase 2)hydra_vortex#395:graph_computedegrade-to-solo (Step 4 — separate)RPC_CMD_RESOLVE_TENSORandRPC_CMD_RESOLVE_TENSES(added by PR fork: zero-copy COMBINED expert tensors (name-based remote binding, replaces network copy) #20) — stable fork API, committed to maintainingllama_hydra_register_local_tensors_for_rpc(added by PR fork: zero-copy COMBINED expert tensors (name-based remote binding, replaces network copy) #20)ggml_backend_rpc_bind_remote_tensor(PR fork: zero-copy COMBINED expert tensors (name-based remote binding, replaces network copy) #20)start_shared_backend_rpc_server(current ggml-RPC server in llama-engine.cpp) — DELETED in Phase 1, not reusedtools/llama-engine/hydra_rpc/(PR fork: Phase 1 — merged Hydra RPC server in tools/llama-engine/hydra_rpc/ #37, Phase 1) — merged Hydra RPC server, KEPT in Phase 2llama_hydra_load_combined_experts(src/llama-hydra.cpp:123-156)llama_hydra_rebind_combined_experts(src/llama-hydra.cpp:209-362)s_hydra_combined_bindings(src/llama-hydra.cpp:169)ffn_*_exps_rpcfields onllama_layer(src/llama-model.h:320-322)llama_hydra_set_expert_mode(src/llama-hydra.cpp:364-370)llama_hydra_lock_compute/unlock_compute/enable_shared_backend_compute_lock(src/llama-hydra.cpp:402-429)llama_hydra_register_local_tensors_for_rpc(PR fork: zero-copy COMBINED expert tensors (name-based remote binding, replaces network copy) #20)src/models/qwen35moe.cpp:507-513How to use this issue
docs/hydra-llama-engine-architecture.mdin PR [design] Hydra llama-engine architecture: per-GPU engines + merged RPC + no-weight-transfer COMBINE #35) for the full context.Acceptance for closing this issue
This issue is closed when all of the following are true:
llama_hydra_*,*_rpcfields,0x44 SET_EXPERT_MODE) is verified working in the new runtime-reconfigure flow.